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

负载均衡 前端 使用 DNS 进行负载均衡。在 DNS 回复中提供多个 A 记录或者 AAAA 记录。 虽然 DNS 看起来简单,但是存在不少问题。 DNS 对客户端行为的约束很弱:记录是随机选择的。 客户端无法识别“最近”的地址 权威服务器不能主动清楚某个解析器的缓存,DNS 记录需要保持一个相对低的失效值(TTL)。 需要在 DNS 负载后面增加一层虚拟 IP 地址,我们常说的 VIP。 使用 VIP 进行负载均衡 虚拟 IP(VIP) 不是绑定在某一个特定的网络接口上的。很多设备共享。外界看 VIP 是一个独立的普通 IP。VIP 是网络负载均衡器。负载均衡器接收网络数据包,转发给 背后的某个服务器。 负载的方案: 对于无状态的服务,理论上说永远优先负载最小的后端服务 对于有转态的服务 某个连接标识取模 一致性哈希 后端 理想情况 某个服务的负载会完全均匀的分发给所有的后端服务。任何时间点,最忙和最不忙的任务消耗相同数量的 CPU。 识别异常任务 限流 客户端限流 某个后端的活跃请求达到一定数量,客户端将后端标记为异常转态,不再发送请求。 正常情况下,后端请求很快完成,限流几乎不会触发 后端过载了,请求响应慢,客户端就会自动避开这个后端 缺点就是,不精确。后端很可能达到限额之前就过载了。反之亦然。 坡脚鸭任务 客户端视角来看,后端任务有以下几个状态 健康 拒绝连接 坡脚鸭状态 后端服务正常,也能服务请求。但是明确要求客户端停止发送请求。 某个请求进入坡脚鸭状态,需要广播给客户端 处于停止过程中的服务不会给正在处理的请求返回错误 可以实现优雅的下线服务 利用划分子集限制连接池大小 子集划分:限制某个客户端任务需要连接的后端数量。 Google 的 RPC 框架对于每个客户端都会维持一个长连接。如果一个集群的规模过大,客户端就要维护很多长连接。 子集选择算法 随机选择 确定性算法 负载均衡策略 简单轮询 造成效果差的因素如下: 子集过小 请求处理的成本不同 物理服务器的差异 无法预支的性能因素 怀邻居(物理服务器上的其他进程) 任务重启 最闲轮询策略 客户端追踪子集中每个后端任务的活跃请求数量,在活跃请求最小的任务中进行轮询。 最危险的坑:如果一个任务不健康,可能 100% 返回错误。取决于错误的类型,错误回复可能延迟非常低。从而给异常任务分配的大量的请求。 需要将错误信息计算为活跃请求,剔除异常任务。 限制: 活跃的请求数量不一定是后端容量的代表 每个客户端的活跃请求不包括其他客户端发往同一个后端的请求 实践中发现,效果很差。 加权轮询 每个客户端为子集中的每个后端任务保持一个“能力”值。请求仍以轮询方式分发,客户端按照能力值权重比例调节。 实践中效果较好。

2022/5/10
articleCard.readMore

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

测试可靠性 预测信息准确的前提: 系统完全没有改变 充分描述整个系统的改变 测试是一个用来证明变更前系统的某些领域相等的手段。 软件测试的类型 传统测试 生产测试 传统测试 单元测试 集成测试 系统测试 冒烟测试 性能测试 回归测试 每个测试都有成本,通常来说单元测试时间成本低 如果要将完整的功能架设起来测试,通常需要几个小时。关注测试成本,是软件提升效率的重要因素。 生产测试 生产测试和一个已经部署在生产环境的业务系统直接交互,而不是运行在封闭的测试环境。有时候称为黑盒测试 配置测试 压力测试 金丝雀测试 一小部分机器先升级,保持一定的孵化期。 将代码置于比较难以预测的用户流量下 需要能够快速的回滚 创造一个构建和测试环境 测试的重点集中在用最小力气得到最大收益的地方 划分优先级 寻找关键函数关键类 寻找提供给其他团队的 API 发布前,通过冒烟测试 寻找到的 bug 变成测试用例 建立良好的测试基础设施 追踪代码变更 每次代码改变就进行构建 精确的构建,只构建修改的地方,并执行修改代码的单侧 使用工具可视化或者量化测试覆盖度 和钱相关的系统需要更多测试 大规模测试 单元测试需要有针对性的覆盖组件中相互依赖的部分 测试大规模使用的工具 针对灾难的测试 灾难恢复工具被精心设计为离线运行 计算出一个可记录状态,等同于服务完全停止的状态 将可记录的状态推送给非灾难验证工具 支持常见的发布安全边界检查 对速度的渴求 有的时候测试的结果会在重复运行下发生改变。所以需要针对某些场景,重复运行一定数量的测试。 发布到生产环境 通常,生产环境的配置文件容易被测试忽略。 集成 使用解释性语言编写配置文件是有风险的。程序的执行时间没有上限,需要加入截止时间检查。 使用成熟的语法(YAML)和大量测试的解析器。 生产环境探针 测试机制是对确定的数据检验系统行为是否可以接受。 监控机制择时在未知数据输入下系统行为是否可以接受。 已知的正确请求应该成功,已知的错误请求应该失败。重放已知请求观察系统是否正常。 (感觉应该是书翻译的问题所谓的探针应该是 mock 服务。mock 服务部署在生产环境 。在确定的入参下,有确定的返回值。调用方可以使用这个探针进行测试) 小结 测试是工程师提高可靠性投入回报比较高的手段。

2022/5/4
articleCard.readMore

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

事后总结:从失败中学习 哲学 保证事故能够被记录下来,理清所有根源问题。确保实施有效的措施是的未来重现的几率和影响得以降低,甚至避免。 书写事后总结不是一种惩罚,而是整个公司的一次学习机会。 需要书写的标准: 用户可见的宕机或者服务质量下降到一定标准 任何形式的数据丢失 on-call 工程师需要人工介入 问题解决耗时超过一定限制 监控问题 事后总结“对事不对人”。必须关注如何定位造成这次事件的根本问题。而不是指责某个人或者某个团队的错误或者不恰当。 事后总结系统性,逻辑性的讨论为什么会在事故过程中获得错误的的信息,才能更好的建立预防措施,防止问题再现。 最佳实践:避免指责,提供建设性意见 协作和知识共享 实时协作 开放的评论 邮件通知 包含内容: 关键的灾难数据是否收集保存起来了 本次事故的影响评估是否完整 造成事故的根源问题是否足够深入 文档记录的任务优先级是否合理,是否及时解决了根源问题 事故处理过程是否共享给了相关部门 最佳实践,所有的事后总结都要评审 建立事后总结文化 本月最佳总结 事后总结小组 事后总结阅读俱乐部 命运之轮 可以对已经发生的事故进行演练 最佳实践:公开奖励做正确事的人 最佳实践:收集关于事后总结有效性的反馈 跟踪故障 聚合 加标签 分析 报告和公告

2022/5/4
articleCard.readMore

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

应急事件响应 测试导致的事故 SRE 故意破坏系统,利用这些测试发现系统的薄弱地方。 在某次测试中发现了额外的系统依赖。 响应 终止测试 用以前 测试过的方法 回滚了数据 找到开发者修复了相关问题 制定了周期性测试机制来保证问题不重现 事后总结 好的方面: 事先沟通,有足够信息推测是测试造成的问题。 快速恢复了系统。 遗留一个代办,彻底修复问题。制定了周期性的测试流程。 不好的方面: 虽然评估了,但是还是发生了问题 没有正确遵守响应流程 没有测试“回滚机制”,发生问题后回滚机制失效。 变更导致的事故 某个周五,某个配置文件推送到所有的服务器。触发了 bug。 响应 各个系统开始报警 on-call 工程师前往灾难安全屋。(有google 生产环境专线) 5 分钟以后发布这个配置的工程师发现问题,回滚发布 某些服务由于这次发布,触发了别的 bug 一小时后才回复 事后总结 好的方面: 监控系统及时报告问题 问题被检测后,应急流程处理得当。SRE 要保持一些可靠的,低成本的访问系统 Google 还有命令行工具和其他访问方式确保能够在其他条件无法访问的时候进行更新和变更回滚。且要频繁测试,让工程师熟悉他们。 限速机制,限制了错误的扩散。抑制了崩溃的速度。 从中学到的: 变更经过了完整的部署测试没有触发 bug,评估并不危险,但是在全球部署的时候触发了 bug 不管风险看起来有多小,都需要严格测试 监控系统在灾难中,发出了很多报警,干扰了 on-call 工程师的工作 流程导致的严重事故 常规自动化测试,对一个集群发送了两次下线请求,触发 bug将全球所有数据中心的的所有机器加入到了磁盘销毁的队列 响应 on-call工程师收到报警,将流量导入其他地区 停止了自动化工具 用户导入其他地方,响应时间变长,但是还是可以正常使用 恢复数据。 总结 好的地方 反向代理可以迅速的切换用户流量。 自动化下线虽然一起下线了监控系统,on-call 工程师快速恢复系统。 工程师训练有素,多亏了应急事故管理系统和平时的训练。 从中学到的: 事故的根源在于自动化系统对发出的指令缺乏合适的合理性校验。 所有的问题都有解决方案 系统不但一定会出问题,而且会以没有人能够想到的方式出现问题。但是所有的问题都有对应的解决方案。如果想不到解决问题的方法,那就再更大的范围里面寻求帮助。很多时候触发事故的人对事故最了解。 一旦紧急事故过去之后,一定要留出时间书写事后报告。 向过去学习,而不是重复它 为事故保留记录 提出那些大的,甚至不可能的问题:加入… 鼓励主动测 小结 遇到事故,不要惊慌失措,必要时引入其他人帮助,事后需要记录。把系统改善为能够更好的处理同类故障。 紧急事故管理 无流程管理事故的剖析 过于关注技术问题 沟通不畅 不请自来 事故管理的要素 嵌套式责任分离 事故处理中,让每个人清楚自己的职责。如果一个人处理的事务过多,就应该申请更多人力资源。把一部分任务交给别人。 事故中的角色: 事故总控 负责组建事故处理团队,负责协调工作 事务处理团队 具体处理事故的团队,唯一能够对系统进行修改的团队 发言人 向事务处理团队和关心事故的人发送周期性的通知。维护事故文档。 规划负责人 为团队提供支持。如填写事故报告,定晚餐,安排交接。 控制中心 很多时候可以设立一个”作战室“。 IRC 处理事故很有帮助。 IRC 很可靠,记录下所有沟通记录。 实时的事故状态文档 事故总控人最重要的职责就是维护事故的实时文档。最好可以多人同时编辑。 明确公开的职责交接 事故总控人的职责能够明确,公开的进行交接很重要。交接结果要宣布给正在处理事故的其他人。 什么时候对外宣布事故 是否需要引入第二个团队来帮助处理问题 是否时候正在影响最终客户 集中分析一个小时后,这个问题是不是依然没有得到解决 最佳实践 划分优先级 事前准备 信任 反思 考虑替代方案 练习 换位思考

2022/5/2
articleCard.readMore

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

有效的故障排查手段 理论: 反复采用假设排除手段的过程: 不断提出一个造成系统问题的假设,进而针对这些假设进行测试和排除 常见的陷阱 关注的错误的系统现象,或者错误地理解了系统现象的含义。 不能正确的修改系统的配置信息,输入信息或者系统运行环境。 将问题过早的归结为极为不可能的因素,或者之前曾经发生过的问题 试图解决与当前问题相关的一些问题,却没有认识到只是巧合。 实践 故障报告 故障报告不鼓励直接汇报给具体的某个人,这样会导致压力集中在几个问题汇报人熟悉的团队成员。而不是质保人员。 需要保证每一个故障报告都有调查的历史和解决方案。 定位 大型问题,不要立即开始排查问题,尽快找到问题的根源。 正确的做法是,尽最大可能使系统回复。(同时尽量保存报错的现场供事后调查复盘) 检查 需要检查系统中每个组件的工作状态,以便了解系统是不是在正常工作。 理想情况下监控可以提供相应指标。 日志很重要,了解系统某个时间在干啥。 将日志结构化,可以保存更长时间 多级记录日志很重要,尤其可以动态调整日志级别 在日志系统中支持过滤条件 诊断 简化和缩略 对于大型系统,逐级查询问题过于耗时,尝试使用二分法。 What 、Where 、Why 最后一次变更 变更是引起问题的最大来源 有针对性的诊断 测试和修复 理想的测试应该具有互斥性,一个测试可以推翻一组假设 先测试最可能的问题 某些测试可能带来误导性的结果 执行测试可能会带来副作用 神奇的负面结果 所谓负面结果,就是一项试验中不符合预期的结果 负面结果不应该被忽略 负面结果需要被记录,供后来人查阅。 比如压测不通过的报告 工具和方法可能超越目前的试验,为未来的工作提供帮助 公布负面结果有利于挺升行业的数据驱动风气 公布结果 负面结果并不是失败 负面结果并非没有价值 良好设计的试验是有价值的,而不是有正向结果的试验才有价值 治愈 理想情况下,可能把错误原因减少到了一个。 下一步复现问题。 然后修复问题 如果一旦解决了某个问题,需要将如何定位问题,如何修复问题,如何防止问题再次发生。进行记录作为事后总结记录。 使故障排查更简单 增加系统的可观察性。为每个系统增加白盒监控和结构化日志 利用成熟的,观察性好的组件接口设计系统

2022/4/18
articleCard.readMore

《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 低很多 付费要比免费低 关系到收入的要低 故障的类型 成本 其他服务指标 基础设施容忍度 可用性目标水平 高可用性很贵 要看人下菜碟,合理保障 故障类型 成本 错误预算使用的目的 错误预算的构建: 产品管理层定义一个 SLO,确定服务的预计正常运行时间 通过监控来度量 而知差值就是不可靠预算 如果预算为正就能够进行发布和变更。 好处 创新和可靠性的平衡点。 使用这个控制回路来调节发布的速度,有预算就快速迭代,如果频繁违反 SLO 或者错误预算被耗尽,就需要暂停发布,在测试和开发环节投入更多资源,提升系统可用性。 如果客观的故障发生比如光缆被挖断,影响了 SLO 需要扣减错误预算么?需要的,每个人都有义务保障服务正常运行。 利用错误预算机制,还能够找到定的过高的可用性指标。如果预算耗尽,团队无法发布,就可以考虑降低 SLO 来提升创新速度。 注:SLO 并非越高越好,稳定和创新通常是矛盾的。使用错误预算机制,闭环平衡稳定和创新的关系。 服务质量目标 术语: SLI (indicator) 服务的某一个量化指标。比如 延迟(rt) 错误(error) 吞吐量(qps) 可用性 SLO (Objective) 可用性目标,通常指: 范围下限 <= SLI <= 范围上限 SLA (Agreement) 服务质量协议,指达到或者没有达到某个 SLO 的后果。 SLI 的实践中的应用 关心什么指标 用户可见的系统: 可用性 延迟 吞吐 存储系统: 延迟 可用性 持久性 大数据系统: 吞吐 延迟 时间 所有系统都有关注延迟 收集 汇总 标准化 SLO 在实践中的应用 目标的定义 指出如何被度量 有效的条件 目标的选择 不要仅以目前的状态为基础选择(要用发展的眼光) 保持简单 避免绝对值 SLO 越少越好 不要追求完美 控制手段 监控并度量 SLI 是否需要人工干预 如果需要干预,决定怎么干预 执行具体干预措施 SLO 建立用户预期 留有余量 实际 SLO 不要过高 SLA 的使用 减少琐事 琐事的定义 手动性 重复性 可被自动化的 战术性的(突然出现的,非策略驱动和主动安排的) 没有持久价值的 与服务同步线性增长的(良好的设计至少是有数量级增长的) SRE 工作内容 50% 琐事,50% 工程项目 工程工作 工程工作,是新颖的,本质上需要主观判断的工作。战略性的。有创新性和创造性的。通过设计来解决问题,越通用越好。 琐事的危害 职业停滞 士气低落 造成误解 进展缓慢 开创先例(如果愿意接受琐事,那就会有更多的琐事) 产生摩擦 违反承诺 分布式系统的监控 术语定义 监控 白盒监控 对系统暴露的性能指标进行监控 黑盒监控 通过测试某种外部用户可见的系统进行监控 dashboard 警报 根源问题 某个缺陷被修复,就可以保证这种缺陷不再发生以同样的方式发生。 节点或者机器 推送 为什么要监控 分析长期趋势 跨世纪范围的比较,或者实验组和对照组之间的区别 报警 构建监控 dashboard 临时性问题的回溯分析 监控可以在系统发生故障或者将要发生故障的时候通知我们。 处理报警会占用员工的时间,报警太频繁会造成“狼来了”效应 对监控系统设置合理预期 Google 倾向于使用简单和快速的监控,配合高效的工具进行分析。避免使用“魔方”系统-试图自动学习或者自动检查故障的系统。 监控系统的规则越简单约好。 监控系统信噪比应该很高,发出报警的组件应该简单可靠。 黑盒和白盒监控 白盒监控应该要作为监控的主要手段。 黑盒监控是面向现象的-现在发生的,而非即将发生的。 白盒监控大量依赖对系统内部信息的检测。白盒监控可以检测到即将发生的问题和重试严掩盖问题。白盒系统既可以面向原因也可以面向现象。 4 个黄金指标 延迟(rt) 流量 错误 饱和度 通常是系统中最为受限的某个具体指标的度量。 复杂系统里面,可以配合其他搞层次的负载度量使用。使用一个简介的指标。 长尾 只使用平均值是不足以描述系统的。需要区分平均值的慢,或者长尾值的慢。可以对数据进行分组统计。 简化直到不能再简化 最能反应正式故障的规则越简单越好 不常见的报警就要删除(定时删除没有用到的报警) 没有被报警规则使用的信息,就应该 报警的深层次理论 每当收到报警,需要立即进行某种操作,每天次数有限,过多会有“狼来了”效应 每个紧急报警都应该是可以具体操作的 报警的回复都应该是需要某种智力过程的,如果只需要固定的机械操作,那就不应该是紧急报警 每个紧急报警都应该是正交的,不应该彼此重叠 监控系统的长期维护 系统不断演变,软件经常重构,负载和性能目标也经常变化。所以监控系统的的设计和决策充分考虑长期目标。每一个报警都会占用优化系统的时间。花时间投入监控,换取未来系统的稳定是值得的。 短期和长期的可用性经常冲突。通过一些“暴力”因素,可以使一个摇摇欲坠系统保持一定的高可用性,这种方案不能长久,且依赖个人英雄主义。 短期接受稳定性的降级获得长期的可用性提升。 Google 自动化演进 自动化的价值 一致性 平台性 自动化的系统可以提供一个可以扩展的、广泛适用的。 同时会将错误集中化、意味着修复的缺陷是一劳永逸的。 修复速度更快 行动速度快 节约时间 发布工程 哲学 自服务模型 追求速度 密闭性 构建工具必须确保一致性和可重复性 强调策略和流程 持续构建和部署 构建 分支 所有代码默认提交到主分支上。 构建一个发布分支 发布分支不会并入主分支 代码从主分支 cherry pick 到发布分支 测试 单测 打包 部署 部署 配置管理 一开始就进行发布工程 不要做时候诸葛亮 简单化 软件系统是本质上是动态和不稳定的 系统的稳定性和灵活性 为了灵活性牺牲稳定性是有意义的。 乏味是一种美德 定期删除无用代码 “负代码行”作为指标 臃肿的软件置管术是不可取的 添加代码可能引入新的缺陷 小的代码容易理解,也容易测试,缺陷就越少 最小 API 书写一个明确的,最小的 API 是软件系统简单的必要部分 方法越少,参数越少也容易理解 模块化 发布简单化 软件的简单是可靠性的前提。

2022/4/11
articleCard.readMore

2021 总结

2021 就这么结束了。 家 今年我做爸爸了。 今年九月,迎来了我们家的小朋友。豆嫂从怀孕一路走来。如打怪升级一样。一关一关的过,颇为不容易。 豆嫂孕早期孕吐严重,某天在地铁上没吃早饭,低血糖晕倒在地铁上,还好我在身边。周围的好心人都把自己的零食给了我们。下车后豆嫂把手里的一捧零食吃完以后,才重新坐上地铁上去上班。 小朋友在肚子里总是不安分,总是脐带绕颈。时而一圈,时而两圈。 九月的最后一次产检。调皮的小朋友臀位。没有正常入盆,只能剖腹产。 小朋友出生那天,五点起来给豆嫂煮了小米粥。吃完以后,又睡了一会。八点把豆嫂送到医院准备手术。 产科大夫,麻醉大夫分别告知了风险。我在知情同意书上签了字 豆嫂进了手术室,我在手术室外面焦虑的不行,一圈一圈的走。就在彻底走不动的时候。手术室的门打开了,护士把我叫了过去,轻轻的掀开了蓝色的无菌布。一个粉白粉白的小生命出现在我的眼前。 看了一眼小朋友。小朋友就被推进了新生儿室。我站在门口,隔着毛玻璃看着里面护士忙碌的影子,突然一股暖流充满了全身,眼睛也湿润了,我做爸爸了。 由于疫情,医院全封闭管理,我并没有看到豆嫂。豆嫂说,麻醉过后特别冷,伤口特别疼。 本以为一切结束了。稍微放松一点准备回家。在路上又被叫回了医院。医生上来就交代,血氧不足,哭声不正常,有风险。瞬间天旋地转,脚如灌铅。艰难的挪到了楼梯边,坐在楼梯上,给家里人打电话。 下午,吸了氧的小朋友终于恢复正常。 欢迎这个小生命来到这个世界,希望你能够健康快乐的成长,身体强壮,取小名“壮壮”。 之后,去月子中心。出月子。满一百天去“中国照相馆”拍了纪念照。现在小朋友正躺在自己的小床上沉沉的睡去。呼吸均匀。大概再过两个小时又会饿得哇哇大哭,要喝奶了。 车 今年我在北京有自己的车了。 今年为了照顾孕妇的出行。买了一辆车。还记得提车那天激动的几乎睡不着。坐在家里都忍不住到地库看了又看。现在回想起来自己小时候最爱的的汽车玩具,应该就是一辆 E30 平台的宝马三系。爱车的我终于在 31 岁生日前有了自己的第一辆车,而且是在北京。 前几天,某人把车撞到了路边的墩子,更换前杠,保险没白买。 旅行 今年只去了稻城亚丁。 海拔 4700 米的壮美雪山。无限风光在险峰。吸光了 4 瓶氧气,终于爬到了牛奶海。总体来说一路高反都是值得的。 投资 今年我没有亏钱。 今年的投资居然没有亏钱。没有最热点新能源赛道。跟着长赢计划慢慢布局。一种踏实的感觉。不急不躁,踏踏实实的,多大点事。中丐互怜被锤,但是中证 500 在涨啊。配置分散,降低风险,控制回撤,是我今年的投资的体会。 内心平和 今年我没有去年焦虑。 信息焦虑在 2020 年给我带来了极大的痛苦。而今年想明白了一些事情反而内心平和了很多。 没看到的就是没有,没注意的的就是不重要。主动离开那些生怕错过的信息渠道。万一群里的信息对我有用、万一这个短视频说得我用得上、万一这个人人脉以后我用得上。这些万一其实消耗着我们的精力,但是没有什么意义。事情不重要,就不需要知道。如果事情足够重要,那我一定会知道。 全情的长时间的投入精力在某一件事情,在网络的社会中是很奢侈的事情。当投入时间到某样事情的时候,我们感知到的机会成本就在上升,所谓机会成本,就是当你做某件事前的时候,不得不放弃别的事情带来的好处。一旦投入时间精力做的事情,出现了挫折,损失就是没有做好这件事情加上没做那件更好事情的收益。现在的互联网就是如此,优秀的作品那么多,但是视频只能一个一个看,文章只能一篇一篇读。这就带来了一个悖论,内容越丰富,机会成本就越高。毕竟因为选择做某件事情,投入了时间。错过的优质信息就越多。这个就是信息爆炸带来的焦虑。 抑制这种焦虑可以从几个角度进行 正视这个问题,为焦虑的情绪寻找出口,比如“收藏了就是读了,买了就是学了”。 回归现实,现实世界中,人的感官处理的事务没有那么多选择,选择某件事情的机会成本,感觉上没有那么大。 期待 明年没有什么过高的期待。 疫情可以结束 小朋友可以健康成长 工作顺利 家人健康 2021 再见。

2022/1/2
articleCard.readMore

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

终于有一个 Java 版的微信机器人了。 公众号很久没有更新了。主要两个原因,换了工作之后,第一,要花更多的时间去了解和学习新的业务。第二,我最近把几乎所有的业余时间都来写这个 Java 版的微信机器人了。 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 中能得到什么 真正的参与开源代码的贡献。 在 Maven 中央库,发布了自己的 Jar 包。 认识了各种各样小伙伴,包括写了 25 年程序的天使投资人 @Huan。 在写 Java-wechaty 的时候,不断的参考伙伴们的 TypeScript,Go,Python 代码,从实际的角度去审视各种编程语言的特性。探寻语言各个特性设计的初衷。 期待你的加入 Wechtay 社区加入了由 中科院软件所 与 openEuler 社区 共同举办的一项面向高校学生的暑期活动《开源软件供应链点亮计划-暑期2020》。 详情见: https://github.com/wechaty/summer-of-code Wechaty 给学生们提供了很多有意思的题目,比如: 利用 AI 技术,开发一个 AI 斗图机器人 利用 Wechaty 的插件技术,开发一个“每日一句”插件,替你向妹子嘘寒问暖的”撩妹“机器人 还有偏向工程的,代码移植工作,让学生真正的参与到开源项目其中 开发语言涉及,TypeScript,Go,Java,Kotlin,Python 甚至还有 Scala,总有一个适合你。 希望看到这里的你,可以把篇文章,转发给学习计算机,或者对编程感兴趣的学生朋友,期待他们加入。 后记 Java-wechaty 项目地址。 加入我们你也可以六行代码写一个微信机器人。

2020/6/3
articleCard.readMore

Vertx入门到实战—实现钉钉机器人内网穿透代理

最近研究 Vetrx 简直爱不释手。迫不及待的想给大家介绍一下。 Vertx 是什么 Vertx 是一个运行在 JVM 上,用来构建响应式应用的工具集。 基于 netty 的高性能的,异步的网络库。 对 netty 进行了封装,提供更加友好的 API。 同时实现了一些基于异步调用的库,包括database connection, monitoring, authentication, logging, service discovery, clustering support, etc。 为什么我推荐学习 其实随着技术的发展。异步调用其实越来越普及了。 1、现在随着 RPC 的普及。类似 Dubbo 这样的框架都是基于 NIO 的概念带来的,了解异步编程有助于学习理解框架。 2、响应式编程逐渐由客户端,前端向后端渗透。 3、更容易的编写出高性能的异步服务。 Vertx 的几个重要概念 Event Loop Event Loop 顾名思义,就是事件循环的。在 Vertx 的生命周期内,会不断的轮询查询事件。 传统的多线程编程模型,每个请求就 fork 一个新的线程对请求进行处理。这样的编程模型有实现起来比较简单,一个连接对应一个线程,如果有大量的请求需要处理,就需要 fork 出大量的线程进行处理,对于操作系统来说调度大量线程造成系统 load 升高。 所以为了能够处理大量请求,就需要过渡到基于 Roactor 模型的 Event Loop上。 官网的这个图就很形象了。Eventloop 不断的轮训,获取事件然后安排上不同的 Handler 处理对应的Event。 这里要注意的是为了保证程序的正常运行,event 必须是非阻塞的。否则就会造成 eventloop 的阻塞,影响Vertx 的表现。但是现实中的程序肯定不能保证都是非阻塞的,Vertx 也提供了相应的处理阻塞的方法的机制。我们在下面会继续介绍。 Verticle 在 Vertx 中我们经常可以看见 Vertical 组件。 Verticle 是由 Vert.x 部署和运行的代码块。默认情况一个 Vert.x 实例维护了N(默认情况下N = CPU核数 x 2)个 Event Loop 线程。Verticle 实例可使用任意 Vert.x 支持的编程语言编写,而且一个简单的应用程序也可以包含多种语言编写的 Verticle。 您可以将 Verticle 想成 Actor Model 中的 Actor。 一个应用程序通常是由在同一个 Vert.x 实例中同时运行的许多 Verticle 实例组合而成。不同的 Verticle 实例通过向 Event Bus 收发送消息来相互通信。 Event bus Vertx 中的 Event bus 如果类比后端常用的 MQ 就更加容易理解了。实际上 Event Bus 就是 Verticle 之间传递 信息的桥梁。 换句话说,就是 Java 通用设计模式中的监听模式,或者是我们常说的 基于 MQ 消息开发模式。 回到 Vertx 上文我们讨论了 vertx 的模型和机制,现在人们就看看怎么使用 vertx 开发一个程序。 我会结合之前写的 暴打钉三多的来进行讲解,一切从 Vertx 开始。 1 val vertx = Vertx.vertx() vertx 是整个 vert.x 框架的核心。通常来说 Vertx 所有的行为就是从 vertx 这个类中产生的。 Don’t call us, we’ll call you Vert.x 是一个事件驱动框架。所谓事件驱动是指当某件事情发生以后,就做这个动作。 我们再回到标题, “Don’t call us, we’ll call you” 这个原则,其实就是当我们 发现你能完成这项工的时候,我们会找你的。你不需要主动来联系我。 我们通过代码来理解一下 Vertx 是怎么实现这个原则的 : 1 2 3 server.requestHandler(request -> { request.response().end("hello world!"); }); 这个代码块的意思是,每当 server 的 request 被调用的时候,就返回一个 hello world 。 所以 Vertx 中的 ‘you’ j就是各种各样的 Handler 。大多数时候我们编写 Vertx 的程序,实际上就是在编写Handler 的行为。然后再告诉 Vertx ,每当 XXX 事件触发以后,你就调用 XXX Handler。 Don’t block me Vertx 是基于事件的,上文我们提到了 Event Loop ,在 Vertx 中,EventLoop 就是一个勤劳的小蜜蜂,不断的去寻找,到底有哪些事件被触发了。然后再执行对应的 Handler。假如执行 Hanlder 的线程,就是 Event Loop 线程。如过 Handler 执行的时间过长。就会阻塞 Event Loop 。造成别的事件触发的时候。Event Loop 还在处理时间花费较长的 Handler。Event loop就不及时的响应其他的事。 但是现实中,不可能所有的事件 都是非阻塞的。比如查询数据库,调用远程接口等等,那怎么办呢? 在事件驱动模型中,大概有两种套路解决,这个问题,比如在 Redis 中,Redis 会十分小心的维护一个时间分片。当某个人物执行事件过长的话,就保存当前事件的状态,然后暂停当前事件,重新由 Event loop 进行调度。防止 Event Loop 被事件阻塞。 还有一种套路,就是把阻塞的事件,交给别的线程来来执行。Event Loop 就可以继续进行事件的循环,防止被阻塞。事实上 Vertx 就是这么操作的。 1 2 3 4 5 6 7 vertx.executeBlocking(promise -> { // Call some blocking API that takes a significant amount of time to return String result = someAPI.blockingMethod("hello"); promise.complete(result); }, res -> { System.out.println("The result is: " + res.result()); }); 如果我们开发的时候意识到这个 Handler 是一个阻塞的,就需要告诉 vertx 这是是一个 Blocking 的需要交给别的线程来处理。 协调异步处理 上文提到. Vertx 是通过 Handler 来处理事件的,但是,很多时候,某个操作,通常需要不止一个 Handler 来对数据进行处理。如果一直使用 callback 的写法,就会形成箭头代码。产生地狱回调的问题。 作为一个异步框架,Vertx 一般使用 Future 来解决回调地狱的问题。理解 Vertx 中的 Future 是编写好的代码的核心。 通常我们理解 Future 只是一个占位符,代表某个操作未来某个时候的结果。不太清楚的可以看我以前写文章。 这里需要特别指出的是 Vertx 的 Future 和 Jdk 里面的 CompletableFuture 原理和理念类似,但是使用起来有很大的区别的。 Jdk 里面的 CompletableFuture 是可以直接使用 result() 阻塞的等待结果,但是 Vertx 中的 Future 如果直接使用 result() ,就会立刻从 Future 中取出结果,而不是阻塞的等待结果,就很容易收获一个 Null。 明确这个区别以后,写起代码就不会出错了。 Event Bus 如果在日常开发中使用过消息系统,就很容易理解 Vertx 中的 Event bus 了。官方文档把 Event bus 比作 Vertx 的神经系统,其实我们就认为,Event bus是 Vertx 的消息系统,就好了。 钉钉内网穿透代理的的开发 这个小 Demo 麻雀虽小但是包含了 Vertx 几个关键组件的使用。写这个 Demo 的时候,正好在学习 Kotlin 所以顺手就用 kotlin 写了。如果写过 Java 或者 Typescript 那你也能很容易的看懂。 项目包含了 Http Service 用于接收钉钉的回调 WebSocket Service 用于向 Client 推送收到的回调,达到内网穿透的目的。 Vertx Config 用于配置项目相关参数,便于使用 Event Bus 的使用,用于 Http Service 和 WebSocket 之间传递消息。 先来一个 Verticle Gradle 配置文件如下先引入包: 1 2 3 implementation ("io.vertx:vertx-core:3.8.5") implementation ("io.vertx:vertx-web:3.8.5") implementation ("io.vertx:vertx-lang-kotlin:3.8.5") 上文我我们已经介绍了 Verticle 是什么了,为了方便开发,Vertx 给我们提供了一个 AbstractVerticle 抽象类。直接继承: 1 2 class DingVerticle : AbstractVerticle() { } AbstractVerticle 中包含了 Vericle 常用的一些方法。 我们可以重写 start() 方法,来初始化我们 Verticle 的行为。 HttpService 的创建 1 2 3 4 5 6 7 8 9 10 11 12 13 override fun start() { val httpServer = vertx.createHttpServer() val router = Router.router(vertx) router.post("/ding/api").handler{event -> val request = event.request() request.bodyHandler { t -> println(t) } event.response().end(); } httpServer.requestHandler(router); httpServer.listen(8080); } 代码比较简单: 创建一个 httpService 设置一个 Router,如果写过 Spring Mvc 相关的代码。这里的 Router 就类似 Controller 里面的 RequestMapping 。用于指定一个 Http 请求 URI 和 Method 对应的 Handler。这里的 Handler 是一个 lambda 表达式。只是简单的把请求的 body 打印出来。 将 Router 加入到 httpService 中,并监听 8080 端口。 WebSocketService webSocket协议是这个 proxy 的关键,因为 WebSocket 不同于 Http,是双向通通信的。依赖这个特性我们可以把消息“推到”内网。达到内网“穿透”的目的。 1 2 3 4 5 6 7 8 httpServer.webSocketHandler { webSocket: ServerWebSocket -> val binaryHandlerID = webSocket.binaryHandlerID() webSocket.endHandler() { log.info("end", binaryHandlerID) } webSocket.writeTextMessage("欢迎使用 xilidou 钉钉 代理") webSocket.writeTextMessage("连接成功") } 代码也比较简单,就是向 Vertx 注册一个处理 WebSocket 的 Handler。 Event Bus 的使用 作为代理最核心的功能就是转发钉钉的回调消息,前面我说到,Event Bus 在 Vertx 中起到了“神经系统的作用”实际上 ,换句话说,就是http 服务收到回调的时候,可以通过 Event Bus 发出消息。WebSocket 在收到 Event Bus 发来的消息的时候,推送给客户端。如下图看图: 为了方便理解,我们就使用 MQ 里面通常的概念生产者和消费者。 所以我们使用在 HttpService 中注册一个生产者,收到钉钉的回调以后,把消息转发出来。 为了便于编写,我们可以单独写一个 HttpHandler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //1 class HttpHandler(private val eventBus: EventBus) : Handler<RoutingContext> { private val log = LoggerFactory.getLogger(this.javaClass); override fun handle(event: RoutingContext) { val request = event.request() request.bodyHandler { t-> val jsonObject = JsonObject(t) val toString = jsonObject.toString() log.info("request is {}",toString); // 2 eventBus.publish("callback", toString) } event.response().end("ok") } } 这里需要注意几个问题: 我们需要使用 Event Bus 发送消息,所以需要在构造函数里面传入一个 Event Bus 我们在收到消息以后,可以先将数据转换为 Json 字符串,然后发送消息,注意这里使用的是 publish() 是广播的意思,这样所有订阅的客户端都能收到新消息。 有了生产者,并发出了数据,我们就可以,在 WebSocket 里面消费这个消息,然后推送给客户端了 再来写一个 WebSocket 的 Handler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 //1 class WebSocketHandler(private val eventBus: EventBus) : Handler<ServerWebSocket> { private val log = LoggerFactory.getLogger(this.javaClass) override fun handle(webSocket: ServerWebSocket) { val binaryHandlerID = webSocket.binaryHandlerID() //2 val consumer = eventBus.consumer<String>("callback") { message -> val body = message.body() log.info("send message {}", body) //3 webSocket.writeTextMessage(body) } webSocket.endHandler() { log.info("end", binaryHandlerID) //4 consumer.unregister(); } webSocket.writeTextMessage("欢迎使用 xilidou 钉钉 代理") webSocket.writeTextMessage("连接成功") } } 这里需要注意几个问题: 初始化的时候需要注入 eventBus 写一个 consumer() 消费 HttpHandler 发来的消息 将消息写入到 webSocket 中,发送给 Client WebSocket 断开后需要回收 consumer 初始化 Vertx 做了那么多准备终于可以初始化我们的 Vertx 了 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class DingVerticleV2: AbstractVerticle(){ override fun start() { //2 val eventBus = vertx.eventBus() val httpServer = vertx.createHttpServer() val router = Router.router(vertx); //3 router.post("/api/ding").handler(HttpHandler(eventBus)); httpServer.requestHandler(router); //4 httpServer.webSocketHandler(WebSocketHandler(eventBus)); httpServer.listen(8080); } } //1 fun main() { val vertx = Vertx.vertx() vertx.deployVerticle(DingVerticleV2()) } 这里需要注意几个问题: 初始化 Vertx 并部署他 初始化 eventBus 注册 HttpHandler 注册 WebSocketHandler 总结 Vertx 是一个工具,不是框架,所以可以很方便的与其他框架组合。 Vertx 是一个基于 Netty 的异步框架。我们可以向编写同步代码一样,编写异步代码。 vertx 在代码中主要有两个作用,一个是初始化组件,比如 : 1 2 val eventBus = vertx.eventBus() val httpServer = vertx.createHttpServer() 还有一个是注册 Handler: 1 httpServer.webSocketHandler(WebSocketHandler(eventBus)); Event Bus 是一个消息系统。用于不同的 Handler 直接传递数据,简化开发。 相关连接 使用教程 钉钉机器人回调内网穿透代理–使用篇 Github 地址: Github 官网教程:A gentle guide to asynchronous programming with Eclipse Vert.x for Java developers; Vert.x Core Manual 欢迎关注我的微信公众号:

2020/4/13
articleCard.readMore

钉钉机器人回调内网穿透代理--使用篇

“山川异域,风月同钉”,被钉钉暴打的你,是不是已经想写一个机器人调戏一下钉钉了。在写机器人的时候,钉钉机器人的回调需要填写一个公网 http 地址。 这还没开发机器人,就没有 http 服务,没有 http 服务就收不到钉钉的回调,没有回调就不能调试机器人。不能调试机器人,就不能上线。 又一次陷入了被钉钉暴打的死循环,办法总比问题多,所以为了解决这个问题。我们就需要一个公网代理。所以我们就来撸一个。 这里注意一下,由于一般开发人员都处在内网环境。要想让代理做内网穿透,技术比较复杂。所以我们就换个思路。我们可以利用 Websocket 的双工的特性。接入代理,当代理收到钉钉的回调的时候,把消息推到我们本地开发环境。提升我们开发的效率。见下图: 使用方法 1 2 3 4 5 6 git clone https://github.com/diaozxin007/DingTalkProxy cd DingProxyServer ./gradlew build java -jar build/libs/dingWs-all.jar # 如果需要在后台运行 nohup java -jar build/libs/dingWs-1.0.0-all.jar &>> nohup.out & tailf nohup.out 可以修改 resources 下的 server.properties 1 2 3 4 # 监听端口 server.port=8080 # 钉钉回调的 uri server.api=/ding/api 然后重新运行: 1 ./gradlew build 这个时候,proxy 已经开始正常运行了。 如果只是想看看一看钉钉回调的报文,那就可以直接使用 [websock-test] (http://www.websocket-test.com/) GUI 调试工具。 如果想在代码里面使用可以参考 DingProxyClinet 里面的代码。 注意事项 Q:1、为什么我连不上服务? A:确认服务是否只开启了 https,如果开启了 https, 需要把协议头修改为 wss。 Q:2、我还是连不上? A:需要确认 nginx 的配置,是否支持 WebSocket。 可以在 nginx 的配置中增加 1 2 3 4 5 6 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; # 如果频繁超时断开可以配置 proxy_connect_timeout 7d; proxy_send_timeout 7d; proxy_read_timeout 7d; Q:3、除了做钉钉的代理,还能干什么? A: 理论上可以代理一切请求,然后转换为 String 通过 WebSocket 推送到客户端。 Q:4、我懒得部署服务了 A:可以使用我提供的公益服务 在回调接口中填写: https://api.xilidou.com/ding/api WebSocket 地址为: wss://api.xilidou.com 为了防止滥用,每个客户端每次连接只能接收 10 条消息,然后会被断开。 Github 传送门 下一篇文章将会具体讲解,如何使用 vertx 实现这个代理。敬请期待。

2020/3/26
articleCard.readMore

周末补习(一)trie 树

前言 是的,最近我又换工作了,在看新团队的代码的时候发现,同事们为了追求服务的响应时间,在项目中大量的使用了很多高级的数据结构。 作为传统 Curd 程序员,对算法和数据结构已经比较生疏了。如今看到这些”高级的代码“有点汗颜。所以趁周末好好的在家补课,重新复习一下。 文章将会是一个系列,慢慢的查缺补漏。 简介 Trie 树又叫字典查找树。顾名思义,字典查找树,主要解决的就是字符串的查找。有以下两个优势。 查找命中的时间复杂度是 O(k),k指的是需要查询的 key 的长度。这里注意和字库的大小无关。 对于未命中的字符,只需要查询若干字符就可。 基本数据结构 首先 Trie 树,是一棵树。树是由需要建立的所有词构成。 假设我们有,bee 、sea、 shells,she,sells,几个单词。我们可以使用这几个单词构建一棵树。 通过图片我们就可以直观的看出 Trie 的数据结构。这个棵树是由若干节点,链接而成,节点可以指向下一个节点,也可以指向空。从 root 节点开始,顺着链接随便找某个链接往下,直到最低端,经过的路径正好是上文的单词。 数据的代码表示 为了方便使用代码表示。可以考虑每个节点使用数组表示。每个节点都含有一个数组,数组的大小为R,R 是数组的基数,对应每个可能出现的字符。R 的选取取决于报错的字符的类型,如果只包含英文则256 就可以了。如果是中文就需要 65536。 字符和键值都保存在数据结构中。 所以实现代码如下: 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 的时候的下标。 我们按照注释逐行讲解一下: 递归跳出的条件之一,就是发现上一次查询指向的节点是空的,说明没有找到匹配的字符串。所以直接返回一个 null,表示没有匹配上。 递归跳出的条件之二,就是key值已经遍历完了。并且找到了对应的 value。可喜可贺。 这里的 c 表示的就是key在下标为 d 的时候对应的字符。因为我们的 root 是第 0 个,所以遍历 key的 c 是从1开始。 递归调用 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 数的插入也是这个思路。 我们按照注释逐行讲解一下: 如果当前节点为空,则在当前节点插入一个空 value。注意:这里是新建一个节点,在这个新节点上插入空的 value,而不是插入一个空节点,注意区分。 同理,如果d == key 的长度,表示已经将 key 遍历完了,需要把 key 对应的值保存在节点上了。 和 Get 一致,略。 递归调用 put 方法,将 x 的下一个节点传入方法,同时下标 d 加 1。然后逐层放回。 看完这 Put 和 Get 方法。我们再回顾一下 trid 的性质。 查询的次数,只和代码中的 key 的长度有关,与字典的大小没有关系。 如果没有命中的数据,查询的次数小于等于 key 的长度 。 应用 这里先着重介绍一下 trie 树的其中一个应用 ”前缀匹配“。 我们在搜索框里面输入一个词的时候,通常会收到提示的列表如下图: 输入 flink 的时候,搜索引擎会提示联想出用户可能的输入,提升用户体验。 有了上面的 Trie 树的介绍。具体实现这个功能就比较简单了。 回到我们原有的例子,假设词库里面有单词 bee 、sea、 shells,she,sells。如果用户输入 se 两个字符,我们应该会向用户提示 se 开始的词: sea 和 sells。 结合图片,我们要找到 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; } 逐条解释一下: 初始化找一个容器存储起来。 其中的 get(root,pre,0) 就是为了找出上图中标红的 e节点。然后把 e 节点放到 collect() 方法中。 递归的退出条件就是到达某一个链的最子节点。 如果 x 节点的 val 不为空就加入到容器中。 暴力的遍历节点上的数组并 c 拼接到 pre 前缀上,递归查找。 我们只需要调用方法 keysWithPrefix("se") 即可。 总结 trie 树在查询的时间复杂度是 O(k) 与词库的大小无关。 但是,有利必有弊。 利用数组表示节点实现的 Trie 树非常占用空间。 如果运用在英文文本处理中,假设单词的平均长度是 11 个字符,R 的大小是 256,100万个键构成的树大约有 2亿5千万个链接数。 是典型的空间换时间应用。 欢迎关注我的微信公众号:

2020/3/8
articleCard.readMore

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

上周在群里有小盆友问 transient 关键字是干什么的。这篇文章就以此为契机介绍一下 transient 的作用,以及在 ArrayList 里面的应用。 要了解 transient 我们先聊聊 Java 的序列化。 复习序列化 所谓序列化是指,把对象转化为字节流的一种机制。同理,反序列化指的就是把字节流转化为对象。 对于 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 存储数据的数组,居然不需要序列化? 莫慌,我们继续往下看代码。上文我们说过,对象的序列化和反序列化是通过调用方法 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 的底层数组 elementData 被 transient 修饰,不会直接被序列化。 为了实现 ArrayList 元素的序列化,ArrayList 重写了 writeObject() 和 readObject() 方法。 按需序列化数组,只序列化存在的数据,而不是序列化整个 elementData 数组。 用多少,序列化多少,真是勤俭持家的 ArrayList。 有趣的代码系列 那些有趣的代码(一)–有点萌的 Tomcat 的线程池 那些有趣的代码(二)–偏不听父母话的 Tomcat 类加载器 欢迎关注我的微信公众号

2019/12/5
articleCard.readMore

那些有趣的代码(二)--偏不听父母话的 Tomcat 类加载器

看 Tomcat 的源码越看越有趣。Tomcat 的代码总有一种处处都有那么一点调皮的感觉。今天就聊一聊 Tomcat 的类加载机制。 了解过 JVM 的类加载一定知道,JVM 类加载的双亲委派机制。但是 Tomcat 却打破了 JVM 固有的双亲委派加载机制。 JVM 的类加载 首先需要明确一下类加载是什么? Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的加载机制。 JVM 预定义的三个加载器: 启动类加载器(Bootstrap ClassLoader):是用本地代码实现的类装入器,它负责将 <Java_Runtime_Home>/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。 标准扩展类加载器(Extension ClassLoader):是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将< Java_Runtime_Home >/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。 应用程序类加载器(Application ClassLoader):是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。 双亲委派机制: 所谓双亲委派机制,这里要指出的是,其实双亲委派来源于英文的 ”parents delegate“,仅仅表示的只是”父辈“,可见翻译的人不但英文是半吊子,而且也不了解 JVM 的类加载策略,造成了很大的误解。尤其是这个”双“字在初学的时候给我造成了极大的干扰。所以换个说法,应该是”父辈代理“。 类加载的时候,把加载的这个动作递归的委托给父辈,由父辈代劳,只有父辈无法加载时,才会由自己加载。 双亲委派加载模型: 这里需要特别注意的是加载器的关系并非是继承的关系。我们看代码: 1 2 3 4 5 6 static class ExtClassLoader extends URLClassLoader{ ... ... } static class AppClassLoader extends URLClassLoader{ ... ... } 二者同时继承了 URLClassLoader ,继承关系如下: 怎么实现委托机制呢?在 ClassLoader 里面有几处比较重要的代码: 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 public abstract class ClassLoader { // The parent class loader for delegation // Note: VM hardcoded the offset of this field, thus all new fields // must be added *after* it. private final ClassLoader parent; protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { // 尝试使用 父辈的 loadClass 方法 c = parent.loadClass(name, false); } else { // 如果没有 父辈的 classLoader 就使用 bootstrap classLoader c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); // 父辈没法加载这个 class,就自己尝试加载 c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } } // 根据类名 寻找 class。我们在之前我们讲过,不通过的 classLoader 加载的 class 的位置不同。 protected Class<?> findClass(String name) throws ClassNotFoundException { return defineClass(name, res); } } 首先在初始化 ClassLoader 的时候需要指定自己的 parent 是谁?(这很重要) 先检查类有没被加载,如果类已经被加载了,直接返回。 如果没有被加载,则通过 parent 的 loadClass 来尝试加载类。(双亲委派的核心逻辑) 找不到 parent 的时候使用 bootstrap ClassLoader 进行加载。 如果委托的 parent 没法加载类,那就自己加载。 Tomcat 的类加载 Tomcat 自己实现了自己的类加载器 WebAppClassLoader。类图关系图如下: 我们就来看看 Tomcat 的类加载器是怎么打破双亲委派的机制的。我们先看代码: findClass 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Override public Class<?> findClass(String name) throws ClassNotFoundException { // Ask our superclass to locate this class, if possible // (throws ClassNotFoundException if it is not found) Class<?> clazz = null; // 先在自己的 Web 应用目录下查找 class clazz = findClassInternal(name); // 找不到 在交由父类来处理 if ((clazz == null) && hasExternalRepositories) { clazz = super.findClass(name); } if (clazz == null) { throw new ClassNotFoundException(name); } return clazz; } 对于 Tomcat 的类加载的 findClass 方法: 首先在 web 目录下查找。(重要) 找不到再交由父类的 findClass 来处理。 都找不到,那就抛出 ClassNotFoundException。 loadClass 方法 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 public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> clazz = null; //1. 先在本地cache查找该类是否已经加载过 clazz = findLoadedClass0(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } //2. 从系统类加载器的cache中查找是否加载过 clazz = findLoadedClass(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } // 3. 尝试用ExtClassLoader类加载器类加载 ClassLoader javaseLoader = getJavaseClassLoader(); try { clazz = javaseLoader.loadClass(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } } catch (ClassNotFoundException e) { // Ignore } // 4. 尝试在本地目录搜索class并加载 try { clazz = findClass(name); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } } catch (ClassNotFoundException e) { // Ignore } // 5. 尝试用系统类加载器(也就是AppClassLoader)来加载 try { clazz = Class.forName(name, false, parent); if (clazz != null) { if (resolve) resolveClass(clazz); return clazz; } } catch (ClassNotFoundException e) { // Ignore } } //6. 上述过程都加载失败,抛出异常 throw new ClassNotFoundException(name); } 总结一下加载的步骤: 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,重点来了,这里并没有首先使用 AppClassLoader 来加载类。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。 加载依然失败,才使用 AppClassLoader 继续加载。 都没有加载成功的话,抛出异常。 总结一下以上步骤,WebAppClassLoader 加载类的时候,故意打破了JVM 双亲委派机制,绕开了 AppClassLoader,直接先使用 ExtClassLoader 来加载类。 保证了基础类不会被同时加载。 由保证了在同一个 Tomcat 下不同 web 之间的 class 是相互隔离的。 more 准备把有趣的代码这个系列慢慢写下去,发现编程的乐趣: 那些有趣的代码(一)–有点萌的 Tomcat 的线程池

2019/10/28
articleCard.readMore

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

最近抓紧时间看看了看tomcat 和 jetty 的源代码。发现了一些有趣的代码,这里和大家分享一下。 Tomcat 作为一个老牌的 servlet 容器,处理多线程肯定得心应手,为了能保证多线程环境下的高效,必然使用了线程池。 但是,Tomcat 并没有直接使用 j.u.c 里面的线程池,而是对线程池进行了扩展,首先我们回忆一下,j.u.c 中的线程池的几个核心参数是怎么配合的: 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。 如果运行的线程等于或多于 corePoolSize,将任务加入 BlockingQueue。 如果 BlockingQueue 内的任务超过上限,则创建新的线程来处理任务。 如果创建的线程超出 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 线程池的逻辑: 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。 如果线程数大于 corePoolSize了,Tomcat 的线程不会直接把线程加入到无界的阻塞队列中,而是去判断,submittedCount(已经提交线程数)是否等于 maximumPoolSize。 如果等于,表示线程池已经满负荷运行,不能再创建线程了,直接把线程提交到队列, 如果不等于,则需要判断,是否有空闲线程可以消费。 如果有空闲线程则加入到阻塞队列中,等待空闲线程消费。 如果没有空闲线程,尝试创建新的线程。(这一步保证了使用无界队列,仍然可以利用线程的 maximumPoolSize)。 如果总线程数达到 maximumPoolSize,则继续尝试把线程加入 BlockingQueue 中。 如果 BlockingQueue 达到上限(假如设置了上限),被默认线程池启动拒绝策略,tomcat 线程池会 catch 住拒绝策略抛出的异常,再次把尝试任务加入中 BlockingQueue 中。 再次加入失败,启动拒绝策略。 如此努力的 Tomcat 线程池,有点萌啊。

2019/10/15
articleCard.readMore

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

恭喜知乎 F 轮融资成功,今天不谈技术,谈谈别的。 从需求第三定律谈起 最近一直在“得到”上学习《薛兆丰的经济学课》,其中一节讲到了需求第三定律。 每当消费者必须支付一笔附加费用的时候,高品质的产品就变得便宜了,这笔附加费用越高,高品质的的产品就就变得越便宜,也叫”好东西运到远方定律“。 换句话说,优质的商品和普通商品价格是有差距的,但是,加上一笔固定的附加费用以后,他们的差距就缩小了,优质的东西就变得便宜了,人们就会倾向于筛选优质的商品进行销售,附加的成本越高,人们越倾向于优质的货品。 你也许就会问了,这个和知乎的回答质量下降有什么关系呢?其实我们换个角度来利用需求第三定律来试着解释一下这个问题。 很久以前,信息的保存,传播的成本及其高昂,刻石头上,写绢帛上。所以自然而然人们就会选择思想价值较高的文本,记录下来。因为石刻,绢帛成本实在太高了,必须有所筛选。 随着社会的发展,使用纸张以后,消息的记录和流通就变得越来越便宜,立著出书的人就变得多了。“需求第三定律”就开始变得不显著了。因为为传递信息,付出的额外费用从立碑,购买绢帛,变成了造纸,降低了太多。于是不那么优秀,不那么经典的信息也有机会进入流通了。 时至今日,随着信息时代的到来,消息的记录的成本变得极其低廉,传递信息的附加费用对比造纸印刷,不知道低到哪里去了,键盘侠和杠精产生的及其低质信息也可以肆无忌惮的产出并流通了。 所以我们总有一种感受,就是老祖宗的智慧,特别厉害。 其实原因只是因为,老祖宗的生产力低下,信息的保存,传播成本高昂,不得不筛选最精华的信息记录下来。 回到知乎 这个时候回到我们的知乎,最早期的知乎,用户采用邀请制,严格筛选的用户才能够回答问题。如此之高的门槛,使得高质量的回答所占比例极高是必然的结果。 随着社区逐渐的开放,用户可以自由注册以后,门槛降低,但是初期形成的”精英气质“,还是要求回答者,需要用较高的成本维护自己的精英属性,才能获得较高的认同。所以社区内容的贡献者会尽量的产出优秀内容,来满足”精英社区“对答题者的人设要求,可以说就是回答需要付出额外的成本。所以这个时候看来,平台的总体回答质量还不错。 直至最近的下沉,2.2 亿用户的涌入。一方面,社区的精英气质,逐渐消散,用户维护自己精英人设,变得不如之前那么迫切,降低了回答者自我要求的门槛。另一方面,平台对于 DAU(每日活跃用户) 的渴望,吸引用户注册知乎,然后主动引导用户回答问题,以降低回答的成本和门槛。这个时候第三需求定律发挥了它的威力。回答的附加成本下降,不需要对内容进行筛选,低质量回答的数量必然增加,从用户的感受上来说,自然觉得总体上知乎的社区的回答质量下降了。 但附加成本上升未必是好事 很多人抱怨,由于回答的成本和门槛降低,造成了知乎的平均水平下降,确实如此。早期知乎的一系列门槛的存在,只有优秀的人才能回答问题,所以早期的知乎社区的平均水平就很高。 但是,知乎的平均水平下降了是不是坏事?不见得坏,这个问题分开来看,从平均水平来看,确实不如从前,优秀信息的浓度下降,造成用户的筛选优质信息成本高,提高了使用成本。这是坏事。 注意,我们一直强调的是平均,是总体。并没有讨论优质答案的绝对值。从另一个角度来看,由于网络外部性的存在,更多的用户使用知乎,就会吸引更多的优秀回答者为平台带来优秀回答,平台上优秀的回答的绝对值会增长。同时为信息的获取者对某一个问题提供更多一种的选择和视角。这个是好事。 站在更高的角度来看,人类的发展历史,就是不断的减少信息存储和流通成本的历史。信息的附加成本就是在不断的下降。 所以面对更多、相对更稀薄的优质信息还是更浓的更少的优质信息,你怎么选择? 之后我还会谈谈我对如何筛选信息的理解,敬请大家期待。 利益相关:知乎员工

2019/8/14
articleCard.readMore

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

我们的业务系统使用了一段时间后,用户的角色类型越来越多,这时候不同类型的用户可以使用不同功能,看见不同数据的需求就变得越来越迫切。 如何设计一个可扩展,且易于接入的权限系统.就显得相当重要了。结合之前我实现的的权限系统,今天就来和大家探讨一下我对权限系统的理解。 这篇文章会从权限系统业务设计,技术架构,关键代码几个方面,详细的阐述权限系统的实现。 背景 权限系统是一个系统的基础功能,但是作为创业公司,秉承着快比完美更重要原则,老系统的权限系统都是硬编码在代码或者写在到配置文件中的。随着业务的发展,如此简陋的权限系统就显得捉襟见肘了。开发一套新的,强大的权限系统就提上了日程。 这里有两个重点: 业务系统已经运行一段时间积累了可观的代码和接口了,新的权限系统权在设计之初的一个要求就是,尽量减少权限系统对原有业务代码的入侵。(为了达成这个目的,我们会大量的使用 spring、springboot、jpa 以及 hibernate 的高级特性) 系统要易于使用,可以由业务方自行进行配置。 需求 权限系统需要支持功能权限和数据权限。 功能权限 所谓功能权限,就是指,拥有某种角色的用户,只能看到某些功能,并使用它。实现功能权限就简化为: 页面元素如何根据不同用户进行渲染 API 的访问权限如何根据不同的用户进行管理 数据权限 所谓数据权限是指,数据是隔离的,用户能看到的数据,是经过控制的,用户只能看到拥有权限的某些数据。 比如,某个地区的 leader 可以查看并操作这个地区的所有员工负责的订单数据,但是员工就只能操作和查看自己负责的的订单数据。 对于数据权限,我们需要考虑的问题就抽象为, 数据的归属问题:数据产生以后归属于谁? 确定了数据的归属,根据某些配置,就能确定谁可以查看归属于谁的数据。 业务设计 经过上面的分析,我们可以抽象出以下几个实体: 功能权限 用户 角色 功能 页面元素 API 信息 我们知道,对于一某个功能来说,它是由若干的前端元素和后端 API 组成的。 比如“合同审核” 这个功能就包括了,“查看按钮”、“审核按钮” 等前端元素。 涉及的 api 就可能包含了 contract 的 get 和 patch 两个 Restful 风格的接口。 抽象出来就是:在权限系统中若干前端元素和后端 API 组成了一个功能。 具体的关系,就是如下图: 数据权限 具体每个系统的数据权限的实现有所不同,我们这里实现的数据权限是依赖于公司的组织架构实现的,所有涉及到的实体如下: 用户 数据权限关系 部门 数据拥有者 具体数据(订单,合同) 这里需要说明一下,要接入数据权限,首先需要梳理数据的归属问题,数据归属于谁?或者准确的来说,数据属于哪个数据拥有者,这个数据拥有者属于哪个部门。通过这个关联关系我们就可以明确,这个数据属于哪个部门。 对于数据的使用用户,来说,就需要查询,这个用户可以查看某个模块的某个部门的数据。 这里需要说明的是,不同的系统的数据权限需要具体分析,我们系统的数据权限是建立在公司的组织架构上的。 本质就是: 数据归属于某个数据拥有者 用户能够看到该数据拥有者的数据 具体的关系图如下: 注意,实际上用户和数据拥有者都是同一个实体 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). 这要的好处的就是通过 sql 的 like 的语句就能快速的查询出某个节点的子节点。 比如要获取节点 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,基本的实现思路如下图: 基本的思路如下: 用户登录以后,获取用户的数据权限相关信息。 把相关信息权限系统放入 ThreadLocal 中。 在 Dao 层中,从 ThreadLocal 中获取权限相关的权限数据。 在 filter 中填充权限相关数据。 从 Hibernate 上下文中取出 Session。 在 Session 上添加相关 filter。 通过图片我们可以看出,我们基本不需要对 Controller、Service、Dao 进行修改,只需要按需实现对应模块的 filter。 看到这里你可能觉得”嚯~~”,还有这种操作?我们就看看代码是怎么具体实现的吧。 首先需要在 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 2 3 4 @Retention(RetentinPolicy.RUNTIME) @Taget(ElementType.METHOD) public @interface OrderFilter{ } 编写一个切面用于处理 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 就能够接入权限系统。 总结 权限系统随着业务的发展,是从可以没有逐渐变成为非常重要的模块。往往需要接入权限系统的时候,系统已经成熟的运行了一段时间了。大量的接口,负责的业务,为权限系统的接入提高了难度。同时权限系统又是看似通用,但是定制的点又不少的系统。 设计套权限系统的初衷就是,不需要大量修改代码,业务方就可方便简单的接入。 具体实现代码的时候,我们充分利用了面向切面的编程思想。同时大量的使用了 Spring、Hibrenate框架的高级特性,保证的代码的灵活,以及横向扩展的能力。 看完文章如果你发现有疑问,或者更好的实现方法,欢迎留言与我讨论。

2019/5/12
articleCard.readMore

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

居然有人忘记吃饭??? 为了解决这个问题,我写了一个微信机器人到点就提醒他吃饭。 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 到时候对方应该不会忘记吃饭了。 实现原理: 这个机器人主要使用两个库: wechaty 一个 node 实现的微信机器人。 node-schedule 一个定时任务触发器。 其实核心的原理,就在 wechaty 登录以后,注册了一个定时任务。这个定时任务,用于在饭点的时候,注册另外一个 schedule ,同时这个 schedule 是为了实现每分钟一次的提示。 当对方按照指定的话术服务短信的时候,我们只需要调用每分钟提醒一次的 schedule cancel() 方法。 希望每一个人都能按时吃饭,谢谢大家。

2019/5/8
articleCard.readMore

我的2018年总结

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(部分) 的源码,产出了“徒手撸框架”系列文章。 其实下半年还花时间,进行了一些方法论的学习。关于方法论是否有效会在下文进行阐述。 生活 今年生活上最大的事情就是在北京买了房子,选房时候的纠结和艰险不表,终于可以有自己的家了。至于买车?啥时候摇上号再说吧。生活进入正轨之后,更多的还是平淡,日常和琐碎。 通过年底的装修,突然发现,现金流的重要性。月光肯定是不行的,手上有现金,才能面对大额的支出。 装修是一项及其繁琐和持久的工程,需要考虑的问题方方面面,所以尝试把公司推进项目的方法论,引入到装修中,按照工作中推进项目的流程要推进装修这件事情。项目文档,还真有不错的体验。其实还是认识到了方法论的重要性,按照一套既有成熟的标准来推进某些事情的时候,虽然不能保证做的都正确,但是还是可以做到心安理得,从容不迫吧。 至于那只暹罗猫,只是又长胖了,又变黑了而已。还是那么可爱。 感谢家人父母对我的支持,还有老婆对我加班的忍耐。 旅游 2018 年国庆,请了五天假,开开心心去了一趟夏威夷。开上了自己心心念念的敞篷野马,浮潜遇上了可爱的野生海豚,开车穿越云层在全世界最适合观星的山顶看到了银河,去活火山国家公园,但是没有看见岩浆。阳光,沙滩,大海,美不胜收。 有机会想带上爸妈,再去一次。 还去了一趟成都,虽然只是匆匆一个周末,但也吃到了“串串”,也算了一桩心愿。 投资 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年更多的是稍微放慢脚步,回头看看,仔细想想。 展望新的一年,又一次充满了希望。

2019/1/1
articleCard.readMore

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

最近在看阿里的 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.atomic 下 Striped64 的一个内部类。 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() 操作归纳以后就是: 如果 cells 数组不为空,对参数进行 casBase 操作,如果 casBase 操作失败。可能是竞争激烈,进入第二步。 如果 cells 为空,直接进入 longAccumulate(); m = cells 数组长度减一,如果数组长度小于 1,则进入 longAccumulate() 如果都没有满足以上条件,则对当前线程进行某种 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 操作成功而告终。否则则会修改上述描述的几个标记位,重新进入循环。 所以整个循环包括如下几种情况: cells 不为空 如果 cell[i] 某个下标为空,则 new 一个 cell,并初始化值,然后退出 如果 cas 失败,继续循环 如果 cell 不为空,且 cell cas 成功,退出 如果 cell 的数量,大于等于 cpu 数量或者已经扩容了,继续重试。(扩容没意义) 设置 collide 为 true。 获取 cellsBusy 成功就对 cell 进行扩容,获取 cellBusy 失败则重新 hash 再重试。 cells 为空且获取到 cellsBusy ,init cells 数组,然后赋值退出。 cellsBusy 获取失败,则进行 baseCas ,操作成功退出,不成功则重试。 至此 longAccumulate 就分析完了。之所以这个方法那么复杂,我认为有两个原因 是因为并发环境下要考虑各种操作的原子性,所以对于锁都进行了 double check。 操作都是逐步升级,以最小的代价实现功能。 最后说说 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 的具体实现却非常精巧和细致,分散竞争,逐步升级竞争的解决方案,相当漂亮,值得我们细细品味。

2018/11/28
articleCard.readMore

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

微服务已经是每个互联网开发者必须掌握的一项技术。而 RPC 框架,是构成微服务最重要的组成部分之一。趁最近有时间。又看了看 dubbo 的源码。dubbo 为了做到灵活和解耦,使用了大量的设计模式和 SPI机制,要看懂 dubbo 的代码也不太容易。 按照《徒手撸框架》系列文章的套路,我还是会极简的实现一个 RPC 框架。帮助大家理解 RPC 框架的原理。 广义的来讲一个完整的 RPC 包含了很多组件,包括服务发现,服务治理,远程调用,调用链分析,网关等等。我将会慢慢的实现这些功能,这篇文章主要先讲解的是 RPC 的基石,远程调用 的实现。 相信,读完这篇文章你也一定可以自己实现一个可以提供 RPC 调用的框架。 1. RPC 的调用过程 通过下图我们来了解一下 RPC 的调用过程,从宏观上来看看到底一次 RPC 调用经过些什么过程。 当一次调用开始: client 会调用本地动态代理 proxy 这个代理会将调用通过协议转序列化字节流 通过 netty 网络框架,将字节流发送到服务端 服务端在受到这个字节流后,会根据协议,反序列化为原始的调用,利用反射原理调用服务方提供的方法 如果请求有返回值,又需要把结果根据协议序列化后,再通过 netty 返回给调用方 2. 框架概览和技术选型 看一看框架的组件: clinet就是调用方。servive是服务的提供者。protocol包定义了通信协议。common包含了通用的一些逻辑组件。 技术选型项目使用 maven 作为包管理工具,json 作为序列化协议,使用spring boot管理对象的生命周期,netty 作为 nio 的网路组件。所以要阅读这篇文章,你需要对spring boot和netty有基本的了解。 下面就看看每个组件的具体实现: 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 。所以只需要实现 ByteToMessageDecoder 和 MessageToByteEncoder 两个接口。就解决了序列化的问题: 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 进行开发。关键代码如下: 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; } 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); 实现具体的 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 的吞吐量,可提供的思路有以下几种: 使用对象池:建立多个 client 以后保存在对象池中。但是代码的复杂度和维护 client 的成本会很高。 尽可能的复用 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 方法,主要三个作用, 生成 RequestId。 拼装 RpcRequest。 调用 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 徒手撸框架–高并发环境下的请求合并

2018/9/27
articleCard.readMore

我的写作工具链

写作是技术输出的重要手段。自己也写了一年多的文章,累计也超过五万多字。今天就想谈谈自己对于写作的一些看法以及写作时使用到的工具。工欲善其事必先利其器。 输入 能做到持续的输出文字,首先需要自己有所积累的同时不断的输入新的内容。要构建自己的知识系统,首先要考虑的是自己知识系统的输入是什么? 我想我的知识输入主要来自于三个方面: 泛读书籍 当我拿到一本书的时候,我需要的是快速的建立印象。略读了解书的结构,知道书的每个章节大致覆盖的内容,在脑子为这本书建立索引。这个时候的读书笔记,或者读书心得就好像一份落地的索引。为将来需要的时候提供查询的依据。 研究技术 这个时候的阅读,就比较有目的性了。对于某个领域的专业知识,依托第一步产生的索引。可以在众多资料中快速定位。成体系,成系统的学习,然后整理消化。 工作中的总结 学习的目的就是使用。在实际使用知识的时候,必然会有各种各样的挑战,这个时候就需要逐步的调试,重复的验证,考验之前的知识体系。每一次解决某个问题,就为我们知识体系打上一个补丁。整项工作完成后需要回顾总结,归档。 总结一下,四个步骤: 第一步,摊大饼,建索引。第二步,抓住某个点,体系学习。第三步,实际应用,发现知识盲区,及时打补丁。第四步,总结归档。 加工 了解了写作的素材的来源,就需要时合适的工具,加工知识。 对于电子书,我使用 MarginNote 这个软件来阅读。MarginNote 是一款,集文档管理,标注,思维导图,大纲等功能于一体的学习软件。可以说功能相当强大。 通这个软件,可以迅速的建立索引,实现把书读薄的目的。 同时 MarginNote 还有更多其他用法,大家可以到他的官网了解。强烈推荐购买。 笔记本和纸 对于实体书,实体的笔记也是得力的助手。对于手写的笔记比较自由,但是思路还是一样的,迅速记录知识要点,同时可以附上自己的思考。 至于如何有效的阅读一本书,推荐大家阅读 《如何阅读一本书》。 写作 写作是检测自己是否真正掌握知识的一种手段。如果能够把一个知识真正的讲明白才是,你才真正的掌握这项知识。 markdown 写作的核心是使用使用 markdown 这种无格式标记语言。 为什么使用 markdown ? 主要是 markdown 是一种 「易读易写」 的纯文本标记语法。语法是由限个(常用不超过20个)符合组成,并没有太大的学习成本。 纯文本的好处就是,不依赖与特定的工具就能编写阅读。与其相反的就是 M$ 的 Office 系列软件。比如 Docx 文件就必须在大型的 Office 条件中才能使用,同时使用 M$ word 的时候,时刻要担心格式和排版的问题。 而对于 markdown 用户来说,在写作的时候,就只需要关注内容。等需要排版的时候,再交由专业的工具来完成。 这里推荐几个我用过,比较好用的 markdown 编辑器: MWeb:是一个在 Mac 环境下的优秀的 markdown 文件编辑器。 使用门槛比较低,同时提供很多高级功能。 功能也比较强大,支持文档导出 PDF,HTML,同时有比较友好的图片解决方案。 缺点:不支持版本控制工具,不能正确识别 hexo 的 yml 配置文件。不过如果不是程序员用户 MWeb 可以说没有缺点。 Visual Studio code 对于程序员来说 Vs code 简直就是完美的 markdown 解决方案。Vs code 默认就极好的支持了 markdown 语法。 优点: 无缝集成 Github 通过安装插件各种模板语言 可以直接操作终端 支持 markdown 预览 无缝集成 hexo, 一站式解决写作,排版,发布,备份等工作。 缺点: 对于非技术人员门槛过高。 输出 完成了写作之后,就需要考虑如何呈现给读者。 图床 七牛云,目前对备案,域名要求越来越高,如果搞定了备案,好用。 阿里云 OSS,我的服务器托管在aliyun,顺手买了一个 OSS,目前来看功能强大,价格也实惠,推荐。 如果以上还是门口比较高,推荐一个神器 iPic。只需要把图片拖拽到他的图标上,一键上传,生成 Markdown 的链接。免费版直接使用微博的图床,支持 https,唯一的缺点就是哪天微博不高兴了取消了api,就不能用了吧。 图片压缩 一般我们直接截图的文件尺寸都很大,影响页面加载速度,可以使用 TinyPng 在不损失图片质量的情况下,尽可能的压缩图片文件大小。 排版 由于我自己使用 hexo 作为静态博客的管理工具,hexo 直接支持 markdown 格式。所以直接使用 hexo 编译 markdown 就能获得很好的效果。 对于掘金、简书、知乎等直接支持 markdown 内容平台,那就再好不过了。直接把源文件粘贴进去–完美。 对于微信公众号和头条号来说,推荐两个排版工具给大家: Markdown Here : 是一个浏览器插件。可以解决大部分富文本编辑器的排版问题。功能及其强大,但是对于一个不会写 css 的后端程序员来说,预设的主题较少,自己定制又不会。比较尴尬。 颜家大少提供的 Md2All 只要把 Markdown 源文件复制到页面中,点击 “复制” 然后粘贴到微信公众编辑页面。直接搞到格式和图片可以说相当靠谱和。大家看到我的微信公众号里面的文章都是用这个工具排版。 备份 直接使用 github 管理文章,文章写完以后 push 到远程分支。同时定期打包 zip 放到坚果云。 后记 这篇文章包含了我这几年写作的心得,还有写作过程中使用的一些工具。希望能对你有所帮助。如有更好的工具,也欢迎你留言告诉我。

2018/8/17
articleCard.readMore

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

最近做了一个比较有意思的需求,实现的比较有意思。 需求 用户上传一个 docx 文件,文档中有占位符若干,识别为文档模板。 用户在前端可以将标签拖拽到模板上,替代占位符。 后端根据标签,获取标签内容,生成 pdf 文档并打上水印。 需求实现的难点 模板文件来自业务方,财务,执行等角色,不可能使用类似 (freemark、velocity、Thymeleaf) 技术常用的模板标记语言。 文档在上传后需要解析,生成 html 供前端拖拽标签,同时渲染的最终文档是 pdf 。由于生成的 pdf 是正式文件,必须要求格式严格保证。 前端如果直接使用富文本编辑器,目前开源没有比较满意的实现,同时自主开发富文本需要极高技术含量。所以不考虑富文本编辑器的可能。 技术调研和技术选型(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); } } 这里需要特别注意: 在解析的文档中,para.getParagraphText()指的是获取段落,para.getRuns()应该指的是获取词。但是问题来了,获取到的 runs 的划分是一个谜。目前我也没有找到规律,很有可能我们的占位符被划分到了多个run中,我们并不是简单的针对 run 做正则表达的替换,而要先把所有的 runs 组合起来再进行正则替换。 在调用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。 这个方法使用前: 使用后: 增加水印 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(); } } 字体 使用文档的时候,字体也同样重要,如果你使用了 libreOffice 没有的字体,比如宋体。需要把字体文件 xxx.ttf 1 2 cp xxx.ttc /usr/share/fonts fc-cache -fv 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 >> 百度知道 欢迎关注我的微信公众号

2018/8/16
articleCard.readMore

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

最近换工作以后,结结实实的写了几个月的业务。需求完结以后,就找找自己喜欢的东西写写,换个口味。 撸码最难的就是给变量取名字了。所以就写一个变量生成器吧。 演示如下 实现思路 使用了 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。效果如下图: 下载地址如下: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/ 欢迎关注我的微信公众号:

2018/7/9
articleCard.readMore

Raft 协议学习笔记

好久没有更新博客了,最近研究了Raft 协议,谈谈自己对 Raft 协议的理解。希望这篇文章能够帮助大家理解 Raft 论文。 Raft 是什么 Raft 是一种分布式系统的一致性算法。 在分布式系统中,我们需要让一组机器作为一个整体向外界提供服务。由于在实际的条件下,我们认为每台机器都是不100%可靠的,随时都可能发生宕机。每台机器之间的通信也不是可靠的,可能发生通信的阻塞、丢失、重试。所以需要某些算法来保证在大多数机器都正常的情况下向外提供可靠的服务。 在 Raft提出之前,Paxos 已经被提出,但是 Paxos 相当复杂。Raft 的目标就是提出一种易于理解的分布式一致性算法。 在了解 Raft 之前需要了解一下什么状态机: 论文指出,Raft 是一种用来管理日志复制的一致性算法。所以我们就要先了解一下。什么是日志复制状态机。我们思考一个问题。如果你要与你的小伙伴分享一个很复杂的操作及计算。一般来说你有两种做法: 第一种:你自己负责计算,经过一段时间的计算,算出结果后,直接把计算结果告诉你的小伙伴。 第二种:你把每一个操作的步骤都告诉你的伙伴,告诉他怎么做,由你的伙伴自己计算出结果。 第二种方式,就是复制状态机的工作原理。复制状态机是通过复制日志来实现的。每一台服务器保存着一份日志,日志中包含一系列的命令,状态机会按顺序执行这些命令。因为每一台计算机的状态机都是确定的,所以每个状态机的状态都是相同的,执行的命令是相同的,最后的执行结果也就是一样的了。 在实际中这种有很多类似的应用比如 mysql 的主从同步就是通过 binlog 进行同步。 在现实生活中,如何有效的组织多人进行协助,最自然的想法就是选举一个领导,交由领导极大的权威,就能极大的提升整个团队工作效率。 下面就谈谈我对 Raft 算法的理解。 基本安全保证 为了保证过程正确性,Raft需要保证以下的性质时刻为真: 选举安全原则: 同一届任期内至多只能有一个领导人。 领导人只加原则: 领导人的日志只能增加,不能重写或者删除。 日志匹配原则: 如果两个日志具有相同的任期和索引,则这两段日志在[0,索引]之间的日志完全相同。 领导人完全原则: 如果一条日志被提交,那么后续的任意任期的领导人都会有这条日志。 状态机安全原则: 如果一个服务器已经将给定索引位置的日志条目应用到状态机中,则所有其他服务器不会在该索引位置应用不同的条目。 选取领导者 所以 Raft 算法成立的最重要的前提之一就是选举。 Raft 由多个节点组成。 强领导者, 整个 Raft 在同一时间,只有一个领导者,日志有领导者负责分发和同步。 领导选举, 领导是由民主选举产生的,集群中多数节点投票通过就能成为主。 对于在集群中的节点。在任意时间中,都有可能处于以下三种状态之一: 跟随者 候选人 领导人 每个领导人都有一个任期限制。每一届任期的开始阶段,都是选举。如果选举出了领导者就由该领导人负责领导集群。如果没有选举出领导,就会进入下一次选举。直到选举出领导者为止。 角色之间的转换: 领导者会周期性的向每台机器发送心跳,确保自己的领导地位。 跟随者在长时间没有收到领导人的心跳,就会发起投票成为候选人,同时任期 + 1,如果获得超过半数的支持,就升任为领导。 如果候选人,在发起投票的时候,发现集群里面有领导人的时候,就会重新成为追随者。 如果候选人,发起投票后,一定时间里面没有收到超过半数的反馈,就会再次发起投票。 如果领导者发现在集群中发现存在下一任期的领导者,就会变为追随者。 日志同步 在选举出领导人以后,就开始处理客户端的日志。 领导者在收到客户端的请求,每个请求包含一个操作的命令。领导者会将命令记录到自己的日志中,并向自己的追随者发起同步的请求,要求自己的追随者复制这个命令。 一旦这个命令被大多数的追随者保存了。领导者就认为这个状态已经处于提交(commited)的状态。同时告知客户端,命令已经被提交。如果这个时候,追随者发生了崩溃或者延时。领导者会一直尝试重试,直到追随者接受命令,并存储到自己的日志中。这个过程一直持续到所有的追随者最终存储了所有的日志条目。 作为 Raft 的节点需要保证如下性质。 如果在不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。 如果在不同日志中的两个条目有着相同的索引和任期号,则它们之间的所有条目都是完全一样的。 有了如上性质的保证。如果在某些情况下,发生了追随者的日志与领导者不同步的情况。(包括的情况,就可能是丢失日志,或者保存了领导者没有的日志,或者两兼有),在 Raft 算法中,领导人通过强制追随者们复制它的日志来处理日志的不一致。这就意味着,在追随者上的冲突日志会被领导者的日志覆盖。 为了使得追随者的日志同自己的一致,领导人需要找到追随者同它的日志一致的地方,然后删除追随者在该位置之后的条目,然后将自己在该位置之后的条目发送给追随者。 安全分析 需要分析在各种情况下,每个角色发生宕机,数据的安全性。 选举限制 Raft 保证自己的日志,永远由领导者向追随者流动。也就是说领导者永远不会删改自己的日志,只能向上增加日志。为了达成这个限制,Raft 使用投票的方式来阻止没有包含全部日志条目的服务器赢得选举。 当一个候选人发起投票的时候,需要告诉大家,自己最新的日志。其他节点在投票的时候,要保证自己的日志不能比候选人的新,否则就拒绝投票。通过这个限制就保证了获取多数票的领导者的日志,至少比大多数人要新。 任期越大,日志越长,越容易成为领导者。 提交之前任期的日志条目 这个在论文中比较难以理解。我看到这一节的时候也是读了好几遍才理解论文的意思。实际上作者表达的意思是图 (d)是正确的,而(e)是错误的。 因为 2 号日志没有commited,但是由于一系列操作,造成了 2 号日志没有提交,但是高任期的leader 却认为 2 号日志被提交了。 与知乎网友讨论发现这个地方还是理解有误,这个图后来作者换了一个更容易理解的图: 应该是说,如果高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 天然保证这个特性。 领导人在访问数据之前需要发送一次心跳,保证自己的领导地位。 参考 Raft 首页 Raft 中文翻译 Raft java 实现

2018/6/5
articleCard.readMore

dubbo 源码学习(一)开篇

今天开始将开启 dubbo 的源码研究。 dubbo 是什么? dubbo 是阿里巴巴开发的一个基于 java 的开源的 RPC 框架。所谓 RPC 指的的是 Remote Procedure Call Protocol 远程过程调用协议。 阅读代码前的准备 下载代码: 1 git clone https://github.com/apache/incubator-dubbo.git IDE 支持 1 mvn idea:idea 然后就可以自由的玩耍了。 架构 我们看代码包的结构: dubbo-common 公共逻辑模块:包括 Util 类和通用模型。 dubbo-remoting 远程通讯模块:相当于 Dubbo 协议的实现,如果 RPC 用 RMI协议则不需要使用此包。 dubbo-rpc 远程调用模块:抽象各种协议,以及动态代理,只包含一对一的调用,不关心集群的管理。 dubbo-cluster 集群模块:将多个服务提供方伪装为一个提供方,包括:负载均衡, 容错,路由等,集群的地址列表可以是静态配置的,也可以是由注册中心下发。 dubbo-registry 注册中心模块:基于注册中心下发地址的集群方式,以及对各种注册中心的抽象。 dubbo-monitor 监控模块:统计服务调用次数,调用时间的,调用链跟踪的服务。 dubbo-config 配置模块:是 Dubbo 对外的 API,用户通过 Config 使用D ubbo,隐藏 Dubbo 所有细节。 dubbo-container 容器模块:是一个 Standlone 的容器,以简单的 Main 加载 Spring 启动,因为服务通常不需要 Tomcat/JBoss 等 Web 容器的特性,没必要用 Web 容器去加载服务。 依赖关系 这张图是从 dubbo 的官网上下载下来的: 顺着序号我们来看看 dubbo 的各个模块是怎么工作的。 名词解释: Container 服务容器,可以类比 tomcat 或者 jetty Provider 服务的提供方 Consumer 服务的消费方,或者称为调用方 Registry 注册中心,用于提供服务发现,注册等功能 Monitor 监控方,用于监控整个集群的工作状态 所以按照序号我们看看 dubbo 各个模块都干什么了? container 是 dubbo 运行的容器,容器启动以后会初始化服务的提供方(Provider)。 Provider 在启动成功以后,会向注册中心(Registry)告知,某ip,某端口,提供某服务。 Comsumer 启动以后会向注册中心订阅自己关心的服务的状态。 服务中心会向 Comsumer 发送通知,告知它关心的服务的动向。 Comsumer 获取了服务提供方(Provider)的相关信息后,就会远程调用服务方提供的方法。完成远程调用。 Comsumer 和 Provider 会定时的上报自己运行的情况。 总结 以上就是对 dubbo 的代码结构和运行步骤的简单介绍。dubbo 的源码学习也就算打开了一个序幕。 下一篇文章就会从 dubbo-container 这个包开始逐步的介绍 dubbo 的源码实现。敬请期待。

2018/4/2
articleCard.readMore

Redis 命令的执行过程

原文地址:https://www.xilidou.com/2018/03/30/redis-recommend/ 之前写了一系列文章,已经很深入的探讨了 Redis 的数据结构,数据库的实现,key的过期策略以及 Redis 是怎么处理事件的。所以距离 Redis 的单机实现只差最后一步了,就是 Redis 是怎么处理 client 发来的命令并返回结果的,所以我们就仔细讨论一下 Redis 是怎么执行命令的。 阅读这篇文章你将会了解到: Redis 是怎么执行远程客户端发来的命令的 Redis client(客户端) Redis 是单线程应用,它是如何与多个客户端简历网络链接并处理命令的? 由于 Redis 是基于 I/O 多路复用技术,为了能够处理多个客户端的请求,Redis 在本地为每一个链接到 Redis 服务器的客户端创建了一个 redisClient 的数据结构,这个数据结构包含了每个客户端各自的状态和执行的命令。 Redis 服务器使用一个链表来维护多个 redisClient 数据结构。 在服务器端用一个链表来管理所有的 redisClient。 1 2 3 4 5 6 7 struct redisServer { //... list *clients; /* List of active clients */ //... } 所以我就看看 redisClient 包含的数据结构和重要参数: 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 typedef struct redisClient { // 客户端状态标志 int flags; /* REDIS_SLAVE | REDIS_MONITOR | REDIS_MULTI ... */ // 套接字描述符 int fd; // 当前正在使用的数据库 redisDb *db; // 当前正在使用的数据库的 id (号码) int dictid; // 客户端的名字 robj *name; /* As set by CLIENT SETNAME */ // 查询缓冲区 sds querybuf; // 查询缓冲区长度峰值 size_t querybuf_peak; /* Recent (100ms or more) peak of querybuf size */ // 参数数量 int argc; // 参数对象数组 robj **argv; // 记录被客户端执行的命令 struct redisCommand *cmd, *lastcmd; // 请求的类型:内联命令还是多条命令 int reqtype; // 剩余未读取的命令内容数量 int multibulklen; /* number of multi bulk arguments left to read */ // 命令内容的长度 long bulklen; /* length of bulk argument in multi bulk request */ // 回复链表 list *reply; // 回复链表中对象的总大小 unsigned long reply_bytes; /* Tot bytes of objects in reply list */ // 已发送字节,处理 short write 用 int sentlen; /* Amount of bytes already sent in the current buffer or object being sent. */ // 回复偏移量 int bufpos; // 回复缓冲区 char buf[REDIS_REPLY_CHUNK_BYTES]; // ... } 这里需要特别的注意,redisClient 并非指远程的客户端,而是一个 Redis 服务本地的数据结构,我们可以理解这个 redisClient 是远程客户端的一个映射或者代理。 flags flags 表示了目前客户端的角色,以及目前所处的状态。他比较特殊可以单独表示一个状态或者多个状态。 querybuf querybuf 是一个 sds 动态字符串类型,所谓 buf 说明是它只是一个缓冲区,用于存储没有被解析的命令。 argc & argv 上文的 querybuf 是一个没有处理过的命令,当 Redis 将 querybuf 命令解析以后,会将得出的参数个数和以及参数分别保存在 argc 和 argv 中。argv 是一个 redisObject 的数组。 cmd Redis 使用一个字典保存了所有的 redisCommand。key 是 redisCommand 的名字,值就是一个 redisCommand 结构,这个结构保存了命令的实现函数,命令的标志,命令应该给定的参数个数,命令的执行次数和总消耗时长等统计信息,cmd 是一个 redisCommand。 当 Redis 解析出 argv 和 argc 后,会根据数组 argv[0],到字典中查询出对应的 redisCommand。上文的例子中 Redis 就会去字典去查找 SET 这个命令对应的 redisCommand。redis 会执行 redisCommand 中命令的实现函数。 buf & bufpos & reply buf 是一个长度为 REDIS_REPLY_CHUNK_BYTES 的数组。Redis 执行相应的操作以后,就会将需要返回的返回的数据存储到 buf 中,bufpos 用于记录 buf 中已用的字节数数量,当需要恢复的数据大于 REDIS_REPLY_CHUNK_BYTES 时,redis 就会是用 reply 这个链表来保存数据。 其他参数 其他参数大家看注释就能明白,就是字面的意思。省略的参数基本上涉及 Redis 集群管理的参数,在之后的文章中会继续讲解。 客户端的链接和断开 上文说过 redisServer 是用一个链表来维护所有的 redisClient 状态,每当有一个客户端发起链接以后,就会在 Redis 中生成一个对应的 redisClient 数据结构,增加到clients这个链表之后。 一个客户端很可能被多种原因断开。 总体分为几种类型: 客户端主动退出或者被 kill。 timeout 超时。 Redis 为了自我保护,会断开发的数据超过限制大小的客户端。 Redis 为了自我保护,会断需要返回的数据超过限制大小的客户端。 调用总结 当客户端和服务器端的嵌套字变得可读的时候,服务器将会调用命令请求处理器来执行以下操作: 读取嵌套字中的数据,写入 querybuf。 解析 querybuf 中的命令,记录到 argc 和 argv 中。 根据 argv[0] 查找对应的 recommand。 执行 recommand 对应的实现函数。 执行以后将结果存入 buf & bufpos & reply 中,返回给调用方。 Redis Server (服务端) 上文是从 redisClient 的角度来观察命令的执行,文章接下来的部分将会从 Redis 的代码层面,微观的观察 Redis 是怎么实现命令的执行的。 redisServer 的启动 在了解redisServer 的工作机制的工作机制之前,需要了解 redisServer 的启动做了什么: 可以继续观察 Redis 的 main() 函数。 1 2 3 4 5 6 7 8 9 int main(int argc, char **argv) { //... // 创建并初始化服务器数据结构 initServer(); //... } 我们只关注 initServer() 这个函数,他负责初始化服务器的数据结构。继续跟踪代码: 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 void initServer() { //... //创建eventLoop server.el = aeCreateEventLoop(server.maxclients+REDIS_EVENTLOOP_FDSET_INCR); /* Create an event handler for accepting new connections in TCP and Unix * domain sockets. */ // 为 TCP 连接关联连接应答(accept)处理器 // 用于接受并应答客户端的 connect() 调用 for (j = 0; j < server.ipfd_count; j++) { if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR) { redisPanic( "Unrecoverable error creating server.ipfd file event."); } } // 为本地套接字关联应答处理器 if (server.sofd > 0 && aeCreateFileEvent(server.el,server.sofd,AE_READABLE, acceptUnixHandler,NULL) == AE_ERR) redisPanic("Unrecoverable error creating server.sofd file event."); //... } 篇幅限制,我们省略了很多与本编文章无关的代码,保留了核心逻辑代码。 在上一篇文章中 《Redis 中的事件驱动模型》 我们讲解过,redis 使用不同的事件处理器,处理不同的事件。 在这段代码里面: 初始化了事件处理器的 eventLoop 向 eventLoop 中注册了两个事件处理器 acceptTcpHandler 和 acceptUnixHandler,分别处理远程的链接和本地链接。 redisClient 的创建 当有一个远程客户端连接到 Redis 的服务器,会触发 acceptTcpHandler 事件处理器. acceptTcpHandler 事件处理器,会创建一个链接。然后继续调用 acceptCommonHandler。 acceptCommonHandler 事件处理器的作用是: 调用 createClient() 方法创建 redisClient 检查已经创建的 redisClient 是否超过 server 允许的数量的上限 如果超过上限就拒绝远程连接 否则创建 redisClient 创建成功 并更新连接的统计次数,更新 redisClinet 的 flags 字段 这个时候 Redis 在服务端创建了 redisClient 数据结构,这个时候远程的客户端就在 redisServer 中创建了一个代理。远程的客户端就与 Redis 服务器建立了联系,就可以向服务器发送命令了。 处理命令 在 createClient() 行数中: 1 2 // 绑定读事件到事件 loop (开始接收命令请求) if (aeCreateFileEvent(server.el,fd,AE_READABLE,readQueryFromClient, c) == AE_ERR) 向 eventLoop 中注册了 readQueryFromClient。 readQueryFromClient 的作用就是从client中读取客户端的查询缓冲区内容。 然后调用函数 processInputBuffer 来处理客户端的请求。在 processInputBuffer 中有几个核心函数: processInlineBuffer 和 processMultibulkBuffer 解析 querybuf 中的命令,记录到 argc 和 argv 中。 processCommand 根据 argv[0] 查找对应的 recommen,执行 recommend 对应的执行函数。在执行之前还会验证命令的正确性。将结果存入 buf & bufpos & reply 中 返回数据 万事具备了,执行完了命令就需要把数据返回给远程的调用方。调用链如下 processCommand -> addReply -> prepareClientToWrite 在 prepareClientToWrite 中我们有见到了熟悉的代码: 1 aeCreateFileEvent(server.el, c->fd, AE_WRITABLE,sendReplyToClient, c) == AE_ERR) return REDIS_ERR; 向 eventloop 绑定了 sendReplyToClient 事件处理器。 在 sendReplyToClient 中观察代码发现,如果 bufpos 大于 0,将会把 buf 发送给远程的客户端,如果链表 reply 的长度大于0,就会将遍历链表 reply,发送给远程的客户端,这里需要注意的是,为了避免 reply 数据量过大,就会过度的占用资源引起 Redis 相应慢。为了解决这个问题,当写入的总数量大于 REDIS_MAX_WRITE_PER_EVENT 时,Redis 将会临时中断写入,记录操作的进度,将处理时间让给其他操作,剩余的内容等下次继续。这样的套路我们一路走来看过太多了。 总结 远程客户端连接到 redis 后,redis服务端会为远程客户端创建一个 redisClient 作为代理。 redis 会读取嵌套字中的数据,写入 querybuf 中。 解析 querybuf 中的命令,记录到 argc 和 argv 中。 根据 argv[0] 查找对应的 recommand。 执行 recommend 对应的执行函数。 执行以后将结果存入 buf & bufpos & reply 中。 返回给调用方。返回数据的时候,会控制写入数据量的大小,如果过大会分成若干次。保证 redis 的相应时间。 Redis 作为单线程应用,一直贯彻的思想就是,每个步骤的执行都有一个上限(包括执行时间的上限或者文件尺寸的上限)一旦达到上限,就会记录下当前的执行进度,下次再执行。保证了 Redis 能够及时响应不发生阻塞。 大家还可以阅读我的 Redis 相关的文章: Redis 的基础数据结构(一) 可变字符串、链表、字典 Redis 的基础数据结构(二) 整数集合、跳跃表、压缩列表 Redis 的基础数据结构(三)对象 Redis 数据库、键过期的实现 Redis 中的事件驱动模型 欢迎关注我的微信公众号:

2018/3/30
articleCard.readMore

Redis 中的事件驱动模型

原文地址:https://www.xilidou.com/2018/03/22/redis-event/ Redis 是一个事件驱动的内存数据库,服务器需要处理两种类型的事件。 文件事件 时间事件 下面就会介绍这两种事件的实现原理。 文件事件 Redis 服务器通过 socket 实现与客户端(或其他redis服务器)的交互,文件事件就是服务器对 socket 操作的抽象。 Redis 服务器,通过监听这些 socket 产生的文件事件并处理这些事件,实现对客户端调用的响应。 Reactor Redis 基于 Reactor 模式开发了自己的事件处理器。 这里就先展开讲一讲 Reactor 模式。看下图: “I/O 多路复用模块”会监听多个 FD ,当这些FD产生,accept,read,write 或 close 的文件事件。会向“文件事件分发器(dispatcher)”传送事件。 文件事件分发器(dispatcher)在收到事件之后,会根据事件的类型将事件分发给对应的 handler。 我们顺着图,从上到下的逐一讲解 Redis 是怎么实现这个 Reactor 模型的。 I/O 多路复用模块 Redis 的 I/O 多路复用模块,其实是封装了操作系统提供的 select,epoll,avport 和 kqueue 这些基础函数。向上层提供了一个统一的接口,屏蔽了底层实现的细节。 一般而言 Redis 都是部署到 Linux 系统上,所以我们就看看使用 Redis 是怎么利用 linux 提供的 epoll 实现I/O 多路复用。 首先看看 epoll 提供的三个方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 /* * 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大 */ int epoll_create(int size); /* * 可以理解为,增删改 fd 需要监听的事件 * epfd 是 epoll_create() 创建的句柄。 * op 表示 增删改 * epoll_event 表示需要监听的事件,Redis 只用到了可读,可写,错误,挂断 四个状态 */ int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); /* * 可以理解为查询符合条件的事件 * epfd 是 epoll_create() 创建的句柄。 * epoll_event 用来存放从内核得到事件的集合 * maxevents 获取的最大事件数 * timeout 等待超时时间 */ int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 再看 Redis 对文件事件,封装epoll向上提供的接口: 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 /* * 事件状态 */ typedef struct aeApiState { // epoll_event 实例描述符 int epfd; // 事件槽 struct epoll_event *events; } aeApiState; /* * 创建一个新的 epoll */ static int aeApiCreate(aeEventLoop *eventLoop) /* * 调整事件槽的大小 */ static int aeApiResize(aeEventLoop *eventLoop, int setsize) /* * 释放 epoll 实例和事件槽 */ static void aeApiFree(aeEventLoop *eventLoop) /* * 关联给定事件到 fd */ static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) /* * 从 fd 中删除给定事件 */ static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask) /* * 获取可执行事件 */ static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) 所以看看这个ae_peoll.c 如何对 epoll 进行封装的: aeApiCreate() 是对 epoll.epoll_create() 的封装。 aeApiAddEvent()和aeApiDelEvent() 是对 epoll.epoll_ctl()的封装。 aeApiPoll() 是对 epoll_wait()的封装。 这样 Redis 的利用 epoll 实现的 I/O 复用器就比较清晰了。 再往上一层次我们需要看看 ea.c 是怎么封装的? 首先需要关注的是事件处理器的数据结构: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 typedef struct aeFileEvent { // 监听事件类型掩码, // 值可以是 AE_READABLE 或 AE_WRITABLE , // 或者 AE_READABLE | AE_WRITABLE int mask; /* one of AE_(READABLE|WRITABLE) */ // 读事件处理器 aeFileProc *rfileProc; // 写事件处理器 aeFileProc *wfileProc; // 多路复用库的私有数据 void *clientData; } aeFileEvent; mask 就是可以理解为事件的类型。 除了使用 ae_peoll.c 提供的方法外,ae.c 还增加 “增删查” 的几个 API。 增:aeCreateFileEvent 删:aeDeleteFileEvent 查: 查包括两个维度 aeGetFileEvents 获取某个 fd 的监听类型和aeWait等待某个fd 直到超时或者达到某个状态。 事件分发器(dispatcher) Redis 的事件分发器 ae.c/aeProcessEvents 不但处理文件事件还处理时间事件,所以这里只贴与文件分发相关的出部分代码,dispather 根据 mask 调用不同的事件处理器。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 //从 epoll 中获关注的事件 numevents = aeApiPoll(eventLoop, tvp); for (j = 0; j < numevents; j++) { // 从已就绪数组中获取事件 aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; int mask = eventLoop->fired[j].mask; int fd = eventLoop->fired[j].fd; int rfired = 0; // 读事件 if (fe->mask & mask & AE_READABLE) { // rfired 确保读/写事件只能执行其中一个 rfired = 1; fe->rfileProc(eventLoop,fd,fe->clientData,mask); } // 写事件 if (fe->mask & mask & AE_WRITABLE) { if (!rfired || fe->wfileProc != fe->rfileProc) fe->wfileProc(eventLoop,fd,fe->clientData,mask); } processed++; } 可以看到这个分发器,根据 mask 的不同将事件分别分发给了读事件和写事件。 文件事件处理器的类型 Redis 有大量的事件处理器类型,我们就讲解处理一个简单命令涉及到的三个处理器: acceptTcpHandler 连接应答处理器,负责处理连接相关的事件,当有client 连接到Redis的时候们就会产生 AE_READABLE 事件。引发它执行。 readQueryFromClinet 命令请求处理器,负责读取通过 sokect 发送来的命令。 sendReplyToClient 命令回复处理器,当Redis处理完命令,就会产生 AE_WRITEABLE 事件,将数据回复给 client。 文件事件实现总结 我们按照开始给出的 Reactor 模型,从上到下讲解了文件事件处理器的实现,下面将会介绍时间时间的实现。 时间事件 Reids 有很多操作需要在给定的时间点进行处理,时间事件就是对这类定时任务的抽象。 先看时间事件的数据结构: 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 /* Time event structure * * 时间事件结构 */ typedef struct aeTimeEvent { // 时间事件的唯一标识符 long long id; /* time event identifier. */ // 事件的到达时间 long when_sec; /* seconds */ long when_ms; /* milliseconds */ // 事件处理函数 aeTimeProc *timeProc; // 事件释放函数 aeEventFinalizerProc *finalizerProc; // 多路复用库的私有数据 void *clientData; // 指向下个时间事件结构,形成链表 struct aeTimeEvent *next; } aeTimeEvent; 看见 next 我们就知道这个 aeTimeEvent 是一个链表结构。看图: 注意这是一个按照id倒序排列的链表,并没有按照事件顺序排序。 processTimeEvent Redis 使用这个函数处理所有的时间事件,我们整理一下执行思路: 记录最新一次执行这个函数的时间,用于处理系统时间被修改产生的问题。 遍历链表找出所有 when_sec 和 when_ms 小于现在时间的事件。 执行事件对应的处理函数。 检查事件类型,如果是周期事件则刷新该事件下一次的执行事件。 否则从列表中删除事件。 综合调度器(aeProcessEvents) 综合调度器是 Redis 统一处理所有事件的地方。我们梳理一下这个函数的简单逻辑: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 1. 获取离当前时间最近的时间事件 shortest = aeSearchNearestTimer(eventLoop); // 2. 获取间隔时间 timeval = shortest - nowTime; // 如果timeval 小于 0,说明已经有需要执行的时间事件了。 if(timeval < 0){ timeval = 0 } // 3. 在 timeval 时间内,取出文件事件。 numevents = aeApiPoll(eventLoop, timeval); // 4.根据文件事件的类型指定不同的文件处理器 if (AE_READABLE) { // 读事件 rfileProc(eventLoop,fd,fe->clientData,mask); } // 写事件 if (AE_WRITABLE) { wfileProc(eventLoop,fd,fe->clientData,mask); } 以上的伪代码就是整个 Redis 事件处理器的逻辑。 我们可以再看看谁执行了这个 aeProcessEvents: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { // 如果有需要在事件处理前执行的函数,那么运行它 if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); // 开始处理事件 aeProcessEvents(eventLoop, AE_ALL_EVENTS); } } 然后我们再看看是谁调用了 eaMain: 1 2 3 4 5 6 7 8 int main(int argc, char **argv) { //一些配置和准备 ... aeMain(server.el); //结束后的回收工作 ... } 我们在 Redis 的 main 方法中找个了它。 这个时候我们整理出的思路就是: Redis 的 main() 方法执行了一些配置和准备以后就调用 eaMain() 方法。 eaMain() while(true) 的调用 aeProcessEvents()。 所以我们说 Redis 是一个事件驱动的程序,期间我们发现,Redis 没有 fork 过任何线程。所以也可以说 Redis 是一个基于事件驱动的单线程应用。 总结 在后端的面试中 Redis 总是一个或多或少会问到的问题。 读完这篇文章你也许就能回答这几个问题: 为什么 Redis 是一个单线程应用? 为什么 Redis 是一个单线程应用,却有如此高的性能? 如果你用本文提供的知识点回答这两个问题,一定会在面试官心中留下一个高大的形象。 大家还可以阅读我的 Redis 相关的文章: Redis 的基础数据结构(一) 可变字符串、链表、字典 Redis 的基础数据结构(二) 整数集合、跳跃表、压缩列表 Redis 的基础数据结构(三)对象 Redis 数据库、键过期的实现 欢迎关注我的微信公众号:

2018/3/23
articleCard.readMore

Redis 数据库、键过期的实现

原文地址:https://www.xilidou.com/2018/03/20/redis-server/ 之前的文章讲解了 Redis 的数据结构,这回就可以看看作为内存数据库,Redis 是怎么存储数据的。以及键是怎么过期的。 阅读这篇文章你将会了解到: Redis 的数据库实现 Redis 键过期的策略 数据库的实现 我们先看代码 server.h/redisServer 1 2 3 4 5 6 7 8 9 10 11 12 struct redisServer{ ... //保存 db 的数组 redisDb *db; //db 的数量 int dbnum; ... } 再看redisDb的代码: 1 2 3 4 5 6 7 8 9 10 typedef struct redisDb { dict *dict; /* The keyspace for this DB */ dict *expires; /* Timeout of keys with a timeout set */ dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/ dict *ready_keys; /* Blocked keys that received a PUSH */ dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */ int id; /* Database ID */ long long avg_ttl; /* Average TTL, just for stats */ } redisDb; 总体来说redis的 server 包含若干个(默认16个) redisDb 数据库。 Redis 是一个 k-v 存储的键值对数据库。其中字典 dict 保存了数据库中的所有键值对,这个地方叫做 keyspace 直译过来就是“键空间”。 所以我们就可以这么认为,在 redisDb 中我们使用 dict(字典)来维护键空间。 keyspace 的 kay 是数据库的 key,每一个key 是一个字符串对象。注意不是字符串,而是字符串对象。 keyspace 的 value 是数据库的 value,这个 value 可以是 redis 的,字符串对象,列表对象,哈希表对象,集合对象或者有序对象中的一种。 数据库读写操作 所以对于数据的增删改查,就是对 keyspace 这个大 map 的增删改查。 当我们执行: 1 >redis SET mobile "13800000000" 实际上就是为 keyspace 增加了一个 key 是包含字符串“mobile”的字符串对象,value 为包含字符“13800000000”的字符串对象。 看图: 对于删改查,没啥好说的。类似java 的 map 操作,大多数程序员应该都能理解。 需要特别注意的是,再执行对键的读写操作的时候,Redis 还要做一些额外的维护动作: 维护 hit 和 miss 两个计数器。用于统计 Redis 的缓存命中率。 更新键的 LRU 时间,记录键的最后活跃时间。 如果在读取的时候发现键已经过期,Redis 先删除这个过期的键然后再执行余下操作。 如果有客户对这个键执行了 WATCH 操作,会把这个键标记为 dirty,让事务注意到这个键已经被改过。 没修改一次 dirty 会增加1。 如果服务器开启了数据库通知功能,键被修改之后,会按照配置发送通知。 键的过期实现 Redis 作为缓存使用最主要的一个特性就是可以为键值对设置过期时间。就看看 Redis 是如果实现这一个最重要的特性的? 在 Redis 中与过期时间有关的命令 EXPIRE 设置 key 的存活时间单位秒 EXPIREAT 设置 key 的过期时间点单位秒 PEXPIRE 设置 key 的存活时间单位毫秒 PEXPIREAT 设置 key 的过期时间点单位毫秒 其实这些命令,底层的命令都是由 REXPIREAT 实现的。 在 redisDb 中使用了 dict *expires,来存储过期时间的。其中 key 指向了 keyspace 中的 key(c 语言中的指针), value 是一个 long long 类型的时间戳,标定这个 key 过期的时间点,单位是毫秒。 如果我们为上文的 mobile 增加一个过期时间。 1 >redis PEXPIREAT mobile 1521469812000 这个时候就会在过期的 字典中增加一个键值对。如下图: 对于过期的判断逻辑就很简单: 在 字典 expires 中 key 是否存在。 如果 key 存在,value 的时间戳是否小于当前系统时间戳。 接下来就需要讨论一下过期的键的删除策略。 key的删除有三种策略: 定时删除,Redis定时的删除内存里面所有过期的键值对,这样能够保证内存友好,过期的key都会被删除,但是如果key的数量很多,一次删除需要CPU运算,CPU不友好。 惰性删除,只有 key 在被调用的时候才去检查键值对是否过期,但是会造成内存中存储大量的过期键值对,内存不友好,但是极大的减轻CPU 的负担。 定时部分删除,Redis定时扫描过期键,但是只删除部分,至于删除多少键,根据当前 Redis 的状态决定。 这三种策略就是对时间和空间有不同的倾向。Redis为了平衡时间和空间,采用了后两种策略 惰性删除和定时部分删除。 惰性删除比较简单,不做过多介绍。主要讨论一下定时部分删除。 过期键的定时删除的策略由 expire.c/activeExpireCycle() 函数实现,server.c/serverCron() 定时的调用 activieExpireCycle() 。 activeExpireCycle 的大的操作原则是,如果过期的key比较少,则删除key的数量也比较保守,如果,过期的键多,删除key的策略就会很激进。 1 2 3 static unsigned int current_db = 0; /* Last DB tested. */ static int timelimit_exit = 0; /* Time limit hit in previous call? */ static long long last_fast_cycle = 0; /* When last fast cycle ran. */ 首先三个 static 全局参数分别记录目前遍历的 db下标,上一次删除是否是超时退出的,上一次快速操作是什么时候进行的。 计算 timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; 可以理解为 25% 的 cpu 时间。 如果 db 中 expire 的大小为0 不操作 expire 占总 key 小于 1% 不操作 num = dictSize(db->expires);num 是 expire 使用的key的数量。 slots = dictSlots(db->expires); slots 是 expire 字典的尺寸大小。 已使用的key(num) 大于 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 则设置为 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP。也就是说每次只检查 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个键。 随机获取带过期的 key。计算是否过期,如果过期就删除。 然后各种统计,包括删除键的次数,平均过期时间。 每遍历十六次,计算操作时间,如果超过 timelimit 结束返回。 如果删除的过期键大于 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 的 1\4 就跳出循环,结束。 步骤比较复杂,总结一下:(这里都是以默认配置描述) redis 会用最多 25% 的 cpu 时间处理键的过期。 遍历所有的 redisDb 在每个 redisDb 中如果数据中没有过期键或者过期键比例过低就直接进入下一个 redisDb。 否则,遍历 redisDb 中的过期键,如果删除的键达到有过期时间的的key 的25% ,或者操作时间大于 cpu 时间的 25% 就结束当前循环,进入下一个redisDb。 后记 这篇文章主要解释了 Redis 的数据库是怎么实现的,同时介绍了 Redis 处理过期键的逻辑。看 Redis 的代码越多越发现,实际上 Redis 一直在做的一件事情就是平衡,一直在平衡程序的空间和时间。其实平时的业务设计,就是在宏观上平衡,平衡宏观系统的时间和空间。所以,看源码是让我们从微观学习系统架构的良好途径,是架构师的成长的必经之路。 我之前的三篇关于 Redis 的基础数据结构链接地址,欢迎大家阅读。 Redis 的基础数据结构(一) 可变字符串、链表、字典 Redis 的基础数据结构(二) 整数集合、跳跃表、压缩列表 Redis 的基础数据结构(三)对象 欢迎关注我的微信公众号:

2018/3/21
articleCard.readMore

Redis 的基础数据结构(三)对象

原文地址:https://xilidou.com/2018/03/15/redis-object/ 前两篇文章介绍了 Redis 的基本数据结构动态字符串,链表,字典,跳跃表,压缩链表,整数集合,但是使用过 Redis 的同学会发现,平时根本没有使用过这些数据结构。 平时使用的数据结构,包括字符串,列表,哈希,集合,还有有序集合。 其实 Redis 的实现是将底层的一种或者几种数据结构进行结合成我们使用的数据结构。 所以今天这篇文章就是要解释 Redis 是怎么实现符串,列表,哈希,集合,还有有序集合的。 对象 对于 Redis 来说使用了 redisObject 来对所有的对象进行了封装: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 typedef struct redisObject { // 对象类型 unsigned type:4; // 编码 unsigned encoding:4; // 对象最后一次被访问的时间 unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */ // 引用计数 int refcount; // 指向实际值的指针 void *ptr; } robj; 我们先关注两个参数 type 和 encoding : 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 /* Object types */ // 对象类型 #define REDIS_STRING 0 #define REDIS_LIST 1 #define REDIS_SET 2 #define REDIS_ZSET 3 #define REDIS_HASH 4 /* Objects encoding. Some kind of objects like Strings and Hashes can be * internally represented in multiple ways. The 'encoding' field of the object * is set to one of this fields for this object. */ // 对象编码 #define REDIS_ENCODING_RAW 0 /* Raw representation */ #define REDIS_ENCODING_INT 1 /* Encoded as integer */ #define REDIS_ENCODING_HT 2 /* Encoded as hash table */ #define REDIS_ENCODING_ZIPMAP 3 /* Encoded as zipmap */ #define REDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */ #define REDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */ #define REDIS_ENCODING_INTSET 6 /* E dncoded as intset */ #define REDIS_ENCODING_SKIPLIST 7 /* Encoded as skiplist */ #define REDIS_ENCODING_EMBSTR 8 /* Embedded sds string encoding */ 所以通过这段代码我们可以知道 Redis 支持的数据类型如下: type类型 REDIS_STRING字符串 REDIS_LIST列表 REDIS_SET集合 REDIS_ZSET有序集合 REDIS_HASH哈希表 Redis 的 Object 通过 ptr 指向具体的底层数据。Redis 的底层数据: 编码类型 REDIS_ENCODING_RAWSDS 实现的动态字符串对象 REDIS_ENCODING_INT整数实现的动态字符串对象 REDIS_ENCODING_HT字典实现的 hash 对象 REDIS_ENCODING_ZIPMAP压缩map实现对对象,(3.0)版本未使用 REDIS_ENCODING_LINKEDLIST双向链表实现的对象 REDIS_ENCODING_ZIPLIST压缩列表实现的对象 REDIS_ENCODING_INTSET整数集合实现的对象 REDIS_ENCODING_SKIPLIST跳跃表实现的对象 REDIS_ENCODING_EMBSTR使用 embstr 实现的动态字符串的对象 PS:下文会解释 RAW 和 EMBSTR 的区别。 我就按照类型的顺序看看 Redis 是怎么利用底层的数据结构实现不同的对象类型的。 REDIS_STRING (字符串) Redis 的字符串 String,主要由 int、raw 和 emstr 底层数据实现的。 Redis 遵循以下的原则来决定使用底层数据结构的使用。 如果数据是可以用 long 表示的整数,那就直接使用将ptr 的类型设置为long。将RedisObject 的 encoding 设置为 REDIS_ENCODING_INT。 如果是一个字符串,那就需要考察字符串的字节数。如果字节数小于 39 就是使用 emstr,encoding 就使用 REDIS_ENCODING_EMBSTR,底层依然是我们之前介绍的 SDS 。 如果字符串的长度超过 39 那就使用 raw,encoding 就是 REDIS_ENCODING_RAW。 问题来了: 为什么是 39 个字符? 我们所String对象是由一个 RedisObject 和 sdshdr 组成的。所以我们如下公式在 在64位的系统中,一个 emstr 最大占用 64bite。 RedisObject(16b) + sds header(8b) + emstr + “\0”(1b) <= 64 简单的 四则运算 emstr <= 39。 一直都是 39 么? 在 3.2 的版本的时候,作者对 sdshdr 做了修改,从 39 改成了 44。为什么? 之前我们说过一个 sdshdr 包含三个参数,len、free 还有 buf,在3.2之前 len 和 free 的数据类型都是 unsigned int。 这个就是为什么上面的公式 sds header 是 8个字节了。新版本的 sdshdr 变成了 sdshdr8, sdshdr16 和 sdshdr32还有 sdshdr64。优化的地方就在于如果 buf 小,使用更小位数的数据类型来描述 len 和 free 减少他们占用的内存,同时增加了一个char flags。emstr使用了最小的 sdshdr8。 这个时候 sds header 就变成了(len(1b) + free(1b) + flags(1b)) 3个字节, 比之前的实现少了5个字节。 所以新版本的 emstr 的最大字节变成了 44。 还是那句话 Redis 对内存真是 “斤斤计较” SDS 是动态的为什么要区分 emstr 和 raw? 区别在于生产 raw 的时候,会有两步操作,分别产生 redisObject 和 sdshdr。而 emstr 一次成型,同时生成 redisObject 和 sdshdr 。就是为了高效。同时注意 emstr 是不可变的。 他们之间是什么关系? 如果不能用 long 表示的数据,double 也是使用 raw 或者 emstr 来保存的。 按照 Redis 的套路这三个底层数据在条件满足的是是会发生装换的。REDIS_ENCODING_INT 的数据如果不是整数了,那就会变成 raw 或者 emstr。emstr 发生了变化就会变成 raw。 REDIS_LIST 列表 Reids 的列表,底层是一个 ziplist 或者 linkedlist。 当列表对象保存的字符串元素的长度都小于64字节。 保存的元素数量小于512个。 两个条件都满足使用ziplist编码,两个条件任意一个不满足时,ziplist会变为linkedlist。 3.2 以后使用 quicklist 保存。这个数据结构之前没有讲解过。 实际上 quicklist 是 ziplist 和双向链表结合的产物。我们这样理解,每个双向链表的节点上是一个ziplist。之所以这么设计,应该是空间和时间之间的取舍或者一个折中的方案。 具体的实现我会在以后的文章里面具体分析。 REDIS_SET (集合) Redis 的集合底层是一个 intset 或者 一个字典(hashtable)。 这个比较容易理解: 当集合都是整数且不超过512个的时候,就使用intset。 剩下都是用字典。 使用字典的时候,字典的每一个 key 就是集合的一个元素,对应的 value 就是一个 null。 REDIS_ZSET (有序集合) Redis 的有序集合使用 ziplist 或者 skiplist 实现的。 元素小于 128 个 每个元素长度 小于 64 字节。 同时满足以上条件使用ziplist,否则使用skiplist。 对于 ziplist 的实现,redis 使用相邻的两个 entity 分别保存对象以及对象的排序因子。这样对于插入和查询的复杂度都是 O(n) 的。直接看图: 元素开发工程师,排序的因子就是月薪。(好吧php是世界上最好的语言)。 对于skiplist 的实现: 1 2 3 4 5 6 7 8 9 typedef struct zset{ zskiplist *zsl; dict *dict }zset; skiplist 的有序链表的实现不只是只有一个 skiplist ,还有一个字典存储对象的key 和 排序因子的映射,这个是为了保证按照key 查询的时候时间负责度为 O(1)。同时有序性依赖 skiplist 维护。大家可以看我之前的教程。所以直接看图: REDIS_HASH (hash表) Redis 的 hash 表 使用 ziplist 和 字典 实现的。 键值对的键和值都小于 64 个字节 键值对的数量小于 512。 都满足的时候使用 ziplist,否则使用字典。 ziplist 的实现类似,类似 zset 的实现。两个entity成对出现。一个存储key,另一个存储 velue。 还是可以使用上面使用过的图。这个时候 entity 不用排序。key 是职位名称,velue 是对应的月薪。(好吧php还是世界上最好的语言)。与zset实现的区别就是查询是 O(n) 的,插入直接往tail后面插入就行时间复杂度O(1)。 使用字典实现一个 hash表。好像没有什么可以多说的。 int refcount(引用计数器) 这个参数是引用计数。Redis 自己管理内存,所以就使用了最简单的内存管理方式–引用计数。 创建对象的时候计数器为1 每被一个地方引用,计数器加一 每被取消引用,计数器减一 计数器为0的时候,就说明没有地方需要这个对象了。内存就会被 Redis 回收。 unsigned lru:REDIS_LRU_BITS 这个参数记录了对象的最后一次活跃时间。 如果 Redis 开启了淘汰策略,且淘汰的方式是 LRU 的时候,这个参数就派上了用场。Redis 会优先回收 lru 最久的对象。 总结 至此 Redis 的数据结构就介绍完了。 大家可以阅读之前的文章: Redis 的基础数据结构(一) 可变字符串、链表、字典 Redis 的基础数据结构(二) 整数集合、跳跃表、压缩列表 欢迎关注我的微信公众号:

2018/3/15
articleCard.readMore

Redis 的基础数据结构(二) 整数集合、跳跃表、压缩列表

原文地址:https://www.xilidou.com/2018/03/13/redis-data2/ 上篇文章写了 Redis 基础数据结构的可变字符串、链表、字典。大家可以点击链接查看。今天我们继续研究 Redis 的基础数据结构。 整数集合 跳跃表 压缩列表 整数集合 当一个集合只包含整数,且这个集合的元素不多的时候,Redis 就会使用整数集合 intset 。首先看 intset 的数据结构: 1 2 3 4 5 6 7 8 9 10 11 12 typedef struct intset { // 编码方式 uint32_t encoding; // 集合包含的元素数量 uint32_t length; // 保存元素的数组 int8_t contents[]; } intset; 其实 intset 的数据结构比较好理解。一个数据保存元素,length 保存元素的数量,也就是contents的大小,encoding 用于保存数据的编码方式。 通过代码我们可以知道,encoding 的编码类型包括了: 1 2 3 #define INTSET_ENC_INT16 (sizeof(int16_t)) #define INTSET_ENC_INT32 (sizeof(int32_t)) #define INTSET_ENC_INT64 (sizeof(int64_t)) 实际上我们可以看出来。 Redis encoding的类型,就是指数据的大小。作为一个内存数据库,采用这种设计就是为了节约内存。 既然有从小到大的三个数据结构,在插入数据的时候尽可能使用小的数据结构来节约内存,如果插入的数据大于原有的数据结构,就会触发扩容。 扩容有三个步骤: 根据新元素的类型,修改整个数组的数据类型,并重新分配空间 将原有的的数据,装换为新的数据类型,重新放到应该在的位置上,且保存顺序性 再插入新元素 整数集合不支持降级操作,一旦升级就不能降级了。 跳跃表 跳跃表是链表的一种,是一种利用空间换时间的数据结构。跳表平均支持 O(logN),最坏O(N)复杂度的查找。 跳表是由一个zskiplist 和 多个 zskiplistNode 组成。我们先看看他们的结构: 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 /* ZSETs use a specialized version of Skiplists */ /* * 跳跃表节点 */ typedef struct zskiplistNode { // 成员对象 robj *obj; // 分值 double score; // 后退指针 struct zskiplistNode *backward; // 层 struct zskiplistLevel { // 前进指针 struct zskiplistNode *forward; // 跨度 unsigned int span; } level[]; } zskiplistNode; /* * 跳跃表 */ typedef struct zskiplist { // 表头节点和表尾节点 struct zskiplistNode *header, *tail; // 表中节点的数量 unsigned long length; // 表中层数最大的节点的层数 int level; } zskiplist; 所以根据这个代码我们可以画出如下的结构图: 其实跳表就是一个利用空间换时间的数据结构,利用 level 作为链表的索引。 之前有人问过 Redis 的作者 为什么使用跳跃表,而不是 tree 来构建索引?作者的回答是: 省内存。 服务于 ZRANGE 或者 ZREVRANGE 是一个典型的链表场景。时间复杂度的表现和平衡树差不多。 最重要的一点是跳跃表的实现很简单就能达到 O(logN)的级别。 压缩列表 压缩链表 Redis 作者的介绍是,为了尽可能节约内存设计出来的双向链表。 对于一个压缩列表代码里注释给出的数据结构如下: zlbytes 表示的是整个压缩列表使用的内存字节数 zltail 指定了压缩列表的尾节点的偏移量 zllen 是压缩列表 entry 的数量 entry 就是 ziplist 的节点 zlend 标记压缩列表的末端 这个列表中还有单个指针: ZIPLIST_ENTRY_HEAD 列表开始节点的头偏移量 ZIPLIST_ENTRY_TAIL 列表结束节点的头偏移量 ZIPLIST_ENTRY_END 列表的尾节点结束的偏移量 再看看一个 entry 的结构: 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 /* * 保存 ziplist 节点信息的结构 */ typedef struct zlentry { // prevrawlen :前置节点的长度 // prevrawlensize :编码 prevrawlen 所需的字节大小 unsigned int prevrawlensize, prevrawlen; // len :当前节点值的长度 // lensize :编码 len 所需的字节大小 unsigned int lensize, len; // 当前节点 header 的大小 // 等于 prevrawlensize + lensize unsigned int headersize; // 当前节点值所使用的编码类型 unsigned char encoding; // 指向当前节点的指针 unsigned char *p; } zlentry; 依次解释一下这几个参数。 prevrawlen 前置节点的长度,这里多了一个 size,其实是记录了 prevrawlen 的尺寸。Redis 为了节约内存并不是直接使用默认的 int 的长度,而是逐渐升级的。 同理 len 记录的是当前节点的长度,lensize 记录的是 len 的长度。 headersize 就是前文提到的两个 size 之和。 encoding 就是这个节点的数据类型。这里注意一下 encoding 的类型只包括整数和字符串。 p 节点的指针,不用过多的解释。 需要注意一点,因为每个节点都保存了前一个节点的长度,如果发生了更新或者删除节点,则这个节点之后的数据也需要修改,有一种最坏的情况就是如果每个节点都处于需要扩容的零界点,就会造成这个节点之后的节点都要修改 size 这个参数,引发连锁反应。这个时候就是 压缩链表最坏的时间复杂度 O(n^2)。不过所有节点都处于临界值,这样的概率可以说比较小。 总结 至此Redis的基本数据结构就介绍完了。我们可以看到 Redis 对内存的使用真是“斤斤计较”,对于内存是使用特别节约。同时 Redis 作为一个单线程应用,不用考虑并发的问题,将很多类似 size 或者 length 的参数暴露出来,将很多 O(n) 的操作降低为 O(1)。大大提升效率。下一讲,将会介绍 Redis 是怎么通过这些数据结构向外提供服务。 Redis 的代码真是写的太棒了,简洁高效。值得大家学习。 欢迎关注我的微信公众号:

2018/3/14
articleCard.readMore

Redis 的基础数据结构(一) 可变字符串、链表、字典

原文地址:https://www.xilidou.com/2018/03/12/redis-data/ 这周开始学习 Redis,看看Redis是怎么实现的。所以会写一系列关于 Redis的文章。这篇文章关于 Redis 的基础数据。阅读这篇文章你可以了解: 动态字符串(SDS) 链表 字典 三个数据结构 Redis 是怎么实现的。 R SDS (Simple Dynamic String)是 Redis 最基础的数据结构。直译过来就是”简单的动态字符串“。Redis 自己实现了一个动态的字符串,而不是直接使用了 C 语言中的字符串。 sds 的数据结构: 1 2 3 4 5 6 7 8 9 10 11 struct sdshdr { // buf 中已占用空间的长度 int len; // buf 中剩余可用空间的长度 int free; // 数据空间 char buf[]; }; 所以一个 SDS 的就如下图: 所以我们看到,sds 包含3个参数。buf 的长度 len,buf 的剩余长度,以及buf。 为什么这么设计呢? 可以直接获取字符串长度。 C 语言中,获取字符串的长度需要用指针遍历字符串,时间复杂度为 O(n),而 SDS 的长度,直接从len 获取复杂度为 O(1)。 杜绝缓冲区溢出。 由于C 语言不记录字符串长度,如果增加一个字符传的长度,如果没有注意就可能溢出,覆盖了紧挨着这个字符的数据。对于SDS 而言增加字符串长度需要验证 free的长度,如果free 不够就会扩容整个 buf,防止溢出。 减少修改字符串长度时造成的内存再次分配。 redis 作为高性能的内存数据库,需要较高的相应速度。字符串也很大概率的频繁修改。 SDS 通过未使用空间这个参数,将字符串的长度和底层buf的长度之间的额关系解除了。buf的长度也不是字符串的长度。基于这个分设计 SDS 实现了空间的预分配和惰性释放。 预分配 如果对 SDS 修改后,如果 len 小于 1MB 那 len = 2 * len + 1byte。 这个 1 是用于保存空字节。 如果 SDS 修改后 len 大于 1MB 那么 len = 1MB + len + 1byte。 惰性释放 如果缩短 SDS 的字符串长度,redis并不是马上减少 SDS 所占内存。只是增加 free 的长度。同时向外提供 API 。真正需要释放的时候,才去重新缩小 SDS 所占的内存 二进制安全。 C 语言中的字符串是以 ”\0“ 作为字符串的结束标记。而 SDS 是使用 len 的长度来标记字符串的结束。所以SDS 可以存储字符串之外的任意二进制流。因为有可能有的二进制流在流中就包含了”\0“造成字符串提前结束。也就是说 SDS 不依赖 “\0” 作为结束的依据。 兼容C语言 SDS 按照惯例使用 ”\0“ 作为结尾的管理。部分普通C 语言的字符串 API 也可以使用。 链表 C语言中并没有链表这个数据结构所以 Redis 自己实现了一个。Redis 中的链表是: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 typedef struct listNode { // 前置节点 struct listNode *prev; // 后置节点 struct listNode *next; // 节点的值 void *value; } listNode; 非常典型的双向链表的数据结构。 同时为双向链表提供了如下操作的函数: 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 /* * 双端链表迭代器 */ typedef struct listIter { // 当前迭代到的节点 listNode *next; // 迭代的方向 int direction; } listIter; /* * 双端链表结构 */ typedef struct list { // 表头节点 listNode *head; // 表尾节点 listNode *tail; // 节点值复制函数 void *(*dup)(void *ptr); // 节点值释放函数 void (*free)(void *ptr); // 节点值对比函数 int (*match)(void *ptr, void *key); // 链表所包含的节点数量 unsigned long len; } list; 链表的结构比较简单,数据结构如下: 总结一下性质: 双向链表,某个节点寻找上一个或者下一个节点时间复杂度 O(1)。 list 记录了 head 和 tail,寻找 head 和 tail 的时间复杂度为 O(1)。 获取链表的长度 len 时间复杂度 O(1)。 字典 字典数据结构极其类似 java 中的 Hashmap。 Redis的字典由三个基础的数据结构组成。最底层的单位是哈希表节点。结构如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 typedef struct dictEntry { // 键 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry; 实际上哈希表节点就是一个单项列表的节点。保存了一下下一个节点的指针。 key 就是节点的键,v是这个节点的值。这个 v 既可以是一个指针,也可以是一个 uint64_t或者 int64_t 整数。*next 指向下一个节点。 通过一个哈希表的数组把各个节点链接起来: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 typedef struct dictht { // 哈希表数组 dictEntry **table; // 哈希表大小 unsigned long size; // 哈希表大小掩码,用于计算索引值 // 总是等于 size - 1 unsigned long sizemask; // 该哈希表已有节点的数量 unsigned long used; } dictht; dictht 通过图示我们观察: 实际上,如果对java 的基本数据结构了解的同学就会发现,这个数据结构和 java 中的 HashMap 是很类似的,就是数组加链表的结构。 字典的数据结构: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 typedef struct dict { // 类型特定函数 dictType *type; // 私有数据 void *privdata; // 哈希表 dictht ht[2]; // rehash 索引 // 当 rehash 不在进行时,值为 -1 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ // 目前正在运行的安全迭代器的数量 int iterators; /* number of iterators currently running */ } dict; 其中的dictType 是一组方法,代码如下: 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 /* * 字典类型特定函数 */ typedef struct dictType { // 计算哈希值的函数 unsigned int (*hashFunction)(const void *key); // 复制键的函数 void *(*keyDup)(void *privdata, const void *key); // 复制值的函数 void *(*valDup)(void *privdata, const void *obj); // 对比键的函数 int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 销毁键的函数 void (*keyDestructor)(void *privdata, void *key); // 销毁值的函数 void (*valDestructor)(void *privdata, void *obj); } dictType; 字典的数据结构如下图: 这里我们可以看到一个dict 拥有两个 dictht。一般来说只使用 ht[0],当扩容的时候发生了rehash的时候,ht[1]才会被使用。 当我们观察或者研究一个hash结构的时候偶我们首先要考虑的这个 dict 如何插入一个数据? 我们梳理一下插入数据的逻辑。 计算Key 的 hash 值。找到 hash 映射到 table 数组的位置。 如果数据已经有一个 key 存在了。那就意味着发生了 hash 碰撞。新加入的节点,就会作为链表的一个节点接到之前节点的 next 指针上。 如果 key 发生了多次碰撞,造成链表的长度越来越长。会使得字典的查询速度下降。为了维持正常的负载。Redis 会对 字典进行 rehash 操作。来增加 table 数组的长度。所以我们要着重了解一下 Redis 的 rehash。步骤如下: 根据ht[0] 的数据和操作的类型(扩大或缩小),分配 ht[1] 的大小。 将 ht[0] 的数据 rehash 到 ht[1] 上。 rehash 完成以后,将ht[1] 设置为 ht[0],生成一个新的ht[1]备用。 渐进式的 rehash 。 其实如果字典的 key 数量很大,达到千万级以上,rehash 就会是一个相对较长的时间。所以为了字典能够在 rehash 的时候能够继续提供服务。Redis 提供了一个渐进式的 rehash 实现,rehash的步骤如下: 分配 ht[1] 的空间,让字典同时持有 ht[1] 和 ht[0]。 在字典中维护一个 rehashidx,设置为 0 ,表示字典正在 rehash。 在rehash期间,每次对字典的操作除了进行指定的操作以外,都会根据 ht[0] 在 rehashidx 上对应的键值对 rehash 到 ht[1]上。 随着操作进行, ht[0] 的数据就会全部 rehash 到 ht[1] 。设置ht[0] 的 rehashidx 为 -1,渐进的 rehash 结束。 这样保证数据能够平滑的进行 rehash。防止 rehash 时间过久阻塞线程。 在进行 rehash 的过程中,如果进行了 delete 和 update 等操作,会在两个哈希表上进行。如果是 find 的话优先在ht[0] 上进行,如果没有找到,再去 ht[1] 中查找。如果是 insert 的话那就只会在 ht[1]中插入数据。这样就会保证了 ht[1] 的数据只增不减,ht[0]的数据只减不增。

2018/3/12
articleCard.readMore

线程池 execute() 的工作逻辑

原文地址:https://www.xilidou.com/2018/02/09/thread-corepoolsize/ 最近在看《Java并发编程的艺术》回顾线程池的原理和参数的时候发现一个问题,如果 corePoolSize = 0 且 阻塞队列是无界的。线程池将如何工作? 我们先回顾一下书里面描述线程池execute()工作的逻辑: 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。 如果运行的线程等于或多于 corePoolSize,将任务加入 BlockingQueue。 如果 BlockingQueue 内的任务超过上限,则创建新的线程来处理任务。 如果创建的线程数是单钱运行的线程超出 maximumPoolSize,任务将被拒绝策略拒绝。 看了这四个步骤,其实描述上是有一个漏洞的。如果核心线程数是0,阻塞队列也是无界的,会怎样?如果按照上文的逻辑,应该没有线程会被运行,然后线程无限的增加到队列里面。然后呢? 于是我做了一下试验看看到底会怎样? 1 2 3 4 5 6 7 8 9 10 11 public class threadTest { private final static ThreadPoolExecutor executor = new ThreadPoolExecutor(0,1,0, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()); public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(); while (true) { executor.execute(() -> { System.out.println(atomicInteger.getAndAdd(1)); }); } } } 结果里面的System.out.println(atomicInteger.getAndAdd(1));语句执行了,与上面的描述矛盾了。到底发生了什么?线程池创建线程的逻辑是什么?我们还是从源码来看看到底线程池的逻辑是什么? ctl 要了解线程池,我们首先要了解的线程池里面的状态控制的参数 ctl。 线程池的ctl是一个原子的 AtomicInteger。 这个ctl包含两个参数 : workerCount 激活的线程数 runState 当前线程池的状态 它的低29位用于存放当前的线程数, 因此一个线程池在理论上最大的线程数是 536870911; 高 3 位是用于表示当前线程池的状态, 其中高三位的值和状态对应如下: 111: RUNNING 000: SHUTDOWN 001: STOP 010: TIDYING 110: TERMINATED 为了能够使用 ctl 线程池提供了三个方法: 1 2 3 4 5 6 7 // Packing and unpacking ctl // 获取线程池的状态 private static int runStateOf(int c) { return c & ~CAPACITY; } // 获取线程池的工作线程数 private static int workerCountOf(int c) { return c & CAPACITY; } // 根据工作线程数和线程池状态获取 ctl private static int ctlOf(int rs, int wc) { return rs | wc; } execute 外界通过 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 25 26 27 28 29 30 31 32 33 public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); //如果工作线程数小于核心线程数, if (workerCountOf(c) < corePoolSize) { //执行addWork,提交为核心线程,提交成功return。提交失败重新获取ctl if (addWorker(command, true)) return; c = ctl.get(); } //如果工作线程数大于核心线程数,则检查线程池状态是否是正在运行,且将新线程向阻塞队列提交。 if (isRunning(c) && workQueue.offer(command)) { //recheck 需要再次检查,主要目的是判断加入到阻塞队里中的线程是否可以被执行 int recheck = ctl.get(); //如果线程池状态不为running,将任务从阻塞队列里面移除,启用拒绝策略 if (! isRunning(recheck) && remove(command)) reject(command); // 如果线程池的工作线程为零,则调用addWoker提交任务 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //添加非核心线程失败,拒绝 else if (!addWorker(command, false)) reject(command); } addWorker 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 private boolean addWorker(Runnable firstTask, boolean core) { retry: for (;;) { int c = ctl.get(); //获取线程池状态 int rs = runStateOf(c); // Check if queue empty only if necessary. // 判断是否可以添加任务。 if (rs >= SHUTDOWN && ! (rs == SHUTDOWN && firstTask == null && ! workQueue.isEmpty())) return false; for (;;) { //获取工作线程数量 int wc = workerCountOf(c); //是否大于线程池上限,是否大于核心线程数,或者最大线程数 if (wc >= CAPACITY || wc >= (core ? corePoolSize : maximumPoolSize)) return false; //CAS 增加工作线程数 if (compareAndIncrementWorkerCount(c)) break retry; c = ctl.get(); // Re-read ctl //如果线程池状态改变,回到开始重新来 if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } boolean workerStarted = false; boolean workerAdded = false; Worker w = null; //上面的逻辑是考虑是否能够添加线程,如果可以就cas的增加工作线程数量 //下面正式启动线程 try { //新建worker w = new Worker(firstTask); //获取当前线程 final Thread t = w.thread; if (t != null) { //获取可重入锁 final ReentrantLock mainLock = this.mainLock; //锁住 mainLock.lock(); try { // Recheck while holding lock. // Back out on ThreadFactory failure or if // shut down before lock acquired. int rs = runStateOf(ctl.get()); // rs < SHUTDOWN ==> 线程处于RUNNING状态 // 或者线程处于SHUTDOWN状态,且firstTask == null(可能是workQueue中仍有未执行完成的任务,创建没有初始任务的worker线程执行) if (rs < SHUTDOWN || (rs == SHUTDOWN && firstTask == null)) { // 当前线程已经启动,抛出异常 if (t.isAlive()) // precheck that t is startable throw new IllegalThreadStateException(); //workers 是一个 HashSet 必须在 lock的情况下操作。 workers.add(w); int s = workers.size(); //设置 largeestPoolSize 标记workAdded if (s > largestPoolSize) largestPoolSize = s; workerAdded = true; } } finally { mainLock.unlock(); } //如果添加成功,启动线程 if (workerAdded) { t.start(); workerStarted = true; } } } finally { //启动线程失败,回滚。 if (! workerStarted) addWorkerFailed(w); } return workerStarted; } 先看看 addWork() 的两个参数,第一个是需要提交的线程 Runnable firstTask,第二个参数是 boolean 类型,表示是否为核心线程。 execute() 中有三处调用了 addWork() 我们逐一分析。 第一次,条件 if (workerCountOf(c) < corePoolSize) 这个很好理解,工作线程数少于核心线程数,提交任务。所以 addWorker(command, true)。 第二次,如果 workerCountOf(recheck) == 0 如果worker的数量为0,那就 addWorker(null,false)。为什么这里是 null ?之前已经把 command 提交到阻塞队列了 workQueue.offer(command) 。所以提交一个空线程,直接从阻塞队列里面取就可以了。 第三次,如果线程池没有 RUNNING 或者 offer 阻塞队列失败,addWorker(command,false),很好理解,对应的就是,阻塞队列满了,将任务提交到,非核心线程池。与最大线程池比较。 至此,重新归纳execute()的逻辑应该是: 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。 如果运行的线程等于或多于 corePoolSize,将任务加入 BlockingQueue。 如果加入 BlockingQueue 成功,需要二次检查线程池的状态如果线程池没有处于 Running,则从 BlockingQueue 移除任务,启动拒绝策略。 如果线程池处于 Running状态,则检查工作线程(worker)是否为0。如果为0,则创建新的线程来处理任务。如果启动线程数大于maximumPoolSize,任务将被拒绝策略拒绝。 如果加入 BlockingQueue 。失败,则创建新的线程来处理任务。 如果启动线程数大于maximumPoolSize,任务将被拒绝策略拒绝。 总结 回顾我开始提出的问题: 如果 corePoolSize = 0 且 阻塞队列是无界的。线程池将如何工作? 这个问题应该就不难回答了。 最后 《Java并发编程的艺术》是一本学习 java 并发编程的好书,在这里推荐给大家。 同时,希望大家在阅读技术数据的时候要仔细思考,结合源码,发现,提出问题,解决问题。这样的学习才能高效且透彻。 欢迎关注我的微信公众号

2018/2/10
articleCard.readMore

JAVA 中的 CAS

原文地址:https://www.xilidou.com/2018/02/01/java-cas/ CAS 是现代操作系统,解决并发问题的一个重要手段,最近在看 eureka 的源码的时候。遇到了很多 CAS 的操作。今天就系统的回顾一下 Java 中的CAS。 阅读这篇文章你将会了解到: 什么是 CAS CAS 实现原理是什么? CAS 在现实中的应用 自旋锁 原子类型 限流器 CAS 的缺点 什么是 CAS CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作: 我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。 比较 A 与 V 是否相等。(比较) 如果比较相等,将 B 写入 V。(交换) 返回操作是否成功。 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。可见 CAS 其实是一个乐观锁。 CAS 是怎么实现的 跟随AtomInteger的代码我们一路往下,就能发现最终调用的是 sum.misc.Unsafe 这个类。看名称 Unsafe 就是一个不安全的类,这个类是利用了 Java 的类和包在可见性的的规则中的一个恰到好处处的漏洞。Unsafe 这个类为了速度,在Java的安全标准上做出了一定的妥协。 再往下寻找我们发现 Unsafe的compareAndSwapInt 是 Native 的方法: 1 public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); 也就是说,这几个 CAS 的方法应该是使用了本地的方法。所以这几个方法的具体实现需要我们自己去 jdk 的源码中搜索。 于是我下载一个 OpenJdk 的源码继续向下探索,我们发现在 /jdk9u/hotspot/src/share/vm/unsafe.cpp 中有这样的代码: 1 {CC "compareAndSetInt", CC "(" OBJ "J""I""I"")Z", FN_PTR(Unsafe_CompareAndSetInt)}, 这个涉及到,JNI 的调用,感兴趣的同学可以自行学习。我们搜索 Unsafe_CompareAndSetInt后发现: 1 2 3 4 5 6 UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSetInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) { oop p = JNIHandles::resolve(obj); jint* addr = (jint *)index_oop_from_field_offset_long(p, offset); return (jint)(Atomic::cmpxchg(x, addr, e)) == e; } UNSAFE_END 最终我们终于看到了核心代码 Atomic::cmpxchg。 继续向底层探索,在文件java/jdk9u/hotspot/src/os_cpu/linux_x86/vm/atomic_linux_x86.hpp有这样的代码: 1 2 3 4 5 6 7 8 inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value, cmpxchg_memory_order order) { int mp = os::is_MP(); __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)" : "=a" (exchange_value) : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp) : "cc", "memory"); return exchange_value; } 我们通过文件名可以知道,针对不同的操作系统,JVM 对于 Atomic::cmpxchg 应该有不同的实现。由于我们服务基本都是使用的是64位linux,所以我们就看看linux_x86 的实现。 我们继续看代码: __asm__ 的意思是这个是一段内嵌汇编代码。也就是在 C 语言中使用汇编代码。 这里的 volatile和 JAVA 有一点类似,但不是为了内存的可见性,而是告诉编译器对访问该变量的代码就不再进行优化。 LOCK_IF_MP(%4) 的意思就比较简单,就是如果操作系统是多线程的,那就增加一个 LOCK。 cmpxchgl 就是汇编版的“比较并交换”。但是我们知道比较并交换,有三个步骤,不是原子的。所以在多核情况下加一个 LOCK,由CPU硬件保证他的原子性。 我们再看看 LOCK 是怎么实现的呢?我们去Intel的官网上看看,可以知道LOCK在的早期实现是直接将 cup 的总线阻塞,这样的实现可见效率是很低下的。后来优化为X86 cpu 有锁定一个特定内存地址的能力,当这个特定内存地址被锁定后,它就可以阻止其他的系统总线读取或修改这个内存地址。 关于 CAS 的底层探索我们就到此为止。我们总结一下 JAVA 的 cas 是怎么实现的: java 的 cas 利用的的是 unsafe 这个类提供的 cas 操作。 unsafe 的cas 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg Atomic::cmpxchg 的实现使用了汇编的 cas 操作,并使用 cpu 硬件提供的 lock信号保证其原子性 CAS 的应用 了解了 CAS 的原理我们继续就看看 CAS 的应用: 自旋锁 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class SpinLock { private AtomicReference<Thread> sign =new AtomicReference<>(); public void lock(){ Thread current = Thread.currentThread(); while(!sign .compareAndSet(null, current)){ } } public void unlock (){ Thread current = Thread.currentThread(); sign .compareAndSet(current, null); } } 所谓自旋锁,我觉得这个名字相当的形象,在lock()的时候,一直while()循环,直到 cas 操作成功为止。 AtomicInteger 的 incrementAndGet() 1 2 3 4 5 6 7 8 public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; } 与自旋锁有异曲同工之妙,就是一直while,直到操作成功为止。 令牌桶限流器 所谓令牌桶限流器,就是系统以恒定的速度向桶内增加令牌。每次请求前从令牌桶里面获取令牌。如果获取到令牌就才可以进行访问。当令牌桶内没有令牌的时候,拒绝提供服务。我们来看看 eureka 的限流器是如何使用 CAS 来维护多线程环境下对 token 的增加和分发的。 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 public class RateLimiter { private final long rateToMsConversion; private final AtomicInteger consumedTokens = new AtomicInteger(); private final AtomicLong lastRefillTime = new AtomicLong(0); @Deprecated public RateLimiter() { this(TimeUnit.SECONDS); } public RateLimiter(TimeUnit averageRateUnit) { switch (averageRateUnit) { case SECONDS: rateToMsConversion = 1000; break; case MINUTES: rateToMsConversion = 60 * 1000; break; default: throw new IllegalArgumentException("TimeUnit of " + averageRateUnit + " is not supported"); } } //提供给外界获取 token 的方法 public boolean acquire(int burstSize, long averageRate) { return acquire(burstSize, averageRate, System.currentTimeMillis()); } public boolean acquire(int burstSize, long averageRate, long currentTimeMillis) { if (burstSize <= 0 || averageRate <= 0) { // Instead of throwing exception, we just let all the traffic go return true; } //添加token refillToken(burstSize, averageRate, currentTimeMillis); //消费token return consumeToken(burstSize); } private void refillToken(int burstSize, long averageRate, long currentTimeMillis) { long refillTime = lastRefillTime.get(); long timeDelta = currentTimeMillis - refillTime; //根据频率计算需要增加多少 token long newTokens = timeDelta * averageRate / rateToMsConversion; if (newTokens > 0) { long newRefillTime = refillTime == 0 ? currentTimeMillis : refillTime + newTokens * rateToMsConversion / averageRate; // CAS 保证有且仅有一个线程进入填充 if (lastRefillTime.compareAndSet(refillTime, newRefillTime)) { while (true) { int currentLevel = consumedTokens.get(); int adjustedLevel = Math.min(currentLevel, burstSize); // In case burstSize decreased int newLevel = (int) Math.max(0, adjustedLevel - newTokens); // while true 直到更新成功为止 if (consumedTokens.compareAndSet(currentLevel, newLevel)) { return; } } } } } private boolean consumeToken(int burstSize) { while (true) { int currentLevel = consumedTokens.get(); if (currentLevel >= burstSize) { return false; } // while true 直到没有token 或者 获取到为止 if (consumedTokens.compareAndSet(currentLevel, currentLevel + 1)) { return true; } } } public void reset() { consumedTokens.set(0); lastRefillTime.set(0); } } 所以梳理一下 CAS 在令牌桶限流器的作用。就是保证在多线程情况下,不阻塞线程的填充token 和消费token。 归纳 通过上面的三个应用我们归纳一下 CAS 的应用场景: CAS 的使用能够避免线程的阻塞。 多数情况下我们使用的是 while true 直到成功为止。 CAS 缺点 ABA 的问题,就是一个值从A变成了B又变成了A,使用CAS操作不能发现这个值发生变化了,处理方式是可以使用携带类似时间戳的版本AtomicStampedReference 性能问题,我们使用时大部分时间使用的是 while true 方式对数据的修改,直到成功为止。优势就是相应极快,但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。 总结 CAS 是整个编程重要的思想之一。整个计算机的实现中都有CAS的身影。微观上看汇编的 CAS 是实现操作系统级别的原子操作的基石。从编程语言角度来看 CAS 是实现多线程非阻塞操作的基石。宏观上看,在分布式系统中,我们可以使用 CAS 的思想利用类似Redis的外部存储,也能实现一个分布式锁。 从某个角度来说架构就将微观的实现放大,或者底层思想就是将宏观的架构进行微缩。计算机的思想是想通的,所以说了解底层的实现可以提升架构能力,提升架构的能力同样可加深对底层实现的理解。计算机知识浩如烟海,但是套路有限。抓住基础的几个套路突破,从思想和思维的角度学习计算机知识。不要将自己的精力花费在不停的追求新技术的脚步上,跟随‘start guide line’只能写一个demo,所得也就是一个demo而已。 停下脚步,回顾基础和经典或许对于技术的提升更大一些。 希望这篇文章对大家有所帮助。 徒手撸框架系列文章地址: 徒手撸框架–高并发环境下的请求合并 徒手撸框架–实现IoC 徒手撸框架–实现Aop 欢迎关注我的微信公众号

2018/2/1
articleCard.readMore

徒手撸框架--高并发环境下的请求合并

原文地址:https://www.xilidou.com/2018/01/22/merge-request/ 在高并发系统中,我们经常遇到这样的需求:系统产生大量的请求,但是这些请求实时性要求不高。我们就可以将这些请求合并,达到一定数量我们统一提交。最大化的利用系统性IO,提升系统的吞吐性能。 所以请求合并框架需要考虑以下两个需求: 当请求收集到一定数量时提交数据 一段时间后如果请求没有达到指定的数量也进行提交 我们就聊聊一如何实现这样一个需求。 阅读这篇文章你将会了解到: ScheduledThreadPoolExecutor 阻塞队列 线程安全的参数 LockSupport的使用 设计思路和实现 我们就聊一聊实现这个东西的具体思路是什么。希望大家能够学习到分析问题,设计模块的一些套路。 1. 底层使用什么数据结构来持有需要合并的请求? 既然我们的系统是在高并发的环境下使用,那我们肯定不能使用,普通的ArrayList来持有。我们可以使用阻塞队列来持有需要合并的请求。 我们的数据结构需要提供一个 add() 的方法给外部,用于提交数据。当外部add数据以后,需要检查队列里面的数据的个数是否达到我们限额?达到数量提交数据,不达到继续等待。 数据结构还需要提供一个timeOut()的方法,外部有一个计时器定时调用这个timeOut方法,如果方法被调用,则直接向远程提交数据。 条件满足的时候线程执行提交动作,条件不满足的时候线程应当暂停,等待队列达到提交数据的条件。所以我们可以考虑使用 LockSupport.park()和LockSupport.unpark 来暂停和激活操作线程。 经过上面的分析,我们就有了这样一个数据结构: 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 private static class FlushThread<Item> implements Runnable{ private final String name; //队列大小 private final int bufferSize; //操作间隔 private int flushInterval; //上一次提交的时间。 private volatile long lastFlushTime; private volatile Thread writer; //持有数据的阻塞队列 private final BlockingQueue<Item> queue; //达成条件后具体执行的方法 private final Processor<Item> processor; //构造函数 public FlushThread(String name, int bufferSize, int flushInterval,int queueSize,Processor<Item> processor) { this.name = name; this.bufferSize = bufferSize; this.flushInterval = flushInterval; this.lastFlushTime = System.currentTimeMillis(); this.processor = processor; this.queue = new ArrayBlockingQueue<>(queueSize); } //外部提交数据的方法 public boolean add(Item item){ boolean result = queue.offer(item); flushOnDemand(); return result; } //提供给外部的超时方法 public void timeOut(){ //超过两次提交超过时间间隔 if(System.currentTimeMillis() - lastFlushTime >= flushInterval){ start(); } } //解除线程的阻塞 private void start(){ LockSupport.unpark(writer); } //当前的数据是否大于提交的条件 private void flushOnDemand(){ if(queue.size() >= bufferSize){ start(); } } //执行提交数据的方法 public void flush(){ lastFlushTime = System.currentTimeMillis(); List<Item> temp = new ArrayList<>(bufferSize); int size = queue.drainTo(temp,bufferSize); if(size > 0){ try { processor.process(temp); }catch (Throwable e){ log.error("process error",e); } } } //根据数据的尺寸和时间间隔判断是否提交 private boolean canFlush(){ return queue.size() > bufferSize || System.currentTimeMillis() - lastFlushTime > flushInterval; } @Override public void run() { writer = Thread.currentThread(); writer.setName(name); while (!writer.isInterrupted()){ while (!canFlush()){ //如果线程没有被打断,且不达到执行的条件,则阻塞线程 LockSupport.park(this); } flush(); } } } 2. 如何实现定时提交呢? 通常我们遇到定时相关的需求,首先想到的应该是使用 ScheduledThreadPoolExecutor定时来调用FlushThread 的 timeOut 方法,如果你想到的是 Thread.sleep()…那需要再努力学习,多看源码了。 3. 怎样进一步的提升系统的吞吐量? 我们使用的FlushThread 实现了 Runnable 所以我们可以考虑使用线程池来持有多个FlushThread。 所以我们就有这样的代码: 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 public class Flusher<Item> { private final FlushThread<Item>[] flushThreads; private AtomicInteger index; //防止多个线程同时执行。增加一个随机数间隔 private static final Random r = new Random(); private static final int delta = 50; private static ScheduledExecutorService TIMER = new ScheduledThreadPoolExecutor(1); private static ExecutorService POOL = Executors.newCachedThreadPool(); public Flusher(String name,int bufferSiz,int flushInterval,int queueSize,int threads,Processor<Item> processor) { this.flushThreads = new FlushThread[threads]; if(threads > 1){ index = new AtomicInteger(); } for (int i = 0; i < threads; i++) { final FlushThread<Item> flushThread = new FlushThread<Item>(name+ "-" + i,bufferSiz,flushInterval,queueSize,processor); flushThreads[i] = flushThread; POOL.submit(flushThread); //定时调用 timeOut()方法。 TIMER.scheduleAtFixedRate(flushThread::timeOut, r.nextInt(delta), flushInterval, TimeUnit.MILLISECONDS); } } // 对 index 取模,保证多线程都能被add public boolean add(Item item){ int len = flushThreads.length; if(len == 1){ return flushThreads[0].add(item); } int mod = index.incrementAndGet() % len; return flushThreads[mod].add(item); } //上文已经描述 private static class FlushThread<Item> implements Runnable{ ...省略 } } 4. 面向接口编程,提升系统扩展性: 1 2 3 public interface Processor<T> { void process(List<T> list); } 使用 我们写个测试方法测试一下: 1 2 3 4 5 6 7 8 9 10 11 12 13 //实现 Processor 将 String 全部输出 public class PrintOutProcessor implements Processor<String>{ @Override public void process(List<String> list) { System.out.println("start flush"); list.forEach(System.out::println); System.out.println("end flush"); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Test { public static void main(String[] args) throws InterruptedException { Flusher<String> stringFlusher = new Flusher<>("test",5,1000,30,1,new PrintOutProcessor()); int index = 1; while (true){ stringFlusher.add(String.valueOf(index++)); Thread.sleep(1000); } } } 执行的结果: 1 2 3 4 5 6 7 8 9 10 11 12 13 start flush 1 2 3 end flush start flush 4 5 6 7 end flush 我们发现并没有达到10个数字就触发了flush。因为出发了超时提交,虽然还没有达到规定的5 个数据,但还是执行了 flush。 如果我们去除 Thread.sleep(1000); 再看看结果: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 start flush 1 2 3 4 5 end flush start flush 6 7 8 9 10 end flush 每5个数一次提交。完美。。。。 总结 一个比较生动的例子给大家讲解了一些多线程的具体运用。学习多线程应该多思考多动手,才会有比较好的效果。希望这篇文章大家读完以后有所收获,欢迎交流。 github地址:https://github.com/diaozxin007/framework 徒手撸框架系列文章地址: 徒手撸框架–实现IoC 徒手撸框架–实现Aop 欢迎关注我的微信公众号

2018/1/23
articleCard.readMore

徒手撸框架--实现Aop

原文地址:犀利豆的博客 上一讲我们讲解了Spring 的 IoC 实现。大家可以去我的博客查看点击链接,这一讲我们继续说说 Spring 的另外一个重要特性 AOP。之前在看过的大部分教程,对于Spring Aop的实现讲解的都不太透彻,大部分文章介绍了Spring Aop的底层技术使用了动态代理,至于Spring Aop的具体实现都语焉不详。这类文章看以后以后,我脑子里浮现的就是这样一个画面: 我的想法就是,带领大家,首先梳理 Spring Aop的实现,然后屏蔽细节,自己实现一个Aop框架。加深对Spring Aop的理解。在了解上图1-4步骤的同时,补充 4 到 5 步骤之间的其他细节。 读完这篇文章你将会了解: Aop是什么? 为什么要使用Aop? Spirng 实现Aop的思路是什么 自己根据Spring 思想实现一个 Aop框架 Aop 是什么 面向切面的程序设计(aspect-oriented programming,AOP)。通过预编译方式和运行期动态代理实现程序功能统一维护的一种技术。 为什么需要使用Aop 面向切面编程,实际上就是通过预编译或者动态代理技术在不修改源代码的情况下给原来的程序统一添加功能的一种技术。我们看几个关键词,第一个是“动态代理技术”,这个就是Spring Aop实现底层技术。第二个“不修改源代码”,这个就是Aop最关键的地方,也就是我们平时所说的非入侵性。。第三个“添加功能”,不改变原有的源代码,为程序添加功能。 举个例子:如果某天你需要统计若干方法的执行时间,如果不是用Aop技术,你要做的就是为每一个方法开始的时候获取一个开始时间,在方法结束的时候获取结束时间。二者之差就是方法的执行时间。如果对每一个需要统计的方法都做如上的操作,那代码简直就是灾难。如果我们使用Aop技术,在不修改代码的情况下,添加一个统计方法执行时间的切面。代码就变得十分优雅。具体这个切面怎么实现?看完下面的文章你一定就会知道。 Spring Aop 是怎么实现的 所谓: 计算机程序 = 数据结构 + 算法 在阅读过Spring源码之后,你就会对这个说法理解更深入了。 Spring Aop实现的代码非常非常的绕。也就是说 Spring 为了灵活做了非常深层次的抽象。同时 Spring为了兼容 @AspectJ 的Aop协议,使用了很多 Adapter (适配器)模式又进一步的增加了代码的复杂程度。 Spring 的 Aop 实现主要以下几个步骤: 初始化 Aop 容器。 读取配置文件。 将配置文件装换为 Aop 能够识别的数据结构 – Advisor。这里展开讲一讲这个advisor。Advisor对象中包又含了两个重要的数据结构,一个是 Advice,一个是 Pointcut。Advice的作用就是描述一个切面的行为,pointcut描述的是切面的位置。两个数据结的组合就是”在哪里,干什么“。这样 Advisor 就包含了”在哪里干什么“的信息,就能够全面的描述切面了。 Spring 将这个 Advisor 转换成自己能够识别的数据结构 – AdvicedSupport。Spirng 动态的将这些方法拦截器织入到对应的方法。 生成动态代理代理。 提供调用,在使用的时候,调用方调用的就是代理方法。也就是已经织入了增强方法的方法。 自己实现一个 Aop 框架 同样,我也是参考了Aop的设计。只实现了基于方法的拦截器。去除了很多的实现细节。 使用上一讲的 IoC 框架管理对象。使用 Cglib 作为动态代理的基础类。使用 maven 管理 jar 包和 module。所以上一讲的 IoC 框架会作为一个 modules 引入项目。 下面我们就来实现我们的Aop 框架吧。 首先来看看代码的基本结构。 代码结构比上一讲的 IoC 复杂不少。我们首先对包每个包都干了什么做一个简单介绍。 invocation 描述的就是一个方法的调用。注意这里指的是“方法的调用”,而不是调用这个动作。 interceptor 大家最熟悉的拦截器,拦截器拦截的目标就是 invcation 包里面的调用。 advisor 这个包里的对象,都是用来描述切面的数据结构。 adapter 这个包里面是一些适配器方法。对于”适配器”不了解的同学可以去看看”设计模式”里面的”适配模式”。他的作用就是将 advice 包里的对象适配为 interceptor。 bean 描述我们 json 配置文件的对象。 core 我们框架的核心逻辑。 这个时候宏观的看我们大概梳理出了一条路线, adaper 将 advisor 适配为 interceptor 去拦截 invoction。 下面我们从这个链条的最末端讲起: invocation 首先 MethodInvocation 作为所有方法调用的接口。要描述一个方法的调用包含三个方法,获取方法本身getMethod,获取方法的参数getArguments,还有执行方法本身proceed()。 1 2 3 4 5 public interface MethodInvocation { Method getMethod(); Object[] getArguments(); Object proceed() throws Throwable; } ProxyMethodInvocation 看名字就知道,是代理方法的调用,增加了一个获取代理的方法。 1 2 3 public interface ProxyMethodInvocation extends MethodInvocation { Object getProxy(); } interceptor AopMethodInterceptor 是 Aop 容器所有拦截器都要实现的接口: 1 2 3 public interface AopMethodInterceptor { Object invoke(MethodInvocation mi) throws Throwable; } 同时我们实现了两种拦截器BeforeMethodAdviceInterceptor和AfterRunningAdviceInterceptor,顾名思义前者就是在方法执行以前拦截,后者就在方法运行结束以后拦截: 1 2 3 4 5 6 7 8 9 10 11 public class BeforeMethodAdviceInterceptor implements AopMethodInterceptor { private BeforeMethodAdvice advice; public BeforeMethodAdviceInterceptor(BeforeMethodAdvice advice) { this.advice = advice; } @Override public Object invoke(MethodInvocation mi) throws Throwable { advice.before(mi.getMethod(),mi.getArguments(),mi); return mi.proceed(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class AfterRunningAdviceInterceptor implements AopMethodInterceptor { private AfterRunningAdvice advice; public AfterRunningAdviceInterceptor(AfterRunningAdvice advice) { this.advice = advice; } @Override public Object invoke(MethodInvocation mi) throws Throwable { Object returnVal = mi.proceed(); advice.after(returnVal,mi.getMethod(),mi.getArguments(),mi); return returnVal; } } 看了上面的代码我们发现,实际上 mi.proceed()才是执行原有的方法。而advice我们上文就说过,是描述增强的方法”干什么“的数据结构,所以对于这个before拦截器,我们就把advice对应的增强方法放在了真正执行的方法前面。而对于after拦截器而言,就放在了真正执行的方法后面。 这个时候我们过头来看最关键的 ReflectioveMethodeInvocation 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 public class ReflectioveMethodeInvocation implements ProxyMethodInvocation { public ReflectioveMethodeInvocation(Object proxy, Object target, Method method, Object[] arguments, List<AopMethodInterceptor> interceptorList) { this.proxy = proxy; this.target = target; this.method = method; this.arguments = arguments; this.interceptorList = interceptorList; } protected final Object proxy; protected final Object target; protected final Method method; protected Object[] arguments = new Object[0]; //存储所有的拦截器 protected final List<AopMethodInterceptor> interceptorList; private int currentInterceptorIndex = -1; @Override public Object getProxy() { return proxy; } @Override public Method getMethod() { return method; } @Override public Object[] getArguments() { return arguments; } @Override public Object proceed() throws Throwable { //执行完所有的拦截器后,执行目标方法 if(currentInterceptorIndex == this.interceptorList.size() - 1) { return invokeOriginal(); } //迭代的执行拦截器。回顾上面的讲解,我们实现的拦击都会执行 im.proceed() 实际上又会调用这个方法。实现了一个递归的调用,直到执行完所有的拦截器。 AopMethodInterceptor interceptor = interceptorList.get(++currentInterceptorIndex); return interceptor.invoke(this); } protected Object invokeOriginal() throws Throwable{ return ReflectionUtils.invokeMethodUseReflection(target,method,arguments); } } 在实际的运用中,我们的方法很可能被多个方法的拦截器所增强。所以我们,使用了一个list来保存所有的拦截器。所以我们需要递归的去增加拦截器。当处理完了所有的拦截器之后,才会真正调用调用被增强的方法。我们可以认为,前文所述的动态的织入代码就发生在这里。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class CglibMethodInvocation extends ReflectioveMethodeInvocation { private MethodProxy methodProxy; public CglibMethodInvocation(Object proxy, Object target, Method method, Object[] arguments, List<AopMethodInterceptor> interceptorList, MethodProxy methodProxy) { super(proxy, target, method, arguments, interceptorList); this.methodProxy = methodProxy; } @Override protected Object invokeOriginal() throws Throwable { return methodProxy.invoke(target,arguments); } } CglibMethodInvocation 只是重写了 invokeOriginal 方法。使用代理类来调用被增强的方法。 advisor 这个包里面都是一些描述切面的数据结构,我们讲解两个重要的。 1 2 3 4 5 6 7 8 @Data public class Advisor { //干什么 private Advice advice; //在哪里 private Pointcut pointcut; } 如上文所说,advisor 描述了在哪里,干什么。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Data public class AdvisedSupport extends Advisor { //目标对象 private TargetSource targetSource; //拦截器列表 private List<AopMethodInterceptor> list = new LinkedList<>(); public void addAopMethodInterceptor(AopMethodInterceptor interceptor){ list.add(interceptor); } public void addAopMethodInterceptors(List<AopMethodInterceptor> interceptors){ list.addAll(interceptors); } } 这个AdvisedSupport就是 我们Aop框架能够理解的数据结构,这个时候问题就变成了–对于哪个目标,增加哪些拦截器。 core 有了上面的准备,我们就开始讲解核心逻辑了。 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 @Data public class CglibAopProxy implements AopProxy{ private AdvisedSupport advised; private Object[] constructorArgs; private Class<?>[] constructorArgTypes; public CglibAopProxy(AdvisedSupport config){ this.advised = config; } @Override public Object getProxy() { return getProxy(null); } @Override public Object getProxy(ClassLoader classLoader) { Class<?> rootClass = advised.getTargetSource().getTagetClass(); if(classLoader == null){ classLoader = ClassUtils.getDefultClassLoader(); } Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(rootClass.getSuperclass()); //增加拦截器的核心方法 Callback callbacks = getCallBack(advised); enhancer.setCallback(callbacks); enhancer.setClassLoader(classLoader); if(constructorArgs != null && constructorArgs.length > 0){ return enhancer.create(constructorArgTypes,constructorArgs); } return enhancer.create(); } private Callback getCallBack(AdvisedSupport advised) { return new DynamicAdvisedIcnterceptor(advised.getList(),advised.getTargetSource()); } } CglibAopProxy就是我们代理对象生成的核心方法。使用 cglib 生成代理类。我们可以与之前ioc框架的代码。比较发现区别就在于: 1 2 Callback callbacks = getCallBack(advised); enhancer.setCallback(callbacks); callback与之前不同了,而是写了一个getCallback()的方法,我们就来看看 getCallback 里面的 DynamicAdvisedIcnterceptor到底干了啥。 篇幅问题,这里不会介绍 cglib 的使用,对于callback的作用,不理解的同学需要自行学习。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class DynamicAdvisedInterceptor implements MethodInterceptor{ protected final List<AopMethodInterceptor> interceptorList; protected final TargetSource targetSource; public DynamicAdvisedInterceptor(List<AopMethodInterceptor> interceptorList, TargetSource targetSource) { this.interceptorList = interceptorList; this.targetSource = targetSource; } @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { MethodInvocation invocation = new CglibMethodInvocation(obj,targetSource.getTagetObject(),method, args,interceptorList,proxy); return invocation.proceed(); } } 这里需要注意,DynamicAdvisedInterceptor这个类实现的 MethodInterceptor 是 gclib的接口,并非我们之前的 AopMethodInterceptor。 我们近距离观察 intercept 这个方法我们看到: 1 MethodInvocation invocation = new CglibMethodInvocation(obj,targetSource.getTagetObject(),method, args,interceptorList,proxy); 通过这行代码,我们的整个逻辑终于连起来了。也就是这个动态的拦截器,把我们通过 CglibMethodInvocation 织入了增强代码的方法,委托给了 cglib 来生成代理对象。 至此我们的 Aop 的核心功能就实现了。 AopBeanFactoryImpl 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 public class AopBeanFactoryImpl extends BeanFactoryImpl{ private static final ConcurrentHashMap<String,AopBeanDefinition> aopBeanDefinitionMap = new ConcurrentHashMap<>(); private static final ConcurrentHashMap<String,Object> aopBeanMap = new ConcurrentHashMap<>(); @Override public Object getBean(String name) throws Exception { Object aopBean = aopBeanMap.get(name); if(aopBean != null){ return aopBean; } if(aopBeanDefinitionMap.containsKey(name)){ AopBeanDefinition aopBeanDefinition = aopBeanDefinitionMap.get(name); AdvisedSupport advisedSupport = getAdvisedSupport(aopBeanDefinition); aopBean = new CglibAopProxy(advisedSupport).getProxy(); aopBeanMap.put(name,aopBean); return aopBean; } return super.getBean(name); } protected void registerBean(String name, AopBeanDefinition aopBeanDefinition){ aopBeanDefinitionMap.put(name,aopBeanDefinition); } private AdvisedSupport getAdvisedSupport(AopBeanDefinition aopBeanDefinition) throws Exception { AdvisedSupport advisedSupport = new AdvisedSupport(); List<String> interceptorNames = aopBeanDefinition.getInterceptorNames(); if(interceptorNames != null && !interceptorNames.isEmpty()){ for (String interceptorName : interceptorNames) { Advice advice = (Advice) getBean(interceptorName); Advisor advisor = new Advisor(); advisor.setAdvice(advice); if(advice instanceof BeforeMethodAdvice){ AopMethodInterceptor interceptor = BeforeMethodAdviceAdapter.getInstants().getInterceptor(advisor); advisedSupport.addAopMethodInterceptor(interceptor); } if(advice instanceof AfterRunningAdvice){ AopMethodInterceptor interceptor = AfterRunningAdviceAdapter.getInstants().getInterceptor(advisor); advisedSupport.addAopMethodInterceptor(interceptor); } } } TargetSource targetSource = new TargetSource(); Object object = getBean(aopBeanDefinition.getTarget()); targetSource.setTagetClass(object.getClass()); targetSource.setTagetObject(object); advisedSupport.setTargetSource(targetSource); return advisedSupport; } } AopBeanFactoryImpl是我们产生代理对象的工厂类,继承了上一讲我们实现的 IoC 容器的BeanFactoryImpl。重写了 getBean方法,如果是一个切面代理类,我们使用Aop框架生成代理类,如果是普通的对象,我们就用原来的IoC容器进行依赖注入。 getAdvisedSupport就是获取 Aop 框架认识的数据结构。 剩下没有讲到的类都比较简单,大家看源码就行。与核心逻辑无关。 写个方法测试一下 我们需要统计一个方法的执行时间。面对这个需求我们怎么做? 1 2 3 4 5 6 7 8 public class StartTimeBeforeMethod implements BeforeMethodAdvice{ @Override public void before(Method method, Object[] args, Object target) { long startTime = System.currentTimeMillis(); System.out.println("开始计时"); ThreadLocalUtils.set(startTime); } } 1 2 3 4 5 6 7 8 9 10 public class EndTimeAfterMethod implements AfterRunningAdvice { @Override public Object after(Object returnVal, Method method, Object[] args, Object target) { long endTime = System.currentTimeMillis(); long startTime = ThreadLocalUtils.get(); ThreadLocalUtils.remove(); System.out.println("方法耗时:" + (endTime - startTime) + "ms"); return returnVal; } } 方法开始前,记录时间,保存到 ThredLocal里面,方法结束记录时间,打印时间差。完成统计。 目标类: 1 2 3 4 5 6 public class TestService { public void testMethod() throws InterruptedException { System.out.println("this is a test method"); Thread.sleep(1000); } } 配置文件: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 [ { "name":"beforeMethod", "className":"com.xilidou.framework.aop.test.StartTimeBeforeMethod" }, { "name":"afterMethod", "className":"com.xilidou.framework.aop.test.EndTimeAfterMethod" }, { "name":"testService", "className":"com.xilidou.framework.aop.test.TestService" }, { "name":"testServiceProxy", "className":"com.xilidou.framework.aop.core.ProxyFactoryBean", "target":"testService", "interceptorNames":[ "beforeMethod", "afterMethod" ] } ] 测试类: 1 2 3 4 5 6 7 8 public class MainTest { public static void main(String[] args) throws Exception { AopApplictionContext aopApplictionContext = new AopApplictionContext("application.json"); aopApplictionContext.init(); TestService testService = (TestService) aopApplictionContext.getBean("testServiceProxy"); testService.testMethod(); } } 最终我们的执行结果: 1 2 3 4 5 开始计时 this is a test method 方法耗时:1015ms Process finished with exit code 0 至此 Aop 框架完成。 后记 Spring 的两大核心特性 IoC 与 Aop 两大特性就讲解完了,希望大家通过我写的两篇文章能够深入理解两个特性。 Spring的源码实在是复杂,阅读起来常常给人极大的挫败感,但是只要能够坚持,并采用一些行之有效的方法。还是能够理解Spring的代码。并且从中汲取营养。 下一篇文章,我会给大家讲讲阅读开源代码的一些方法和我自己的体会,敬请期待。 最后 github:https://github.com/diaozxin007/framework 欢迎关注我的微信公众号

2018/1/13
articleCard.readMore

徒手撸框架--实现IoC

原文地址:https://www.xilidou.com/2018/01/08/spring-ioc/ Spring 作为 J2ee 开发事实上的标准,是每个Java开发人员都需要了解的框架。但是Spring 的 IoC 和 Aop 的特性,对于初级的Java开发人员来说还是比较难于理解的。所以我就想写一系列的文章给大家讲解这些特性。从而能够进一步深入了解 Spring 框架。 读完这篇文章,你将会了解: 什么是依赖注入和控制反转 Ioc有什么用 Spring的 Ioc 是怎么实现的 按照Spring的思路开发一个简单的Ioc框架 IoC 是什么 wiki百科的解释是: 控制反转(Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称DI)。通过控制反转,对象在被创建的时候,由一个调控系统内所有对象的外界实体,将其所依赖的对象的引用传递给它。也可以说,依赖被注入到对象中。 Ioc 有什么用 看完上面的解释你一定没有理解什么是 Ioc,因为是第一次看见上面的话也觉得云里雾里。 不过通过上面的描述我们可以大概的了解到,使用IoC的目的是为了解耦。也就是说IoC 是解耦的一种方法。 我们知道Java 是一门面向对象的语言,在 Java 中 Everything is Object,我们的程序就是由若干对象组成的。当我们的项目越来越大,合作的开发者越来越多的时候,我们的类就会越来越多,类与类之间的引用就会成指数级的增长。如下图所示: 这样的工程简直就是灾难,如果我们引入 Ioc 框架。由框架来维护类的生命周期和类之间的引用。我们的系统就会变成这样: 这个时候我们发现,我们类之间的关系都由 IoC 框架负责维护类,同时将类注入到需要的类中。也就是类的使用者只负责使用,而不负责维护。把专业的事情交给专业的框架来完成。大大的减少开发的复杂度。 用一个类比来理解这个问题。Ioc 框架就是我们生活中的房屋中介,首先中介会收集市场上的房源,分别和各个房源的房东建立联系。当我们需要租房的时候,并不需要我们四处寻找各类租房信息。我们直接找房屋中介,中介就会根据你的需求提供相应的房屋信息。大大提升了租房的效率,减少了你与各类房东之间的沟通次数。 Spring 的 IoC 是怎么实现的 了解Spring框架最直接的方法就阅读Spring的源码。但是Spring的代码抽象的层次很高,且处理的细节很高。对于大多数人来说不是太容易理解。我读了Spirng的源码以后以我的理解做一个总结,Spirng IoC 主要是以下几个步骤。 1. 初始化 IoC 容器。2. 读取配置文件。3. 将配置文件转换为容器识别对的数据结构(这个数据结构在Spring中叫做 BeanDefinition4. 利用数据结构依次实例化相应的对象5. 注入对象之间的依赖关系 自己实现一个IoC框架 为了方便,我们参考 Spirng 的 IoC 实现,去除所有与核心原理无关的逻辑。极简的实现 IoC 的框架。 项目使用 json 作为配置文件。使用 maven 管理 jar 包的依赖。 在这个框架中我们的对象都是单例的,并不支持Spirng的多种作用域。框架的实现使用了cglib 和 Java 的反射。项目中我还使用了 lombok 用来简化代码。 下面我们就来编写 IoC 框架吧。 首先我们看看这个框架的基本结构: 从宏观上观察一下这个框架,包含了3个package、在包 bean 中定义了我们框架的数据结构。core 是我们框架的核心逻辑所在。utils 是一些通用工具类。接下来我们就逐一讲解一下: 1. bean 定义了框架的数据结构 BeanDefinition 是我们项目的核心数据结构。用于描述我们需要 IoC 框架管理的对象。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Data @ToString public class BeanDefinition { private String name; private String className; private String interfaceName; private List<ConstructorArg> constructorArgs; private List<PropertyArg> propertyArgs; } 包含了对象的 name,class的名称。如果是接口的实现,还有该对象实现的接口。以及构造函数的传参的列表 constructorArgs 和需要注入的参数列表 `propertyArgs。 2. 再看看我们的工具类包里面的对象 ClassUtils 负责处理 Java 类的加载,代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 public class ClassUtils { public static ClassLoader getDefultClassLoader(){ return Thread.currentThread().getContextClassLoader(); } public static Class loadClass(String className){ try { return getDefultClassLoader().loadClass(className); } catch (ClassNotFoundException e) { e.printStackTrace(); } return null; } } 我们只写了一个方法,就是通过 className 这个参数获取对象的 Class。 BeanUtils 负责处理对象的实例化,这里我们使用了 cglib 这个工具包,代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 public class BeanUtils { public static <T> T instanceByCglib(Class<T> clz,Constructor ctr,Object[] args) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(clz); enhancer.setCallback(NoOp.INSTANCE); if(ctr == null){ return (T) enhancer.create(); }else { return (T) enhancer.create(ctr.getParameterTypes(),args); } } } ReflectionUtils 主要通过 Java 的反射原理来完成对象的依赖注入: 1 2 3 4 5 6 7 8 9 public class ReflectionUtils { public static void injectField(Field field,Object obj,Object value) throws IllegalAccessException { if(field != null) { field.setAccessible(true); field.set(obj, value); } } } injectField(Field field,Object obj,Object value) 这个方法的作用就是,设置 obj 的 field 为 value。 JsonUtils 的作用就是为了解析我们的json配置文件。代码比较长,与我们的 IoC 原理关系不大,感兴趣的同学可以自行从github上下载代码看看。 有了这几个趁手的工具,我们就可以开始完成 Ioc 框架的核心代码了。 3. 核心逻辑 我的 IoC 框架,目前只支持一种 ByName 的注入。所以我们的 BeanFactory 就只有一个方法: 1 2 3 public interface BeanFactory { Object getBean(String name) throws Exception; } 然后我们实现了这个方法: 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 public class BeanFactoryImpl implements BeanFactory{ private static final ConcurrentHashMap<String,Object> beanMap = new ConcurrentHashMap<>(); private static final ConcurrentHashMap<String,BeanDefinition> beanDefineMap= new ConcurrentHashMap<>(); private static final Set<String> beanNameSet = Collections.synchronizedSet(new HashSet<>()); @Override public Object getBean(String name) throws Exception { //查找对象是否已经实例化过 Object bean = beanMap.get(name); if(bean != null){ return bean; } //如果没有实例化,那就需要调用createBean来创建对象 bean = createBean(beanDefineMap.get(name)); if(bean != null) { //对象创建成功以后,注入对象需要的参数 populatebean(bean); //再把对象存入Map中方便下次使用。 beanMap.put(name,bean; } //结束返回 return bean; } protected void registerBean(String name, BeanDefinition bd){ beanDefineMap.put(name,bd); beanNameSet.add(name); } private Object createBean(BeanDefinition beanDefinition) throws Exception { String beanName = beanDefinition.getClassName(); Class clz = ClassUtils.loadClass(beanName); if(clz == null) { throw new Exception("can not find bean by beanName"); } List<ConstructorArg> constructorArgs = beanDefinition.getConstructorArgs(); if(constructorArgs != null && !constructorArgs.isEmpty()){ List<Object> objects = new ArrayList<>(); for (ConstructorArg constructorArg : constructorArgs) { objects.add(getBean(constructorArg.getRef())); } return BeanUtils.instanceByCglib(clz,clz.getConstructor(),objects.toArray()); }else { return BeanUtils.instanceByCglib(clz,null,null); } } private void populatebean(Object bean) throws Exception { Field[] fields = bean.getClass().getSuperclass().getDeclaredFields(); if (fields != null && fields.length > 0) { for (Field field : fields) { String beanName = field.getName(); beanName = StringUtils.uncapitalize(beanName); if (beanNameSet.contains(field.getName())) { Object fieldBean = getBean(beanName); if (fieldBean != null) { ReflectionUtils.injectField(field,bean,fieldBean); } } } } } } 首先我们看到在 BeanFactory 的实现中。我们有两 HashMap,beanMap 和 beanDefineMap。 beanDefineMap 存储的是对象的名称和对象对应的数据结构的映射。beanMap 用于保存 beanName和实例化之后的对象。 容器初始化的时候,会调用 BeanFactoryImpl.registerBean 方法。把 对象的 BeanDefination 数据结构,存储起来。 当我们调用 getBean() 的方法的时候。会先到 beanMap 里面查找,有没有实例化好的对象。如果没有,就会去beanDefineMap查找这个对象对应的 BeanDefination。再利用DeanDefination去实例化一个对象。 对象实例化成功以后,我们还需要注入相应的参数,调用 populatebean()这个方法。在 populateBean 这个方法中,会扫描对象里面的Field,如果对象中的 Field 是我们IoC容器管理的对象,那就会调用 我们上文实现的 ReflectionUtils.injectField来注入对象。 一切准备妥当之后,我们对象就完成了整个 IoC 流程。最后这个对象放入 beanMap 中,方便下一次使用。 所以我们可以知道 BeanFactory 是管理和生成对象的地方。 4. 容器 我们所谓的容器,就是对BeanFactory的扩展,负责管理 BeanFactory。我们的这个IoC 框架使用 Json 作为配置文件,所以我们容器就命名为 JsonApplicationContext。当然之后你愿意实现 XML 作为配置文件的容器你就可以自己写一个 XmlApplicationContext,如果基于注解的容器就可以叫AnnotationApplcationContext。这些实现留个大家去完成。 我们看看 ApplicationContext 的代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class JsonApplicationContext extends BeanFactoryImpl{ private String fileName; public JsonApplicationContext(String fileName) { this.fileName = fileName; } public void init(){ loadFile(); } private void loadFile(){ InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(fileName); List<BeanDefinition> beanDefinitions = JsonUtils.readValue(is,new TypeReference<List<BeanDefinition>>(){}); if(beanDefinitions != null && !beanDefinitions.isEmpty()) { for (BeanDefinition beanDefinition : beanDefinitions) { registerBean(beanDefinition.getName(), beanDefinition); } } } } 这个容器的作用就是 读取配置文件。将配置文件转换为容器能够理解的 BeanDefination。然后使用 registerBean 方法。注册这个对象。 至此,一个简单版的 IoC 框架就完成。 5. 框架的使用 我们写一个测试类来看看我们这个框架怎么使用: 首先我们有三个对象 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class Hand { public void waveHand(){ System.out.println("挥一挥手"); } } public class Mouth { public void speak(){ System.out.println("say hello world"); } } public class Robot { //需要注入 hand 和 mouth private Hand hand; private Mouth mouth; public void show(){ hand.waveHand(); mouth.speak(); } } 我们需要为我们的 Robot 机器人注入 hand 和 mouth。 配置文件: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 [ { "name":"robot", "className":"com.xilidou.framework.ioc.entity.Robot" }, { "name":"hand", "className":"com.xilidou.framework.ioc.entity.Hand" }, { "name":"mouth", "className":"com.xilidou.framework.ioc.entity.Mouth" } ] 这个时候写一个测试类: 1 2 3 4 5 6 7 8 public class Test { public static void main(String[] args) throws Exception { JsonApplicationContext applicationContext = new JsonApplicationContext("application.json"); applicationContext.init(); Robot aiRobot = (Robot) applicationContext.getBean("robot"); aiRobot.show(); } } 运行以后输出: 1 2 3 4 挥一挥手 say hello world Process finished with exit code 0 可以看到我们成功的给我的 aiRobot 注入了 hand 和 mouth。 至此我们 Ioc 框架开发完成。 总结 这篇文章读完以后相信你一定也实现了一个简单的 IoC 框架。 虽然说阅读源码是了解框架的最终手段。但是 Spring 框架作为一个生产框架,为了保证通用和稳定,源码必定是高度抽象,且处理大量细节。所以 Spring 的源码阅读起来还是相当困难。希望这篇文章能够帮助理解 Spring Ioc 的实现。 下一篇文章 应该会是 《徒手撸框架–实现AOP》。 更新 感谢 Heeexy 同学为这个不成熟的框架增加了循环依赖的处理。大家可以阅读这篇文章《极简 Spring 框架 – 浅析循环依赖》 github 地址:https://github.com/diaozxin007/xilidou-framework 欢迎关注我的微信公众号

2018/1/9
articleCard.readMore

2017个人总结

2017年结束了 总的来说2017年是充实的一年,也是在北京从生存逐渐向生活靠拢的一年。 工作 在58从T4晋级到了T5,工资也涨了一点。负责的业务也从英才的APP到整个英才的C端,最后负责了新的58速聘业务。从一个项目的单纯的执行者,到某个模块的的架构者,再到现在变成了某个业务的架构者。虽然现在项目的主要关键点还需要与架构师讨论。但是对于如何设计一个完整的业务系统,也是从零到一的突破。 这一年所在团队的业务也是命途多舛,之前的白领招聘被交接给别的部门。这一路走来十多个版本的迭代就这样交接出去。一度也想到了离职,出去面试了一圈,也拿到了ofo的offer。但是综合考虑,还是留在了58。这边可以从零开始规划架构一个业务。也算是能力的一个考验。 最终新项目也在12月26日正式上线。如今项目刚刚上线,希望在新的一年业务能有新的气象。 学习 17年重新回顾了Java多线程相关的知识,集中的了解分布式系统的知识,入门了解了go语言,用python写了几个小玩具。 在17年下半年,开始意识到写作的重要性。先在简书上开始写一些技术的相关的文章,每篇文章阅读人数一百出头。虽然不多但是对于自己来说,学习新的技术不再满足自己能看懂,而要自己理解了,再写出来。确实对于学习提出了一更高的要求。下半年一共写了11篇blog。用hexo搭建了自己的静态博客。还为自己的域名进行了备案。 17年是AI技术爆发的一年。花了一些精力学习了DeepLearning相关的课程,达到了入门级的水平。仅仅只是从理论上了解了如何训练一个神经网络。但是由于下半年新业务起来以后实在太忙,终止了学习,颇为遗憾。在2018年会继续开始AI相关学习。 生活 17年完成了一件人生大事就是结婚了。在17年里面完成了求婚,举行婚礼,蜜月旅行几件大事。从此所有的苦两人分担,所有的甜两人分享。谢谢我的爱人。 还有一件事情,就是养了一只可爱的暹罗猫。取名“皮蛋”。每天多了铲屎,煮猫饭,喂猫的工作。带她接种疫苗三次,接受了绝育。总之猫是一种相当治愈的小动物。她的呼噜声总能帮你赶走一肚子怨气。 当然,父母总是我坚定的支持者,他们总是那么无私。 旅游 2017去了两个国家。澳大利亚和日本。 澳洲,自然环境优越,海边也是美不胜收,总体来说澳洲人民比较幸运,坐拥相当优渥的自然资源,贫富差距也不是特别大,总体生活在一个比较高的生活水平。 日本,彬彬有礼,一切事情按部就班,各个流程相当人性化。好吃的东西特别多。作为一个互联网从业人员总感觉日本的互联网并没有像国内融入了百姓日常的生活。 投资 17年不再满足把钱放到货币基金的收益了。自己做了一些ETF指数相关的投资。接触了“且慢”平台,跟投了长赢指数。目前年化收益应该可以买一个iPhone吧。 尝试投资美股港股,但是担心老虎证券的安全性,虽然开户了,但是没有入金,错过了腾讯的大涨和58的翻倍。新的一年应该还会研究一次美股开户。 至于比特币,也就看看。作为一个比较厌倦风险的人不太会投资比特币吧。 读书 虽然17年买了不少书,但是认真读下来的书只有《如何阅读一本书》、《Java 8 in action》、《Netty in action》、《Java 并发编程的艺术》、《重构改善现有设计》、《刻意练习》。 2018年计划 买房,希望新的一年在北京站稳脚跟。 晋级,向T6进发。 学习,新的一年着重应该聚焦两个相关点吧,一个是自己的老本行,更加深入的研究分布式系统。还有就是重启AI相关的学习。 博客,每个月应该会有两篇文章。保证一年24篇文章。 读书,每个月应该完成一本书。 总结 2017年是生存向生活靠拢的一年。最重要的一点是意识到记录和写作很重要的一年。希望在2018年回望这一篇文章的时候,不会感到遗憾。新的一年加油。 最后 一张图片总结一下:

2017/12/26
articleCard.readMore

最近遇到的几个问题集合

最近在写项目的时候遇到了几个小问题,记录下来。希望对大家也有所帮助。 如何获取 T 的 class 在写BaseDao 之类的代码的时候,经常会遇到获取泛型T的class的情况?我们发现并没有T.class这种写法,那怎么办呢?想起之前写的Hibernate 里面有相关的代码。通过反射获取T的class 1 2 3 4 5 6 7 8 9 10 11 12 public class AbstractDao<T>{ private Class<T> clz; public AbstractDao(){ this.clz = (Class<T>) ((ParameterizedType)getClass() .getGenericSuperclass()) .getActualTypeArguments()[0]; } ... } Spring中的 @Value 加载时间 首先 @Value 注解可以方便的获取配置文件*.properties的参数。 在写代码的时候遇到这样一个问题。为了减少重复代码,我们通常需要写一个抽象类把共有的方法抽象到Abstract 类中。AbstractDao<T> 如果遇到需要向这个class的构造方法注入参数。且这个参数是通过抽象方法获取的。且这个数据是使用 Spring的 @Value 注解获取的。这个描述比较绕,我们直接看代码: 1 2 3 4 5 6 7 8 9 public class AbstractDao<T>{ private int tableId; public AbstractDao(){ this.tableId = setTableId(); } protected abstract int setTableId(); } 1 2 3 4 5 6 7 8 9 public class UserDao extends AbstractDao<User>{ @Value("${tableid.user}") private int userTableId; protected int setTableId() { return buserTableId; } } 代码运行起来以后,我们发现 userTableId,并不能取到相应的值,这个时候@Value失效了。实际上这个问题的根源是因为@Value的加载是发生在对象实例化之后。也就是首先调用对象的构造函数,然后再获取配置文件中的数据。 解决的方案是使用注解 @PostConstruct,意思是构造函数执行完以后再执行注解标记的方法。我们可以吧抽象函数做如下修改: 1 2 3 4 5 6 7 8 9 10 public class AbstractDao<T>{ private int tableId; @PostConstruct public init(){ this.tableId = setTableId(); } protected abstract int setTableId(); } 使用 Java 8 的 lambda 和 stream 来 merge List 在使用微服务架构以后。我们经常会遇到 Merge两个List的场景。比如我们从索引里面获取了一个 List<Long> 包含的是对象的ID的 list。由于前端对象展示的元素需要。用这个ID 的list分别从两个服务批量的查询得到 List<A> 和 List<B>,然后将两个List合二为一成为一个List<C>,返回给前端作为列表页展示。 看代码: 首先我们有一个对象 A: 1 2 3 4 public class A{ private long id; private String aStr; } 有另一个对象 B: 1 2 3 4 public class B{ private long id; private String bStr; } 页面需要的对象C: 1 2 3 4 5 public class C{ private long id; private String aStr; private String bStr; } 如何有效的把 List<A> 和 List<B> merge 为一个 List呢? 总体思路,因为两个list从不同服务里面获取。有可能两个服务出于健壮性的考虑会抛弃某些查询不到的对象,所以两个list的长度有可能不一致。所以使用一个Map<Long,B> 作为索引。 如果直接写代码会相当繁琐。如果使用 Java 8 的新特性Lamabda和stream api 就能快速写出代码。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private C getCForAAndB(A a,B b){ C c = new C(); c.setId(a.getId()); c.setAstr(a.getAStr()) c.setBstr(b.getBStr()) return c; } private List<C> mergeList(List<A> aList,List<B> bList){ //映射Map Map<Long,B> bMap = bList.parallelStream() .collect(Collectors.toMap(B::getId,b -> b)); return aList.parallelStream() .map(a -> getCForAAndB(a,bMap.get(a.getId))) .collect(Collectors.toList()); } 这样merge的代码简洁明了。 如果不明白的同学可以参考我之前的 Java 8 教程。

2017/11/29
articleCard.readMore

Redis RedLock 完美的分布式锁么?

上周花了点时间研究了 Redis 的作者提的 RedLock 的算法来实现一个分布式锁,文章地址。在官方的文档最下面发现了这样一句话。 Analysis of RedLock Martin Kleppmann analyzed Redlock here. I disagree with the analysis and posted my reply to his analysis here. 突然觉得事情好像没有那么简单,就点进去看了看。仔细读了读文章,发现了一个不得了的世界。于是静下心来研究了 Martin 对 RedLock 的批评,还有 RedLock 作者 antirez 的反击。 Martin 的批评 Martin上来就问,我们要锁来干啥呢?两个原因: 提升效率,用锁来保证一个任务没有必要被执行两次。比如(很昂贵的计算) 保证正确,使用锁来保证任务按照正常的步骤执行,防止两个节点同时操作一份数据,造成文件冲突,数据丢失。 对于第一种原因,我们对锁是有一定宽容度的,就算发生了两个节点同时工作,对系统的影响也仅仅是多付出了一些计算的成本,没什么额外的影响。这个时候 使用单点的 Redis 就能很好的解决问题,没有必要使用RedLock,维护那么多的Redis实例,提升系统的维护成本。 对于第二种原因,对正确性严格要求的场景(比如订单,或者消费),就算使用了 RedLock 算法仍然不能保证锁的正确性。 我们分析一下 RedLock 的有啥缺陷吧: 作者 Martin 给出这张图,首先我们上一讲说过,RedLock中,为了防止死锁,锁是具有过期时间的。这个过期时间被 Martin 抓住了小辫子。 如果 Client 1 在持有锁的时候,发生了一次很长时间的 FGC 超过了锁的过期时间。锁就被释放了。 这个时候 Client 2 又获得了一把锁,提交数据。 这个时候 Client 1 从 FGC 中苏醒过来了,又一次提交数据。 这还了得,数据就发生了错误。RedLock 只是保证了锁的高可用性,并没有保证锁的正确性。 这个时候也许你会说,如果 Client 1 在提交任务之前去查询一下锁的持有者是不自己就能解决这个问题? 答案是否定的,FGC 会发生在任何时候,如果 FGC 发生在查询之后,一样会有如上讨论的问题。 那换一个没有 GC 的编程语言? 答案还是否定的, FGC 只是造成系统停顿的原因之一,IO或者网络的堵塞或波动都可能造成系统停顿。 文章读到这里,我都绝望了,还好 Martin给出了一个解决的方案: 为锁增加一个 token-fencing。 获取锁的时候,还需要获取一个递增的token,在上图中 Client 1 还获得了一个 token=33的 fencing。 发生了上文的 FGC 问题后,Client 获取了 token=34 的锁。 在提交数据的时候,需要判断token的大小,如果token 小于 上一次提交的 token 数据就会被拒绝。 我们其实可以理解这个 token-fencing 就是一个乐观锁,或者一个 CAS。 Martin 还指出了,RedLock 是一个严重依赖系统时钟的分布式系统。 还是这个过期时间的小辫子。如果某个 Redis Master的系统时间发生了错误,造成了它持有的锁提前过期被释放。 Client 1 从 A、B、C、D、E五个节点中,获取了 A、B、C三个节点获取到锁,我们认为他持有了锁 这个时候,由于 B 的系统时间比别的系统走得快,B就会先于其他两个节点优先释放锁。 Clinet 2 可以从 B、D、E三个节点获取到锁。在整个分布式系统就造成 两个 Client 同时持有锁了。 这个时候 Martin 又提出了一个相当重要的关于分布式系统的设计要点: 好的分布式系统应当是异步的,且不能时间作为安全保障的。因为在分布式系统中有会程序暂停,网络延迟,系统时间错误,这些因数都不能影响分布式系统的安全性,只能影响系统的活性(liveness property)。换句话说,就是在极端情况下,分布式系统顶多在有限的时间内不能给出结果,但是不能给出错误的结果。 所以总结一下 Martin 对 RedLock 的批评: 对于提升效率的场景下,RedLock 太重。 对于对正确性要求极高的场景下,RedLock 并不能保证正确性。 这个时候感觉醍醐灌顶,简直写的太好了。 RedLock 的作者,同时也Redis 的作者对 Martin的文章也做了回应,条理也是相当的清楚。 antirez 的回应 antirez 看到了 Martin 的文章以后,就写了一篇文章回应。剧情会不会反转呢? antirez 总结了 Martin 对 RedLock的指控: 分布式的锁具有一个自动释放的功能。锁的互斥性,只在过期时间之内有效,锁过期释放以后就会造成多个Client 持有锁。 RedLock 整个系统是建立在,一个在实际系统无法保证的系统模型上的。在这个例子中就是系统假设时间是同步且可信的。 对于第一个问题: antirez 洋洋洒洒的写了很多,仔细看半天,也没有解决我心中的疑问。回顾一下RedLock 获取锁的步骤: 获取开始时间 去各个节点获取锁 再次获取时间。 计算获取锁的时间,检查获取锁的时间是否小于获取锁的时间。 持有锁,该干啥干啥去 如果,程序在1-3步之间发生了阻塞,RedLock可以感知到锁已经过期,没有问题。 如果,程序在第 4 步之后发生了阻塞?怎么办??? 答案是,其他具有自动释放锁的分布式锁都没办解决这个问题。 对于第二个指控: antirez 认为,首先在实际的系统中,从两个方面来看: 系统暂停,网络延迟。 系统的时间发生阶跃。 对于第一个问题。上文已经提到了,RedLock做了一些微小的工作,但是没办法完全避免。其他带有自动释放的分布式锁也没有办法。 第二个问题,Martin认为系统时间的阶跃主要来自两个方面: 人为修改。 从NTP服务收到了一个跳跃时时钟更新。 对于人为修改,能说啥呢?人要搞破坏没办法避免。 NTP受到一个阶跃时钟更新,对于这个问题,需要通过运维来保证。需要将阶跃的时间更新到服务器的时候,应当采取小步快跑的方式。多次修改,每次更新时间尽量小。**** 说个题外话,读到这里我突然理解了运维同学的邮件: 所以严格来说确实, RedLock建立在了 Time 是可信的模型上,理论上 Time 也是发生错误,但是在现实中,良好的运维和工程一些机制是可以最大限度的保证 Time 可信。 最后, antirez 还打出了一个暴击,既然 Martin 提出的系统使用 fecting token 保证数据的顺序处理。还需要 RedLock,或者别的分布式锁 干啥?? 回顾 看完二人的博客来往,感觉就是看武侠戏里面的高手过招,相当得爽快。二人思路清晰,Martin 上来就看到RedLock的死穴,一顿猛打,antirez见招拆招成功化解。 至于二人谁对谁错? 我觉得,每一个系统设计都有自己的侧重或者局限。工程也不是完美的。在现实中工程中不存在完美的解决方案。我们应当深入了解其中的原理,了解解决方案的优缺点。明白选用方案的局限性。是否可以接受方案的局限带来的后果。 架构本来就是一门平衡的艺术。 最后 Martin 推荐使用ZooKeeper 实现分布事务锁。Zookeeper 和 Redis的锁有什么区别? Zookeeper解决了Redis没有解决的问题了么?且听下回分解。 参考 Distributed locks with Redis How to do distributed locking Is Redlock safe? 基于Redis的分布式锁到底安全吗(上)?

2017/10/30
articleCard.readMore

JAVA 8入门(二)流

1.简单使用 书接上回,我们这一讲要讨论 JAVA 8 的新的 API 流。如果我们有这样一个需求,需要挑选出菜谱里面卡路里小于1000,且卡路里排名前三的菜品的名称。 如果使用JAVA 7的传统写法我们应该这样写: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public List<String> findDish(List<Dish> menu){ List<Dish> lowCaloricDishes = new ArrayList<>(); for (Dish dish : lowCaloricDishes) { if(dish.getCalories() < 1000 ){ lowCaloricDishes.add(dish); } } Collections.sort(lowCaloricDishes, new Comparator<Dish>() { @Override public int compare(Dish o1, Dish o2) { return Integer.compare(o1.getCalories(),o2.getCalories()); } }); List<Dish> result = lowCaloricDishes.subList(0,3); List<String> resultName = new ArrayList<>(); for (Dish dish : result) { resultName.add(dish.getName()); } return resultName; } 通过上面的例子我们可以看到我们使用了很多的中间变量,来存储中介结果,lowCaloricDishes、result、resultName。相当繁琐。如果我们使用 JAVA 8的流的方式来实现代码是这样的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 public List<String> findDishWithSteam(List<Dish> menu){ List<String> resultName = menu.stream() .filter(dish -> dish.getCalories()<1000) .sorted(Comparator.comparing(Dish::getCalories)) .limit(3) .map(Dish::getName) .collect(Collectors.toList()); return resultName; } 如果我们把 steam() 变换为 parallelStream(),整个操作就变成并行的。代码十分优雅,想到每天我们处理那么多的集合,反正我已经迫不及待的想使用上`JAVA 8了。 2. 流的定义 到底什么是流?书上给的定义是 “从支持数据处理操作的源生成的元素序列”。 元素序列 和集合一样,我们可以理解为是一堆有序的值。但是集合侧重的是数据,流侧重的是计算。 源 流会使用一个提供数据的源。比如 menu.stream()中,meun就是这个流的源。 数据处理操作 流的数据处理功能类似,数据库的操作,同时也支持函数式编程中的操作。 我们看看接口java.util.stream.Stream都有一些什么方法: 我们可以看到,之前我们在上一个例子里面使用的方法,filter(),sorted(),limit(),map() 的返回值也是一个流Stream,也就是说我们可以,把所有的操作串起来。这个是流的一个特点流水线 还有一个特点就是内部迭代,与集合的迭代不同,流的迭代不是显式的迭代。 3. 流的基本操作 1. 筛选和切片 筛选filter() 所谓筛选就是找出符合条件的元素,filter()接受一个返回boolean类型的函数。 比如: 1 filter(dish -> dish.getCalories()<1000) 去重distinct() 去重的方法我们可以类比SQL 语句中的 distinct 截断limit() 同样类似 SQL里面的 limit,接受一个 Long 值。返回流中的前n个元素。 跳过skip() 跳过前n个元素。很好理解 2. 映射 map() map对流中的每一个元素应用函数,可以理解为将元素转化为另一个元素。 1 .map(Dish::getName) flatmap() flatmap方法就是把流中的每一个元素都装换为另外一个流,然后合并为一个流。 1 2 3 4 5 List<List<String>> lists = new ArrayList<>(); lists.add(Arrays.asList("apple", "click")); lists.add(Arrays.asList("boss", "dig", "qq", "vivo")); lists.add(Arrays.asList("c#", "biezhi")); 找出所有大于两个字符的元素: 1 2 3 4 lists.stream() .flatMap(Collection::stream) .filter(str -> str.length() > 2) .count(); 3. 查找匹配 match、anyMatch、allMatch、noneMatch 以上方法都能返回一个boolean类型。 1 boolean hasLowCalories = mune.stream().anyMatch(dish -> dish.getCalories()<1000) 4. 归约 reduce(T,BinaryOperator<T>) reduce 操作是 反复结合每一个元素,直到流被归约成一个值。其中: T 指的是初始值; BinaryOperator<T> 两个元素结合起来获得一个元素,举个例子: Lamdba: (a,b)-> a + b。 所以给定一个 List<Integer> 计算出和所有int 的和: 1 int sum = list.stream().reduce(0,(a,b)-> a + b); 或者: 1 int sum = list.stream().reduce(0,Integer::sum); 5. 收集数据 一顿操作之后,我们需要把数据收集起来。就需要使用collect()方法。 1 2 Map<String, Integer> map = meun.stream() .collect(Collectors.toMap(Dish::getName, Dish::getCalories));

2017/10/25
articleCard.readMore

JAVA 8入门(一)Lambda表达式

机房迁移以后终于可以用上 Java 8了,本教程将会分为三个方面介绍Java 8 的新特性。首先给大家介绍 Java 8 的Lambda 表达式。 1. 让代码更灵活 作为程序员,每天除了写代码,最重要的事情就是吃饭了,为了吃饭,我们设计了一个Dish 对象,代码如下: 1 2 3 4 5 6 7 8 9 10 11 public class Dish { private final String name; private final boolean vegetarian; private final int calories; private final Type type; public enum Type {MEAT,FISH,OTHER} //省略get set方法 } 作为一个减肥人士,寻求医生建议。医生说,低卡路里饮食,比较健康,为了找出卡路里低于1000的菜品。于是就有了一下代码: 1 2 3 4 5 6 7 8 public static List<Dish> filterDish(List<Dish> dishes){ List<Dish> healthDishes = new ArraryList<>(); for(Dish dish: dishes){ if(dish.getCalories()<1000){ healthDishes.add(dish) } } } 后来医生说,不只卡路里要低,而且肉就不要吃了,吃素比较有利于健康,于是含泪写了以下代码: 1 2 3 4 5 6 7 8 public static List<Dish> filterDish(List<Dish> dishes){ List<Dish> healthDishes = new ArraryList<>(); for(Dish dish: dishes){ if(dish.getCalories()<1000 && dish.getVegerarian()){ healthDishes.add(dish) } } } 不能吃肉哪憋得住,于是医生又说你可以吃一点鱼。最为一个有骨气的程序员,已经不想去迎合(产品经理了)医生去修改代码了?有没有什么办法,能快速找出健康食物,万一哪天减肥成功了,又能吃肉了也不用去修改代码? 于是我们写这样一段代码: 1 2 3 4 5 6 7 8 9 10 public static List<Dish> filterDish(List<Dish> dishes,int calorites,boolean isMeat,Type type){ List<Dish> healthDishes = new ArraryList<>(); for(Dish dish: dishes){ if(dish.getCalories()<calorites && dish.getVegerarian() == isMeat && dish.getType() == type ){ healthDishes.add(dish) } } } 需求是满足了,但是作为一个有品位的程序员肯定不允许这样代码出现,实在过于繁琐了。万一再加入一个条件怎么办? 我们可以考虑将医生的医嘱作为一个方法传入我们的filerDish这个方法,医生说啥就是啥,不必要自己封装一个方法来响应医生的要求?于是我们这么考虑: 首先规定一个接口叫”医生说”: 1 2 3 public interface DoctorSaid{ boolean test(Dish dish); } 我们挑选菜品的时候这样写: 1 2 3 4 5 6 7 8 public static List<Dish> filterDish(Dish dishes,DoctorSaid doctorSaid){ List<Dish> lowCaloriesDishes = new ArraryList<>(); for(Dish dish: dishe){ if(doctorSaid.test(dish) ){ healthDishes.add(dish) } } } 如果医生说吃 1000 卡路里一下的食物,我们实现一个1000 以下卡路里的食物: 1 2 3 4 5 public boolean DoctorSaidLowCalorites implements DoctorSaid{ boolean test(Dish dish){ return dish.getCalorites() < 1000; } } 这样我们只用这样调用filterDish就解决问题了: 1 List<Dish> dishes = filterDish(dishes,new DoctorSaidLowCalorites()); 问题来了,对于善变的(产品经理) 医生,总是不能提前准备好所有的接口实现? 这个时候我们就可以使用JAVA的匿名了内部类来使用这个挑选菜品的方法更加灵活。于是我们就有这样的代码了 1 2 3 List<Dish> dishes = filterDish(dishes,new DoctorSaid(){ return dish.getCalorites() < 1000; }); 稍微好了一点,但是匿名内部类还是有一个不好的地方,就是太啰嗦了其实核心代码就是dish.getCalorites() < 1000 为什么我们要写那么多代码?这个也是java老被诟病的地方,代码十分繁琐。 终于我们的Lambda表达式要出场了: 1 List<Dish> dishes = filterDish(dishes, (Dish dish)-> dish.getCalorites() < 1000); 现在看上去好多了,但是作为一个程序员还是很贪心啊,现在只能过滤 Dish 能不能再抽象一点呢?当然啊,看这个: 1 2 3 4 5 6 7 8 9 10 11 12 13 public interface Predicate<T>{ boolean test(T t); } public static <T> List<T> filter(List<T> list, Predicate<T> p){ List<T> result = new ArrayList<>(); for(T e: list){ if(p.test(e)){ result.add(e); } } return result; } 使用泛型让我们的代码更加通用。随便产品经理改需求,从 List 里面按规则过滤符合要求的需求不用多写代码都能搞定了。这时候你是不是觉得胸前的红领巾更加鲜艳了? 2. 实际应用: 作为一个招聘网站的程序员,一定有很多将职位列表排序的需求,比如按照更新时间将职位列表排序我们可以这么写: 1 2 3 jobList.sort((JobInfo job1,JobInfo job2)->job1.getUpdateTime.compareTo(job2.getUpdateTime); 或者作为高端程序员的多线程可以这样写: 1 Thread t = new Thread(()-> System.out.println("Hello world")); 3. 近距离观察Lambda 1. 什么是Lambda 所以通过上面例子我们尝试定义一下Lambda 表达式是什么? Lambda表达式为简洁的表示可传递的匿名函数的表达式的一种方式。分开来说: 匿名:没有必要给他取一个函数名称。 简洁:相对于匿名内部内不需要写很多模板代码 传递:可以做为参数传递给方法 函数:不属特定类。但是和方法一样,有参数,函数主题,返回值,有时候还能抛异常。 标准的Lambda表达式: 1 (Dish dish) -> dish.getCalories() > 1000; 从上面的标准的表达式:一共三个部分组成 * 参数: * 箭头: * Lamdba主体:也就是函数的主体 2. 什么时候使用Lambda 函数式接口: 所谓函数式接口,我们可以理解为就是只有一个方法的接口。 函数描述符: 函数式接口的签名基本上就是Lambda表达式的签名。我们降这种抽象方法叫做函数描述符。举个例子,Ruannable 方法就可以看做一个什么都不接受,什么都不返回的函数。这个时候我们可以发现传入的 Lambda 函数为 ()->void. 3. Lambda的类型推断 Java编译器会根据上下文推断使用什么函数式接口来配合Lambda表达式,比如我们之前的例子,以下两种写法都是正确的: 1 2 3 (Dish dish) -> dish.getCalories() > 1000; dish -> dish.getCalories() > 1000; 第二个语句并没有显式的制定dish的类型是Dish,编译器也能正确的编译代码。 4.方法的引用 在Lambda中我们可以利用方法的引用来重复使用。可以认为是一个Lamdba带来的语法糖🍬。 对于之前我们为职位排序的例子: 1 jobList.sort((JobInfo job1,JobInfo job2)->job1.getUpdateTime.compareTo(job2.getUpdateTime); 我们可以改写为 1 jobList.sort(comparing(JobInfo::getUpdateTime)); 反正我第一次看到这种写法也不禁感叹,这也行?还有这种操作? 我们就来看看 JDK 8 中对于方法的引用有以下四种类型: static 静态方法的引用,这个没啥好说的,语法就是 (ClassName:staticMethod)。 任意类型的实例方法的引用,比如 String 方法的 length 方法: 1 2 3 (String s1) -> s1.length() (String::length) 现有对象的实例方法的引用: 1 2 3 ()-> s.length() (s::length) 构造函数的引用:直接上代码也很好理解(Dish::new)。 5.现成的函数式接口 JDK 8 中已经包含了若干现成的函数式接口。在java.util.function中。包括Predicate<T>,Function<T,R>,Consumer<T>。大家可以直接查看源码,在这里就不讲解了。 第一部分教程结束了,请大家期待《JAVA 8入门(二) 数据流的操作》

2017/10/25
articleCard.readMore

有道 Alfred Workflow 威力加强版

最近学习 吴恩达 的DeepLearning 的时候,发现自己的 python水平有点弱。就像想找个练手的东西写一写。想来想去也没有什么想法。今天闲逛知乎的时候发现,有一个用 php 实现的 workflow。可以使用Alfred 调用网易有道的翻译API,查出单词,但是上网一搜使用的 api 网易将会在 2017年12月下线。于是决定自己使用新的api撸一个,提升自己的 python 的水平。 于是就有了这个小玩具。 截图: 下载地址 有道 workflow 威力加强版 github

2017/10/25
articleCard.readMore

Kafka实现原理笔记

最近想了解一下分布式消息系统是怎么组成的于是就花了一些时间研究了kafka的实现原理。记录下来方便自己复习和回忆。kafka的设计思想很精妙,可以借鉴到大部分的分布式系统中。 kafka可以解决什么问题? kafka可以支持大量数据吞吐。 可以优雅的处理数据堆积问题。 低延迟 支持分布式 设计理念 持久化 尽量线性的读写磁盘。一个硬盘的顺序读写速度一般是4k读写的千倍以上。线性的读写是可以被预测,也能被操作系统大幅的优化的。 以pagecache为中心的设计风格,使用文件系统并依赖于pagecache要优于维护内存中缓存或其他结构。一方面避免 JVM 中的 gc带来的性能损耗。同时简化了代码实现。 持久化队列,只需要简单的在文件后面追加写入即可。而不用考虑建立一个索引文件(BTree)。查询和写入的复杂度由 BTree的 O(logN) 减小为线性的 O(1)。大幅提升数据的吞吐量,有利于处理海量数据,且对存储系统的性能要求不高,降低成本。 考虑将多条消息聚合在一次。减少平均每条消息的开销。 使用 zero-copy减少字符拷贝时候的开销。 开启压缩协议,减少数据所占的空间。 生产者 (Producer) Producer 向 Leader Partition 发送消息。 Producer 可以向任何一个 Partition 询问整个集群的状态,以及谁是 Leader Partition Producer 自己决定写入到哪个 Partition。Producer 可以考虑使用何种负载的策略。随机,轮询,按照key分区都可以。 支持批量操作。消息攒够一定数量再发送,使用适当的延迟换来更高的数据吞吐量。 消费者 (Consumer) 消费者直接向 Leader Partition发送一个 fatch 的请求,并制定消费的起始位置(offset),取回offset后的一段数据进行处理。 Consumer 自己决定 Offset,自己决定从什么地方进行消费。 Push 和 Pull 的问题。 消息到底是推还是拉? kafka 采取的机制是,Producer 向 Broker push 消息。 Consumer 向 Broker Pull 消息。这样做有几个好处。第一,消息消费的速率由 Consumer自己决定。第二,可以聚合的数据批量处理数据,如果使用 push,Broker需要考虑到底要等到多条数据,还是及时发送,Consumer可以尽可能多的拉取数据,保证消息尽可能及时被消费。 如何记录那些消息被有效消费?Topic 被划分为多个有序的分区,保证每个分区任何时候只会被同一个Group里面的 Consumer消费。只需要记录消费的偏移量。同时这个位置可以作为CkeckPonit,定时检查。保证ACK的代价很小。 如果我们可以使用某个 Consumer 消费数据后,存储到 类似Hadoop的平台上持久化。 kafka 消息的语义 消息系统系统一般有以下的语义: At most once:消息可能丢失,但不会重复投递 At least once:消息不会丢失,但可能会重复投递 Exactly once:消息不丢失、不重复,会且只会被分发一次(真正想要的) Producer 发送消息以后,有一个commit的概念,如果commit成功,则意味着消息不会丢失,但是Producer有可能提交成功后,没有收到commit的消息。这有可能造成 at least once 语义。 从 Consumer 角度来看,我们知道 Offset 是由 Consumer 自己维护。所以何时更新 Offset 就决定了 Consumer 的语义。如果收到消息后更新 Offset,如果 Consumer crash,那新的 Cunsumer再次重启消费,就会造成 At most once 语义(消息会丢,但不重复)。 如果 Consumser 消费完成后,再更新 Offset。如果 Consumer crash,别的 Consumer 重新用这个 Offser 拉取消息,这个时候就会造成 at least once 的语义(消息不丢,但多次被处理)。 所以结论:默认Kafka提供at-least-once语义的消息分发,允许用户通过在处理消息之前保存位置信息的方式来提供at-most-once语义。如果我们可以实现消费是幂等的,这个时候就可以认为整个系统是Exactly once的了。 备份 kafka 对每个 topic 的 partiotion 进行备份,份数由用户自己设置。 默认情况下 kafka 有一个 Leader 和 0至多个 Follower。 我们可以认为 Follower 也是一个 Consumer,通过消费 Leader 上的日志然后备份到本地。 所有的读写都是在 Leader 上进行的,所以 Follower 真的就只是备份。 kafka 如何确认一个 Follower 是活的? 和 zookeeper 保持联系。 Follower 复制 Leader 上的消息,且落后的不多(可配置)。 消息同步到所有的 Follower 才认为是提交成功,提交成功才能被消费。所以 Leader 宕机不会造成消息丢失(注意之前的Producer的 at least once 语义)。 选举 Leader宕机以后,需要在Follower中选出一个新 Leader。 Kafak动态维护一个同步备份集合(ISR)。这个集合中的 Follower 都能成为 Leader。 一个写入,要同步到所有的 ISR 中才能算做 Commit 成功。同时 ISR 会被持久化到 ZK 中。 如果全部节点都故障了,kafka会选择第一副本(无需在ISR中) 作为Leader。这个时候会造成丢消息。 Producer 可以选择是否等待备份响应。所谓的备份相应,是指 ISR 集合中的备份响应。

2017/10/25
articleCard.readMore

《交易系统:更新与跨越》读后笔记

十一假期,本来想找一本股票相关书读一读。机缘巧合就找到了这本武剑锋博士写的《交易系统》。这本书主要讲了上海证券交易系统在技术管理、架构设计、应用调优、切换部署、运行维护等方面的经验和教训。成书的时间大概是在2010年,交易系统的上线时间大概在2008年,聚现在已经接近十年了,但是书中介绍的很多开发时候的原则和思路放在今天来看也有很大的价值可以学习。同时,这也是一本介绍大型系统开发的简要过程的参考书目。 本书涉及了不少的证券相关知识,涉及证券知识的章节我就略读做了解。对剩下的关于大型系统的设计,管理,架构,优化相关的章节进行了精读,记录相关笔记供自己回顾和思考。 大型系统的管理 团队管理: 将整个系统自上而下的分割为多个松耦合的且相对独立的系统,这样来避免出现整个系统级的问题,遇到问题分而治之。在系统初期设计系统时隐藏细节实现,提前发现系统总体架构上的缺陷,提前修正。各个独立的小系统可用分别的启动,评审,开发测试。 配合系统分制的结构,人员也分解为小团队。团队目标一致,团队内的文档,设计,代码均是公开的,团队成员都是知晓的。 系统的设计和开发过程中,团队成员在有纪律和规则的前提下进行发散和创新。团队中应该有一个人来确保,讨论以后收敛结论,保证团队“有序”和“规则”。无限的发散只会带来大量内耗。 项目质量 这一部分,没有什么争议,对于测试的重要性,应该已经是开发人员公认的公理。 保证测试时间。 测试范畴推广,作者指出,不但要对代码进行测试,还需要对文档,设计提前测试。 纠错优于创新。 面向変更 看完作者这一部分的描写,简直不能同意更多。 这一章的第一个小标题就是“为了将来丢弃而现在建设”。大型系统的开发不是一蹴而就的,很多系统的初版都是不太能令人满意的,所以不断的重构是一直贯穿到系统的生命周期之中的。作者所谓的“为了将来丢弃而现在建设”,就是开发过程中准备在将来用更高效的和更易于扩展的代码来替代目前正在编写的模块。 重构的过程中,要有精确定义的需求为指导。否则重构结果并不乐观。 系统的不断演进和需求不断的增加,会导致系统代码混乱,“熵”不断增加。重构就是为了减少系统的“熵”。 系统的不断开发中,会产生“死代码”,需要及时去除。重构中要考虑去除复制张贴的代码,并把这些代码抽象为公共库。 需求的变更是常态,开发中不应该抗拒和讨厌,对变更进行管理。开发过程中应该为变更做好准备。但是一个系统不能“广泛参数化”。平衡度也很关键。 系统架构 系统设计目标 在设计开始前应当估计系统的极限,留有一定的安全余量,进行开发。 在大型系统设计中,一直性是重要的考量,有的时候可以牺牲不规则的特性和改进,保证一致性。同时应当保证一致性的文档应该落地。 高内聚低耦合,这个原则应该是我们初学编程就一直强调的原则,我们应该保证模块之间的正交性质。保证低耦合,我们应该做的就是强制接口定义,隐藏实现细节,不使用公共的数据结构。所谓高内聚是指一个模块只实现一个功能。高内聚的软件易于维护和改进。 系统应当适当的进行“过度设计”,提升系统的灵活性。 使用“打包”提升系统的吞吐量。一个是“时间片”,定时进行打包操作一次请求。二是收集足够多的的处理请求后打包处理。在系统优化的过程中,一个是优化算法的实现,二是使用空间换时间的。 高可用的设计,使用“持久化”和“冗余”提升系统的高可靠性。 系统的高扩展性,通过以下方式提升: 按照功能划分模块独立部署 按照负载划分系统 避免分布式事务 在模块间使用异步处理 系统设计原则 系统分层设计,系统设计成金字塔型,越核心的测系统,设备接入越少,关系越简单,可靠性越高。 故障隔离设计。 无单点。 消息驱动模型的设计: 前台发消息无ack重发 后台消息处理需要幂等 消息在每个界定啊都被存储并加标记 其实这个设计,很类似Kafka的实现。 调优原则 先将单机的能力提升,再水平扩展 对大量访问的数据,使用内存方式提高速度(现在看来就是使用缓存),结合磁盘“持久化”。 优化核心算法。 使用异步I/O。 传输压缩,打包和流控技术。 后记 这本书,提出的一些系统设计和开发原则,现在看来大部分已经在我接触过的的系统中大量的使用。可见系统的基本设计原则,经过快十年的发展也没有太大的变化。十年前的一些原则已经有很多成熟的技术实现了。大型系统的开发逐渐变得容易,系统的设计人员可以将更多的精力来组合各个成熟实现,快速搭建系统。推荐这本书给大家,感受大型系统构建的各个方面细节,开阔视野。

2017/10/25
articleCard.readMore

Netty-Apns接入实现

极光推送免费版每分钟600次的请求限制实在是把我恶心坏了,考虑到现在我们 Android 的推送已经全量接入了小米,所以接下来就是要把 iOS 的推送直接接入 APNS 这样就可以彻底摆脱极光的推送。不再受这个600次/分钟的限制了。APNS使用 HTTP2 协议进行通信所以自然就想到了使用Netty作为网络框架,进行开发。下面逐个给大家介绍使用 Netty 接入 APNS 的注意事项和接入的时候踩到的坑。 APNS APNS 是 Apple 提供的推送服务。官方文档 下面说一说自己对接入APNS的时候遇到的坑: APNS 使用 HTTP/2 协议通信,必须使用 TLS 1.2 或以上的加密方式通信,如果使用JDK提供的加密方法,如果使用 JDK7 ,需要对 JVM 的启动参数进行设置,另一个比较简单的解决方法,就是使用 JDK 8 。JDK 8 就能很好的解决加密的问题,或者调用系统的Openssl 进行加解密。具体看大家的线上环境决定。 在开发的时候还遇到一个比较奇葩的问题,就是使用开发证书可以推送到达,但是切换到线上后发现怎么推送都不能到达,后来仔细读了官方文档,发现在请求头中有一个 apns-topic 字段,如果你的推送证书中包括了不止一个应用,这个字段就是一个必填字段,且为应用的 bundle ID。 由于使用了 HTTP/2 协议,APPLE 推荐尽量复用链接,因为使用了 TLS 加密,每次建立连接的握手会消耗大量的时间。同时还可以使用多个连接提升推送的效率,所以之后的实现中,我使用 Common pool2 作为连接池来提高推送的效率。可以使用 PING 来检查连接是否有效。所以在实现的时候使用了 Netty 的 IdleStateEvent 来检查连接。 代码的具体实现: 首先看看代码的结构: ![屏幕快照 2017-05-14 下午11.10.10](http://7u2r32.com1.z0.glb.clouddn.com/屏幕快照 2017-05-14 下午11.10.10.png) Module 中的 Payload 和 PsuhsNotifcation 的是对 APNS 的数据结构的封装,具体可以参考 Apple 提供的文档 ApnsConfig 是对整个推送系统的相关设置,提供了一些默认参数。可以根据需要自己进行设置。 PingMessage 从名字就能看出,是对链接进行检测的 PING frame。 下面着重介绍一下Service相关的实现思路。 具体实现 Netty 的 Client 的代码我参考了 Netty官方提供的 Example。 ApnsConnection 实现了 Connection 接口,主要负责维护和 APNS 的链接。 ![屏幕快照 2017-05-14 下午11.20.37](http://7u2r32.com1.z0.glb.clouddn.com/屏幕快照 2017-05-14 下午11.20.37.png) 接池的实现: 链接池相关的代码主要在 ApnsConnectionPool 中。整个链接池的使用了 Commone Pool2作为底层实现,实现的时候主要参考了jedies的链接池的实现。 3.NettyApnsService 向外暴露了推送的接口。直接调用sendNotification()方法就能推送消息了。 ![屏幕快照 2017-05-14 下午11.26.31](http://7u2r32.com1.z0.glb.clouddn.com/屏幕快照 2017-05-14 下午11.26.31.png) 总结 代码地址:https://github.com/diaozxin007/Netty-apns 欢迎大家拍砖指点。 看开源的代码是快速学习的方法,在整个项目中,参考了很多开源的工程的具体实现。希望对大家有所帮助。

2017/10/24
articleCard.readMore

Future研究

Future是什么? 最近写了一些关于netty的相关代码,发现类似netty 的这种异步框架大量的使用一个Future的类。利用这个future类可以实现,代码的异步调用,程序调用耗时的网络或者IO相关的方法的时候,首先获得一个Future的代理类,同时线程并不会被阻塞。继续执行之后的逻辑,直到真正要使用远程调用返回的结果的时候,才需要调用future的get()方法。这样可以提高代码的执行效率。 于是就花了一点时间研究future是如何实现的。调用方式如何知道,结果什么时候返回的呢?如果使用一个线程去轮询flag 标记,那么就很难及时的感知对象的改变,同时还很难降低开销。。所以我们需要了解java的等待通知机制。利用这个机制来构建一个节能环保的Future。 等待通知机制 一个线程修改了一个对象的值,另一个线程感知到了变化,然后进行相应的操作。一个线程是生产者,另一个线程是消费者。这种模式做到了解耦,隔离了“做什么”和“做什么”。如果要实现这个功能,我们可以利用java内对象内置的等待通知机制来实现。 我们知道’java.lang.Object’有以下方法 方法名称描述 notify()随机选择通知一个在对象上等待的的线程,解除其阻塞状态。 notfiyAll()解除所有那些在该对象上调用wait方法的线程的阻塞状态 wait()导致线程进入等待状态。 wait(long)同上,同时设置一个超时时间,线程等待一段时间。 wait(long,int)同上,且为超时时间设置一个单位。 ps:敲黑板,面试中面试官可能会问,你了解’Object’的哪些方法?如果只答出 toString()的话。估计得出门右转慢走不送了。 所谓等待通知机制,就是某个线程A调用了对象 O 的wait()方法,另一个线程B调用对象 O 的 notify() 或者 notifyAll() 方法。 线程 A 接收到线程 B 的通知,从wait状态中返回,继续执行后续操作。两个线程通过对象 O 来进行通信。 我们看damo: 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 public class waitAndNotify { private static Object object = new Object(); private static boolean flag = true; public static void main(String[] args) throws InterruptedException { Thread a = new Thread(new waitThread(),"wait"); a.start(); TimeUnit.SECONDS.sleep(5); Thread b = new Thread(new notifyThread(),"notify"); b.start(); } static class waitThread implements Runnable{ @Override public void run() { synchronized (object){ while (flag){ try { System.out.println(Thread.currentThread() + "flag is true wait @" + new SimpleDateFormat("HH:mm:ss").format(new Date())); object.wait(); }catch (InterruptedException e){ } } System.out.println(Thread.currentThread() + "flag is false go on @"+ new SimpleDateFormat("HH:mm:ss").format(new Date())); } } } static class notifyThread implements Runnable{ @Override public void run() { synchronized (object){ System.out.println(Thread.currentThread() + "lock the thread and change flag" + new SimpleDateFormat("HH:mm:ss").format(new Date())); object.notify(); flag = false; try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (object){ System.out.println(Thread.currentThread() + "lock the thread again@" + new SimpleDateFormat("HH:mm:ss").format(new Date())); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } } } } 程序的输出: Thread[wait,5,main]flag is true wait @22:46:52 Thread[notify,5,main]lock the thread and change flag22:46:57 Thread[notify,5,main]lock the thread again@22:47:02 Thread[wait,5,main]flag is false go on @22:47:07 wait() 和 notify()以及notifyAll() 需要在对象被加锁以后会使用。 调用notify() 和notifyAll() 后,对象并不是立即就从wait()返回。而是需要对象的锁释放以后,等待线程才会从wait()中返回。 等待通知经典范式 通过以上的代码我们可以把等待通知模式进行抽象。 wait线程: 获取对象的锁。 条件不满足,调用对象wait()方法。 等待另外线程通知,如果满足条件,继续余下操作执行。 伪码如下: 1 2 3 4 5 6 lock(object){ while(condition){ object.wait(); } doOthers(); } notify线程: 获取对象的锁。 修改条件。 调用对象的notify()或者notifyAll()方法通知等待的线程。 释放锁. 伪码如下: 1 2 3 4 lock(object){ change(condition); objcet.notify(); } Future的实现原理: 了解了java的等待通知机制,我们来看看如何利用这个机制实现一个简单的Future。 首先我们定义一个Future的接口: 1 2 3 4 5 public interface IData { String getResult(); } 假设我们有一个很耗时的远程方法: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class RealData implements IData { private String result; RealData(String str){ StringBuilder sb = new StringBuilder(); //假设一个相当耗时的远程方法 for (int i = 0; i < 20; i++) { sb.append("i").append(i); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } result = sb.append(str).toString(); } @Override public String getResult() { return result; } } 同时还要有一个实现了IData的RealData包装类: 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 FutureData implements IData { private RealData realData; private volatile boolean isReal = false; @Override public synchronized String getResult() { while (!isReal){ try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } return realData.getResult(); } public synchronized void setReault(RealData realData){ if(isReal){ return; } this.realData = realData; isReal = true; notifyAll(); } } 可以看出来我们的这个包装类就是一个相当标准的等待通知机制的类。 再看看我们Service类,在Service中的getData方法被调用的时候,程序只接返回了一个FutureData的代理类,同时起了一个新的线程去执行真正耗时的RealData。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Service { public IData getData(final String str){ final FutureData futureData = new FutureData(); new Thread(new Runnable() { @Override public void run() { RealData realData = new RealData(str); futureData.setReault(realData); } }).start(); return futureData; } } 最后看看是如何使用的: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Clinet { public static void main(String[] args) { Service service = new Service(); IData data = service.getData("test"); System.out.println("a"); System.out.println("b"); System.out.println("result is " + data.getResult()); } } 执行的结果是: 1 2 3 a b result is i0i1i2i3i4i5i6i7i8i9i10i11i12i13i14i15i16i17i18i19test 可见程序并没有因为调用耗时的方法阻塞,先打印了a和b,在程序调用getReslut()才打印出真正的结果。 总结: 通过以上的讲解,我们总结一下future,首先使用future可以实现异步调用,实现future我们使用了java的等待通知机制。这个时候们回过头再来看netty的future就很简单了。 参考 《java并发编程的艺术》 漫谈并发编程:Future模型(Java、Clojure、Scala多语言角度分析)

2017/10/24
articleCard.readMore

Hystrix入门研究

1、Hystrix是什么 Hystrix 是Netflix开源的一个针对分布式容错和库。Hystrix的主要功能是隔离分布式系统之间的故障,防止故障带来的雪崩效应。同时也能提供一个分布式服务的优雅的降级方案。从而提高系统的可用性的组件。 2、Hystrix设计理念是什么(其实也是高可用系统设计的理念) 防止单个系统故障后,造成容器(tomcat,scf)的线程全部占满,影响服务响应。 使用快速失败和泄洪代替队列等待。 在系统故障之后提供优雅的降级措施。 使用隔离技术降低故障影响面。 提供准实时的监控报警系统。 提供准实时动态的配置系统。å 客户端感知下游服务状态,防止错误的发展,而不通过真实的调用就能感知。 3、Hystrix怎么用 Hello World: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class CommandHelloWorld extends HystrixCommand<String>{ private final String name; public CommandHelloWorld(String name){ super(HystrixCommandGroupKey.Factory.asKey("ExampleGroup")); this.name = name; } protected String run() throws Exception { return "Hello " + name; } public static void main(String[] args) throws ExecutionException, InterruptedException { String s = new CommandHelloWorld("BoB").execute(); System.out.println(s); } } 降级: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class CommandHelloFailure extends HystrixCommand<String> { private final String name; public CommandHelloFailure(String name) { super(HystrixCommandGroupKey.Factory.asKey("ExampleGroup")); this.name = name; } @Override protected String run() { throw new RuntimeException("this command always fails"); } @Override protected String getFallback() { return "Hello Failure " + name + "!"; } } 4、Hystrix实现思路分析 1、数据流 初始化 HystrixCommand 或者 HystrixObservableCommand 对象。 执行。 判断是否有缓存? 判断是否调用链路是否通畅? 判断线程池/队列/信号量 是否满了? 执行HystrixObservableCommand.construct() 或者HystrixCommand.run()方法 计算调用下游的健康程度 判断时候需要降级 完成请求 2、熔断器 每个熔断器维护10个buckets窗口,每秒生成一个新的bucket,把最早的bucket抛弃,每个bucket记录了调用的,成功、失败、超时、拒绝的次数,如果失败数量达到某个阈值,就会触发熔断。 3、隔离 线程隔离 每个下游调用使用独立的线程池,而非与请求的调用共用一个线程池,这样可以防止失败的调用占用共用的线程池,造成整个系统拒绝服务。 优缺点 优点: 相互独立,减少互相影响的风险,总的来说就是隔离解耦,不会互相影响》 缺点: 过多的线程池造成cpu计算能力的消耗,和增加代码的复杂度。 信号量隔离 信号隔离也可以用于限制并发访问,防止阻塞扩散, 与线程隔离最大不同在于执行依赖代码的线程依然是请求线程(该线程需要通过信号申请), 如果客户端是可信的且可以快速返回,可以使用信号隔离替换线程隔离,降低开销. 4、请求折叠 可以使用组件HystrixCollapser把前端的多个请求折叠为单一的一个后端请求。减少线程和链接的开销。 5、请求缓存 把请求缓存起来。这个不过多解释了

2017/10/24
articleCard.readMore

Redis实现分布式锁

之前我们使用的定时任务都是只部署在了单台机器上,为了解决单点的问题,为了保证一个任务,只被一台机器执行,就需要考虑锁的问题,于是就花时间研究了这个问题。到底怎样实现一个分布式锁呢? 锁的本质就是互斥,保证任何时候能有一个客户端持有同一个锁,如果考虑使用redis来实现一个分布式锁,最简单的方案就是在实例里面创建一个键值,释放锁的时候,将键值删除。但是一个可靠完善的分布式锁需要考虑的细节比较多,我们就来看看如何写一个正确的分布式锁。 单机版分布式锁 SETNX 所以我们直接基于 redis 的 setNX (SET if Not eXists)命令,实现一个简单的锁。直接上伪码 锁的获取: 1 SET resource_name my_random_value NX PX 30000 锁的释放: 1 2 3 4 5 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end 几个细节需要注意: 首先在获取锁的时候我们需要设置设置超时时间。设置超时时间是为了,防止客户端崩溃,或者网络出现问题以后锁一直被持有。真个系统就死锁了。 使用 setNX 命令,保证查询和写入两个步骤是原子的 在锁释放的时候我们判断了KEYS[1]) == ARGV[1],在这里 KEYS[1]是从redis里面取出来的value,ARGV[1]是上文生成的my_random_value。之所以进行以上的判断,是为了保证锁被锁的持有者释放。我们假设不进行这一步校验: 客户端A获取锁,后发线程挂起了。时间大于锁的过期时间。 锁过期后,客户端B获取锁。 客户端A恢复以后,处理完相关事件,向redis发起 del命令。锁被释放 客户端C获取锁。这个时候一个系统中同时两个客户端持有锁。 造成这个问题的关键,在于客户端B持有的锁,被客户端A释放了。 锁的释放必须使用lua脚本,保证操作的原子性。锁的释放包含了get,判断,del三个步骤。如果不能保证三个步骤的原子性,分布式锁就会有并发问题。 注意了以上细节,一个单redis节点的分布式锁就达成了。 在这个分布式锁中还是存在一个单点的redis。也许你会说,Redis是 master-slave的架构,发生故障的时候切换到slave就好,但是Redis的复制是异步的。 如果在客户端A在master上拿到了锁。 在master将数据同步到slave上之前,master宕机。 客户端B就从slave上又一次拿到了锁。 这样由于Master的宕机,造成了同时多人持有锁。如果你的系统可用接受短时时间内,有多人持有锁。这个简单的方案就能解决问题。 但是如果解决这个问题。Redis的官方提供了一个Redlock的解决方案。 RedLock 的实现 为了解决,Redis单点的问题。Redis的作者提出了RedLock的解决方案。方案非常的巧妙和简洁。 RedLock的核心思想就是,同时使用多个Redis Master来冗余,且这些节点都是完全的独立的,也不需要对这些节点之间的数据进行同步。 假设我们有N个Redis节点,N应该是一个大于2的奇数。RedLock的实现步骤: 取得当前时间 使用上文提到的方法依次获取N个节点的Redis锁。 如果获取到的锁的数量大于 (N/2+1)个,且获取的时间小于锁的有效时间(lock validity time)就认为获取到了一个有效的锁。锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。 如果获取锁的数量小于 (N/2+1),或者在锁的有效时间(lock validity time)内没有获取到足够的说,就认为获取锁失败。这个时候需要向所有节点发送释放锁的消息。 对于释放锁的实现就很简单了。想所有的Redis节点发起释放的操作,无论之前是否获取锁成功。 同时需要注意几个细节: 重试获取锁的间隔时间应当是一个随机范围而非一个固定时间。这样可以防止,多客户端同时一起向Redis集群发送获取锁的操作,避免同时竞争。同时获取相同数量锁的情况。(虽然概率很低) 如果某master节点故障之后,回复的时间间隔应当大于锁的有效时间。 假设有A,B,C三个Redis节点。 客户端foo获取到了A、B两个锁。 这个时候B宕机,所有内存的数据丢失。 B节点恢复。 这个时候客户端bar重新获取锁,获取到B,C两个节点。 此时又有两个客户端获取到锁了。 所以如果恢复的时间将大于锁的有效时间,就可以避免以上情况发生。同时如果性能要求不高,甚至可以开启Redis的持久化选项。 总结 了解了Redis分布式的实现以后,其实觉得大多数的分布式系统其实原理很简单,但是为了保证分布式系统的可靠性需要注意很多的细节,琐碎异常。 RedLock算法实现的分布式锁就是简单高效,思路相当巧妙。 但是RedLock就一定安全么?我还会写一篇文章来讨论这个问题。敬请大家期待。

2017/10/24
articleCard.readMore