阅读视图

发现新文章,点击刷新页面。
☑️ ⭐

《SRE google 运维解密》读书笔记 (五)

测试可靠性

预测信息准确的前提:

  1. 系统完全没有改变
  2. 充分描述整个系统的改变

测试是一个用来证明变更前系统的某些领域相等的手段。

软件测试的类型

  • 传统测试
  • 生产测试

传统测试

  • 单元测试
  • 集成测试
  • 系统测试
    • 冒烟测试
    • 性能测试
    • 回归测试

每个测试都有成本,通常来说单元测试时间成本低 如果要将完整的功能架设起来测试,通常需要几个小时。关注测试成本,是软件提升效率的重要因素。

生产测试

生产测试和一个已经部署在生产环境的业务系统直接交互,而不是运行在封闭的测试环境。有时候称为黑盒测试

  • 配置测试
  • 压力测试
  • 金丝雀测试
    • 一小部分机器先升级,保持一定的孵化期。
    • 将代码置于比较难以预测的用户流量下
    • 需要能够快速的回滚

创造一个构建和测试环境

  • 测试的重点集中在用最小力气得到最大收益的地方
    • 划分优先级
    • 寻找关键函数关键类
    • 寻找提供给其他团队的 API
  • 发布前,通过冒烟测试
  • 寻找到的 bug 变成测试用例
  • 建立良好的测试基础设施
    • 追踪代码变更
    • 每次代码改变就进行构建
  • 精确的构建,只构建修改的地方,并执行修改代码的单侧
  • 使用工具可视化或者量化测试覆盖度
  • 和钱相关的系统需要更多测试

大规模测试

单元测试需要有针对性的覆盖组件中相互依赖的部分

测试大规模使用的工具

针对灾难的测试

灾难恢复工具被精心设计为离线运行

  • 计算出一个可记录状态,等同于服务完全停止的状态
  • 将可记录的状态推送给非灾难验证工具
  • 支持常见的发布安全边界检查

对速度的渴求

有的时候测试的结果会在重复运行下发生改变。所以需要针对某些场景,重复运行一定数量的测试。

发布到生产环境

通常,生产环境的配置文件容易被测试忽略。

集成

使用解释性语言编写配置文件是有风险的。程序的执行时间没有上限,需要加入截止时间检查。

使用成熟的语法(YAML)和大量测试的解析器。

生产环境探针

测试机制是对确定的数据检验系统行为是否可以接受。
监控机制择时在未知数据输入下系统行为是否可以接受。

已知的正确请求应该成功,已知的错误请求应该失败。重放已知请求观察系统是否正常。

(感觉应该是书翻译的问题所谓的探针应该是 mock 服务。mock 服务部署在生产环境 。在确定的入参下,有确定的返回值。调用方可以使用这个探针进行测试)

小结

测试是工程师提高可靠性投入回报比较高的手段。

☑️ ☆

《SRE google 运维解密》读书笔记 (四)

事后总结:从失败中学习

哲学

保证事故能够被记录下来,理清所有根源问题。确保实施有效的措施是的未来重现的几率和影响得以降低,甚至避免。

书写事后总结不是一种惩罚,而是整个公司的一次学习机会。

需要书写的标准:

  • 用户可见的宕机或者服务质量下降到一定标准
  • 任何形式的数据丢失
  • on-call 工程师需要人工介入
  • 问题解决耗时超过一定限制
  • 监控问题

事后总结“对事不对人”。必须关注如何定位造成这次事件的根本问题。而不是指责某个人或者某个团队的错误或者不恰当。

事后总结系统性,逻辑性的讨论为什么会在事故过程中获得错误的的信息,才能更好的建立预防措施,防止问题再现。

最佳实践:避免指责,提供建设性意见

协作和知识共享

  • 实时协作
  • 开放的评论
  • 邮件通知

包含内容:

  • 关键的灾难数据是否收集保存起来了
  • 本次事故的影响评估是否完整
  • 造成事故的根源问题是否足够深入
  • 文档记录的任务优先级是否合理,是否及时解决了根源问题
  • 事故处理过程是否共享给了相关部门

最佳实践,所有的事后总结都要评审

建立事后总结文化

  • 本月最佳总结
  • 事后总结小组
  • 事后总结阅读俱乐部
  • 命运之轮
    • 可以对已经发生的事故进行演练

最佳实践:公开奖励做正确事的人
最佳实践:收集关于事后总结有效性的反馈

跟踪故障

  • 聚合
  • 加标签
  • 分析
    • 报告和公告
☑️ ☆

《SRE google 运维解密》读书笔记 (二)

有效的故障排查手段

理论:

反复采用假设排除手段的过程:
不断提出一个造成系统问题的假设,进而针对这些假设进行测试和排除

常见的陷阱

  • 关注的错误的系统现象,或者错误地理解了系统现象的含义。
  • 不能正确的修改系统的配置信息,输入信息或者系统运行环境。
  • 将问题过早的归结为极为不可能的因素,或者之前曾经发生过的问题
  • 试图解决与当前问题相关的一些问题,却没有认识到只是巧合。

实践

故障报告

故障报告不鼓励直接汇报给具体的某个人,这样会导致压力集中在几个问题汇报人熟悉的团队成员。而不是质保人员。
需要保证每一个故障报告都有调查的历史和解决方案。

定位

大型问题,不要立即开始排查问题,尽快找到问题的根源。

正确的做法是,尽最大可能使系统回复。(同时尽量保存报错的现场供事后调查复盘)

检查

需要检查系统中每个组件的工作状态,以便了解系统是不是在正常工作。

理想情况下监控可以提供相应指标。

日志很重要,了解系统某个时间在干啥。

将日志结构化,可以保存更长时间
多级记录日志很重要,尤其可以动态调整日志级别
在日志系统中支持过滤条件

诊断

  • 简化和缩略
    • 对于大型系统,逐级查询问题过于耗时,尝试使用二分法。
  • What 、Where 、Why
  • 最后一次变更
    • 变更是引起问题的最大来源
  • 有针对性的诊断

测试和修复

  • 理想的测试应该具有互斥性,一个测试可以推翻一组假设
  • 先测试最可能的问题
  • 某些测试可能带来误导性的结果
  • 执行测试可能会带来副作用

    神奇的负面结果
    所谓负面结果,就是一项试验中不符合预期的结果

    • 负面结果不应该被忽略
    • 负面结果需要被记录,供后来人查阅。
      • 比如压测不通过的报告
    • 工具和方法可能超越目前的试验,为未来的工作提供帮助
    • 公布负面结果有利于挺升行业的数据驱动风气
    • 公布结果
      • 负面结果并不是失败
      • 负面结果并非没有价值
      • 良好设计的试验是有价值的,而不是有正向结果的试验才有价值

治愈

理想情况下,可能把错误原因减少到了一个。
下一步复现问题。
然后修复问题

如果一旦解决了某个问题,需要将如何定位问题,如何修复问题,如何防止问题再次发生。进行记录作为事后总结记录。

使故障排查更简单

  • 增加系统的可观察性。为每个系统增加白盒监控和结构化日志
  • 利用成熟的,观察性好的组件接口设计系统
☑️ ⭐

《SRE google 运维解密》读书笔记 (一)

新财年换了领导,管理风格也有一些区别。在团队内增加了一个 SRE 的职位。这一财年我将会承担一部分 SRE 的工作。

之前作为开发者,总的来说从开发的角度来思考系统的稳定性。现在需要从更高更全面的角度来思考和理解站点的稳定性。上网研究了一番,SRE 是 google 的一个职位同时 SRE 也是一套 google 总结出来的站点稳定性的方法论。所以找来了 《SRE google 运维解密》。这本书成书比较早,里面有些章节介绍的技术栈可能过时。具体我也不了解 google 内部是否还在使用。但是方法论还是很合理、科学的。

一直以来我工作过的团队对于风险的态度都是,预防和杜绝。但是在这本书里面,google 对于风险的态度就变成了管理,合理使用,甚至利用风险来保证项目的迭代。

介绍

SER 是指 Site Reliability Engineer(站点可靠性工程师)。SRE 在 google 中有一套比较成熟的方法论包括如下:

  • 可用性改造
  • 延迟优化
  • 性能优化
  • 效率优化
  • 变更管理
  • 监控
  • 紧急事务处理
  • 容量规划与管理

SRE 方法论:

确保长期关注研发

SRE 只有 50% 的时间投入运维工作,如果超过就需要将任务分配至研发团队,形成良性循环,激励研发团队设计构建出不需要人工干预,自主运行的系统。
出现事故需要推动事后总结。

保证服务在 SLO 的前提下最大化迭代

  • 正确认识“错误预算”,系统不能 100% 可用,也不应该追求 100 %可用。
  • 业务系统可用利用错误预算,上新功能,黑度,AB test 等。
  • SRE 目标并不是 0 事故,而是与业务团队一起管理好“错误预算”

监控系统

  • 监控是 SRE 了解系统的重要手段
  • 监控只有三类输出
    • 紧急报警:收到报警的用户必须立即采取某些操作,解决问题或者避免即将发生的问题
    • 工单:收到报警的用户可以采取某些操作非立即,只需要在时效内完成。系统不会受到影响
    • 日志:平时无需关注日志,日志作为调试或者事后分析使用

应急事件处理

  • 可靠性是 MTTF(平均失败时间) 和 MTTR (平均回复时间)的函数
  • 人工操作的事情会延长回复时间
    • 运维手册
    • 事故演练

可以缩短恢复时间

变更的管理

  • 采用渐进式的发布
  • 迅速检测出问题的机制
  • 出现问题可以快速回滚

需求的预测和容量规划

  • 有明确的自然增加的预测
  • 规划中还要考虑非自然增涨的需求来源的统计
  • 定期压测,了解系统

资源部署

资源是变更和规划的产物

  • 快速正确的部署资源是基本的要求

效率和性能

改善利用率,降低成本。
从三个因素推动效率提升

  • 用户需求
  • 可用容量
  • 资源利用率

Google 的生产环境

成书较早,参考价值不大(略)

拥抱风险

管理风险

可靠性的提升,投入并不是线性的

冗余

  • 设备的冗余
  • 计算的冗余,增加一些空间进行奇偶校验

机会成本

  • 如果工程师投入到可靠性建设,就不能从事为用户开发的工作中了

所以,可靠性的管理是通过风险的管理进行的。提升系统可靠性和服务故障的耐受水平同等重要。努力提升服务可靠性,但是不超过服务需要的可靠性。否则将会付出更多的成本。

度量服务的风险

按时间:

可用性= 正常时间/(正常时间+ 不可用时间)

四个九 一年宕机 52 分钟
合计次数

可用性 = 成功次数/总调用次数

对于分布式系统按时间是不合理的,总有部分系统在线,所以 google 倾向使用按次统计

服务的风险容忍度

  • 客户对服务失败的容忍度
    • toB 要比 toC 低很多
    • 付费要比免费低
    • 关系到收入的要低
  • 故障的类型
  • 成本
  • 其他服务指标

基础设施容忍度

  • 可用性目标水平
    • 高可用性很贵
    • 要看人下菜碟,合理保障
  • 故障类型
  • 成本

错误预算使用的目的

错误预算的构建:

  1. 产品管理层定义一个 SLO,确定服务的预计正常运行时间
  2. 通过监控来度量
  3. 而知差值就是不可靠预算
  4. 如果预算为正就能够进行发布和变更。

好处

创新和可靠性的平衡点。

使用这个控制回路来调节发布的速度,有预算就快速迭代,如果频繁违反 SLO 或者错误预算被耗尽,就需要暂停发布,在测试和开发环节投入更多资源,提升系统可用性。

如果客观的故障发生比如光缆被挖断,影响了 SLO 需要扣减错误预算么?需要的,每个人都有义务保障服务正常运行。

利用错误预算机制,还能够找到定的过高的可用性指标。如果预算耗尽,团队无法发布,就可以考虑降低 SLO 来提升创新速度。

注:SLO 并非越高越好,稳定和创新通常是矛盾的。使用错误预算机制,闭环平衡稳定和创新的关系。

服务质量目标

术语:

  • SLI (indicator) 服务的某一个量化指标。比如
    • 延迟(rt)
    • 错误(error)
    • 吞吐量(qps)
    • 可用性
  • SLO (Objective) 可用性目标,通常指:

范围下限 <= SLI <= 范围上限

  • SLA (Agreement) 服务质量协议,指达到或者没有达到某个 SLO 的后果。

SLI 的实践中的应用

关心什么指标

  • 用户可见的系统:
    • 可用性
    • 延迟
    • 吞吐
  • 存储系统:
    • 延迟
    • 可用性
    • 持久性
  • 大数据系统:
    • 吞吐
    • 延迟
    • 时间
  • 所有系统都有关注延迟

收集

汇总

标准化

SLO 在实践中的应用

目标的定义

  • 指出如何被度量
  • 有效的条件

目标的选择

  • 不要仅以目前的状态为基础选择(要用发展的眼光)
  • 保持简单
  • 避免绝对值
  • SLO 越少越好
  • 不要追求完美

控制手段

  1. 监控并度量 SLI
  2. 是否需要人工干预
  3. 如果需要干预,决定怎么干预
  4. 执行具体干预措施

SLO 建立用户预期

  • 留有余量
  • 实际 SLO 不要过高

SLA 的使用

减少琐事

琐事的定义

  • 手动性
  • 重复性
  • 可被自动化的
  • 战术性的(突然出现的,非策略驱动和主动安排的)
  • 没有持久价值的
  • 与服务同步线性增长的(良好的设计至少是有数量级增长的)

SRE 工作内容

50% 琐事,50% 工程项目

工程工作

工程工作,是新颖的,本质上需要主观判断的工作。战略性的。有创新性和创造性的。通过设计来解决问题,越通用越好。

琐事的危害

  • 职业停滞
  • 士气低落
  • 造成误解
  • 进展缓慢
  • 开创先例(如果愿意接受琐事,那就会有更多的琐事)
  • 产生摩擦
  • 违反承诺

分布式系统的监控

术语定义

  • 监控
  • 白盒监控
    • 对系统暴露的性能指标进行监控
  • 黑盒监控
    • 通过测试某种外部用户可见的系统进行监控
  • dashboard
  • 警报
  • 根源问题
    • 某个缺陷被修复,就可以保证这种缺陷不再发生以同样的方式发生。
  • 节点或者机器
  • 推送

为什么要监控

  • 分析长期趋势
  • 跨世纪范围的比较,或者实验组和对照组之间的区别
  • 报警
  • 构建监控 dashboard
  • 临时性问题的回溯分析

监控可以在系统发生故障或者将要发生故障的时候通知我们。
处理报警会占用员工的时间,报警太频繁会造成“狼来了”效应

对监控系统设置合理预期

Google 倾向于使用简单和快速的监控,配合高效的工具进行分析。避免使用“魔方”系统-试图自动学习或者自动检查故障的系统。
监控系统的规则越简单约好。
监控系统信噪比应该很高,发出报警的组件应该简单可靠。

黑盒和白盒监控

白盒监控应该要作为监控的主要手段。
黑盒监控是面向现象的-现在发生的,而非即将发生的。
白盒监控大量依赖对系统内部信息的检测。白盒监控可以检测到即将发生的问题和重试严掩盖问题。白盒系统既可以面向原因也可以面向现象。

4 个黄金指标

  • 延迟(rt)
  • 流量
  • 错误
  • 饱和度
    • 通常是系统中最为受限的某个具体指标的度量。
    • 复杂系统里面,可以配合其他搞层次的负载度量使用。使用一个简介的指标。

长尾

只使用平均值是不足以描述系统的。需要区分平均值的慢,或者长尾值的慢。可以对数据进行分组统计。

简化直到不能再简化

  • 最能反应正式故障的规则越简单越好
  • 不常见的报警就要删除(定时删除没有用到的报警)
  • 没有被报警规则使用的信息,就应该

报警的深层次理论

  • 每当收到报警,需要立即进行某种操作,每天次数有限,过多会有“狼来了”效应
  • 每个紧急报警都应该是可以具体操作的
  • 报警的回复都应该是需要某种智力过程的,如果只需要固定的机械操作,那就不应该是紧急报警
  • 每个紧急报警都应该是正交的,不应该彼此重叠

监控系统的长期维护

系统不断演变,软件经常重构,负载和性能目标也经常变化。所以监控系统的的设计和决策充分考虑长期目标。每一个报警都会占用优化系统的时间。花时间投入监控,换取未来系统的稳定是值得的。

短期和长期的可用性经常冲突。通过一些“暴力”因素,可以使一个摇摇欲坠系统保持一定的高可用性,这种方案不能长久,且依赖个人英雄主义。

短期接受稳定性的降级获得长期的可用性提升。

Google 自动化演进

自动化的价值

  • 一致性
  • 平台性
    • 自动化的系统可以提供一个可以扩展的、广泛适用的。
    • 同时会将错误集中化、意味着修复的缺陷是一劳永逸的。
  • 修复速度更快
  • 行动速度快
  • 节约时间

发布工程

哲学

  • 自服务模型
  • 追求速度
  • 密闭性
    • 构建工具必须确保一致性和可重复性
  • 强调策略和流程

持续构建和部署

  • 构建
  • 分支
    • 所有代码默认提交到主分支上。
    • 构建一个发布分支
    • 发布分支不会并入主分支
    • 代码从主分支 cherry pick 到发布分支
  • 测试
    • 单测
  • 打包
  • 部署
  • 部署

配置管理

一开始就进行发布工程

不要做时候诸葛亮

简单化

软件系统是本质上是动态和不稳定的

  • 系统的稳定性和灵活性

    • 为了灵活性牺牲稳定性是有意义的。
  • 乏味是一种美德

  • 定期删除无用代码

  • “负代码行”作为指标

    • 臃肿的软件置管术是不可取的
    • 添加代码可能引入新的缺陷
    • 小的代码容易理解,也容易测试,缺陷就越少
  • 最小 API

    • 书写一个明确的,最小的 API 是软件系统简单的必要部分
    • 方法越少,参数越少也容易理解
  • 模块化

  • 发布简单化

软件的简单是可靠性的前提。

☑️ ☆

2021 总结

2021 就这么结束了。

今年我做爸爸了。
今年九月,迎来了我们家的小朋友。豆嫂从怀孕一路走来。如打怪升级一样。一关一关的过,颇为不容易。

jia

  • 豆嫂孕早期孕吐严重,某天在地铁上没吃早饭,低血糖晕倒在地铁上,还好我在身边。周围的好心人都把自己的零食给了我们。下车后豆嫂把手里的一捧零食吃完以后,才重新坐上地铁上去上班。
  • 小朋友在肚子里总是不安分,总是脐带绕颈。时而一圈,时而两圈。
  • 九月的最后一次产检。调皮的小朋友臀位。没有正常入盆,只能剖腹产。
  • 小朋友出生那天,五点起来给豆嫂煮了小米粥。吃完以后,又睡了一会。八点把豆嫂送到医院准备手术。
  • 产科大夫,麻醉大夫分别告知了风险。我在知情同意书上签了字
  • 豆嫂进了手术室,我在手术室外面焦虑的不行,一圈一圈的走。就在彻底走不动的时候。手术室的门打开了,护士把我叫了过去,轻轻的掀开了蓝色的无菌布。一个粉白粉白的小生命出现在我的眼前。
  • 看了一眼小朋友。小朋友就被推进了新生儿室。我站在门口,隔着毛玻璃看着里面护士忙碌的影子,突然一股暖流充满了全身,眼睛也湿润了,我做爸爸了。
  • 由于疫情,医院全封闭管理,我并没有看到豆嫂。豆嫂说,麻醉过后特别冷,伤口特别疼。
  • 本以为一切结束了。稍微放松一点准备回家。在路上又被叫回了医院。医生上来就交代,血氧不足,哭声不正常,有风险。瞬间天旋地转,脚如灌铅。艰难的挪到了楼梯边,坐在楼梯上,给家里人打电话。
  • 下午,吸了氧的小朋友终于恢复正常。
  • 欢迎这个小生命来到这个世界,希望你能够健康快乐的成长,身体强壮,取小名“壮壮”。

之后,去月子中心。出月子。满一百天去“中国照相馆”拍了纪念照。现在小朋友正躺在自己的小床上沉沉的睡去。呼吸均匀。大概再过两个小时又会饿得哇哇大哭,要喝奶了。

今年我在北京有自己的车了。
今年为了照顾孕妇的出行。买了一辆车。还记得提车那天激动的几乎睡不着。坐在家里都忍不住到地库看了又看。现在回想起来自己小时候最爱的的汽车玩具,应该就是一辆 E30 平台的宝马三系。爱车的我终于在 31 岁生日前有了自己的第一辆车,而且是在北京。

che

前几天,某人把车撞到了路边的墩子,更换前杠,保险没白买。

旅行

今年只去了稻城亚丁。
海拔 4700 米的壮美雪山。无限风光在险峰。吸光了 4 瓶氧气,终于爬到了牛奶海。总体来说一路高反都是值得的。

4700

投资

今年我没有亏钱。
今年的投资居然没有亏钱。没有最热点新能源赛道。跟着长赢计划慢慢布局。一种踏实的感觉。不急不躁,踏踏实实的,多大点事。中丐互怜被锤,但是中证 500 在涨啊。配置分散,降低风险,控制回撤,是我今年的投资的体会。

内心平和

今年我没有去年焦虑。
信息焦虑在 2020 年给我带来了极大的痛苦。而今年想明白了一些事情反而内心平和了很多。

  • 没看到的就是没有,没注意的的就是不重要。主动离开那些生怕错过的信息渠道。万一群里的信息对我有用、万一这个短视频说得我用得上、万一这个人人脉以后我用得上。这些万一其实消耗着我们的精力,但是没有什么意义。事情不重要,就不需要知道。如果事情足够重要,那我一定会知道。

  • 全情的长时间的投入精力在某一件事情,在网络的社会中是很奢侈的事情。当投入时间到某样事情的时候,我们感知到的机会成本就在上升,所谓机会成本,就是当你做某件事前的时候,不得不放弃别的事情带来的好处。一旦投入时间精力做的事情,出现了挫折,损失就是没有做好这件事情加上没做那件更好事情的收益。现在的互联网就是如此,优秀的作品那么多,但是视频只能一个一个看,文章只能一篇一篇读。这就带来了一个悖论,内容越丰富,机会成本就越高。毕竟因为选择做某件事情,投入了时间。错过的优质信息就越多。这个就是信息爆炸带来的焦虑。

  • 抑制这种焦虑可以从几个角度进行

    • 正视这个问题,为焦虑的情绪寻找出口,比如“收藏了就是读了,买了就是学了”。
    • 回归现实,现实世界中,人的感官处理的事务没有那么多选择,选择某件事情的机会成本,感觉上没有那么大。

期待

明年没有什么过高的期待。

  • 疫情可以结束
  • 小朋友可以健康成长
  • 工作顺利
  • 家人健康

2021 再见。

☑️ ☆

终于有一个 Java 可以用的微信机器人了

终于有一个 Java 版的微信机器人了。

公众号很久没有更新了。主要两个原因,换了工作之后,第一,要花更多的时间去了解和学习新的业务。第二,我最近把几乎所有的业余时间都来写这个 Java 版的微信机器人了。

java-wechaty

Wechaty 是什么

官网的描述是:

  • A Conversational AI RPA SDK for Chatbot

其实就是一个能够快速构建聊天机器人的开源 SDK。最早的时候,Wechaty 只是一个基于服务于微信工具库,现在逐渐的发展到可以对接世面上的主流聊天软件包括不限于:微信,企业微信,钉钉,Line 等。

编程语言也由原来的单一语言(TypeScript) 发展到,Java,Scala,Python,Go 等多语言实现的工具库了,同时社区生态还在不断的壮大。

Github 地址:https://github.com/wechaty/wechaty 目前已经有 7.9k 的 star 了。

与 Wechaty 结缘

之前的工作,老板有一个要求,是就每天下班后,发一封邮件日报简单描述一下今天工作进展。如果忘记发日报,第二天就负责整理 全组人的日报。作为一个健忘的人,忘记发日报简直就是家常便饭。

于是就考虑需要一个机制:

  • 每天提醒我发日报
  • 动作尽可能简单,且自动化。

当时就想能不能在微信上有一个机器人,每天定时提醒我发日报,而且只要回复这个机器人,他就能够把我回复的消息,按照固定模板生成日报并发送给老板。这样既不会忘记,也能简单自动化的完成这个工作。

一顿 Google 还真找到了 Wechaty 这个工具。尝试写了一个日报机器人满足了我的需求。于是再接再厉,又写了一个提醒女朋友吃饭的工具,但是因为不熟悉 TypeScript。写出的机器人没法停止,变成了一个信息轰炸机,差点被拉黑。居然有人能忘记吃饭?写个微信机器人提醒他

就是因为这篇文章,还结识了 Wechaty 的作者李佳芮。现在她的公司已经估值很多个 0 了。

由于我的主要工作语言是 Java ,对 TypeScript 还是了解不多,就暂时放下了。

Java 版的 Wechaty

在 Wechaty 的某个版本后,开始支持 GRPC 作为传输协议。这个时候我觉得多语言开发的环境就比较成熟了。于是我就开始尝试写一个 Java 版的 wechaty。

Java vs Kotlin

Wechaty 使用 TypeScripe 开发,在移植的过程中,发现要实现 TS 版对应的功能,Java 所需要的模板代码就太多了,开发起来效率不够快。于是就考虑可不可以使用 Kotlin 来构建 Java-wechaty sdk。

Kotlin 有以下特性感觉比较适合 Wechaty 的开发:

  • Java 和 Kotlin 之间可以无障碍的互相操作
  • 在 Kotlin 中,函数也是第一公民,可以脱离类的存在,这一点在移植 TS 代码的时候优势就比较明显了。
  • 空指针安全,之前写 Java 的时候,受够了一步一检查。Kotlin 在语言层面就解决了空指针安全的问题。写起来有效的减少心智负担。
  • Kotlin 是务实的,更有表现力的语言。语法更加接近 TS 和 GO,相对 Java 来说更加简洁。

事件驱动

TS 版的 Wechaty 是基于 Nodejs 开发的,一个典型的事件驱动的架构。在开发初期我就自然想到了使用 Vertx 框架来开发。但是开发一段时间后发现,其实 Vertx 是一个事件驱动的网络框架。主要解决的还是网络相关的问题,放到 Java-wechaty 中还是太重了。

于是移除了代码中的 Vertx 框架,自己参考 Nodejs 中的 EventEmitter 实现了 Kotlin 版的事件驱动组件。

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  +--------------------------+ +--------------------------+
| | | |
| Wechaty (TypeScript) | | Wechaty (Java) |
| | | |
+--------------------------+ +--------------------------+

+-------------------------------------------------------+
| Wechaty Puppet Hostie |
| |
| (wechaty-puppet-hostie) |
+-------------------------------------------------------+

+--------------------- @chatie/grpc ----------------------+

+-------------------------------------------------------+
| Wechaty Puppet Abstract |
| |
| (wechaty-puppet) |
+-------------------------------------------------------+

+--------------------------+ +--------------------------+
| Pad Protocol | | Web Protocol |
| | | |
| wechaty-puppet-padplus | |(wechaty-puppet-puppeteer)|
+--------------------------+ +--------------------------+
+--------------------------+ +--------------------------+
| Windows Protocol | | Mac Protocol |
| | | |
| (wechaty-puppet-windows) | | (wechaty-puppet-macpro) |
+--------------------------+ +--------------------------+

通过这个图可看到,Wechaty 的结构设计还比清晰。利用 Puppet 的架构,将真正的通信协议和具体的 IM 软件进行了隔离。基于这一点不同的语言基于 Puppet 的协议就可以进行多语言开发。

好用么

感谢 Wechaty 前期良好的 API 设计几行代码就可以开发自己聊天机器人:

Demo 1:

1
2
3
4
5
6
7
8
9
class Bot{
public static void main(String args[]){
Wechaty bot = Wechaty.instance()
.onScan((qrcode, statusScanStatus, data) -> System.out.println(QrcodeUtils.getQr(qrcode)))
.onLogin(user -> System.out.println("User logined :" + user))
.onMessage(message -> System.out.println("Message:" + message))
.start(true);
}
}

这个 Demo 6 行代码,就实现了机器人的扫码登录,接受消息的功能。同时现在 Java-wechaty 还支持可插拔的插件。利用插件,可以更简单的构建机器人。

Demo 2:

1
2
3
4
5
6
7
8
9
10
class Bot{
public static void main(String args[]){
Wechaty bot = Wechaty.instance()
.use(
WechatyPlugins.ScanPlugin(),
WechatyPlugins.DingDongPlugin(null)
)
.start(true);
}
}

随着插件的原来越丰富,可能以后,用户只需要组合各种插件,就能达成自己的需求,尽量的做到低代码开发。

现在达到什么程度了

目前 Java-wechaty 已经完成了 TS 版的功能的移植。

实现了基础的的聊天,好友管理,群管理功能。接下来的开发就会集中在 API 的打磨,稳定性的提升。同时也期待你的加入为 Java-wechaty 贡献代码。

从 Java-wechaty 中能得到什么

  1. 真正的参与开源代码的贡献。
  2. 在 Maven 中央库,发布了自己的 Jar 包。
  3. 认识了各种各样小伙伴,包括写了 25 年程序的天使投资人 @Huan。
  4. 在写 Java-wechaty 的时候,不断的参考伙伴们的 TypeScript,Go,Python 代码,从实际的角度去审视各种编程语言的特性。探寻语言各个特性设计的初衷。

期待你的加入

Wechtay 社区加入了由 中科院软件所openEuler 社区 共同举办的一项面向高校学生的暑期活动《开源软件供应链点亮计划-暑期2020》。

详情见: https://github.com/wechaty/summer-of-code

Wechaty 给学生们提供了很多有意思的题目,比如:

  1. 利用 AI 技术,开发一个 AI 斗图机器人
  2. 利用 Wechaty 的插件技术,开发一个“每日一句”插件,替你向妹子嘘寒问暖的”撩妹“机器人
  3. 还有偏向工程的,代码移植工作,让学生真正的参与到开源项目其中

开发语言涉及,TypeScript,Go,Java,Kotlin,Python 甚至还有 Scala,总有一个适合你。

希望看到这里的你,可以把篇文章,转发给学习计算机,或者对编程感兴趣的学生朋友,期待他们加入。

后记

Java-wechaty 项目地址。 加入我们你也可以六行代码写一个微信机器人。

☑️ ☆

周末补习(一)trie 树

前言

是的,最近我又换工作了,在看新团队的代码的时候发现,同事们为了追求服务的响应时间,在项目中大量的使用了很多高级的数据结构。

作为传统 Curd 程序员,对算法和数据结构已经比较生疏了。如今看到这些”高级的代码“有点汗颜。所以趁周末好好的在家补课,重新复习一下。

文章将会是一个系列,慢慢的查缺补漏。

Trie/TrieTree1.png

简介

Trie 树又叫字典查找树。顾名思义,字典查找树,主要解决的就是字符串的查找。有以下两个优势。

  • 查找命中的时间复杂度是 O(k),k指的是需要查询的 key 的长度。这里注意和字库的大小无关。
  • 对于未命中的字符,只需要查询若干字符就可。

基本数据结构

首先 Trie 树,是一棵树。树是由需要建立的所有词构成。

假设我们有,bee 、sea、 shells,she,sells,几个单词。我们可以使用这几个单词构建一棵树。

通过图片我们就可以直观的看出 Trie 的数据结构。这个棵树是由若干节点,链接而成,节点可以指向下一个节点,也可以指向空。从 root 节点开始,顺着链接随便找某个链接往下,直到最低端,经过的路径正好是上文的单词。

Trie/TrieTree1.png

数据的代码表示

为了方便使用代码表示。可以考虑每个节点使用数组表示。每个节点都含有一个数组,数组的大小为R,R 是数组的基数,对应每个可能出现的字符。R 的选取取决于报错的字符的类型,如果只包含英文则256 就可以了。如果是中文就需要 65536。

字符和键值都保存在数据结构中。

Trie/TrieTree3.png

所以实现代码如下:

1
2
3
4
5
6
7
8
9
10
11

public class TrieST<Value> {

public static final int R = 256;
private Node root;

private static class Node {
public Object val; // 键值
public Node[] next = new Node[R];
}
}

Get 和 Put 方法

对于数据结构的键值的读写方法,我可以使用递归的方式进行查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private Node get(Node x, String key, int d) {

// 1
if (x == null) {
return null;
}

//2
if (d == key.length()) {
return x;
}

//3
char c = key.charAt(d);

//4
return get(x.next[c], key, d + 1);
}

public Value get(String key) {
Node x = get(root, key, 0);
if (x == null) {
return null;
}
return (Value) x.val;
}

对于递归的我们需要考虑两个问题。递归的退出的条件是什么,如何进入下一层递归。

对于 Node get(Node x, String key, int d),入参 x 是当前的节点,key 是需要查找的字字符串,d 是目前递归到的层数,也可以理解为,我们逐个遍历 key 的时候的下标。

我们按照注释逐行讲解一下:

  1. 递归跳出的条件之一,就是发现上一次查询指向的节点是空的,说明没有找到匹配的字符串。所以直接返回一个 null,表示没有匹配上。
  2. 递归跳出的条件之二,就是key值已经遍历完了。并且找到了对应的 value。可喜可贺。
  3. 这里的 c 表示的就是key在下标为 d 的时候对应的字符。因为我们的 root 是第 0 个,所以遍历 key的 c 是从1开始。
  4. 递归调用 get 方法。将 x 的下一个节点传入方法,同时下标 d 加 1。

我们再来看 put 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private Node put(Node x, String key, Value val,int d) {

//1
if(x == null) {
x= new Node();
}

//2
if(d == key.length()){
x.val = val;
return x;
}

//3
char c = key.charAt(d);

//4
x.next[c] = put(x.next[c],key,val,d + 1);
return x;
}

public void put(String key,Value val){
root = put(root,key,val,0);
}

put 方法和 get 方法非常类似,习惯上来说我们在保存数据的时候,都需要先查询一下看看数据存不存在,如果存在直接返回,如果不存在再插入数据。trie 数的插入也是这个思路。

我们按照注释逐行讲解一下:

  1. 如果当前节点为空,则在当前节点插入一个空 value。注意:这里是新建一个节点,在这个新节点上插入空的 value,而不是插入一个空节点,注意区分。
  2. 同理,如果d == key 的长度,表示已经将 key 遍历完了,需要把 key 对应的值保存在节点上了。
  3. 和 Get 一致,略。
  4. 递归调用 put 方法,将 x 的下一个节点传入方法,同时下标 d 加 1。然后逐层放回。

看完这 Put 和 Get 方法。我们再回顾一下 trid 的性质。

查询的次数,只和代码中的 key 的长度有关,与字典的大小没有关系。

如果没有命中的数据,查询的次数小于等于 key 的长度 。

应用

这里先着重介绍一下 trie 树的其中一个应用 ”前缀匹配“。

我们在搜索框里面输入一个词的时候,通常会收到提示的列表如下图:

Trie/Untitled.png

输入 flink 的时候,搜索引擎会提示联想出用户可能的输入,提升用户体验。

有了上面的 Trie 树的介绍。具体实现这个功能就比较简单了。

回到我们原有的例子,假设词库里面有单词 bee 、sea、 shells,she,sells。如果用户输入 se 两个字符,我们应该会向用户提示 se 开始的词: sea 和 sells。

Trie/TrieTree2.png

结合图片,我们要找到 se 开头的字符。我们首先要定位出图中红色的链条,然后把红色 e 的所有子链找出来。当然如果 e 的子链特别多,我们就需要考虑对子链进行截断。具体怎么截断我们以后会的文章里面可能会讲解。

我们先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void collect(Node x, String pre, Queue<String> q){

//3
if(x == null){
return;
}

//4
if(x.val != null){
q.add(pre);
}

//5
for(char c = 0;c < R; c++){
collect(x.next[c],pre + c, q);
}
}

public Iterable<String> keysWithPrefix(String pre){

//1
Queue<String> q = new LinkedList<String>();

//2
collect(get(root,pre,0),pre,q);

return q;

}

逐条解释一下:

  1. 初始化找一个容器存储起来。
  2. 其中的 get(root,pre,0) 就是为了找出上图中标红的 e节点。然后把 e 节点放到 collect() 方法中。
  3. 递归的退出条件就是到达某一个链的最子节点。
  4. 如果 x 节点的 val 不为空就加入到容器中。
  5. 暴力的遍历节点上的数组并 c 拼接到 pre 前缀上,递归查找。

我们只需要调用方法 keysWithPrefix("se") 即可。

总结

trie 树在查询的时间复杂度是 O(k) 与词库的大小无关。
但是,有利必有弊。
利用数组表示节点实现的 Trie 树非常占用空间。

如果运用在英文文本处理中,假设单词的平均长度是 11 个字符,R 的大小是 256,100万个键构成的树大约有 2亿5千万个链接数。

是典型的空间换时间应用。

欢迎关注我的微信公众号:

二维码

☑️ ☆

那些有趣的代码(三)--勤俭持家的 ArrayList

上周在群里有小盆友问 transient 关键字是干什么的。这篇文章就以此为契机介绍一下 transient 的作用,以及在 ArrayList 里面的应用。

要了解 transient 我们先聊聊 Java 的序列化。

复习序列化

所谓序列化是指,把对象转化为字节流的一种机制。同理,反序列化指的就是把字节流转化为对象。

serializable

  • 对于 Java 对象来说,如果使用 JDK 的序列化实现。对象需要实现 java.io.Serializable 接口。
  • 可以使用 ObjectOutputStream()ObjectInputStream() 对对象进行序列化和反序列化。
  • 序列化的时候会调用 writeObject() 方法,把对象转换为字节流。
  • 反序列化的时候会调用 readObject() 方法,把字节流转换为对象。
  • Java 在反序列化的时候会校验字节流中的 serialVersionUID 与对象的 serialVersionUID 时候一致。如果不一致就会抛出 InvalidClassException 异常。官方强烈推荐为序列化的对象指定一个固定的 serialVersionUID。否则虚拟机会根据类的相关信息通过一个摘要算法生成,所以当我们改变类的参数的时候虚拟机生成的 serialVersionUID 是会变化的。
  • transient 关键字修饰的变量 不会 被序列化为字节流

复习ArrayList

1、ArrayList 是基于数组实现的,是一个动态数组,容量支持自动自动增长
2、ArrayList 线程不安全
3、ArrayList 实现了 Serializable,支持序列化

勤俭持家

上文我们说到 ArrayList 是基于数组实现,我们看看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/

transient Object[] elementData; // non-private to simplify nested class access

/**
* The size of the ArrayList (the number of elements it contains).
*
* @serial
*/
private int size;

有几个重要的信息:

  • ArraryList 是动态数组,这个 elementData 就是存储对象的数据。
  • 这个数组居然使用了 transient 来修饰。
  • 数组的长度等于 ArrayList 的容量。而不是 ArrayList 的元素数量。
  • size 是指的 ArrayList 中元素的数量,不是动态数组的长度。
  • size 没有被 transient 修饰,是可以被序列化的。

这,怎么回事。ArrayList 存储数据的数组,居然不需要序列化?

black

莫慌,我们继续往下看代码。上文我们说过,对象的序列化和反序列化是通过调用方法 writeObject() 和 readObject() 完成了,我们发现,ArrayList 自己实现这两个方法看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* Save the state of the <tt>ArrayList</tt> instance to a stream (that
* is, serialize it).
*
* @serialData The length of the array backing the <tt>ArrayList</tt>
* instance is emitted (int), followed by all of its elements
* (each an <tt>Object</tt>) in the proper order.
*/
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}

/**
* Reconstitute the <tt>ArrayList</tt> instance from a stream (that is,
* deserialize it).
*/
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;

// Read in size, and any hidden stuff
s.defaultReadObject();

// Read in capacity
s.readInt(); // ignored

if (size > 0) {
// be like clone(), allocate array based upon size not capacity
int capacity = calculateCapacity(elementData, size);
SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
ensureCapacityInternal(size);
Object[] a = elementData;
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}

注意,在 writeObject() 方法中,

1
2
3
4
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

按需序列化,用了几个下标序列化几个对象。

读取的时候也是:

1
2
3
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}

有几个读几个。

总结一下:

  • transient 修饰的变量不会被序列化。
  • ArrayList 的底层数组 elementDatatransient 修饰,不会直接被序列化。
  • 为了实现 ArrayList 元素的序列化,ArrayList 重写了 writeObject()readObject() 方法。
  • 按需序列化数组,只序列化存在的数据,而不是序列化整个 elementData 数组。

用多少,序列化多少,真是勤俭持家的 ArrayList。

有趣的代码系列

那些有趣的代码(一)–有点萌的 Tomcat 的线程池
那些有趣的代码(二)–偏不听父母话的 Tomcat 类加载器

欢迎关注我的微信公众号
二维码

☑️ ☆

那些有趣的代码(一)--有点萌的 Tomcat 的线程池

最近抓紧时间看看了看tomcat 和 jetty 的源代码。发现了一些有趣的代码,这里和大家分享一下。

Tomcat 作为一个老牌的 servlet 容器,处理多线程肯定得心应手,为了能保证多线程环境下的高效,必然使用了线程池。

但是,Tomcat 并没有直接使用 j.u.c 里面的线程池,而是对线程池进行了扩展,首先我们回忆一下,j.u.c 中的线程池的几个核心参数是怎么配合的:

  1. 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。
  2. 如果运行的线程等于或多于 corePoolSize,将任务加入 BlockingQueue。
  3. 如果 BlockingQueue 内的任务超过上限,则创建新的线程来处理任务。
  4. 如果创建的线程超出 maximumPoolSize,任务将被拒绝策略拒绝。

这个时候我们来仔细看看 Tomcat 的代码:

首先写了一个 TaskQueue 继承了非阻塞无界队列 LinkedBlockingQueue<Runnable> 并重写了的 offer 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Override
public boolean offer(Runnable o) {
//we can't do any checks
if (parent==null) return super.offer(o);
//we are maxed out on threads, simply queue the object
if (parent.getPoolSize() == parent.getMaximumPoolSize()){
return super.offer(o);
}
//we have idle threads, just add it to the queue
if (parent.getSubmittedCount()<=(parent.getPoolSize())) {
return super.offer(o);
}
//if we have less threads than maximum force creation of a new thread
if (parent.getPoolSize()<parent.getMaximumPoolSize()) {
return false;
}
//if we reached here, we need to add it to the queue
return super.offer(o);
}

在提交任务的时候,增加了几个分支判断。

首先我们看看 parent 是什么:

1
private transient volatile ThreadPoolExecutor parent = null;

这里需要特别注意这里的 ThreadPoolExecutor 并不是 jdk里面的 java.util.concurrent.ThreadPoolExecutor 而是 tomcat 自己实现的。

我们分别来看 offer 中的几个 if 分支。

首先我们需要明确一下,当一个线程池需要调用阻塞队列的 offer 的时候,说明线程池的核心线程数已经被占满了。(记住这个前提非常重要)

要理解下面的代码,首先需要复习一下线程池的 getPoolSize() 获取的是什么?我们看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Returns the current number of threads in the pool.
*
* @return the number of threads
*/
public int getPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Remove rare and surprising possibility of
// isTerminated() && getPoolSize() > 0
return runStateAtLeast(ctl.get(), TIDYING) ? 0
: workers.size();
} finally {
mainLock.unlock();
}
}

需要注意的是,workers.size() 包含了 coreSize 的核心线程和临时创建的小于 maxSize 的临时线程。

先看第一个 if

1
2
3
4
// 如果线程池的工作线程数等于 线程池的最大线程数,这个时候没有工作线程了,就尝试加入到阻塞队列中
if (parent.getPoolSize() == parent.getMaximumPoolSize()){
return super.offer(o);
}

经过第一个 if 之后,线程数必然在核心线程数和最大线程数之间。

1
2
3
if (parent.getSubmittedCount()<=(parent.getPoolSize())) {
return super.offer(o);
}

对于 parent.getSubiitedCount() ,我们要先搞清楚 submiitedCount 是什么

1
2
3
4
5
6
7
/**
* The number of tasks submitted but not yet finished. This includes tasks
* in the queue and tasks that have been handed to a worker thread but the
* latter did not start executing the task yet.
* This number is always greater or equal to {@link #getActiveCount()}.
*/
private final AtomicInteger submittedCount = new AtomicInteger(0);

这个数是一个原子类的整数,用于记录提交到线程中,且还没有结束的任务数。包含了在阻塞队列中的任务数和正在被执行的任务数两部分之和 。

所以这行代码的策略是,如果已提交的线程数小于等于线程池中的线程数,表明这个时候还有空闲线程,直接加入阻塞队列中。为什么会有这种情况发生?其实我的理解是,之前创建的临时线程还没有被回收,这个时候直接把线程加入到队里里面,自然就会被空闲的临时线程消费掉了。

我们继续往下看:

1
2
3
4
//if we have less threads than maximum force creation of a new thread
if (parent.getPoolSize()<parent.getMaximumPoolSize()) {
return false;
}

由于上一个 if 条件的存在,走到这个 if 条件的时候,提交的线程数已经大于核心线程数了,且没有空闲线程,所以返回一个 false 标明,表示任务添加到阻塞队列失败。线程池就会认为阻塞队列已经无法继续添加任务到队列中了,根据默认线程池的工作逻辑,线程池就会创建新的线程直到最大线程数。

回忆一下 jdk 默认线程池的实现,如果阻塞队列是无界的,任务会无限的添加到无界的阻塞队列中,线程池就无法利用核心线程数和最大线程数之间的线程数了。

Tomcat 的实现就是为了,线程池即使核心线程数满了以后,且使用无界队列的时候,线程池依然有机会创建新的线程,直到达到线程池的最大线程数。

Tomcat 对线程池的优化并没结束,Tomcat 还重写了线程池的 execute 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void execute(Runnable command, long timeout, TimeUnit unit) {
//提交任务数加一
submittedCount.incrementAndGet();
try {
super.execute(command);
} catch (RejectedExecutionException rx) {
// 被拒绝以后尝试,再次向阻塞队列中提交任务
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
throw new RejectedExecutionException(x);
}
} else {
submittedCount.decrementAndGet();
throw rx;
}
}
}

终于到整篇文章的萌点了,就是提交线程的时候,如果被线程池拒绝了,Tomcat 的线程池,还会厚着脸皮再次尝试,调用 force() 方法”强行”的尝试向阻塞队列中添加任务。

tomcat

在群里和朋友讲完 Tomcat 线程池的实现,帆哥给了一个特别厉害的例子。

总结一下:

Tomcat 线程池的逻辑:

  1. 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。
  2. 如果线程数大于 corePoolSize了,Tomcat 的线程不会直接把线程加入到无界的阻塞队列中,而是去判断,submittedCount(已经提交线程数)是否等于 maximumPoolSize。
  3. 如果等于,表示线程池已经满负荷运行,不能再创建线程了,直接把线程提交到队列,
  4. 如果不等于,则需要判断,是否有空闲线程可以消费。
  5. 如果有空闲线程则加入到阻塞队列中,等待空闲线程消费。
  6. 如果没有空闲线程,尝试创建新的线程。(这一步保证了使用无界队列,仍然可以利用线程的 maximumPoolSize)。
  7. 如果总线程数达到 maximumPoolSize,则继续尝试把线程加入 BlockingQueue 中。
  8. 如果 BlockingQueue 达到上限(假如设置了上限),被默认线程池启动拒绝策略,tomcat 线程池会 catch 住拒绝策略抛出的异常,再次把尝试任务加入中 BlockingQueue 中。
  9. 再次加入失败,启动拒绝策略。

如此努力的 Tomcat 线程池,有点萌啊。

☑️ ☆

从需求第三定律说起--为什么知乎的回答质量下降了

恭喜知乎 F 轮融资成功,今天不谈技术,谈谈别的。

刘看山

从需求第三定律谈起

最近一直在“得到”上学习《薛兆丰的经济学课》,其中一节讲到了需求第三定律。

每当消费者必须支付一笔附加费用的时候,高品质的产品就变得便宜了,这笔附加费用越高,高品质的的产品就就变得越便宜,也叫”好东西运到远方定律“。

换句话说,优质的商品和普通商品价格是有差距的,但是,加上一笔固定的附加费用以后,他们的差距就缩小了,优质的东西就变得便宜了,人们就会倾向于筛选优质的商品进行销售,附加的成本越高,人们越倾向于优质的货品。

你也许就会问了,这个和知乎的回答质量下降有什么关系呢?其实我们换个角度来利用需求第三定律来试着解释一下这个问题。

很久以前,信息的保存,传播的成本及其高昂,刻石头上,写绢帛上。所以自然而然人们就会选择思想价值较高的文本,记录下来。因为石刻,绢帛成本实在太高了,必须有所筛选。

随着社会的发展,使用纸张以后,消息的记录和流通就变得越来越便宜,立著出书的人就变得多了。“需求第三定律”就开始变得不显著了。因为为传递信息,付出的额外费用从立碑,购买绢帛,变成了造纸,降低了太多。于是不那么优秀,不那么经典的信息也有机会进入流通了。

时至今日,随着信息时代的到来,消息的记录的成本变得极其低廉,传递信息的附加费用对比造纸印刷,不知道低到哪里去了,键盘侠和杠精产生的及其低质信息也可以肆无忌惮的产出并流通了。

所以我们总有一种感受,就是老祖宗的智慧,特别厉害。

其实原因只是因为,老祖宗的生产力低下,信息的保存,传播成本高昂,不得不筛选最精华的信息记录下来。

回到知乎

这个时候回到我们的知乎,最早期的知乎,用户采用邀请制,严格筛选的用户才能够回答问题。如此之高的门槛,使得高质量的回答所占比例极高是必然的结果。

随着社区逐渐的开放,用户可以自由注册以后,门槛降低,但是初期形成的”精英气质“,还是要求回答者,需要用较高的成本维护自己的精英属性,才能获得较高的认同。所以社区内容的贡献者会尽量的产出优秀内容,来满足”精英社区“对答题者的人设要求,可以说就是回答需要付出额外的成本。所以这个时候看来,平台的总体回答质量还不错。

直至最近的下沉,2.2 亿用户的涌入。一方面,社区的精英气质,逐渐消散,用户维护自己精英人设,变得不如之前那么迫切,降低了回答者自我要求的门槛。另一方面,平台对于 DAU(每日活跃用户) 的渴望,吸引用户注册知乎,然后主动引导用户回答问题,以降低回答的成本和门槛。这个时候第三需求定律发挥了它的威力。回答的附加成本下降,不需要对内容进行筛选,低质量回答的数量必然增加,从用户的感受上来说,自然觉得总体上知乎的社区的回答质量下降了。

但附加成本上升未必是好事

很多人抱怨,由于回答的成本和门槛降低,造成了知乎的平均水平下降,确实如此。早期知乎的一系列门槛的存在,只有优秀的人才能回答问题,所以早期的知乎社区的平均水平就很高。

但是,知乎的平均水平下降了是不是坏事?不见得坏,这个问题分开来看,从平均水平来看,确实不如从前,优秀信息的浓度下降,造成用户的筛选优质信息成本高,提高了使用成本。这是坏事。

注意,我们一直强调的是平均,是总体。并没有讨论优质答案的绝对值。从另一个角度来看,由于网络外部性的存在,更多的用户使用知乎,就会吸引更多的优秀回答者为平台带来优秀回答,平台上优秀的回答的绝对值会增长。同时为信息的获取者对某一个问题提供更多一种的选择和视角。这个是好事。

站在更高的角度来看,人类的发展历史,就是不断的减少信息存储和流通成本的历史。信息的附加成本就是在不断的下降。

所以面对更多、相对更稀薄的优质信息还是更浓的更少的优质信息,你怎么选择?

之后我还会谈谈我对如何筛选信息的理解,敬请大家期待。

利益相关:知乎员工

☑️ ☆

如何利用 Spring Hibernate 高级特性设计实现一个权限系统

keepout

我们的业务系统使用了一段时间后,用户的角色类型越来越多,这时候不同类型的用户可以使用不同功能,看见不同数据的需求就变得越来越迫切。
如何设计一个可扩展,且易于接入的权限系统.就显得相当重要了。结合之前我实现的的权限系统,今天就来和大家探讨一下我对权限系统的理解。

这篇文章会从权限系统业务设计,技术架构,关键代码几个方面,详细的阐述权限系统的实现。

背景

权限系统是一个系统的基础功能,但是作为创业公司,秉承着快比完美更重要原则,老系统的权限系统都是硬编码在代码或者写在到配置文件中的。随着业务的发展,如此简陋的权限系统就显得捉襟见肘了。开发一套新的,强大的权限系统就提上了日程。

这里有两个重点:

  • 业务系统已经运行一段时间积累了可观的代码和接口了,新的权限系统权在设计之初的一个要求就是,尽量减少权限系统对原有业务代码的入侵。(为了达成这个目的,我们会大量的使用 spring、springboot、jpa 以及 hibernate 的高级特性)
  • 系统要易于使用,可以由业务方自行进行配置。

需求

权限系统需要支持功能权限和数据权限。

功能权限

所谓功能权限,就是指,拥有某种角色的用户,只能看到某些功能,并使用它。实现功能权限就简化为:

  • 页面元素如何根据不同用户进行渲染
  • API 的访问权限如何根据不同的用户进行管理

数据权限

所谓数据权限是指,数据是隔离的,用户能看到的数据,是经过控制的,用户只能看到拥有权限的某些数据。

比如,某个地区的 leader 可以查看并操作这个地区的所有员工负责的订单数据,但是员工就只能操作和查看自己负责的的订单数据。

对于数据权限,我们需要考虑的问题就抽象为,

  1. 数据的归属问题:数据产生以后归属于谁?
  2. 确定了数据的归属,根据某些配置,就能确定谁可以查看归属于谁的数据。

业务设计

经过上面的分析,我们可以抽象出以下几个实体:

功能权限

  • 用户
  • 角色
  • 功能
  • 页面元素
  • API 信息

我们知道,对于一某个功能来说,它是由若干的前端元素和后端 API 组成的。

比如“合同审核” 这个功能就包括了,“查看按钮”、“审核按钮” 等前端元素。

涉及的 api 就可能包含了 contractgetpatch 两个 Restful 风格的接口。

抽象出来就是:在权限系统中若干前端元素和后端 API 组成了一个功能。

具体的关系,就是如下图:

permission-er

数据权限

具体每个系统的数据权限的实现有所不同,我们这里实现的数据权限是依赖于公司的组织架构实现的,所有涉及到的实体如下:

  • 用户
  • 数据权限关系
  • 部门
  • 数据拥有者
  • 具体数据(订单,合同)

这里需要说明一下,要接入数据权限,首先需要梳理数据的归属问题,数据归属于谁?或者准确的来说,数据属于哪个数据拥有者,这个数据拥有者属于哪个部门。通过这个关联关系我们就可以明确,这个数据属于哪个部门。

对于数据的使用用户,来说,就需要查询,这个用户可以查看某个模块的某个部门的数据。

这里需要说明的是,不同的系统的数据权限需要具体分析,我们系统的数据权限是建立在公司的组织架构上的。

本质就是:

  • 数据归属于某个数据拥有者
  • 用户能够看到该数据拥有者的数据

具体的关系图如下:

date-permission

注意,实际上用户和数据拥有者都是同一个实体 User 表示,只是为了表述方便进行了区分。

实现的技术难点

Mysql 中树的储存

可以看出来,我们的功能和组织架构都是典型的树形结构。

我们最常见的场景如下

  • 查询某个功能,及其所有子功能。
  • 查询某个部门,及其所有子部门的所属员工。

抽象以后就是查询树的某个节点,和他的所有子节点。

为了便于查询,我们可以增加两个冗余字段,一个是 parent_id ,还有一个是 path

  • parent_id 很好理解,就是父节点的 id;
  • path 指的是,这个节点,路径上的 id 的。使用’.’进行分隔的一个字符串。 比如
1
2
3
4
5
6
7
    A
/ \
B C
/\ /\
D E F G
/\
H I

对于 D 的 path 就是 (A.id).(B.id). 这要的好处的就是通过 sqllike 的语句就能快速的查询出某个节点的子节点。

比如要获取节点 C 的所有子节点:

1
Select * from user where path like (A.id).(C.id).%

一次查询可以获取所有子节点,是一种查询友好的设计。如果需要我们可以为 path 字段增加索引,根据索引的左值定律,这样的 like 查询是可以走索引的。提升查询效率。

快速的自动的获取 API 信息

我们知道 Spirng mvc 在启动的时候会扫描被 @RequestMapping 注解标记的方法,并把数据放在 RequestMappingHandlerMapping 中。所以我们可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Componet
public class ApiScanSerivce{

@Autoired
private RequestMappingHandlerMapping requestMapping;

@PostConstruct
public void update(){

Map<RequestMappingInfo,HandlerMethed> handlerMethods = requestMapping.getHandlerMethods();
for(Map.Entry RequestMappinInfo,HandlerMethod) entry: handlerMethods.entrySet(){
// 处理 API 上传的相关逻辑
updateApiInfo();
}

}

}

获取项目的所有 http 接口。这样我们就可以遍历处理项目的接口数据。

描述一个 API

1
2
3
4
5
6
7
8
9
10
11
public class ApiInfo{

private Long id;
private String uri; // api 的 uri
private String method; //请求的 method:eg: get、 post、 patch。
private String project; // 这组 api 属于哪一个 web 工程。
private String signature; //方法的签名
private Intger status; // api 状态
private Intger whiteList; // 是否是白名单 api 如果是就不需过滤

}

其中方法的签名生成的算法伪代码:

1
signature = className + "#" + methodName +"(" + parameterTypeList+")"

用户的权限数据

首先我们定义的用户权限数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@ToString
public class UserPermisson{

//用户可以看到的前端元素的列表
private List<Long> pageElementIdList;

//用户可以使用的 API 列表
private List<String> apiSignatureList;

//用户不同模块的数据权限 的 map。map 的 key 是模块名称,value 是这个能够看到数据属于那些用户的列表
private Map<String,List<Long>> dataAccessMap;
}

利用 Spring 特性实现功能权限

对于如何使用 Spring 实现方法拦截,很自然的就像到了使用拦截器来实现。考虑到我们这个权限的组件是一个通用组件,所以就可以写一个抽象类,暴露出getUid(HttpServletRequest requset) 用户获取使用系统的 userId,以及 onPermission(String msg)留给业务方自己实现,没有权限以后的动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public abstract class PermissonAbstractInterceptor extends HandlerInterceptorAdapter{

protected abstarct long getUid(HttpServletRequest requset);

protected abstract onPermession(String str) throws Exception;

@Override
public boolean preHandler(HttpServletRequest request,HttoServletResponse respponse,Object handler) throws Excption{
// 获取用户的 uid
long uid = getUid(request);

// 根据用户 获取用户相关的 权限对象
UserPermisson userPermission = getUserPermissonByUid(uid);

if(inandler instanceof HanderMethod){
//获取请求方的签名
String methodSignerture = getMethodSignerture(handler);

if(!userPermisson.getApiSignatureList().contains(methodSignerture)){

onPermession("该用户没有权限");

}
}

}

}

以上的代码只是提供一个思路。不是真实的代码实现。

所以接入方就只需要继承这个抽象方法,并实现对应的方法,如果你使用的是 Springboot 的,只需要把实现的拦截器注册到拦截器里面就可以使用了:

1
2
3
4
5
6
7
8
9
10
@Configuration
public class MyWebAppConfigurer extends WebMvcConfigurerAdapter {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(permissionInterceptor);
super.addInterceptors(registry);
}

}

利用 Hibrenate 特性实现数据权限

通过上面的代码可以看出来,功能权限的实现,基本做到了没有侵入代码。对于数据权限的实现的原则还是尽量少的减少代码的入侵。

我们默认代码使用 Java 经典的 Controller、Service、Dao 三层架构。 主要使用的技术 Spring Aop、Jpa 的 filter,基本的实现思路如下图:

date permission

基本的思路如下:

  1. 用户登录以后,获取用户的数据权限相关信息。
  2. 把相关信息权限系统放入 ThreadLocal 中。
  3. 在 Dao 层中,从 ThreadLocal 中获取权限相关的权限数据。
  4. 在 filter 中填充权限相关数据。
  5. 从 Hibernate 上下文中取出 Session。
  6. 在 Session 上添加相关 filter。

通过图片我们可以看出,我们基本不需要对 Controller、Service、Dao 进行修改,只需要按需实现对应模块的 filter。

看到这里你可能觉得”嚯~~”,还有这种操作?我们就看看代码是怎么具体实现的吧。

  1. 首先需要在 Entity 上写一个 Filter,假设我们写的是订单模块。
1
2
3
4
5
6
7
8
9
10
11
@Entity
@Table(name = "order")
@Data
@ToString
@FilterDef(name = "orderOwnerFilter", parameters = {@ParamDef name= "ownerIds",type = "long"})
@Filters({@Filter name= "orderOwnerFiler", condition = "ownder in (:ownerIds)"})
public class order{
private Long id;
private Long ownerId;
//其他参数省略
}
  1. 写个注解
1
2
3
4
@Retention(RetentinPolicy.RUNTIME)
@Taget(ElementType.METHOD)
public @interface OrderFilter{
}
  1. 编写一个切面用于处理 Session、datePermission、和 Filter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
@Aspect
public class OrderFilterAdvice{
@PersistenceContext
private EntityManager entityManager;
@Around("annotation(OrderFilter)")
pblict Object doProcess (ProceedingJoinPoint joinPonit) throws ThrowableP{
try{
//从上下文里面获取 owerId,这个 Id 在 web 中就已经存好了
List<Long> ownerIds = getListFromThreadLocal();
//获取查询中的 session
Session session = entityManager.unwrap(Session.class);
// 在 session 中加入 filter
Filter filter = unwrap.enableFilter("orderOwnerFilter");
// filter 中加入数据
filter.setParameterList("ownerIds",ownerIds)
//执行 被拦截的方法
return join.proceed();
}catch(Throwable e){
log.error();
}finally{
// 最后 disable filter
entityManager.unwrap(Session.class).disbaleFilter("orderOwnerFilter");
}
}
}
这个拦截器,拦截被打了 `@OrderFilter` 的方法。

易于接入

为了方便接入项目,我们可以将涉及到的整套代码封装为一个 springboot-starter 这样使用者只需要引入对应的 starter 就能够接入权限系统。

总结

权限系统随着业务的发展,是从可以没有逐渐变成为非常重要的模块。往往需要接入权限系统的时候,系统已经成熟的运行了一段时间了。大量的接口,负责的业务,为权限系统的接入提高了难度。同时权限系统又是看似通用,但是定制的点又不少的系统。

设计套权限系统的初衷就是,不需要大量修改代码,业务方就可方便简单的接入。
具体实现代码的时候,我们充分利用了面向切面的编程思想。同时大量的使用了 SpringHibrenate框架的高级特性,保证的代码的灵活,以及横向扩展的能力。

看完文章如果你发现有疑问,或者更好的实现方法,欢迎留言与我讨论。

☑️ ☆

居然有人能忘记吃饭?写个微信机器人提醒他

居然有人忘记吃饭???

img

为了解决这个问题,我写了一个微信机器人到点就提醒他吃饭。

Github 地址

使用方法

1
git clone https://github.com/diaozxin007/remindEat

修改 config/default.json 里面的 ‘toName’ 为要提醒人的备注名称。

1
2
cd remindEat
npm install

wechaty 使用了无头浏览器,安装的过程中会到 google 下载 chromium。如果遇到下载不成功的错误。可以尝试

1
2
export PUPPETEER_DOWNLOAD_HOST=https://storage.googleapis.com.cnpmjs.org
npm install

编译完成后:

1
node remindEat.js

如果在 ubuntu 上启动报错缺少包,可以参考 puppeteer/docs/troubleshooting.md

到时候对方应该不会忘记吃饭了。

wxbot

实现原理:

这个机器人主要使用两个库:

其实核心的原理,就在 wechaty 登录以后,注册了一个定时任务。这个定时任务,用于在饭点的时候,注册另外一个 schedule ,同时这个 schedule 是为了实现每分钟一次的提示。

当对方按照指定的话术服务短信的时候,我们只需要调用每分钟提醒一次的 schedule cancel() 方法。

希望每一个人都能按时吃饭,谢谢大家。

☑️ ☆

我的2018年总结

winter

2018年结束了,这一年成长是的一年。

目标回顾:

2017年底给自己定了几个目标:

  • 买房,希望新的一年在北京站稳脚跟。(1/1)

  • 晋级,向T6进发。(入职新公司,给了资深 title,1/1)

  • 学习,新的一年着重应该聚焦两个相关点吧,一个是自己的老本行,更加深入的研究分布式系统。还有就是重启AI相关的学习。(确实研究了不少分布式的知识,AI 还是没有开始 2/1)

  • 博客,每个月应该会有两篇文章。保证一年24篇文章。(博客一共更新18篇文章 18/24)

  • 读书,每个月应该完成一本书(4/12)。

总体来说对于目标的完成程度给自己今年目标的完成打个 70 分吧。主要的欠缺还是读书的本数和 AI 的学习。

工作

离开了老东家,入职了知乎。从原来的招聘业务,切换到了商业变现业务。对业务的积累归零,重新开始,对我来说也是不小的挑战。从 CPM,CPC 开始学习广告知识。了解了广告,创意,素材,排期,订单,合同,刊例,库存等等的概念。

说到工作,就不得不谈谈。年底的互联网寒冬,公司迎来了“优化”。同事,早上还在愉快的写代码,中午谈话,下午回收账号,连交接的邮件都来不及发出来,一天之内再也和公司没有任何关系,真是无情而残酷。震撼与庆幸之余,不得不拷问自己,如何能够时刻保持自己的竞争力?我想只能是做一个持续学习者,终生学习者。保有随时具有失去工作的危机感,才能在这种每天都在快速变化的环境中存活。

学习

今年,持续的输出了很多文章,虽然没有达到年前的目标 24 篇文章但是,输出的 18 篇,文章质量我还是比较满意的。

  • 深入的从源码级别了解了 Redis 的设计和实现,阅读了《Redis设计与实现》,并结合 Reids 的源码,了解了 Redis 的 底层数据结构,了解了 Redis 是如何使用合理的数据结构,平衡时间复杂度和空间复杂度。同时,还学习了 Redis 如何使用 Reactor 模型,基于 epoll 实现了 NIO ,提高 IO 的利用率。这一系列关于 Redis 的学习,从数据结构和 IO 两方面提升了自己的水平。

  • 通过一年学习总结,摸索了一套如何有效阅读源码的思路:借助资料(图书,博客)-> 源码走读思考 -> debug 调试 -> 基于思想简化细节,造轮子。基于这一套方法论,学习了 Spring,Hystrix(部分),dubbo(部分) 的源码,产出了“徒手撸框架”系列文章。

  • 其实下半年还花时间,进行了一些方法论的学习。关于方法论是否有效会在下文进行阐述。

生活

今年生活上最大的事情就是在北京买了房子,选房时候的纠结和艰险不表,终于可以有自己的家了。至于买车?啥时候摇上号再说吧。生活进入正轨之后,更多的还是平淡,日常和琐碎。

通过年底的装修,突然发现,现金流的重要性。月光肯定是不行的,手上有现金,才能面对大额的支出。

装修是一项及其繁琐和持久的工程,需要考虑的问题方方面面,所以尝试把公司推进项目的方法论,引入到装修中,按照工作中推进项目的流程要推进装修这件事情。项目文档,还真有不错的体验。其实还是认识到了方法论的重要性,按照一套既有成熟的标准来推进某些事情的时候,虽然不能保证做的都正确,但是还是可以做到心安理得,从容不迫吧。

至于那只暹罗猫,只是又长胖了,又变黑了而已。还是那么可爱。

cat

感谢家人父母对我的支持,还有老婆对我加班的忍耐。

旅游

2018 年国庆,请了五天假,开开心心去了一趟夏威夷。开上了自己心心念念的敞篷野马,浮潜遇上了可爱的野生海豚,开车穿越云层在全世界最适合观星的山顶看到了银河,去活火山国家公园,但是没有看见岩浆。阳光,沙滩,大海,美不胜收。

有机会想带上爸妈,再去一次。

Mustang

还去了一趟成都,虽然只是匆匆一个周末,但也吃到了“串串”,也算了一桩心愿。

chuanchuan

投资

2017年小试牛刀的成功,有了一种天选之人的蜜汁自信,当然,2018 最终亏钱了。不过教训不少,投资这种反人性的活动,只有真正亏钱了,才会领教到市场的无情,才会去敬畏他。2019年要做的就是,努力工作保证现金流持续流入、强制储蓄保证应急资金的充足、最后用积极的心态面对市场。

思考和总结

2018 对于我来说,今年的主题是成长。或者对于某些事情有了新的思考。或者,对于已经有的思维有着新的认识和更新。

友好的和自己相处

我们生活在一个贩卖焦虑的时代,如何友好的和自己相处,不被焦虑困扰,是今年思考最多的一个问题。今年下半年的自己,一直处在一个焦虑的状态。当一件事情处于自己无法掌控情况下的时候,就会处于一种相当焦虑的状态。总是担心最坏的结果发生在自己身上。如何与自己友好的相处?接受事情的不完美,接受不确定的世界,让自己相信事情总会有解决的办法,勇敢面对自己,勇敢面对这个世界。2019年重要的一项目标,就是如何的自恰,如何友好的和自己相处。

方法论的学习

一直以来都不太看得上方法论,觉得方法论是笨的人才需要学习的,方法论是按部就班,不懂变通的代名词。今年对这个问题的理解有了根本的转变,实际上方法论就是前人的经验总结,虽然看上去比较呆板,但是他确实有效。实际上按照一定的、通用的方法论推进某个事情的时候,至少保证事情的结果,达到预期的60%。剩下的就需要自己对于该事情的经验和积累了。所以现在想来,对于普通人来说:

通用方法论 + 行业经验 = (80% ~ 90%) 预期效果

如果要达到 100 % 那就需要拼上天赋了。所以新的一年,我还会着重训练自己的阅读,写作的方法论。提升自己的通用能力,在寒冬中为自己储备更多的竞争力。

复杂 VS 简单

解决复杂问题的其中一种思路就是,把复杂的问题,通过抽象以后简单看待,用最简单的规律去总结复杂的事情。事情处理完以后,及时复盘,形成沉淀,记录下来,变成某件事情的方法论。

但是面对简单问题的时候,总需要用多个角度,充分的思考,得出不一样的看法,保证对这个简单事情,全面的认识。不遗漏任何一个可能出现问题的点。

无限的边界 VS 确定的边界

对自己的要求不要设置边界,不要对知识自我设立边界。如今的社会,是一个分工高度明确的社会。在工作中需要的技能越来越单一。所谓“边界的无限”实际就是时刻需要突破舒适区,去尝试了解不属于自己负责的系统。

  • 了解上下游运行逻辑:

    这里所谓的上下游,需要从两个角度去理解,一个角度是实际参与系统中,数据流向的上下游。比如,作为广告的投放后端,需要了解广告投放引擎,算法,数据的基本原理。第二,作为技术开发的角色,需要去了解产品,测试,运营运行的基本逻辑。只有了解了上下游的运行逻辑,理解你的同事手中的工作的运行逻辑。才做到,合理响应上游提出的要求、和合理的向下游提出要求

  • 了解整个系统运作的逻辑:

    就是要求自己从整个系统的角度着眼,实现自己手上的系统。在实际开发中我们经常遇到一个问题,就是如果整个系统灵活多变,意味的大量的抽象和更多的开发成本,后期可维护性增加,修改起来比较迅速。如果一个系统比较死板,那开发的成本就会大量减少,但是扩展起来就是灾难。所以从整个系统运行的逻辑的高度去看这个问题,平衡灵活和成本,才能保证开发效率和后期可变更的一个平衡。

对自己的要求是不设边界,但是与人合作的时候,却需要与对方明确事情的边界,尤其在项目开始前,就明确边界。在明确的边界内做到最好,这个才是保证与人合作能够顺利进行的基石。

知识付费

不知道从什么时候开始,所谓知识付费这个事情就火了,作为一个新知青年,2018年的的确为知识付出了不少费,但是任然处于买的多,学的少的社会主义初级阶段。反思以后发现,优秀的知识付费产品,或者说干货为主的知识付费产品,并不能减少学习需要投入的精力成本。觉得付费的,经过编排的知识,学起来就能容易一点,并不是一个正确的理解。或者保守一点说,付费的知识产品,在减少精力成本上,贡献有限,只是减少资料的收集和整理这个过程。所以:

知识付费 不等于 买了就会
知识付费 不等于 简单好学
知识付费 不等于 都能学会

所以今年知识付费,给我带来的困扰就是不聚焦,摊子铺的大但是效果并不好。学习还是只能脚踏实地,付费的知识,也只是一个学习路上的拐杖,学习之路上真正走路的还是你自己。

对 feed 流的警惕

feed:

vt. 喂养;供给;放牧;抚养(家庭等);靠…为生

可以说这个 feed 这个单词相当形象和传神。信息被喂到你面前,而不是你去搜索,寻觅获得。依赖了 feed 限流,就失去了对信息选择的权利。

2018年,是头条系最成功的一年,基于算法分发信息这个模式全面统治互联网的一年。下拉刷新,上滑加载更多,这两个简单的动作完全就是时间的黑洞。算法一定会根据你的点击,阅读时长,阅读的字数,不断的推荐你感兴趣的信息,不断的把你喜欢的信息喂给你。这个时候就形成了一个恐怖的“信息茧房”。wiki 的定义:

在信息传播中,因公众自身的信息需求并非全方位的,公众只注意自己选择的东西和使自己愉悦的通讯领域,久而久之,会将自身桎梏于像蚕茧一般的“茧房”中。

在“茧房”中自娱自乐。最终被束缚的是自己的思想。所以新的一年我依然会对 feed 流保持警惕。尽可能使用 “搜索” 而不是 “推荐”。

2019年目标

高高立起的 flag:

  • 写作,保持现在写作的节奏。新的一年需要更新 20 篇文章。
  • 读书,去年给自己的要求过于高了,2019年妥协一些 8 本书。
  • 学习,技术上,继续学习开源组件源码。业务上,全面了解商业变现业务。
  • 完成装修,入住新家。
  • 友好的和自己相处。

总结

2018 主题颜色,是暗色的,经历了严酷的互联网寒冬,虽然活下来了,但是更不能放松对自己的要求。比起2017年的奋勇前进,2018年更多的是稍微放慢脚步,回头看看,仔细想想。

展望新的一年,又一次充满了希望。

hope

☑️ ☆

从 LongAdder 中窥见并发组件的设计思路

IMG

最近在看阿里的 Sentinel 的源码的时候。发现使用了一个类 LongAdder 来在并发环境中计数。这个时候就提出了疑问,JDK 中已经有 AtomicLong 了,为啥还要使用 LongAdder ? AtomicLong 已经是基于 CAS 的无锁结构,已经有很好的并发表现了,为啥还要用 LongAdder ?于是赶快找来源码一探究竟。

AtomicLong 的缺陷

大家可以阅读我之前写的 JAVA 中的 CAS 详细了解 AtomicLong 的实现原理。需要注意的一点是,AtomicLong 的 Add() 是依赖自旋不断的 CAS 去累加一个 Long 值。如果在竞争激烈的情况下,CAS 操作不断的失败,就会有大量的线程不断的自旋尝试 CAS 会造成 CPU 的极大的消耗。

LongAdder 解决方案

通过阅读 LongAdder 的 Javadoc 我们了解到:

This class is usually preferable to {@link AtomicLong} when multiple threads update a common sum that is used for purposes such as collecting statistics, not for fine-grained synchronization control. Under low update contention, the two classes have similar characteristics. But under high contention, expected throughput of this class is significantly higher, at the expense of higher space consumption.

大概意思就是,LongAdder 功能类似 AtomicLong ,在低并发情况下二者表现差不多,在高并发情况下 LongAdder 的表现就会好很多。

LongAdder 到底用了什么黑科技能做到高性比 AtomicLong 还要好呢呢?对于同样的一个 add() 操作,上文说到 AtomicLong 只对一个 Long 值进行 CAS 操作。而 LongAdder 是针对 Cell 数组的某个 Cell 进行 CAS 操作 ,把线程的名字的 hash 值,作为 Cell 数组的下标,然后对 Cell[i] 的 long 进行 CAS 操作。简单粗暴的分散了高并发下的竞争压力。

LongAdder 的实现细节

虽然原理简单粗暴,但是代码写得却相当细致和精巧。

java.util.concurrent.atomic 包下面我们可以看到 LongAdder 的源码。首先看 add() 方法的源码。

1
2
3
4
5
6
7
8
9
10
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
if ((as = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}

看到这个 add() 方法,首先需要了解 Cell 是什么?

Cell 是 java.util.concurrent.atomicStriped64 的一个内部类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@sun.misc.Contended static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}

// unsafe 机制
// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Cell.class;
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}

首先 Cell 被 @sun.misc.Contended 修饰。意思是让Java编译器和JRE运行时来决定如何填充。不理解不要紧,不影响理解。

其实一个 Cell 的本质就是一个 volatile 修饰的 long 值,且这个值能够进行 cas 操作。

回到我们的 add() 方法。

这里涉及四个额外的方法 casBase() , getProbe() , a.cas() , longAccumulate();

我们看名字就知道 casBase() 和 a.cas() 都是对参数的 cas 操作。

getProbe() 的作用,就是根据当前线程 hash 出一个 int 值。

longAccumlate() 的作用比较复杂,之后我们会讲解。

所以这个 add() 操作归纳以后就是:

  1. 如果 cells 数组不为空,对参数进行 casBase 操作,如果 casBase 操作失败。可能是竞争激烈,进入第二步。
  2. 如果 cells 为空,直接进入 longAccumulate();
  3. m = cells 数组长度减一,如果数组长度小于 1,则进入 longAccumulate()
  4. 如果都没有满足以上条件,则对当前线程进行某种 hash 生成一个数组下标,对下标保存的值进行 cas 操作。如果操作失败,则说明竞争依然激烈,则进入 longAccumulate().

可见,操作的核心思想还是基于 cas。但是 cas 失败后,并不是傻乎乎的自旋,而是逐渐升级。升级的 cas 都不管用了则进入 longAccumulate() 这个方法。

下面就开始揭开 longAccumulate 的神秘面纱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
if ((h = getProbe()) == 0) {
ThreadLocalRandom.current(); // force initialization
h = getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
for (;;) {
Cell[] as; Cell a; int n; long v;
//如果操作的cell 为空,double check 新建 cell
if ((as = cells) != null && (n = as.length) > 0) {
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
Cell r = new Cell(x); // Optimistically create
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}

// cas 失败 继续循环
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash

// 如果 cell cas 成功 break
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;

// 如果 cell 的长度已经大于等于 cpu 的数量,扩容意义不大,就不用标记冲突,重试
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
else if (!collide)
collide = true;
// 获取锁,上锁扩容,将冲突标记为否,继续执行
else if (cellsBusy == 0 && casCellsBusy()) {
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 没法获取锁,重散列,尝试其他槽
h = advanceProbe(h);
}

// 获取锁,初始化 cell 数组
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
boolean init = false;
try { // Initialize table
if (cells == as) {
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}

// 表未被初始化,可能正在初始化,回退使用 base。
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
}

longAccumulate 看上去比较复杂。我们慢慢分析。

回忆一下,什么情况会进入到这个 longAccumulate 方法中

  • cell[] 数组为空,
  • cell[i] 数据的某个下标元素为空,
  • casBase 失败,
  • a.cas 失败,
  • cell.length - 1 < 0

在 longAccumulate 中有几个标记位,我们也先理解一下

  • cellsBusy cells 的操作标记位,如果正在修改、新建、操作 cells 数组中的元素会,会将其 cas 为 1,否则为0。
  • wasUncontended 表示 cas 是否失败,如果失败则考虑操作升级。
  • collide 是否冲突,如果冲突,则考虑扩容 cells 的长度。

整个 for(;;) 死循环,都是以 cas 操作成功而告终。否则则会修改上述描述的几个标记位,重新进入循环。

所以整个循环包括如下几种情况:

  1. cells 不为空

    1. 如果 cell[i] 某个下标为空,则 new 一个 cell,并初始化值,然后退出
    2. 如果 cas 失败,继续循环
    3. 如果 cell 不为空,且 cell cas 成功,退出
    4. 如果 cell 的数量,大于等于 cpu 数量或者已经扩容了,继续重试。(扩容没意义)
    5. 设置 collide 为 true。
    6. 获取 cellsBusy 成功就对 cell 进行扩容,获取 cellBusy 失败则重新 hash 再重试。
  2. cells 为空且获取到 cellsBusy ,init cells 数组,然后赋值退出。

  3. cellsBusy 获取失败,则进行 baseCas ,操作成功退出,不成功则重试。

至此 longAccumulate 就分析完了。之所以这个方法那么复杂,我认为有两个原因

  1. 是因为并发环境下要考虑各种操作的原子性,所以对于锁都进行了 double check。
  2. 操作都是逐步升级,以最小的代价实现功能。

最后说说 LongAddr 的 sum() 方法,这个就很简单了。

1
2
3
4
5
6
7
8
9
10
11
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}

就是遍历 cell 数组,累加 value 就行。LongAdder 余下的方法就比较简单,没有什么可以讨论的了。

LongAdder VS AtomicLong

看上去 LongAdder 性能全面超越了 AtomicLong。为什么 jdk 1.8 中还是保留了 AtomicLong 的实现呢?

其实我们可以发现,LongAdder 使用了一个 cell 列表去承接并发的 cas,以提升性能,但是 LongAdder 在统计的时候如果有并发更新,可能导致统计的数据有误差。

如果用于自增 id 的生成,就不适合使用 LongAdder 了。这个时候使用 AtomicLong 就是一个明智的选择。

而在 Sentinel 中 LongAdder 承担的只是统计任务,且允许误差。

总结

LongAdder 使用了一个比较简单的原理,解决了 AtomicLong 类,在极高竞争下的性能问题。但是 LongAdder 的具体实现却非常精巧和细致,分散竞争,逐步升级竞争的解决方案,相当漂亮,值得我们细细品味。

☑️ ☆

徒手撸框架--实现 RPC 远程调用

title

微服务已经是每个互联网开发者必须掌握的一项技术。而 RPC 框架,是构成微服务最重要的组成部分之一。趁最近有时间。又看了看 dubbo 的源码。dubbo 为了做到灵活和解耦,使用了大量的设计模式和 SPI机制,要看懂 dubbo 的代码也不太容易。

按照《徒手撸框架》系列文章的套路,我还是会极简的实现一个 RPC 框架。帮助大家理解 RPC 框架的原理。

广义的来讲一个完整的 RPC 包含了很多组件,包括服务发现,服务治理,远程调用,调用链分析,网关等等。我将会慢慢的实现这些功能,这篇文章主要先讲解的是 RPC 的基石,远程调用 的实现。

相信,读完这篇文章你也一定可以自己实现一个可以提供 RPC 调用的框架。

1. RPC 的调用过程

通过下图我们来了解一下 RPC 的调用过程,从宏观上来看看到底一次 RPC 调用经过些什么过程。

当一次调用开始:

img

  1. client 会调用本地动态代理 proxy
  2. 这个代理会将调用通过协议转序列化字节流
  3. 通过 netty 网络框架,将字节流发送到服务端
  4. 服务端在受到这个字节流后,会根据协议,反序列化为原始的调用,利用反射原理调用服务方提供的方法
  5. 如果请求有返回值,又需要把结果根据协议序列化后,再通过 netty 返回给调用方

2. 框架概览和技术选型

看一看框架的组件:

ig

clinet就是调用方。servive是服务的提供者。protocol包定义了通信协议。common包含了通用的一些逻辑组件。

技术选型项目使用 maven 作为包管理工具,json 作为序列化协议,使用spring boot管理对象的生命周期,netty 作为 nio 的网路组件。所以要阅读这篇文章,你需要对spring bootnetty有基本的了解。

下面就看看每个组件的具体实现:

3. protocol

其实作为 RPC 的协议,只需要考虑一个问题,就是怎么把一次本地方法的调用,变成能够被网络传输的字节流。

我们需要定义方法的调用和返回两个对象实体:

请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class RpcRequest {
// 调用编号
private String requestId;
// 类名
private String className;
// 方法名
private String methodName;
// 请求参数的数据类型
private Class<?>[] parameterTypes;
// 请求的参数
private Object[] parameters;
}

响应:

1
2
3
4
5
6
7
8
9
10
@Data
public class RpcResponse {
// 调用编号
private String requestId;
// 抛出的异常
private Throwable throwable;
// 返回结果
private Object result;

}

确定了需要序列化的对象实体,就要确定序列化的协议,实现两个方法,序列化和反序列化。

1
2
3
4
public interface Serialization {
<T> byte[] serialize(T obj);
<T> T deSerialize(byte[] data,Class<T> clz);
}

可选用的序列化的协议很多,比如:

  • jdk 的序列化方法。(不推荐,不利于之后的跨语言调用)
  • json 可读性强,但是序列化速度慢,体积大。
  • protobuf,kyro,Hessian 等都是优秀的序列化框架,也可按需选择。

为了简单和便于调试,我们就选择 json 作为序列化协议,使用jackson作为 json 解析框架。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @author Zhengxin
*/
public class JsonSerialization implements Serialization {

private ObjectMapper objectMapper;

public JsonSerialization(){
this.objectMapper = new ObjectMapper();
}


@Override
public <T> byte[] serialize(T obj) {
try {
return objectMapper.writeValueAsBytes(obj);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}

@Override
public <T> T deSerialize(byte[] data, Class<T> clz) {
try {
return objectMapper.readValue(data,clz);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}

因为 netty 支持自定义 coder 。所以只需要实现 ByteToMessageDecoderMessageToByteEncoder 两个接口。就解决了序列化的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class RpcDecoder extends ByteToMessageDecoder {

private Class<?> clz;
private Serialization serialization;

public RpcDecoder(Class<?> clz,Serialization serialization){
this.clz = clz;
this.serialization = serialization;
}

@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
if(in.readableBytes() < 4){
return;
}

in.markReaderIndex();
int dataLength = in.readInt();
if (in.readableBytes() < dataLength) {
in.resetReaderIndex();
return;
}
byte[] data = new byte[dataLength];
in.readBytes(data);

Object obj = serialization.deSerialize(data, clz);
out.add(obj);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RpcEncoder extends MessageToByteEncoder {

private Class<?> clz;
private Serialization serialization;

public RpcEncoder(Class<?> clz, Serialization serialization){
this.clz = clz;
this.serialization = serialization;
}

@Override
protected void encode(ChannelHandlerContext ctx, Object msg, ByteBuf out) throws Exception {
if(clz != null){
byte[] bytes = serialization.serialize(msg);
out.writeInt(bytes.length);
out.writeBytes(bytes);
}
}
}

至此,protocol 就实现了,我们就可以把方法的调用和结果的响应转换为一串可以在网络中传输的 byte[] 数组了。

4. server

server 是负责处理客户端请求的组件。在互联网高并发的环境下,使用 Nio 非阻塞的方式可以相对轻松的应付高并发的场景。netty 是一个优秀的 Nio 处理框架。Server 就基于 netty 进行开发。关键代码如下:

  1. netty 是基于 Reacotr 模型的。所以需要初始化两组线程 boss 和 worker 。boss 负责分发请求,worker 负责执行相应的 handler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Bean
public ServerBootstrap serverBootstrap() throws InterruptedException {

ServerBootstrap serverBootstrap = new ServerBootstrap();

serverBootstrap.group(bossGroup(), workerGroup())
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.DEBUG))
.childHandler(serverInitializer);

Map<ChannelOption<?>, Object> tcpChannelOptions = tcpChannelOptions();
Set<ChannelOption<?>> keySet = tcpChannelOptions.keySet();
for (@SuppressWarnings("rawtypes") ChannelOption option : keySet) {
serverBootstrap.option(option, tcpChannelOptions.get(option));
}

return serverBootstrap;
}
  1. netty 的操作是基于 pipeline 的。所以我们需要把在 protocol 实现的几个 coder 注册到 netty 的 pipeline 中。
1
2
3
4
5
6
7
8
9
10
11
12

ChannelPipeline pipeline = ch.pipeline();
// 处理 tcp 请求中粘包的 coder,具体作用可以自行 google
pipeline.addLast(new LengthFieldBasedFrameDecoder(65535,0,4));

// protocol 中实现的 序列化和反序列化 coder
pipeline.addLast(new RpcEncoder(RpcResponse.class,new JsonSerialization()));
pipeline.addLast(new RpcDecoder(RpcRequest.class,new JsonSerialization()));

// 具体处理请求的 handler 下文具体解释
pipeline.addLast(serverHandler);

  1. 实现具体的 ServerHandler 用于处理真正的调用。

ServerHandler 继承 SimpleChannelInboundHandler<RpcRequest>。简单来说这个 InboundHandler 会在数据被接受时或者对于的 Channel 的状态发生变化的时候被调用。当这个 handler 读取数据的时候方法 channelRead0() 会被用,所以我们就重写这个方法就够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

@Override
protected void channelRead0(ChannelHandlerContext ctx, RpcRequest msg) throws Exception {
RpcResponse rpcResponse = new RpcResponse();
rpcResponse.setRequestId(msg.getRequestId());
try{
// 收到请求后开始处理请求
Object handler = handler(msg);
rpcResponse.setResult(handler);
}catch (Throwable throwable){
// 如果抛出异常也将异常存入 response 中
rpcResponse.setThrowable(throwable);
throwable.printStackTrace();
}
// 操作完以后写入 netty 的上下文中。netty 自己处理返回值。
ctx.writeAndFlush(rpcResponse);
}

handler(msg) 实际上使用的是 cglib 的 Fastclass 实现的,其实根本原理,还是反射。学好 java 中的反射真的可以为所欲为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private Object handler(RpcRequest request) throws Throwable {
Class<?> clz = Class.forName(request.getClassName());
Object serviceBean = applicationContext.getBean(clz);

Class<?> serviceClass = serviceBean.getClass();
String methodName = request.getMethodName();

Class<?>[] parameterTypes = request.getParameterTypes();
Object[] parameters = request.getParameters();

// 根本思路还是获取类名和方法名,利用反射实现调用
FastClass fastClass = FastClass.create(serviceClass);
FastMethod fastMethod = fastClass.getMethod(methodName,parameterTypes);

// 实际调用发生的地方
return fastMethod.invoke(serviceBean,parameters);
}

总体上来看,server 的实现不是很困难。核心的知识点是 netty 的 channel 的使用和 cglib 的反射机制。

5. client

future

其实,对于我来说,client 的实现难度,远远大于 server 的实现。netty 是一个异步框架,所有的返回都是基于 Future 和 Callback 的机制。

所以在阅读以下文字前强烈推荐,我之前写的一篇文章 Future 研究。利用经典的 wite 和 notify 机制,实现异步的获取请求结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* @author zhengxin
*/
public class DefaultFuture {
private RpcResponse rpcResponse;
private volatile boolean isSucceed = false;
private final Object object = new Object();
public RpcResponse getResponse(int timeout){
synchronized (object){
while (!isSucceed){
try {
//wait
object.wait(timeout);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return rpcResponse;
}
}

public void setResponse(RpcResponse response){
if(isSucceed){
return;
}
synchronized (object) {
this.rpcResponse = response;
this.isSucceed = true;
//notiy
object.notify();
}
}
}


复用资源

为了能够提升 client 的吞吐量,可提供的思路有以下几种:

  1. 使用对象池:建立多个 client 以后保存在对象池中。但是代码的复杂度和维护 client 的成本会很高。

  2. 尽可能的复用 netty 中的 channel。
    之前你可能注意到,为什么要在 RpcRequest 和 RpcResponse 中增加一个 ID。因为 netty 中的 channel 是会被多个线程使用的。当一个结果异步的返回后,你并不知道是哪个线程返回的。这个时候就可以考虑利用一个 Map,建立一个 ID 和 Future 映射。这样请求的线程只要使用对应的 ID 就能获取,相应的返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* @author Zhengxin
*/
public class ClientHandler extends ChannelDuplexHandler {
// 使用 map 维护 id 和 Future 的映射关系,在多线程环境下需要使用线程安全的容器
private final Map<String, DefaultFuture> futureMap = new ConcurrentHashMap<>();
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
if(msg instanceof RpcRequest){
RpcRequest request = (RpcRequest) msg;
// 写数据的时候,增加映射
futureMap.putIfAbsent(request.getRequestId(),new DefaultFuture());
}
super.write(ctx, msg, promise);
}

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if(msg instanceof RpcResponse){
RpcResponse response = (RpcResponse) msg;
// 获取数据的时候 将结果放入 future 中
DefaultFuture defaultFuture = futureMap.get(response.getRequestId());
defaultFuture.setResponse(response);
}
super.channelRead(ctx, msg);
}

public RpcResponse getRpcResponse(String requestId){
try {
// 从 future 中获取真正的结果。
DefaultFuture defaultFuture = futureMap.get(requestId);
return defaultFuture.getResponse(10);
}finally {
// 完成后从 map 中移除。
futureMap.remove(requestId);
}


}
}

这里没有继承 server 中的 InboundHandler 而使用了 ChannelDuplexHandler。顾名思义就是在写入和读取数据的时候,都会触发相应的方法。写入的时候在 Map 中保存 ID 和 Future。读到数据的时候从 Map 中取出 Future 并将结果放入 Future 中。获取结果的时候需要对应的 ID。

使用 Transporters 对请求进行封装。

1
2
3
4
5
6
7
8
public class Transporters {
public static RpcResponse send(RpcRequest request){
NettyClient nettyClient = new NettyClient("127.0.0.1", 8080);
nettyClient.connect(nettyClient.getInetSocketAddress());
RpcResponse send = nettyClient.send(request);
return send;
}
}

动态代理的实现

动态代理技术最广为人知的应用,应该就是 Spring 的 Aop,面向切面的编程实现,动态的在原有方法Before 或者 After 添加代码。而 RPC 框架中动态代理的作用就是彻底替换原有方法,直接调用远程方法。

代理工厂类:

1
2
3
4
5
6
7
8
9
10
public class ProxyFactory {
@SuppressWarnings("unchecked")
public static <T> T create(Class<T> interfaceClass){
return (T) Proxy.newProxyInstance(
interfaceClass.getClassLoader(),
new Class<?>[]{interfaceClass},
new RpcInvoker<T>(interfaceClass)
);
}
}

当 proxyFactory 生成的类被调用的时候,就会执行 RpcInvoker 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class RpcInvoker<T> implements InvocationHandler {
private Class<T> clz;
public RpcInvoker(Class<T> clz){
this.clz = clz;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
RpcRequest request = new RpcRequest();

String requestId = UUID.randomUUID().toString();

String className = method.getDeclaringClass().getName();
String methodName = method.getName();
Class<?>[] parameterTypes = method.getParameterTypes();

request.setRequestId(requestId);
request.setClassName(className);
request.setMethodName(methodName);
request.setParameterTypes(parameterTypes);
request.setParameters(args);

return Transporters.send(request).getResult();
}
}

看到这个 invoke 方法,主要三个作用,

  1. 生成 RequestId。
  2. 拼装 RpcRequest。
  3. 调用 Transports 发送请求,获取结果。

至此,整个调用链完整了。我们终于完成了一次 RPC 调用。

与 Spring 集成

为了使我们的 client 能够易于使用我们需要考虑,定义一个自定义注解 @RpcInterface 当我们的项目接入 Spring 以后,Spring 扫描到这个注解之后,自动的通过我们的 ProxyFactory 创建代理对象,并存放在 spring 的 applicationContext 中。这样我们就可以通过 @Autowired 注解直接注入使用了。

1
2
3
4
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RpcInterface {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@Slf4j
public class RpcConfig implements ApplicationContextAware,InitializingBean {
private ApplicationContext applicationContext;

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}

@Override
public void afterPropertiesSet() throws Exception {
Reflections reflections = new Reflections("com.xilidou");
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
// 获取 @RpcInterfac 标注的接口
Set<Class<?>> typesAnnotatedWith = reflections.getTypesAnnotatedWith(RpcInterface.class);
for (Class<?> aClass : typesAnnotatedWith) {
// 创建代理对象,并注册到 spring 上下文。
beanFactory.registerSingleton(aClass.getSimpleName(),ProxyFactory.create(aClass));
}
log.info("afterPropertiesSet is {}",typesAnnotatedWith);
}
}

终于我们最简单的 RPC 框架就开发完了。下面可以测试一下。

6. Demo

api

1
2
3
4
5
@RpcInterface
public interface IHelloService {
String sayHi(String name);
}

server

IHelloSerivce 的实现:

1
2
3
4
5
6
7
8
9
10
@Service
@Slf4j
public class TestServiceImpl implements IHelloService {

@Override
public String sayHi(String name) {
log.info(name);
return "Hello " + name;
}
}

启动服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@SpringBootApplication
public class Application {

public static void main(String[] args) throws InterruptedException {
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
TcpService tcpService = context.getBean(TcpService.class);
tcpService.start();
}
}
````

### client

```java
@SpringBootApplication()
public class ClientApplication {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(ClientApplication.class);
IHelloService helloService = context.getBean(IHelloService.class);
System.out.println(helloService.sayHi("doudou"));
}
}

运行以后输出的结果:

Hello doudou

总结

终于我们实现了一个最简版的 RPC 远程调用的模块。只是包含最最基础的远程调用功能。

如果你对这个项目感兴趣,欢迎你与我联系,为这个框架贡献代码。

老规矩 Github 地址:DouPpc

徒手撸框架系列文章地址:

徒手撸框架–实现IoC
徒手撸框架–实现Aop
徒手撸框架–高并发环境下的请求合并

☑️ ☆

我的写作工具链

img

写作是技术输出的重要手段。自己也写了一年多的文章,累计也超过五万多字。今天就想谈谈自己对于写作的一些看法以及写作时使用到的工具。工欲善其事必先利其器。

输入

能做到持续的输出文字,首先需要自己有所积累的同时不断的输入新的内容。要构建自己的知识系统,首先要考虑的是自己知识系统的输入是什么?

我想我的知识输入主要来自于三个方面:

  1. 泛读书籍

当我拿到一本书的时候,我需要的是快速的建立印象。略读了解书的结构,知道书的每个章节大致覆盖的内容,在脑子为这本书建立索引。这个时候的读书笔记,或者读书心得就好像一份落地的索引。为将来需要的时候提供查询的依据。

  1. 研究技术

这个时候的阅读,就比较有目的性了。对于某个领域的专业知识,依托第一步产生的索引。可以在众多资料中快速定位。成体系,成系统的学习,然后整理消化。

  1. 工作中的总结

学习的目的就是使用。在实际使用知识的时候,必然会有各种各样的挑战,这个时候就需要逐步的调试,重复的验证,考验之前的知识体系。每一次解决某个问题,就为我们知识体系打上一个补丁。整项工作完成后需要回顾总结,归档。

总结一下,四个步骤:
第一步,摊大饼,建索引。第二步,抓住某个点,体系学习。第三步,实际应用,发现知识盲区,及时打补丁。第四步,总结归档。

加工

了解了写作的素材的来源,就需要时合适的工具,加工知识。

  1. 对于电子书,我使用 MarginNote 这个软件来阅读。MarginNote 是一款,集文档管理,标注,思维导图,大纲等功能于一体的学习软件。可以说功能相当强大。

img

通这个软件,可以迅速的建立索引,实现把书读薄的目的。 同时 MarginNote 还有更多其他用法,大家可以到他的官网了解。强烈推荐购买。

  1. 笔记本和纸

对于实体书,实体的笔记也是得力的助手。对于手写的笔记比较自由,但是思路还是一样的,迅速记录知识要点,同时可以附上自己的思考。

img

  1. 至于如何有效的阅读一本书,推荐大家阅读 《如何阅读一本书》

写作

写作是检测自己是否真正掌握知识的一种手段。如果能够把一个知识真正的讲明白才是,你才真正的掌握这项知识。

markdown

写作的核心是使用使用 markdown 这种无格式标记语言。

为什么使用 markdown ?

主要是 markdown 是一种 「易读易写」 的纯文本标记语法。语法是由限个(常用不超过20个)符合组成,并没有太大的学习成本。

纯文本的好处就是,不依赖与特定的工具就能编写阅读。与其相反的就是 M$ 的 Office 系列软件。比如 Docx 文件就必须在大型的 Office 条件中才能使用,同时使用 M$ word 的时候,时刻要担心格式和排版的问题。

而对于 markdown 用户来说,在写作的时候,就只需要关注内容。等需要排版的时候,再交由专业的工具来完成。

这里推荐几个我用过,比较好用的 markdown 编辑器:

  • MWeb:是一个在 Mac 环境下的优秀的 markdown 文件编辑器。

MWeb

使用门槛比较低,同时提供很多高级功能。

img

功能也比较强大,支持文档导出 PDF,HTML,同时有比较友好的图片解决方案。

缺点:不支持版本控制工具,不能正确识别 hexo 的 yml 配置文件。不过如果不是程序员用户 MWeb 可以说没有缺点。

img

对于程序员来说 Vs code 简直就是完美的 markdown 解决方案。Vs code 默认就极好的支持了 markdown 语法。

img

优点:

  • 无缝集成 Github
  • 通过安装插件各种模板语言
  • 可以直接操作终端
  • 支持 markdown 预览
  • 无缝集成 hexo,
  • 一站式解决写作,排版,发布,备份等工作。

缺点:

  • 对于非技术人员门槛过高。

输出

完成了写作之后,就需要考虑如何呈现给读者。

图床

  1. 七牛云,目前对备案,域名要求越来越高,如果搞定了备案,好用。
  2. 阿里云 OSS,我的服务器托管在aliyun,顺手买了一个 OSS,目前来看功能强大,价格也实惠,推荐。
  3. 如果以上还是门口比较高,推荐一个神器 iPic。只需要把图片拖拽到他的图标上,一键上传,生成 Markdown 的链接。免费版直接使用微博的图床,支持 https,唯一的缺点就是哪天微博不高兴了取消了api,就不能用了吧。

图片压缩

一般我们直接截图的文件尺寸都很大,影响页面加载速度,可以使用 TinyPng 在不损失图片质量的情况下,尽可能的压缩图片文件大小。

排版

由于我自己使用 hexo 作为静态博客的管理工具,hexo 直接支持 markdown 格式。所以直接使用 hexo 编译 markdown 就能获得很好的效果。

对于掘金简书知乎等直接支持 markdown 内容平台,那就再好不过了。直接把源文件粘贴进去–完美。

对于微信公众号和头条号来说,推荐两个排版工具给大家:

  1. Markdown Here : 是一个浏览器插件。可以解决大部分富文本编辑器的排版问题。功能及其强大,但是对于一个不会写 css 的后端程序员来说,预设的主题较少,自己定制又不会。比较尴尬。

  2. 颜家大少提供的 Md2All 只要把 Markdown 源文件复制到页面中,点击 “复制” 然后粘贴到微信公众编辑页面。直接搞到格式和图片可以说相当靠谱和。大家看到我的微信公众号里面的文章都是用这个工具排版。

备份

直接使用 github 管理文章,文章写完以后 push 到远程分支。同时定期打包 zip 放到坚果云。

后记

这篇文章包含了我这几年写作的心得,还有写作过程中使用的一些工具。希望能对你有所帮助。如有更好的工具,也欢迎你留言告诉我。

☑️ ☆

Java 渲染 docx 文件,并生成 pdf 加水印

img

最近做了一个比较有意思的需求,实现的比较有意思。

需求

  1. 用户上传一个 docx 文件,文档中有占位符若干,识别为文档模板。
  2. 用户在前端可以将标签拖拽到模板上,替代占位符。
  3. 后端根据标签,获取标签内容,生成 pdf 文档并打上水印。

需求实现的难点

  1. 模板文件来自业务方,财务,执行等角色,不可能使用类似 (freemark、velocity、Thymeleaf) 技术常用的模板标记语言。
  2. 文档在上传后需要解析,生成 html 供前端拖拽标签,同时渲染的最终文档是 pdf 。由于生成的 pdf 是正式文件,必须要求格式严格保证。
  3. 前端如果直接使用富文本编辑器,目前开源没有比较满意的实现,同时自主开发富文本需要极高技术含量。所以不考虑富文本编辑器的可能。

技术调研和技术选型(Java 技术栈)

1. 对 docx 文档格式的转换

一顿google以后发现了 StackOverflow 上的这个回答:Converting docx into pdf in java 使用如下的 jar 包:

1
2
3
4
5
6
7
Apache POI 3.15
org.apache.poi.xwpf.converter.core-1.0.6.jar
org.apache.poi.xwpf.converter.pdf-1.0.6.jar
fr.opensagres.xdocreport.itext.extension-2.0.0.jar
itext-2.1.7.jar
ooxml-schemas-1.3.jar

实际上写了一个 Demo 测试以后发现,这套组合以及年久失修,对于复杂的 docx 文档都不能友好支持,代码不严谨,不时有 Nullpoint 的异常抛出,还有莫名的jar包冲突的错误,最致命的一个问题是,不能严格保证格式。复杂的序号会出现各种问题。 pass。

第二种思路,使用 LibreOffice, LibreOffice 提供了一套 api 可以提供给 java 程序调用。
所以使用 jodconverter 来调用 LibreOffice。之前网上搜到的教程早就已经过时。jodconverter 早就推出了 4.2 版本。最靠谱的文档还是直接看官方提供的wiki

2. 渲染模板

第一种思路,将 docx 装换为 html 的纯文本格式,再使用 Java 现有的模板引擎(freemark,velocity)渲染内容。但是 docx 文件装换为 html 还是会有极大的格式损失。 pass。

第二种思路。直接操作 docx 文档在 docx 文档中直接将占位符替换为内容。这样保证了格式不会损失,但是没有现成的模板引擎可以支持 docx 的渲染。需要自己实现。

3. 水印

这个相对比较简单,直接使用 itextpdf 免费版就能解决问题。需要注意中文的问题字体,下文会逐步讲解。

关键技术实现

jodconverter + libreoffice 的使用

jodconverter 已经提供了一套完整的spring-boot解决方案,只需要在 pom.xml中增加如下配置:

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.jodconverter</groupId>
<artifactId>jodconverter-local</artifactId>
<version>4.2.0</version>
</dependenc>
<dependency>
<groupId>org.jodconverter</groupId>
<artifactId>jodconverter-spring-boot-starter</artifactId>
<version>4.2.0</version>
</dependency>

增加配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13

@Configuration
public class ApplicationConfig {
@Autowired
private OfficeManager officeManager;
@Bean
public DocumentConverter documentConverter(){
return LocalConverter.builder()
.officeManager(officeManager)
.build();
}
}

在配置文件 application.properties 中添加:

1
2
3
4
# libreoffice 安装目录
jodconverter.local.office-home=/Applications/LibreOffice.app/Contents
# 开启jodconverter
jodconverter.local.enabled=true

直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Autowired
private DocumentConverter documentConverter;
private byte[] docxToPDF(InputStream inputStream) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
documentConverter
.convert(inputStream)
.as(DefaultDocumentFormatRegistry.DOCX)
.to(byteArrayOutputStream)
.as(DefaultDocumentFormatRegistry.PDF)
.execute();
return byteArrayOutputStream.toByteArray();
} catch (OfficeException | IOException e) {
log.error("convert pdf error");
}
return null;
}

就将 docx 转换为 pdf。注意流需要关闭,防止内存泄漏。

模板的渲染

直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88

@Service
public class OfficeService{

//占位符 {}
private static final Pattern SymbolPattern = Pattern.compile("\\{(.+?)\\}", Pattern.CASE_INSENSITIVE);

public byte[] replaceSymbol(InputStream inputStream,Map<String,String> symbolMap) throws IOException {
XWPFDocument doc = new XWPFDocument(inputStream)
replaceSymbolInPara(doc,symbolMap);
replaceInTable(doc,symbolMap)
try(ByteArrayOutputStream os = new ByteArrayOutputStream()) {
doc.write(os);
return os.toByteArray();
}finally {
inputStream.close();
}
}


private int replaceSymbolInPara(XWPFDocument doc,Map<String,String> symbolMap){
XWPFParagraph para;
Iterator<XWPFParagraph> iterator = doc.getParagraphsIterator();
while(iterator.hasNext()){
para = iterator.next();
replaceInPara(para,symbolMap);
}
}

//替换正文
private void replaceInPara(XWPFParagraph para,Map<String,String> symbolMap) {

List<XWPFRun> runs;
if (symbolMatcher(para.getParagraphText()).find()) {
String text = para.getParagraphText();
Matcher matcher3 = SymbolPattern.matcher(text);
while (matcher3.find()) {
String group = matcher3.group(1);
String symbol = symbolMap.get(group);
if (StringUtils.isBlank(symbol)) {
symbol = " ";
}
text = matcher3.replaceFirst(symbol);
matcher3 = SymbolPattern.matcher(text);
}
runs = para.getRuns();
String fontFamily = runs.get(0).getFontFamily();
int fontSize = runs.get(0).getFontSize();
XWPFRun xwpfRun = para.insertNewRun(0);
xwpfRun.setFontFamily(fontFamily);
xwpfRun.setText(text);
if(fontSize > 0) {
xwpfRun.setFontSize(fontSize);
}
int max = runs.size();
for (int i = 1; i < max; i++) {
para.removeRun(1);
}

}
}

//替换表格
private void replaceInTable(XWPFDocument doc,Map<String,String> symbolMap) {
Iterator<XWPFTable> iterator = doc.getTablesIterator();
XWPFTable table;
List<XWPFTableRow> rows;
List<XWPFTableCell> cells;
List<XWPFParagraph> paras;
while (iterator.hasNext()) {
table = iterator.next();
rows = table.getRows();
for (XWPFTableRow row : rows) {
cells = row.getTableCells();
for (XWPFTableCell cell : cells) {
paras = cell.getParagraphs();
for (XWPFParagraph para : paras) {
replaceInPara(para,symbolMap);
}
}
}
}
}

private Matcher symbolMatcher(String str){
return SymbolPattern.matcher(str);
}
}

这里需要特别注意

  1. 在解析的文档中,para.getParagraphText()指的是获取段落,para.getRuns()应该指的是获取词。但是问题来了,获取到的 runs 的划分是一个谜。目前我也没有找到规律,很有可能我们的占位符被划分到了多个run中,我们并不是简单的针对 run 做正则表达的替换,而要先把所有的 runs 组合起来再进行正则替换。
  2. 在调用para.insertNewRun()的时候 run 并不会保持字体样式和字体大小需要手动获取并设置。
    由于以上两个蜜汁实现,所以就写了一坨蜜汁代码才能保证正则替换和格式正确。

test 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void replaceSymbol() throws IOException {
File file = new File("symbol.docx");
InputStream inputStream = new FileInputStream(file);

File outputFile = new File("out.docx");
FileOutputStream outputStream = new FileOutputStream(outputFile);
Map<String,String> map = new HashMap<>();
map.put("tableName","水果价目表");
map.put("name","苹果");
map.put("price","1.5/斤");
byte[] bytes = office.replaceSymbol(inputStream, map, );

outputStream.write(bytes);
}

replaceSymbol() 方法接受两个参数,一个是输入的docx文件数据流,另一个是占位符和内容的map。

这个方法使用前:

before

使用后:
after

增加水印

pom.xml需要增加:

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/com.itextpdf/itextpdf -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13</version>
</dependency>

增加水印的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public byte[] addWatermark(InputStream inputStream,String watermark) throws IOException, DocumentException {

PdfReader reader = new PdfReader(inputStream);
try(ByteArrayOutputStream os = new ByteArrayOutputStream()) {
PdfStamper stamper = new PdfStamper(reader, os);
int total = reader.getNumberOfPages() + 1;
PdfContentByte content;
// 设置字体
BaseFont baseFont = BaseFont.createFont("simsun.ttf", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
// 循环对每页插入水印
for (int i = 1; i < total; i++) {
// 水印的起始
content = stamper.getUnderContent(i);
// 开始
content.beginText();
// 设置颜色
content.setColorFill(new BaseColor(244, 244, 244));
// 设置字体及字号
content.setFontAndSize(baseFont, 50);
// 设置起始位置
content.setTextMatrix(400, 780);
for (int x = 0; x < 5; x++) {
for (int y = 0; y < 5; y++) {
content.showTextAlignedKerned(Element.ALIGN_CENTER,
watermark,
(100f + x * 350),
(40.0f + y * 150),
30);
}
}
content.endText();
}
stamper.close();
return os.toByteArray();
}finally {
reader.close();
}

}


字体

  1. 使用文档的时候,字体也同样重要,如果你使用了 libreOffice 没有的字体,比如宋体。需要把字体文件 xxx.ttf
1
2
cp xxx.ttc /usr/share/fonts
fc-cache -fv
  1. itextpdf 不支持汉字,需要提供额外的字体:
1
2
3
4
5
//字体路径
String fontPath = "simsun.ttf"
//设置字体
BaseFont baseFont = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

后记

整个需求挺有意思,但是在查询的时候发现中文文档的质量实在堪忧,要么极度过时,要么就是大家互相抄袭。
查询一个项目的技术文档,最好的路径应该如下:

项目官网 Getting Started == github demo > StackOverflow >> CSDN >> 百度知道

欢迎关注我的微信公众号
二维码

☑️ ☆

撸码的福音--变量名生成器的实现

最近换工作以后,结结实实的写了几个月的业务。需求完结以后,就找找自己喜欢的东西写写,换个口味。

撸码最难的就是给变量取名字了。所以就写一个变量生成器吧。

演示如下

实现思路

使用了 Mac 上最出名的效率工具 Alfred。利用 Alfred 调用本地的 python 脚本,利用 http 模块,请求远程的 API 接口。

远程 API 获取查询的字符后,首先使用结巴分词,对查询的句子进行分词,然后调用有道词典的 API 翻译,拼接以后返回。

最终,一个回车就能把结果输入到我们的 IDE 里面减少很多操作,妈妈再也不会担心我取不出变量名啦。

API 的实现

既然说换个口味,那 API 我肯定不会使用 ‘Spring mvc’ 啦。

主要采用的是 ‘vertx’ 这个基于’netty’ 的全异步的 java 库。有兴趣的同学可以参考 http://vartx.io

使用 Spring boot 管理对象的生命周期。

使用 “结巴分词” 对查询的语句进行分词。

使用 guava cache 来对查询结果进行缓存。为啥要缓存?主要是有道的翻译API是收费的,查完把结果缓存起来能节约一点算一点。

至于为什么使用本地缓存而不是 Redis?因为阿里云的 Redis 一个月要25块钱啊。自己搭一个?我的vps 一共只有 1G 内存啊。

说到底,架构设计需要考虑实际情况,一味上高大上的技术也不可取。适合的才是最好的。

vertx-web

写过 netty 的同学就知道,netty 的业务逻辑是写在一个个的 handler中的。

同样 vertx 也类似于 netty 也是使用 handler 来处理请求。

vertx 通过 Router 这个类,将请求路由到不同的 Handler 中。所以我们直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class StaticServer extends AbstractVerticle {

@Autowired
private VariableHandler variableHandler;

@Override
public void start() throws Exception {
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create());
router.post("/api/hump").handler(routingContext ->variableHandler.get(routingContext));
vertx.createHttpServer().requestHandler(router::accept).listen(8080);
}
}

我们把 VariableHandler 绑定到了 ’/api/hump‘ 这个 uri 的 post 方法上了。服务器启动以后会监听 ’8080‘ 端口。 vertx-web的运行是不需要类似 tomcat 这样的容器的。

RestTemplate

我们一般是用 Httpclient 在代码中调用 http 接口。但是我觉得 HTTPClient 封装的不是很好。我们可以直接使用 Spring boot web 提供的 RestTemplate (真香)。直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private ApiResponse requestYoudao(String param){
long timeMillis = System.currentTimeMillis();
String salt = String.valueOf(timeMillis);
String sign = Md5Utils.md5(appKey + param + salt + secretKey);
MultiValueMap<String,String> bodyMap = new LinkedMultiValueMap<>();
bodyMap.add("q",param);
bodyMap.add("from","auto");
bodyMap.add("to","auto");
bodyMap.add("appKey",appKey);
bodyMap.add("salt",salt);
bodyMap.add("sign",sign);
MultiValueMap<String,String> headersMap = new LinkedMultiValueMap<>();
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(bodyMap, headersMap);
return restTemplate.postForObject(url, requestEntity,ApiResponse.class);
}

Guava

Guava 是 google 提供的一个java 基础库类,如果会使用 Guava 的话,会成倍的提升你的开发效率。在本项目中主要使用 Guava 提供的本地缓存和字符串操作:

Guava cache 的使用很简单直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
@Autowired
private Cache<String,ApiResponse> cache;

private ApiResponse cachedResponse(String param){
try {

return cache.get(param, () -> requestYoudao(param));
}catch (Exception e){
log.error("error",e);
}
return null;
}

Guava 对提供了很多给力的字符串的操作。尤其是对字符串下划线,大小写,驼峰形式,提供的强有力的支持。这样使得我们的 API 提供各种风格的变量形式。我们直接看代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

switch (status){
case Constants.LOWER_CAMEL:
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL,underline);
case Constants.LOWER_HYPHEN:
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN,underline);
case Constants.LOWER_UNDERSCORE:
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_UNDERSCORE,underline);
case Constants.UPPER_CAMEL:
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL,underline);
case Constants.UPPER_UNDERSCORE:
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.UPPER_UNDERSCORE,underline);
default:
return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL,underline);
}

以上就是 API 接口的实现。

python 脚本

本地的python 脚本就极其简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# -*- coding:utf-8 -*-
import httplib,urllib,json

url = 'xilidou.com'


def query(q,status=0):
response = get(q,status)
dates = json.loads(response.read())
items = list()
for date in dates:
item = {}
item['title'] = date.encode('utf-8')
item['arg'] = date.encode('utf-8')
item['subtitle'] = '回车复制'
item['icon'] = getIcon()
items.append(item)
jsonBean = {}
jsonBean['items'] = items
json_str = json.dumps(jsonBean)
if json_str:
print json_str
return str


def get(q,status=0):
parameters= dict()
parameters['q'] = q
parameters['status'] = status

parameters = urllib.urlencode(parameters)
headers = {"Content-type": "application/x-www-form-urlencoded"}

conn = httplib.HTTPSConnection(url)
conn.request('POST','/api/hump',parameters,headers)
response = conn.getresponse()
return response

def getIcon():
icon = {}
icon['path'] = 'icon.png'
return icon


if __name__ == '__main__':
query('中文')


干两件事情:

  • 从 Alfred 中获取用户输入的待查询字符串。
  • 调用远程的 API 接口获取返回后格式化然后打印结果。

Alfred

大家可以直接下载 github 代码。在 python 文件夹里面找到 hump.alfredworkflow 双击。就安装到你的 Mac 上了。

前提是你的 Mac 安装了 aflred 且付费成为高级用户。

最后

老规矩 github 地址:https://github.com/diaozxin007/HumpApi

workflow 下载地址:下载

我之前还开发了一个利用 alfred 直接查询有道词典的 workflow。效果如下图:

youdao

下载地址如下:https://www.xilidou.com/2017/10/24/%E6%9C%89%E9%81%93-Alfred-Workflow-%E5%A8%81%E5%8A%9B%E5%8A%A0%E5%BC%BA%E7%89%88/

欢迎关注我的微信公众号:
二维码

☑️ ☆

Raft 协议学习笔记

好久没有更新博客了,最近研究了Raft 协议,谈谈自己对 Raft 协议的理解。希望这篇文章能够帮助大家理解 Raft 论文

Raft 是什么

Raft 是一种分布式系统的一致性算法。

在分布式系统中,我们需要让一组机器作为一个整体向外界提供服务。由于在实际的条件下,我们认为每台机器都是不100%可靠的,随时都可能发生宕机。每台机器之间的通信也不是可靠的,可能发生通信的阻塞、丢失、重试。所以需要某些算法来保证在大多数机器都正常的情况下向外提供可靠的服务。

在 Raft提出之前,Paxos 已经被提出,但是 Paxos 相当复杂。Raft 的目标就是提出一种易于理解的分布式一致性算法。

在了解 Raft 之前需要了解一下什么状态机:

论文指出,Raft 是一种用来管理日志复制的一致性算法。所以我们就要先了解一下。什么是日志复制状态机。我们思考一个问题。如果你要与你的小伙伴分享一个很复杂的操作及计算。一般来说你有两种做法:
第一种:你自己负责计算,经过一段时间的计算,算出结果后,直接把计算结果告诉你的小伙伴。
第二种:你把每一个操作的步骤都告诉你的伙伴,告诉他怎么做,由你的伙伴自己计算出结果。

第二种方式,就是复制状态机的工作原理。复制状态机是通过复制日志来实现的。每一台服务器保存着一份日志,日志中包含一系列的命令,状态机会按顺序执行这些命令。因为每一台计算机的状态机都是确定的,所以每个状态机的状态都是相同的,执行的命令是相同的,最后的执行结果也就是一样的了。

在实际中这种有很多类似的应用比如 mysql 的主从同步就是通过 binlog 进行同步。

在现实生活中,如何有效的组织多人进行协助,最自然的想法就是选举一个领导,交由领导极大的权威,就能极大的提升整个团队工作效率。

下面就谈谈我对 Raft 算法的理解。

基本安全保证

为了保证过程正确性,Raft需要保证以下的性质时刻为真:

  • 选举安全原则:
    同一届任期内至多只能有一个领导人。

  • 领导人只加原则:
    领导人的日志只能增加,不能重写或者删除。

  • 日志匹配原则:
    如果两个日志具有相同的任期和索引,则这两段日志在[0,索引]之间的日志完全相同。

  • 领导人完全原则:
    如果一条日志被提交,那么后续的任意任期的领导人都会有这条日志。

  • 状态机安全原则:
    如果一个服务器已经将给定索引位置的日志条目应用到状态机中,则所有其他服务器不会在该索引位置应用不同的条目。

选取领导者

所以 Raft 算法成立的最重要的前提之一就是选举。

  • Raft 由多个节点组成。
  • 强领导者, 整个 Raft 在同一时间,只有一个领导者,日志有领导者负责分发和同步。
  • 领导选举, 领导是由民主选举产生的,集群中多数节点投票通过就能成为主。

对于在集群中的节点。在任意时间中,都有可能处于以下三种状态之一:

  • 跟随者
  • 候选人
  • 领导人

每个领导人都有一个任期限制。每一届任期的开始阶段,都是选举。如果选举出了领导者就由该领导人负责领导集群。如果没有选举出领导,就会进入下一次选举。直到选举出领导者为止。

角色之间的转换:

role

领导者会周期性的向每台机器发送心跳,确保自己的领导地位。

跟随者在长时间没有收到领导人的心跳,就会发起投票成为候选人,同时任期 + 1,如果获得超过半数的支持,就升任为领导。

如果候选人,在发起投票的时候,发现集群里面有领导人的时候,就会重新成为追随者。

如果候选人,发起投票后,一定时间里面没有收到超过半数的反馈,就会再次发起投票。

如果领导者发现在集群中发现存在下一任期的领导者,就会变为追随者。

日志同步

在选举出领导人以后,就开始处理客户端的日志。

领导者在收到客户端的请求,每个请求包含一个操作的命令。领导者会将命令记录到自己的日志中,并向自己的追随者发起同步的请求,要求自己的追随者复制这个命令。

一旦这个命令被大多数的追随者保存了。领导者就认为这个状态已经处于提交(commited)的状态。同时告知客户端,命令已经被提交。如果这个时候,追随者发生了崩溃或者延时。领导者会一直尝试重试,直到追随者接受命令,并存储到自己的日志中。这个过程一直持续到所有的追随者最终存储了所有的日志条目。

作为 Raft 的节点需要保证如下性质。

  • 如果在不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。
  • 如果在不同日志中的两个条目有着相同的索引和任期号,则它们之间的所有条目都是完全一样的。

有了如上性质的保证。如果在某些情况下,发生了追随者的日志与领导者不同步的情况。(包括的情况,就可能是丢失日志,或者保存了领导者没有的日志,或者两兼有),在 Raft 算法中,领导人通过强制追随者们复制它的日志来处理日志的不一致。这就意味着,在追随者上的冲突日志会被领导者的日志覆盖。

为了使得追随者的日志同自己的一致,领导人需要找到追随者同它的日志一致的地方,然后删除追随者在该位置之后的条目,然后将自己在该位置之后的条目发送给追随者。

安全分析

需要分析在各种情况下,每个角色发生宕机,数据的安全性。

选举限制

Raft 保证自己的日志,永远由领导者向追随者流动。也就是说领导者永远不会删改自己的日志,只能向上增加日志。为了达成这个限制,Raft 使用投票的方式来阻止没有包含全部日志条目的服务器赢得选举。

当一个候选人发起投票的时候,需要告诉大家,自己最新的日志。其他节点在投票的时候,要保证自己的日志不能比候选人的新,否则就拒绝投票。通过这个限制就保证了获取多数票的领导者的日志,至少比大多数人要新。

任期越大,日志越长,越容易成为领导者。

提交之前任期的日志条目

erro

这个在论文中比较难以理解。我看到这一节的时候也是读了好几遍才理解论文的意思。实际上作者表达的意思是图 (d)是正确的,而(e)是错误的。

因为 2 号日志没有commited,但是由于一系列操作,造成了 2 号日志没有提交,但是高任期的leader 却认为 2 号日志被提交了。

与知乎网友讨论发现这个地方还是理解有误,这个图后来作者换了一个更容易理解的图:

error2

应该是说,如果高term的leader,可以操作低任期的 log 的话,会造成 d 和 e 情况错误。且 d 造成了 2 号日志的丢失。所以加上限制以后,就不会出现这种问题了。

为了解决这个问题。Raft 限制,只有当前任期的 leader 可以决定一条日志是否 commited,而不能由高任期的 leader 通过计算某条日志(例子中的 2号日志)超过半数节点持有,就确定日志被commited。

换句话说,就是 Raft 限制每个leader 只能确定自己任期内的日志是否commited。而不能由高任期的 leader确定。

追随者和候选人崩溃

由于 Raft 是一个强领导的,少数服从多数的系统。上面花了了很多的篇幅讨论 leader 奔溃后 Raft 协议是如何保证准确性和安全性的。如果追随者或者候选人挂了,就比较简单了。

如果候选人崩溃,一段时间以后,某个节点会出发超时,重新发起选举,一切就回复正常了。

如果一个追随者崩溃,会被 leader 感知。 leader 会一直重试,直到追随者恢复,并同步所有日志。

系统的扩容

分布式系统一大优势就是能够快速扩容。

Raft 为了保证扩容的安全性,采用了两段two-phase)方法。

在Cold 和 Cnew 之间存在一个中间态, Cold,new 的状态。防止刚开始扩容的时候,新的一组机器数量大于老集群数量,就有可能在新机器中自发投票选举出一个 leader,造成集群中有两个leader形成脑裂。

  • 日志条目被复制给集群中新、老配置的所有服务器。
  • 新、老配置的服务器都能成为领导人。
  • 需要分别在两种配置上获得大多数的支持才能达成一致(针对选举和提交)

需要解决三个问题:

  • 为了不拖慢整个集群相应速度,可以不给新加入的节点投票权。知道日志追齐以后再开放投票权力
  • 如果扩容以后,老的 leader 属于被踢出的节点,老 leader 不会立即下线,而是继续工作,直到 Cnew 被提交。这个时候 leader 自己只负责管理集群而自己不追加日志。
  • 将要被被删除的节点,不会收到领导的心跳,就会不停的认为自己超时,会不断的成为候选人,并不断的发起投票。造成集群的 leader 不断的退位,然后再次产生 leader。造成集群的响应能力降低。为了避免这个问题,当服务器确认当前领导人存在时,服务器会忽略请求投票。每个服务器在开始一次选举之前,至少等待一个最小选举超时时间。

日志的压缩

日志的压缩比较容易理解,随着集群的使用,日志的数量越来越大,就会降低集群的性能,同时占用大量的存储空间。所以需要定期对日志进行压缩。快照是最简单的压缩方法。在快照系统中,整个系统的状态都以快照的形式写入到稳定的持久化存储中,然后到那个时间点之前的日志全部丢弃。

客户端交互

整个 Raft 协议中,客户端只与 leader 进行交互。

客户端与集群通信的时候,首先随便与集群中的任意一个节点交互,询问 leader 是谁。

是客户端对于每一条指令都赋予一个唯一的序列号。然后,状态机跟踪每条指令最新的序列号和相应的响应。如果接收到一条指令,它的序列号已经被执行了,那么就立即返回结果,而不重新执行指令。这样保证交互的命令是幂等的。如果一条命令被重复提交,并不会造成状态机的错误。

对于读取的命令来说,如领导人已经被废黜,而自己不知道。就容易造成客户端读取到脏数据。最新的数据由别的 leader 维护了。为了避免这个问题:

  • 领导人必须拥有最新的数据,这一点是必然的。Raft 天然保证这个特性。
  • 领导人在访问数据之前需要发送一次心跳,保证自己的领导地位。

参考

❌