背景介绍

首先介绍一下实体电商通用的售后流程。

  1. 用户申请:在用户申请操作时需要填写退货、换货,以及原因。
  2. 商家审核:商家会根据沟通情况审核售后申请。
  3. 用户回寄:审核通过后需要用户回寄商品。
  4. 确认退换:商家收货确认后会给用户退款或者邮寄新货。

虚拟商品的售后通用流程如下:

  1. 管理员发起退换操作
  2. 处理退换
    • 退:先退货后退款
    • 换:先退货后发货

在以上两个流程的处理流程有个共通的地方,就是一次操作需要涉及多个子流程的处理,这就是接下来需要讲的通用售后流程抽象。多个子流程的处理意味着要和多个子系统分别进行沟通处理退货、换货和退款。

这里就涉及到分布式系统的一致性问题了,售后模块作为资源的协调方,我们是否可以采用 TCC 的强一致性方案?答案是 No,成本有点高。普遍的做法是采用弱一致性方案保证最终一致性,我们可以考虑采用 Pipeline 机制。

概念比较

Pipeline 管道模式

在 Pipeline 机制中有三个基本概念:

  • Pipeline 管道
  • Valve 阀门
  • Context 上下文数据

一个 Pipeline 管理多个 Valve,多个 Valve 共享一个 Context 数据。用类图来表达如下:

为了管理处理的进度,我们每个 Valve 都是有状态的,那么所有的状态和就是整个 Pipeline 的状态。

TCC 方案

TCC 是一种补偿事务方案,他要求对每个服务提供如下三个接口:

  1. Try:尝试对业务进行一致性检查(一致性),然后对资源的锁定(隔离性)。
  2. Confirm:如果所有的服务都锁定成功并且可执行,就进行确认资源的处理。
  3. Cancel:如果有问题就释放资源。

它的适用场景有以下两个要求:

  1. 一致性和隔离性要求比较强
  2. 执行时间比较短的业务

举例说明是账户间转账、跨系统转账等资金类操作上。

但是它有一个比较明显的缺点就是实现成本比较高,需要协调的服务方越多,系统压力也就越大。

在售后场景中,TCC 是明显不适合的。对于整个售后流程说,各个环节也会出现资源占用导致处理失败的情况,受到 Try 的启发,我们不锁资源,只是在整个处理前挨个进行 qualification 资格检查,全部通过后再进入执行阶段。

责任链模式

责任链模式,用来处理相关业务逻辑的一条执行链,执行链上有多个节点,每个节点都可以处理请求,如果某个节点处理完毕就可以根据实际业务需求传递给下一个节点继续处理或者返回处理完毕的结果。

对于责任链模式,为什么有的实现和 Pipeline 很像。有人对责任链模式进行了如下两种分类:

  • 纯责任链:链上只有一个节点会处理请求。比如双亲委派。
  • 不纯责任链:链上的每个节点都可以处理请求,并转发到下一个节点。比如 Spring Filter 机制。

从上面来看不纯责任链就是 Pipeline 模式,要说区别目前有如下结论。不纯责任链更偏重于数据的过滤和加工,Pipeline 模式是数据的加工,并且更突出节点的状态。

总结

对于售后事件的各个环节处理,如果其中一个环节执行失败,之前的环节怎么办,有以下几种方式来解决:

  1. 增加审核环节,对整个事件审核并预判后续环节的可行性。
  2. 状态回滚,某个环节执行失败,之前的环节都回滚。
  3. 执行前对各个环节进行资格检查。
  4. 最严格的 TCC 资源锁定,一起提交一起失败。

以上四种方式各有利弊,需要根据实际场景来判定使用。

本文主要介绍知乎订单系统后端语言栈的转型升级过程,包括其间踩过的一些坑和遇到的一些问题。一来是想通过本篇文章为其它应用服务转型提供借鉴经验,二来是总结对于订单系统的理解。鉴于文字功底不足,对于业务理解不充分的地方,欢迎留言交流。文章大纲如下:

  • 迁移背景
  • 前期准备
    • 迁移方案
    • 接口验证
    • 指标梳理
  • 可用性保障
    • MTTR 快速响应
    • MTBF 降低故障率
  • 一致性保障
  • 事后总结
    • 目标回顾
    • 执行计划
    • 执行结果
    • 问题整理
  • 业务沉淀
  • 方法论实践
  • 参考文章
  • 招聘信息

迁移背景

随着知乎整体技术栈的变化,原有的 Python 技术栈逐渐被抛弃,新的 Go 和 Java 技术栈逐渐兴起。知乎交易系统的稳定性相比其它业务系统的稳定性重要很多,因为交易系统核心链路发生故障不仅会造成数据问题,还会造成严重的资损问题。
随着公司业务的不断壮大发展,交易场景变得复杂,重构和优化难以避免,因为语言特性,Python 虽然开始撸代码很爽,但是后期的维护成本慢慢变高,不过 Python 在数据分析和人工智能方向上还是有很大优势的,只是在交易领域目前看起来不太合适。从技术生态上来说,用 Java 做交易系统会更有优势,所以接下来要说的知乎订单系统语言栈转型。
另外一个因素是 Python 的 GIL 锁导致它无法发挥多核的优势,性能上受到很大限制,在实际情况中遇到过多次主线程被 hang 住导致的可用性故障,所以坚定决心来迁移掉旧系统。

前期准备

工欲善其事,必先利其器。

语言栈转型首先要明确转型的三个开发流程,即 MRO (Migration, Reconstruction, Optimization)

  • 迁移 就是把原语言代码照着抄一遍到新语言项目上,按照新语言的工程实现风格来做就可以。其间最忌掺杂代码优化和 bug 修复,会容易引起新的问题,增加验证代码的难度。
  • 重构 目的是提高项目代码的可维护性和可迭代性,让代码更优雅和易读懂,可以放到迁移完成来做。
  • 优化 通过在模块依赖、调用关系、接口字段等方面的调整来降低项目的复杂性,提高合理性。

对于语言栈转型来说,迁移流程是肯定要做的,重构和优化如何选择,可以按模块划分功能拆成子任务来分别评估方案,参考依据为现有模块如果同时优化或重构带来的直接收益和间接收益有多少。

  • 收益:完成新旧语言栈的转换,系统维护性更好,模块边界更清晰。
  • 成本:需要投入的人力成本,迁移过程中的并行开发成本,使有更高价值的工作被阻塞的损失。
  • 风险:引入新的 bug,增加测试的复杂性。

在风险可控的前提下,成本与收益要互相权衡,一般会有两种方案可供参考:第一种是锁定需求,堆人力开发上线,一步到位;第二种则是小步快走,迭代上线,分批交付。

基于以上分析,在本次转型过程中,人力成本是一个更重要的因素,所以采用只迁移的方案,来压缩人力成本,降低 bug 引入风险的同时也具有很好的可测试性。并且为了不阻塞业务需求,采用小步快走的方式分批交付,以最长两周作为一个迭代周期进行交付。

迁移方案

确定了交付方式,下面我们需要梳理当前系统中的功能模块,做好任务拆分和排期计划。知乎交易系统在迁移前的业务是针对虚拟商品的交易场景,交易路径比较短,用户从购买到消费内容的流程如下:

  1. 在商品详情页浏览
  2. 生成订单进入收银台和用户支付
  3. 确认支付后订单交付
  4. 用户回到详情页消费内容
  5. 特定商品的七天无理由退款

当时订单系统支持的功能还不多,业务模型和订单模型没有足够地抽象,梳理订单系统业务如下:

完成了订单模块的拆分后,新老系统如何无缝切换?如何做到业务无感?如何保障交易系统稳定性?出现故障如何及时止损?基于上面讲述的原则,将整个系统的迁移划分成两个阶段,迁移前后的数据存储和模型都不变。

接口验证

不论是在迁移的哪个阶段,总需要调整订单接口,可以从订单操作角度分为读操作和写操作,需要针对读接口和写接口做不同的验证方案。

写操作可以通过白名单测试以及灰度放量的方式进行验证上线,将接口未预期异常输出到 IM 工具以得到及时响应。主要的写操作相关接口有:

  • 订单的创建接口。
  • 订单绑定支付单的提交接口。
  • 用户支付后回调确认接口。
  • 用户发起退款接口。

下图展示的是 AB 平台的流量配置界面:

下图展示了部分交易预警通知消息:

读操作往往伴随在写操作中。我们利用平台的录制回放功能进行接口的一致性检查,通过对比得出差异排查问题。主要的读操作接口有:

  • 获取支付方式列表接口
  • 获取订单支付履约状态接口
  • 获取充值列表接口
  • 批量查询用户新客状态接口

下图展示的是流量录制回放系统的数据大盘:

指标梳理

监控是我们系统的『第三只眼』,可以及时反应系统的健康状况,及时发出告警信息,并帮助我们在出现故障时分析问题和快速缩小排查范围。硬件、数据库、中间件的监控已经在平台层得到支持,这里只需要梳理出应用的监控指标。

  • 日志监控:请求日志、服务端的错误日志。
  • 订单业务指标
    • 下单量、成单量、掉单量
      • 单量环比数据
    • 首次履约异常量
    • 补偿机制履约量
    • 各通知事件 P95 耗时
    • 成功履约 P95 耗时
    • 履约准时率/成功率
  • 支付业务指标
    • 支付渠道履约延迟 P95
    • 支付履约延迟 P95。
  • 用户购买完整耗时 P95。

可用性保障

在整个交付的过程中,转型前后对 SLA 要提供一致的可用性保障,可以看看下面的几个衡量标准:

一般 3 个 9 的可用性全年宕机时间约为 8.76 小时,不同系统不同用户规模对于系统可用性的要求不一样,边缘业务的要求可能会低一些,但是对于核心链路场景 TPS 可能不高,但是必须要求保证高可用级别。如何保证或者提升服务的 SLA 是我们接下来要探讨的内容,一般有下面两个影响因素:

  • MTBF (Mean Time Between Failures) 系统服务平均故障时间间隔

  • MTTR (Mean Time To Recover) 系统服务平均故障恢复时长

也就是说我们要尽可能地降低故障频率,并确保出现故障后可以快速恢复。基于这两点我们在做系统平稳过渡时,要充分测试所有 case ,并且进行灰度方案和流量录制回放,发现异常立即回滚,定位问题解决后再重新灰度。

MTTR 快速响应

持续监控

感知系统稳定性的第一步就是监控,通过监控来反映系统的健康状况以及辅助定位问题,监控有两个方向:

第一个方向是指标型监控,这里监控是在系统代码中安排各种实时打点,上报数据后通过配置报表呈现出来的。

  1. 基础设施提供的机器监控以及接口粒度的响应稳定性监控。
    1. 物理资源监控,如 CPU、硬盘、内存、网络 IO 等。
    2. 中间件监控,消息队列、缓存、Nginx 等。
    3. 服务接口,HTTP、RPC 接口等。
    4. 数据库监控,连接数、QPS、TPS、缓存命中率、主从延迟等。
  2. 业务数据层面的多维度监控,从客户端和服务端两个角度来划分。
    1. 从客户端角度来监控服务端的接口成功率,支付成功率等维度。
    2. 从服务端角度从单量突变、环比变化、交易各阶段耗时等维度持续监控。

以上两点基于公司的 statsd 组件进行业务打点,通过配置 Grafana 监控大盘实时展示系统的健康状况。

第二个方向是日志型监控,这主要依赖公司的 ELK 日志分析平台和 Sentry 异常捕获平台。通过 Sentry 平台可以及时发现系统告警日志和新发生的异常,便于快速定位异常代码的发生位置。ELK 平台则可以将关键的日志详细记录下来以便于分析产生的场景和复现问题,用来辅助修复问题。

异常告警

基于以上实时监控数据配置异常告警指标,能够提前预知故障风险,并及时发出告警信息。然而达到什么阈值需要告警?对应的故障等级是多少呢?

首先我们要在交易的黄金链路上制定比较严格的告警指标,从下单、提单、确认支付到履约发货的每个环节做好配置,配置的严重程度依次递增分为 Info、Warning、Critical。按照人员类别和通知手段来举例说明告警渠道:

IM 中的预警消息截图如下:

订单主要预警点如下:

  • 核心接口异常
  • 掉单率、成单率突变
  • 交易各阶段耗时增加
  • 用户支付后履约耗时增加
  • 下单成功率过低

MTBF 降低故障率

系统监控告警以及日志系统可以帮我们快速的发现和定位问题,以及时止损。接下来说的质量提升则可以帮助我们降低故障发生率以避免损失,主要从两个方向来说明:

规范化的验收方案

① 开发完成包括逻辑功能和单元测试,优先保证单测行数覆盖率再去保证分支覆盖率。然后在联调测试环境中自测,通过后向 QA 同学提测。
② QA 同学可以在测试环境下同时进行功能验收和接口测试,测试通过后便部署到 Staging 环境。
③ 在 Staging 环境下进行功能验收并通过。
④ 灰度交付以及双读验证可以根据实际情况选择性使用。
⑤ 上线后需要最后进行回归测试。

统一的编码规约以及多轮 CR 保障

代码上线前一般至少要经过两次代码评审,太小的 MR 直接拉一位同事在工位 CR 即可,超过百行的变更需要拉会研讨,两次评审的关注点也不同。

第一次评审应关注编码风格,这样可以避免一些因在写法上自由发挥而带来的坑,以此来沉淀出组内相对统一的编码规约,在编码的稳定性上建立基本的共识,提升代码质量。

第二次评审应关注代码逻辑,这里有个需要注意的点是,如果明确只做迁移,那么其间发现旧逻辑难理解的地方不要随便优化,因为在不了解背景的情况下很有可能会写一个 bug 带上线(这种事见过好几次)。另外这样也好去对比验证,验证通过上线后再去优化。

只有通过明确目的和流程并且遵循这个流程做,才能更快更好地交付有质量的代码。

一致性保障

每一个微服务都有自己的数据库,微服务内部的数据一致性由数据库事务来保障,Java 中采用 Spring 的 @Transtaction 注解可以很方便地实现。

而跨微服务的分布式事务,像 支付、订单、会员三个微服务之间采用最终一致性,类似 TCC 模式的两阶段提交,订单通过全局发号器生成订单 ID,然后基于订单 ID 创建支付单,如果用户支付后订单会变更自身状态后通知会员微服务,履约成功则事务结束,履约失败则触发退款,如果用户未支付,那么订单系统将该订单以及支付单做关单处理。

对应一致性保障,我们对订单接口做了两个方面的处理:

分布式锁

对于上游的支付消息监听、支付 HTTP 回调、订单主动查询支付结果三个同步机制分别基于订单 ID 加锁后再处理,保证同步机制不会被并发处理。

接口幂等

加锁后对订单状态做了检查,处理过则响应成功,否则处理后响应成功,保证上游消息不会被重复处理。

订单对于下游的履约,是通过订单 ID 作为幂等 key 来实现的,以保证同一个订单不会被重复履约,并且通过 ACK 机制保证履约后不会再重复调到下游。

其中分布式锁采用 etcd 锁,通过锁租约续期机制以及数据库唯一索引来进一步保障数据的一致性。

补偿模式,虽然我们通过多种手段来保证了系统最终一致,但是分布式环境下会有诸多的因素,如网络抖动、磁盘 IO、数据库异常等都可能导致我们的处理中断。这时我们有两种补偿机制来恢复我们的处理:

带惩罚机制的延时重试

如果通知中断,或者未收到下游的 ACK 响应,则可以将任务放到延迟队列进行有限次的重试,重试间隔逐次递增。最后一次处理失败报警人工处理。

定时任务兜底

为了防止以上机制都失效,我们的兜底方案是定时扫描异常中断的订单再进行处理。如果处理依然失败则报警人工处理。

事后总结

目标回顾

目标一:统一技术栈,降低项目维护成本。目标结果是下线旧订单系统。
目标二:简化下单流程,降低端接入成本。目标结果是后端统一接口,端上整合 SDK。

执行计划

迁移的执行总共分成了三个大阶段:

  • 第一阶段是迁移逻辑,即将客户端发起的 HTTP 请求转发到 RPC 接口,再由新系统执行。第一阶段做到所有的新功能需求都在新系统上开发,旧系统只需要日常维护。

  • 第二阶段是通过和客户端同学合作,迁移并整合当前知乎所有下单场景,提供统一的下单购买接口,同时客户端也统一提供交易 SDK,新组件相对更加稳定和可监控,在经过灰度放量后于去年底完全上线。第二阶段做到了接口层的统一,更利于系统的维护和稳定,随着新版的发布,旧接口流量已经变得很低,大大降低了下阶段迁移的风险。

  • 第三阶段是旧 HTTP 接口迁移,由新系统承载所有端的请求,提供相同规格的 HTTP 接口,最后通过修改 NGINX 配置完成接口迁移。第三阶段迁移完成后旧系统最终实现了下线。

执行结果

截至此文撰写时间,语言栈已经 100% 迁移到新的系统上,旧系统已经完全下线,总计下线 12 个系统服务, 32 个对外 HTTP 接口,21 个 RPC 接口,15 个后台 HTTP 接口。

根据 halo 指标,迁移前后接口 P95 耗时平均减少约 40%,硬件资源消耗减少约 20%。根据压测结果比较,迁移后支撑的业务容量增长约 10 倍。

系统迁移完成只是取得了阶段性的胜利,接下来系统还需要经过一些小手术来消除病灶,主要是以下几点:

  1. 不断细化监控粒度,优化告警配置,继续提高服务的稳定性。
  2. 对于 Python 的硬翻译还需要不断重构和优化,这里借鉴 DDD 设计思想。
  3. 完善监控大盘,通过数据驱动来运营优化我们的流程。
  4. 项目复盘总结以及业务普及宣讲,提升人员对于业务细节的认知。

问题整理

迁移总是不能一帆风顺的,其间遇到了很多奇奇怪怪的问题,为此头发是真没少掉。

问题 1:迁移了一半新需求来了,又没有人力补上来怎么办?

迁移后再做重构和优化过程,其实很大一部分考量是因为人力不足啊,而且现状也不允许锁定需求。那么只能写两遍了,优先支持需求,后面再迁移。如果人力充足可以选择一个小组维护新的系统一个小组维护旧的系统。

问题 2:我明明请求了,可日志怎么就是不出来呢?

不要怀疑平台的问题,要先从自身找问题。总结两个原因吧,一个是新旧系统的迁移点太分散导致灰度不好控制,另一个是灰度开关忘记操作了,导致流量没有成功导到新系统上。这里要注意一个点就是在迁移过程中要尽可能的快速交付上线。

问题 3:公司 Java 基础服务不够完善,很多基础平台没有支持怎么办?

于是自研了分布式延迟队列、分布式定时任务等组件,这里就不展开聊了。

问题 4:如何保证迁移过程中两个系统数据的一致性?

首先我们前面讲到的是系统代码迁移,而数据存储不变,也就是说两个系统处理的数据会存在竞争,解决的办法是在处理时加上分布式锁,同时接口的处理也是要幂等的。这样即使在上下游系统做数据同步的时候也能避免竞争,保证数据的一致性。
就用户支付后支付结果同步到订单系统这一机制来说,采用推拉的机制。
① 用户支付后订单主动轮询支付结果,则是在主动拉取数据。
② 支付系统发出 MQ 消息被订单系统监听到,这是被动推送。
③ 支付成功后触发的订单系统 HTTP 回调机制,这也是被动推送。
以上三种机制结合使用使得我们系统数据一致性有一个比较高的保障。我们要知道,一个系统绝非 100% 可靠,作为交易支付的核心链路,需要有多条机制保证数据的一致性。

问题 5:用户支付后没有收到会员权益是怎么回事?

在交易过程中,订单、支付、会员是三个独立的服务,如果订单丢失了支付的消息或者会员丢失了订单的消息都会导致用户收不到会员权益。上一个问题中已经讲到最终一致性同步机制,可能因为中间件或者网络故障导致消息无法同步,这时可以再增加一个补偿机制,通过定时任务扫描未完成的订单,主动检查支付状态后去会员业务履约,这是兜底策略,可保障数据的最终一致。

业务沉淀

从接收项目到现在也是对订单系统从懵懂到逐渐加深理解的一个过程,对于当前交易的业务和业务架构也有了一个理解。

交易系统本身作为支付系统的上层系统,提供商品管理能力、交易收单能力、履约核销能力。外围业务子系统主要关注业务内容资源的管理。业务的收单履约管理接入交易系统即可,可减轻业务的开发复杂度。收单流程展示如下:

  1. 业务定制商品详情页,然后通过详情页底栏调用端能力进入订单收银台。在这里客户端需要调用业务后端接口来获取商品详情,然后调用交易底栏的展示接口获取底部按钮的情况。
  2. 用户通过底部按钮进入收银台后,在收银台可以选择支付方式和优惠券,点击确认支付调起微信或者支付宝付款。收银台展示以及获取支付参数的接口由交易系统提供。
  3. 订单后台确认收款后会通知业务履约,用户端会回到详情页,用户在详情页进入内容播放页享受权益。履约核销流程是业务后端与交易系统后端的接口调用来完成的。

现在知乎站内主要是虚拟商品的交易,一个通用的交易流程如下图:

用户经历了从商品的浏览到进入收银台下单支付,再回到内容页消费内容。随着业务的发展,不同的交易场景和交易流程叠加,系统开始变得复杂,一个交易的业务架构慢慢呈现。

订单系统主要承载知乎站内站外的各种交易服务,提供稳定可靠的交易场景支撑。主要分为以下几个部分:

  1. 首先产品服务层是面向用户能感受到的交互界面,提供对于这些页面的统一下单支付 API 网关。
  2. 然后是订单服务层,由上层网关调用,提供着不同场景下的交易服务支撑。
  3. 再往下是订单领域层,承载订单最核心逻辑代码,首先是用户购买需要的算价聚合,然后是管理订单模型的交易聚合,最后是买完商品后的履约处理的交付聚合。
  4. 最底层是基础支撑服务层,主要是提供基本的服务支持以及交易依赖的一些服务。
  5. 最后是运营服务,提供交易相关的后台功能支持。

方法论实践

凡此以上,不论系统迁移方案还是架构理解都归结于参与人员的理解与认知,一个优秀的方案或合适的架构不是设计出来的,是迭代出来的。人的认知也是这样,需要不断的迭代升级,和很多的方法论一样,PDCA 循环为我们提炼了一个提升路径。

  • Plan 计划,明确我们迁移的目标,调研现状指定计划。
  • Do 执行,实现计划中的内容。
  • Check 检查,归纳总结,分析哪些做好了,还有什么问题。
  • Action 调整,总结经验教训,在下一个循环中解决。

很多时候,也许你只做了前两步,但其实后两步对你的提升会有很大帮助。所以一个项目的复盘,一次 Code Review 很重要,有语言的交流和碰撞才更容易打破你的固有思维,做到业务认知的提升。

参考文章

https://mp.weixin.qq.com/s/eKc8qoqNCgqrnont2nYNgA
https://zhuanlan.zhihu.com/p/138222300
https://blog.csdn.net/g6U8W7p06dCO99fQ3/article/details/103415254

招聘信息

知乎技术团队大量岗位持续招聘中,欢迎感兴趣的同学加入我们,可投简历至:luohuijuan@zhihu.com

本文是对于订单建模的一点思考整理,里面应用一些对领域驱动设计的思考,默认读者对于领域概念有一些基本了解。

基本元素

交易最早是通过以物易物的方式来交换,后面产生等价通用物品即货币。交易上下文领域对象包括:

1
2
3
4
顾客 Consumer
货 Goods
货币 Currency
商家 Merchant

对于交易一句话描述就是,顾客在商家那里通过等价货币购换取了货物。

上面描述的四种领域对象作为领域实体,每一种领域实体通过 key 组合来确定实体的唯一性,ext 是值对象,用来描述实体。对应的实体描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Consumer:
{
"key": "USER_ID",
"ext": { }
}
Goods
{
"key": "SKU_ID",
"amount": 100,
"ext": { }
}
Currency
{
"type": "CNY|VIRTUAL_COIN",
"amount": 100
}
Merchant
{
"key": "yO7LmcWjqsJz1N5j",
"ext": { }
}

状态事件抽象

订单状态变更命令通用流程抽象如下图,一个领域命令被触发后,首先进行状态变更前的资格校验事件回调,所有校验方均校验通过后方可进行后续处理,将交易修改为对应状态,状态修改后发布对应的状态变更事件,经由事件总线发布事件,所有监听方进行后续处理。一个领域命令执行结束。此流程可以套用到任何状态变更命令。

状态变化通过领域命令发起 command,首先进行 BeforeOperating 操作前的处理,每一个命令都定义了事件的资格校验 qualification 接口,实现接口的可能是子域内处理,比如下单的库存校验,优惠校验等,也可能是具体业务的校验,需要实现资格校验接口。通过后进行事务性的状态变更操作 updateState,操作后进行状态变更后 AfterOperating 的事件发布,由各个业务监听进行后续处理。这样处理方便业务逻辑解耦,订单子域专注于订单状态的管理。

对应到订单系统,对于订单状态和物流状态节点以及领域事件如下表示:

订单状态

  • 已创建 CREATE(COMMIT)
  • 已支付 PAID
  • 已退款 REFUND(REFUNDING)
  • 已关闭 CLOSED

物流状态

  • 待发货 WAITING
  • 已发货 DELIVERED
  • 已收货 RECEIVED
  • 已退货 RETURN

领域命令

  • 创建订单 createOrder
  • 超时关单 closeOrder
  • 支付订单 payOrder
  • 申请退款 refundOrder
  • 订单履约 deliverOrder
  • 商家发货 deliverGoods
  • 用户收货 receiveGoods
  • 用户退货 returnGoods

子域拆分

订单子域的拆分也是业务职责的拆分,订单管理和物流管理是相对独立的两个模块,订单主要关注收款履约退款,物流主要关注用户的收退货状态,所以整个交易域的订单和物流会被拆分成两个子域。

订单事件举例

订单创建命令,首先对注册的条件回调方进行回调,确认当前的购买资格,通过后进行事务性的订单创建,创建后进行订单已创建的事件发布,所有监听方接收到消息后进行分析处理。

BPMN 有什么优势呢,用了一段时间主要使用在业务流程表达上,表达符号比较多,相比流程图可以更清楚的表达业务流程,同步、异步,异常中断、事件消息等等,如果看图的人都对这些符号有概念,可以比较轻松的看懂业务流程。缺点就是符号太多,学习成本相对高一些。学会了就会对业务表达上有很好的助力。

范围

BPMN仅限于支持对业务流程有用的建模概念。这意味着组织所做的非业务目的其他类型建模将排除在BPMN之外。例如,以下方面的建模不属于BPMN的一部分:

  • 组织结构
  • 职能分解
  • 数据模型

此外,虽然BPMN会显示数据的流(消息)以及活动与数据器物的关联,但它并非数据流图(data flow diagram)。

要素

BPMN用很小一套图形要素做简单的图来建模,这将令业务用户与开发者一样容易理解其中的过程和流。它的四种基本要素如下:

  1. 流对象(Flow Object):事件(Events),活动(Activities),关口(Gateways)
  2. 连接对象(Connecting Objects):顺序流(Sequence Flow),消息流(Message Flow),关联(Association)
  3. 泳道(Swimlanes):池(Pool),道(Lane)
  4. 器物(Artifacts/Artefacts):数据对象(Data Object),组(Group),注释(Annotation)

这四大类对象令我们有机会做出简单的业务流程图(BPD, business process diagram)。同时,BPMN也允许在BPD中创建你自己的流对象、器物类型,使图更好理解。

流对象与连接对象

泳道与器物

业务流程图的类型

常用符号

免费画 BPMN 流程图的工具

https://app.diagrams.net/

参考

https://github.com/Pingren/bpmn-dataflow-diagram-editor
https://github.com/zhangqiangboss/WorkflowAndCamunda/blob/master/docs/camunda/CamundaProEngine.md
https://www.edrawsoft.cn/bpmn-symbols/

领域驱动设计中定义了超多的概念,如果不多找几篇资料综合的去看,正确的理解比较困难,下面搜集整理了大部分的领域驱动中的概念,并加以理解描述。

战略设计与战术设计

战略设计主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的限界上下文,限界上下文可以作为微服务设计的参考边界。它是从高层事业来绅士我们的软件系统。从战略设计角度来看,一套基础的电商业务应该包含如下领域,支付域、交易域、商品域、库存域、履约域。不同领域之间通过界限上下文来划分边界。

战术设计则从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。它主要关注的是技术层面的实施,也是对我们程序员最实在的地方。战术设计的具体落地案例在后面的内容中会讲解。

领域 & 子领域

DDD 的领域就是这个边界内要解决的业务问题域。既然领域是用来限定业务边界和范围的,那么就会有大小之分,领域越大,业务范围就越大,反之则相反。领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。

核心域 & 通用域 & 支撑域

在领域不断划分的过程中,领域会细分为不同的子域,子域可以根据自身重要性和功能属性划分为三类子域,它们分别是:核心域、通用域和支撑域。决定产品和公司核心竞争力的子域是核心域,它是业务成功的主要因素和公司的核心竞争力。没有太多个性化的诉求,同时被多个子域使用的通用功能子域是通用域。还有一种功能子域是必需的,但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,它就是支撑域。

基于以上概念定义,对订单域进行如下的拆分,其中交易子域和算价子域是最关键的核心子域,限购子域、交付子域、报表子域、会员订阅子域是支撑子域,消息子域为沟通各个子域的桥梁分类为通用子域。注意这里的曲线只是用来区分不同子域类型,不是界限上下文。

事件风暴

事件风暴是一项团队活动,领域专家与项目团队通过头脑风暴的形式,罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对每一个事件,标注出导致该事件的命令,再为每一个事件标注出命令发起方的角色。命令可以是用户发起,也可以是第三方系统调用或者定时器触发等,最后对事件进行分类,整理出实体、聚合、聚合根以及限界上下文。而事件风暴正是 DDD 战略设计中经常使用的一种方法,它可以快速分析和分解复杂的业务领域,完成领域建模。

通用语言 & 界限上下文

在 DDD 领域建模和系统建设过程中,有很多的参与者,包括领域专家、产品经理、项目经理、架构师、开发经理和测试经理等。对同样的领域知识,不同的参与角色可能会有不同的理解,那大家交流起来就会有障碍,怎么办呢?因此,在 DDD 中就出现了“通用语言”和“限界上下文”这两个重要的概念。这两者相辅相成,通用语言定义上下文含义,限界上下文则定义领域边界,以确保每个上下文含义在它特定的边界内都具有唯一的含义,领域模型则存在于这个边界之内。

通用语言

团队交流达成共识的能够明确简单清晰的描述业务规则和业务含义的语言就是通用语言。解决各岗位的沟通障碍问题,促进不同岗位的和合作,确保业务需求的正确表达。通用语言贯穿于整个设计过程,基于通用语言可以开发出可读性更好的代码,能准确的把业务需求转化为代码。

界限上下文

用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。这个边界定义了模型的适用范围,使团队所有成员能够明确地知道什么应该在模型中实现,什么不应该在模型中实现。

栗子说明

在商品域,商品实体则对应着一个具体的 SKU 商品,包含着标题和金额,如现在的课程、会员服务。在订单域中的商品实体并不等同域商品域中的实体,比如可以将优惠券做成可以被售卖的商品,coupon_no 就是 product_key,具有 non-consumable 属性;或者将付费咨询的用户服务打包成商品售卖,那么 member_id 就能映射成 producer_key,并且觉有 consumable 属性。界限上下文就是区分不同领域下的领域对象,划定了领域对象含义的边界。在订单域中,商品实体默认可以是商品系统 SKU,也可以是优惠券和用户服务等。

上下文映射图

上下文映射图就通过画图的方式展示N(N>=2)个上下文之间的映射关系。

ACL表示防腐层,OHS表示开放主机服务,PL表示发布语言,U代表上游,D代表下游。

领域服务

你是否遇到过这样的问题:想建模一个领域概念,把它放在实体上不合适,把它放在值对象上也不合适,然后你冥思苦想着自己的建模方式是不是出了问题。恭喜你,祝贺你,你的建模手法完全没有问题,只是你还没有接触到领域服务(Domain Service)这个概念,因为领域服务本来就是来处理这种场景的。比如,要对客户端类型和版本进行判断是否支持某一项功能,我们可以创建一个 ClientVersionService 来负责。
值得一提的是,领域服务和上文中提到的应用服务是不同的,领域服务是领域模型的一部分,而应用服务不是。应用服务是领域服务的客户,它将领域模型变成对外界可用的软件系统。

领域事件

在DDD中,领域事件便可以用于处理上述问题,此时最终一致性取代了事务一致性,通过领域事件的方式达到各个组件之间的数据一致性。领域事件的命名遵循英语中的“名词+动词过去分词”格式,即表示的是先前发生过的一件事情。比如,购买者提交商品订单之后发布 OrderCreated 事件,用户支付 TradePaid 事件。需要注意的是,既然是领域事件,他们便应该从领域模型中发布。领域事件的最终接收者可以是本限界上下文中的组件,也可以是另一个限界上下文。
领域事件的额外好处在于它可以记录发生在软件系统中所有的重要修改,这样可以很好地支持程序调试和商业智能化。另外,在CQRS架构的软件系统中,领域事件还用于写模型和读模型之间的数据同步。再进一步发展,事件驱动架构可以演变成事件源(Event Sourcing),即对聚合的获取并不是通过加载数据库中的瞬时状态,而是通过重放发生在聚合生命周期中的所有领域事件完成。

实体 & 值对象 & 聚合 & 聚合根

实体 & 值对象

实体和值对象是组成领域模型的基础单元。
在 DDD 中有这样一类对象,它们拥有唯一标识符,且标识符在历经各种状态变更后仍能保持一致。对这些对象而言,重要的不是其属性,而是其延续性和标识,对象的延续性和标识会跨越甚至超出软件的生命周期。我们把这样的对象称为实体。
通过对象属性值来识别的对象,它将多个相关属性组合为一个概念整体。在 DDD 中用来描述领域的特定方面,并且是一个没有标识符的对象,叫作值对象。值对象本质就是一个集合,可以保证属性归类的清晰和概念的完整性。

举例说明

消费者实体原本包括:ID、昵称、注册手机号、姓名以及人员所在的省、市、县和街道等属性。这样显示地址相关的属性就很零碎了对不对?现在,我们可以将“省、市、县和街道等属性”拿出来构成一个“地址属性集合”,这个集合就是值对象了。

聚合 & 聚合根

聚合是业务和逻辑紧密关联的实体和值对象组合而成,聚合是数据修改和持久化的基本单元,一个聚合对应一个数据的持久化。聚合在DDD分层架构中属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑,聚合内的实体以充血模型实现个体业务能力,以及业务逻辑的高内聚。跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
如果把聚合比作组织,聚合根则是组织的负责人,聚合根也叫做根实体,它不仅仅是实体,还是实体的管理者。聚合之间通过聚合根关联引用,如果需要访问其他聚合的实体,先访问聚合根,再导航到聚合内部的实体。即外部对象不能直接访问聚合内的实体。

举例说明

上图说明聚合与聚合根的关系,交易聚合有一个唯一的聚合根交易单,交易单组织了消费者实体、商品实体、商铺实体、优惠券实体同时消费金额之对象。下面具体对比说明下:

聚合的特点:高内聚、低耦合,它是领域模型中最底层的边界,可以作为拆分微服务的最小单位,但我不建议你对微服务过度拆分。但在对性能有极致要求的场景中,聚合可以独立作为一个微服务,以满足版本的高频发布和极致的弹性伸缩能力。
一个微服务可以包含多个聚合,聚合之间的边界是微服务内天然的逻辑边界。有了这个逻辑边界,在微服务架构演进时就可以以聚合为单位进行拆分和组合了,微服务的架构演进也就不再是一件难事了。
聚合根的特点:聚合根是实体,有实体的特点,具有全局唯一标识,有独立的生命周期。一个聚合只有一个聚合根,聚合根在聚合内对实体和值对象采用直接对象引用的方式进行组织和协调,聚合根与聚合根之间通过 ID 关联的方式实现聚合之间的协同。
实体的特点:有 ID 标识,通过 ID 判断相等性,ID 在聚合内唯一即可。状态可变,它依附于聚合根,其生命周期由聚合根管理。实体一般会持久化,但与数据库持久化对象不一定是一对一的关系。实体可以引用聚合内的聚合根、实体和值对象。
值对象的特点:无 ID,不可变,无生命周期,用完即扔。值对象之间通过属性值判断相等性。它的核心本质是值,是一组概念完整的属性组成的集合,用于描述实体的状态和特征。值对象尽量只引用值对象。

防腐层

通过在遗留系统和现代系统之间使用防腐层来隔离它们。该层转换两个系统之间的通信,允许遗留系统保持不变,同时可以避免损害现代应用程序的设计和技术方法。

现代应用与防腐层之间的通信始终使用应用程序的数据模型和架构。从防腐层到遗留系统的调用都符合该系统的数据模型或方法。 防腐层包含两个系统之间转换所需的所有逻辑。该层可以作为应用程序中的组件或作为独立服务来实现。

贫血模型

贫血模型就是模型对象之间存在完整的关联(可能存在多余的关联),但是对象除了get和set方外外几乎就没有其它的方 法,整个对象充当的就是一个数据容器,用C语言的话来说就是一个结构体,所有的业务方法都在一个无状态的Service类中实现,Service类仅仅包 含一些行为。
贫血模型的优点是很明显的:

  1. 被许多程序员所掌握,许多教材采用的是这种模型,对于初学者,这种模型很自然,甚至被很多人认为是java中最正统的模型。
  2. 它非常简单,对于并不复杂的业务(转帐业务),它工作得很好,开发起来非常迅速。它似乎也不需要对领域的充分了解,只要给出要实现功能的每一个步骤,就能实现它。
  3. 事务边界相当清楚,一般来说service的每个方法都可以看成一个事务,因为通常Service的每个方法对应着一个用例。(在这个例子中我使用了facade作为事务边界,后面我要讲这个是多余的)
    其缺点为也是很明显的:
  4. 所有的业务都在service中处理,当业越来越复杂时,service会变得越来越庞大,最终难以理解和维护。
  5. 将所有的业务放在无状态的service中实际上是一个过程化的设计,它在组织复杂的业务存在天然的劣势,随着业务的复杂,业务会在service中多个方法间重复。
  6. 当添加一个新的UI时,很多业务逻辑得重新写。例如,当要提供Web Service的接口时,原先为Web界面提供的service就很难重用,导致重复的业务逻辑(在贫血模型的分层图中可以看得更清楚),如何保持业务逻辑一致是很大的挑战。

领域模型

领域模型是对领域内的概念类或现实世界中对象的可视化表示。又称概念模型、领域对象模型、分析对象模型。它专注于分析问题领域本身,发掘重要的业务领域概念,并建立业务领域概念之间的关系。
领域驱动模型,与贫血模型相反,领域模型要承担关键业务逻辑,业务逻辑在多个领域对象之间分配,而Service只是完成一些不适合放在模型中的业务逻辑,它是非常薄的一层,它指挥多个模型对象来完成业务功能。
其优点是:

  1. 领域模型采用OO设计,通过将职责分配到相应的模型对象或Service,可以很好的组织业务逻辑,当业务变得复杂时,领域模型显出巨大的优势。
  2. 当需要多个UI接口时,领域模型可以重用,并且业务逻辑只在领域层中出现,这使得很容易对多个UI接口保持业务逻辑的一致(从领域模型的分层图可以看得更清楚)。
    其缺点是:
  3. 对程序员的要求较高,初学者对这种将职责分配到多个协作对象中的方式感到极不适应。
  4. 领域驱动建模要求对领域模型完整而透彻的了解,只给出一个用例的实现步骤是无法得到领域模型的,这需要和领域专家的充分讨论。错误的领域模型对项目的危害非常之大,而实现一个好的领域模型非常困难。
  5. 对于简单的软件,使用领域模型,显得有些杀鸡用牛刀了。

开放主机服务

该模式可以通过REST实现。通常来讲,我们可以将开放主机服务看作是远程过程调用(RPC)的API。同时,也可以通过消息机制实现。

参考

https://www.cnblogs.com/snidget/p/12995676.html
https://zhuanlan.zhihu.com/p/130945830
https://blog.csdn.net/itfly8/article/details/109554847
https://www.cnblogs.com/netfocus/archive/2011/10/10/2204949.html
https://iambowen.gitbooks.io/cloud-design-pattern/content/patterns/anti-corruption-layer.html

关注树莓派很久了,只是没有很感兴趣的应用场景,就没有买来玩。几个月前偶然得到一个小度音箱,发现了新大陆,各种语音控制功能,便捷性不言而喻,还买了一些外部设备可以通过小度控制,发现有红外遥控器可以控制家里的大部分红外家电,奈何码库不是很全,有些设备还是不能控制的,而且不支持定制功能。恰好在知乎看到了一些 geek 视频,想着自己也做一个,可以支持红外数据的定制,做到自由遥控。于是乎说干就干,从一个什么硬件都不懂的小白一步步的了解了点硬件知识,软件部分相对好实现一些。主要计划的功能是通过语音来控制红外家电、温湿度监控以及智能提醒等功能,先完成主体框架然后再不断开发插件形式来增强可玩性。

计划主要分为两个子系统

  1. 软件子系统,主要实现语音到文字和文字到语音的转换,逻辑功能的处理等。
  2. 硬件子系统提供收音、音箱、温湿度传感器、红外收发、系统供电等能力的支持。

总体功能点进度如下

  • ok 显示,信息简单展示界面,计划采用 OLED12832 屏。
  • ok 收音,收集外接语音信息。
  • ok 音响,输出系统响应结果。
  • 温湿度,收集设备所处环境的温度和湿度。
  • ok 风扇,硬件系统散热。
  • ok 红外收/发,红外设备系统的录入和红外信号的发射,用于控制红外家电。
  • 供电模块,给音响和树莓派硬件供电。
  • ok pcb 电路版设计,传感器集成。
  • 3d 打印外壳,最后根据硬件的排列情况定制一个简洁的外壳。
  • ok 语音汉字互转,计划采用讯飞 API 接口实现,后面尝试做简单的语音识别模型。
  • ok 逻辑控制和输出输入设备控制模块,基于硬件传感器数据的采集和信息的归纳整理能力。

硬件部分

一直以来都是做的软件,这次从 0 到 1 一点点学的硬件,到 PCB 打样,焊板。没有遵循设计规范,只是按照能用的级别做的。

v0.2

实验数据收集

树莓派4 GPIO 引脚

一、电源输出引脚

3v3、5v代表:3.3伏特和5伏特,是输出供电的正极,也就是我们常说的Vcc

GND代表接地和输出供电的负极

特别注意:每个引脚最大输出电流为16毫安(mA),且同一时刻所有引脚的总输出电流不超过51毫安

二、GPIO

GPIO(General Purpose I/O Ports)意思为通用输入/输出端口,通俗地说,就是一些引脚,可以通过它们输出高低电平或者通过它们读入引脚的状态-是高电平或是低电平。GPIO是个比较重要的概念,用户可以通过GPIO口和硬件进行数据交互(如UART),控制硬件工作(如LED、蜂鸣器等),读取硬件的工作状态信号(如中断信号)等。GPIO口的使用非常广泛。掌握了GPIO,差不多相当于掌握了操作硬件的能力。树莓派有26个GPIO接口,其中有一部分是复用接口。

  1. 引脚3、5为IC总线复用接口
  2. 引脚7为(GCLK)全局时钟引脚复用接口
  3. 引脚19、21、23为SPI总线复用接口
  4. 引脚8、10为串口复用接口,TX发送,RX接收
  5. 引脚12、32、33、35为PWM复用接口

三、IC总线

IC是内部整合电路的称呼,是一种串行通讯总线,使用多主从架构,由飞利浦公司在1980年代为了让主板、嵌入式系统或手机用以连接低速周边装置而发展。IC的正确读法为”Inter-Integrated Circuit” 。

  • SDA:数据线
  • SCL:时钟线

四、SPI总线

SPI是串行外设接口(Serial Peripheral Interface)的缩写,是一种高速的,全双工,同步的通信总线,并且在芯片的管脚上只占用四根线,节约了芯片的管脚,同时为PCB的布局上节省空间,提供方便,正是出于这种简单易用的特性,如今越来越多的芯片集成了这种通信协议。

  • MISO:数据输入
  • MOSI:数据输出
  • SCLK:时钟信号
  • SS:使能信号

五、UART总线

UART是一种通用串行数据总线,用于异步通信。该总线双向通信,可以实现全双工传输和接收。在嵌入式设计中,UART用于主机与辅助设备通信,如汽车音响与外接AP之间的通信,与PC机通信包括与监控调试器和其它器件,如EEPROM通信。
可以理解为计算机的串口。RS232、TTL。

  • RX是接收
  • TX是发送

六、PWM脉冲宽度调制

脉冲宽度调制是一种模拟控制方式,其根据相应载荷的变化来调制晶体管基极或MOS管栅极的偏置,来实现晶体管或MOS管导通时间的改变,从而实现开关稳压电源输出的改变。这种方式能使电源的输出电压在工作条件变化时保持恒定,是利用微处理器的数字信号对模拟电路进行控制的一种非常有效的技术。脉冲宽度调制是利用微处理器的数字输出来对模拟电路进行控制的一种非常有效的技术,广泛应用在从测量、通信到功率控制与变换的许多领域中。

AD 教程

AD 使用与硬件电路图画图和PCB图设计使用的。主要是看了B站的一个入门视频教程,然后再不断搜搜改改来实现的。

https://blog.csdn.net/wxh0000mm/article/details/70237722
https://www.bilibili.com/video/av94518044?p=1
https://www.zhihu.com/question/32069273

单位转换

  • 1.0mil = 0.025mm
  • 1.2mil = 0.030mm
  • 1.25mil = 0.032mm

DHT11 温湿度传感器

下面是温湿度传感器的基本电路图,这里本来是3pin方案到树莓派的,板子上也画好了,只不过在焊接的时候没有处理好,现在系统始终无法正确读数,只是在测试期间能正常读。

https://shumeipai.nxez.com/2019/10/06/reading-temperature-and-humidity-from-dht11-with-raspberry-pi.html

OLED 12832

这里使用了 Adafruit_Python_SSD1306 库来驱动液晶屏显示。

https://shumeipai.nxez.com/2019/04/29/use-the-ssd1306-oled-display-on-the-raspberry-pi.html

IR 收发

红外发射图,这里只画了两个,实际我是配置了4个红外发射二极管,限流电阻调整成 100R。

红外接收图,这里直接使用已经简单封装的传感器

IR的收发是主要调试的功能:

红外录入功能使用:
安装 Linux 下的红外控制库:

1
2
sudo apt-get update
sudo apt-get install lirc

更新 /boot/config.txt 文件来开启红外收发接口:

1
2
# Uncomment this to enable the lirc-rpi module
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=18,gpio_in_pull=up

更新 /etc/lirc/lirc_options.conf 文件来控制当前是接收模式还是发射模式,修改完重启服务生效:

1
device=/dev/lirc0

测试能否正常接收到红外信号:

1
2
3
4
5
6
7
8
mode2 -d /dev/lirc0

space 16777215
pulse 8999
space 4457
pulse 680
space 1627
......

可以通过 lirc 录制简单的红外设备生成遥控文件,如果空调这种比较复杂的不太好弄。

1
2
3
4
5
6
7
# 查看按键名称,这里一个红外码是绑定到一个按键上的,你需要找一些你录制的按键然后记下来。
irrecord -l
# 开启录制命令,这个录制过程比较复杂,需要先判断环境噪音,然后随机按键,最后才是录制按键,而我的有些红外设备按键无法录上有点奇怪,目前只有台灯的录进去了。
irrecord ~/lircd.conf
# 如果有问题可以录制 raw code
irrecord -f ~/lircd.conf

录制好的文件内容像下面这样,如果没有内容则说明没有录制上。

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
begin remote
name myir
flags RAW_CODES|CONST_LENGTH
eps 30
aeps 100
gap 108055
begin raw_codes
name KEY_1
9062 4462 621 531 627 532
626 531 626 532 629 531
601 556 627 531 628 530
628 1610 629 1611 603 1636
603 1636 629 1612 629 1609
631 1609 630 1610 627 1612
630 530 629 1608 629 532
626 534 625 532 628 1609
629 532 628 529 630 1609
629 530 626 1612 629 1610
629 1610 629 540 633 1596
629
name KEY_2
9067 4455 632 528 630 528
633 524 631 529 630 529
630 528 630 530 630 528

最后要把录制的文件内容复制到对应目录,重启,让 lirc 服务能加载上:

1
sudo cp ~/xx.lircd.conf /etc/lirc/lircd.d/xx.lircd.conf

实际上发送按键需要执行的命令包含你复制的文件名(device-name)以及按键名(KEY_1):

1
irsend SEND_ONCE <device-name> KEY_1

红外输入输出参考

https://www.pythonheidong.com/blog/article/191812/
https://www.jianshu.com/p/96f16846dfa3
https://segmentfault.com/a/1190000014135418
https://www.jianshu.com/p/9cfb0bf02006
https://www.cnblogs.com/huanglufei/articles/5562330.html
https://www.jianshu.com/p/abdcd3e06726

软件部分

简单的将软件部分分为前台功能和后台功能,前台功能主要是面向用户使用层面,后台功能主要是配置相关功能。

前台功能分为三个模块,输入模块、逻辑处理模块和输出模块。

其中热词唤醒方案使用的 snowboy ,语音文字互转采用的讯飞免费接口,后面可以考虑实现一些简单的部分。

按照这个方案,后续只要不断配置和扩展功能即可,主要处理流程不会有太大变化产生。

语音部分参考

https://www.jianshu.com/p/a1c06020f5fd
https://www.cnblogs.com/lovesKey/p/11080448.html
https://www.cnblogs.com/DragonFire/p/9212935.html
https://www.xfyun.cn/doc/asr/voicedictation/API.html
https://www.xfyun.cn/doc/tts/online_tts/API.html
https://www.xfyun.cn/doc/asr/voicedictation/Audio.html

软件部分目前不打算公开,主要写的太烂。等优化后再放出来。

总结

目前一期实现了核心部分的功能,可以语音控制普通红外家电,耗时有两周(晚上),目前的时间精力上也只能做到这样,毕竟工作和生活还要占据绝大部分时间的。使用上流程比较简单,插电开机自启动后就可以了,只是语音和音箱部分还没有很方便的集成到整个项目里面。下一期做的时候计划优化电路,支持更多的传感器,然后把麦和音箱集成进去,再做一个外壳。
整个项目从计划到实施还是学到了一些,主要是硬件方面上的了解,电路原理图、PCB画图打样、硬件电路 IO 接口标准等,软件部分并没有太多的实践,准备放到三期做软件层面的优化,把外部 API 调用改成自己训练的语音模型。

环境

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `people` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(16) DEFAULT NULL,
`pass` varchar(256) DEFAULT NULL,
`card` varchar(32) DEFAULT NULL,
`age` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `card_UNIQUE` (`card`),
KEY `name_age_key` (`name`,`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入十一万条随机数据

1
INSERT INTO `people` (`name`,`pass`,`card`,`age`) VALUES ('强欣笑','eSbJgrXAVjEy','143185791961386356',53), ... ;

EXPLAIN 查询计划

https://www.cnblogs.com/DataArt/p/10215663.html

OPTIMIZER_TRACE 使用

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
-- 1. 打开 optimizer_trace功能,默认是关着的
SHOW VARIABLES LIKE 'optimizer_trace';
SET optimizer_trace="enabled=on";

-- 2. 执行查询
SELECT name,
count(*) cnt,
group_concat(distinct card) gd_card
FROM people
WHERE age > 10
AND age > -1
AND 1=1
AND age < 99
GROUP BY name
HAVING cnt > 1
ORDER BY cnt DESC limit 20000;

-- 3. 从OPTIMIZER_TRACE表中查看上一个查询的优化过程
SELECT * FROM information_schema.OPTIMIZER_TRACE;

-- 4. 还要用的话就重复2、3
-- ...

-- 5. 不用了就关掉
SET optimizer_trace="enabled=off";

解读

MySQL分为以上几个模块,主要是 server 层和存储引擎层,service 层又分为连接器、分析器、优化器、执行器和查询缓存几个部分。

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
{
"steps": [
{
"join_preparation": {
"select#": 1,
"steps": [
{
"expanded_query": "/* select#1 */ select `people`.`name` AS `name`,count(0) AS `cnt`,group_concat(distinct `people`.`card` separator ',') AS `gd_card` from `people` where ((`people`.`age` > 10) and (`people`.`age` > -(1)) and (1 = 1) and (`people`.`age` < 99)) group by `people`.`name` having (`cnt` > 1) order by `cnt` desc limit 20000"
}
]
}
},
{
"join_optimization": {
"select#": 1,
"steps": [
{
// 处理搜索条件
"condition_processing": {
"condition": "WHERE",
// 原始搜索条件
"original_condition": "((`people`.`age` > 10) and (`people`.`age` > -(1)) and (1 = 1) and (`people`.`age` < 99))",
"steps": [
{
// 等值传递转换
"transformation": "equality_propagation",
"resulting_condition": "((`people`.`age` > 10) and (`people`.`age` > -(1)) and (1 = 1) and (`people`.`age` < 99))"
},
{
// 常量传递转换
"transformation": "constant_propagation",
"resulting_condition": "((`people`.`age` > 10) and (`people`.`age` > -(1)) and (1 = 1) and (`people`.`age` < 99))"
},
{
// 去除没用的条件
"transformation": "trivial_condition_removal",
"resulting_condition": "((`people`.`age` > 10) and (`people`.`age` > -(1)) and (`people`.`age` < 99))"
}
]
}
},
{
"condition_processing": {
"condition": "HAVING",
"original_condition": "(`cnt` > 1)",
"steps": [
{
"transformation": "constant_propagation",
"resulting_condition": "(`cnt` > 1)"
},
{
"transformation": "trivial_condition_removal",
"resulting_condition": "(`cnt` > 1)"
}
]
}
},
{
// 替换虚拟生成列
"substitute_generated_columns": {}
},
{
// 表的依赖信息
"table_dependencies": [
{
"table": "`people`",
"row_may_be_null": false,
"map_bit": 0,
"depends_on_map_bits": []
}
]
},
{
"ref_optimizer_key_uses": []
},
{
// 预估不同单表访问方法的访问成本
"rows_estimation": [
{
"table": "`people`",
"const_keys_added": {
"keys": [
"name_age_key"
],
"cause": "group_by"
},
"range_analysis": {
// 全表扫描的行数及成本
"table_scan": {
"rows": 109674,
"cost": 22482
},
// 分析可能使用的索引
"potential_range_indexes": [
{
"index": "PRIMARY",
"usable": false,
"cause": "not_applicable"
},
{
"index": "card_UNIQUE",
"usable": false,
"cause": "not_applicable"
},
{
"index": "name_age_key",
"usable": true,
"key_parts": [
"name",
"age",
"id"
]
}
],
"setup_range_conditions": [],
"group_index_range": {
"chosen": false,
"cause": "not_applicable_aggregate_function"
},
// 分析各种可能使用的索引的成本
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "name_age_key",
"chosen": false,
"cause": "unknown"
}
],
"analyzing_roworder_intersect": {
"usable": false,
"cause": "too_few_roworder_scans"
}
}
}
}
]
},
{
"considered_execution_plans": [
{
"plan_prefix": [],
"table": "`people`",
"best_access_path": {
"considered_access_paths": [
{
"rows_to_scan": 109674,
"access_type": "scan",
"resulting_rows": 4060.8,
"cost": 22480,
"chosen": true
}
]
},
"condition_filtering_pct": 100,
"rows_for_plan": 4060.8,
"cost_for_plan": 22480,
"chosen": true
}
]
},
{
// 尝试给查询添加一些其他的查询条件
"attaching_conditions_to_tables": {
"original_condition": "((`people`.`age` > 10) and (`people`.`age` > -(1)) and (`people`.`age` < 99))",
"attached_conditions_computation": [],
"attached_conditions_summary": [
{
"table": "`people`",
"attached": "((`people`.`age` > 10) and (`people`.`age` > -(1)) and (`people`.`age` < 99))"
}
]
}
},
{
"clause_processing": {
"clause": "ORDER BY",
"original_clause": "`cnt` desc",
"items": [
{
"item": "count(0)"
}
],
"resulting_clause_is_simple": false,
"resulting_clause": "`cnt` desc"
}
},
{
"clause_processing": {
"clause": "GROUP BY",
"original_clause": "`people`.`name`",
"items": [
{
"item": "`people`.`name`"
}
],
"resulting_clause_is_simple": true,
"resulting_clause": "`people`.`name`"
}
},
{
"reconsidering_access_paths_for_index_ordering": {
"clause": "GROUP BY",
"steps": [],
"index_order_summary": {
"table": "`people`",
"index_provides_order": true,
"order_direction": "asc",
"index": "name_age_key",
"plan_changed": true,
"access_type": "index"
}
}
},
{
"refine_plan": [
{
"table": "`people`"
}
]
},
{
"creating_tmp_table": {
"tmp_table_info": {
"table": "intermediate_tmp_table",
"row_length": 130,
"key_length": 0,
"unique_constraint": false,
"location": "memory (heap)",
"row_limit_estimate": 129055
}
}
},
{
"sort_using_internal_table": {
"condition_for_sort": "(`cnt` > 1)",
"having_after_sort": null
}
}
]
}
},
{
"join_execution": {
"select#": 1,
"steps": [
{
"creating_tmp_table": {
"tmp_table_info": {
"table": "intermediate_tmp_table",
"row_length": 84,
"key_length": 0,
"unique_constraint": false,
"location": "disk (InnoDB)",
"record_format": "packed"
}
}
},
{
"filesort_information": [
{
"direction": "desc",
"table": "intermediate_tmp_table",
"field": "cnt"
}
],
"filesort_priority_queue_optimization": {
"usable": false,
"cause": "not applicable (no LIMIT)"
},
"filesort_execution": [],
"filesort_summary": {
// 排序过程中持有的行数
"rows": 27045,
// 参与排序的行数,InnoDB 返回的行数
"examined_rows": 34054,
// 排序使用的临时文件数量
"number_of_tmp_files": 24,
// 内存排序使用的内存大小
"sort_buffer_size": 25744,
// 排序模式
"sort_mode": "<sort_key, rowid>"
}
}
]
}
}
]
}

优化过程大致分为了三个阶段:

prepare阶段

optimize阶段

execute阶段

我们所说的基于成本的优化主要集中在optimize阶段,对于单表查询来说,我们主要关注optimize阶段的”rows_estimation”这个过程,这个过程深入分析了对单表查询的各种执行方案的成本;对于多表连接查询来说,我们更多需要关注”considered_execution_plans”这个过程,这个过程里会写明各种不同的连接方式所对应的成本。反正优化器最终会选择成本最低的那种方案来作为最终的执行计划,也就是我们使用EXPLAIN语句所展现出的那种方案。

参考

https://www.cnblogs.com/taosiyu/p/13206378.html

这个标题看起有点鸡汤文,不过我还是建议对以下总结出的几点做些深入思考,这些会在今后的工作中越来越多的感受到它的作用。

寻找你行业内的专家

找到你所属行业内的专家,这些人往往做事高效并且很有才华。你要做的是跟随他们所关注的方向,学习他们做事的方法,思考如何应用到你的工作和生活上。找到他们,和他们去交流思考,提出自己的观点和想法。不要仅仅把眼光放到身边的人身上,这样会局限住你的视野。

每天都写新代码

工作重复枯燥?也许有时候我们只是懒得思考,用最顺手的方式把工作做完,容易形成惯性思维。为什么会有很多的复制粘贴?简单的修改来适配当前需求,这里我们更需要的是想想能不能把这段逻辑抽象出来变得更通用,整个模块的设计是否不够合理,多想一想多做一点,下一次再来需求也许可以提升十倍的效率。

底层的原理更重要

客观的说,更快进步的方法之一是忽略掉那些并不能提高技能的东西,比如语言语法和配置工具,这些技能属于“知其然”,而你更需要的是“知其所以然”。有一次去医院科室挂号使用的是先到先叫的模式,而在急诊室挂号是按照轻重缓急分成四个等级的,危重病人优先抢救的模式。这不就和操作系统中的任务调度概念是一样的,优先级调度模式,这些底层的概念才是一通百通真正提高帮助你的东西。我在尝试去找行业经典论文看。

学会调研

作为程序员会比较容易脑子一热,有一个想法很容易趁热着急写代码,但往往缺乏思考写出来的代码不能尽如人意。这时候你更需要的是慢下来,好好思考一下,也许这些别人已经做过,有更好的方案,看看别人是如何做的。先调研再实施,这样会彻底改变你解决问题的思路。

学好英语

真的是这样,如果你英语不好,那么会比别人走更多的弯路,就像走在密林深处看不清路一样。不得不承认很多优秀框架的官方文档还是英文为主,如果再经过翻译里面的很多语义语境会丢失,在项目的社区中,你还能与作者们去交流你学习中遇到的问题。

如何去做

说了这么多,看着就好像道理我都懂,但是我不知道怎么做。我这里先总结几个点,也是自己在不断尝试学习的方法。

  1. 看行业经典论文,比如 mapreduce、raft 这些都是一通百通的底层概念。
  2. 研究优秀框架的源代码,理解核心原理,尝试造轮子。
  3. 每天学英语,尝试在开源社区与作者们进行互动。
  4. 找到一两位行业专家,向他们学习和请教问题。
  5. 坚持以上几点。

end.

ORM(Object/Relational Mapper),即“对象-关系型数据映射组件”。对于O/R,即 Object(对象)和Relational(关系型数据),表示必须同时使用面向对象和关系型数据进行开发。本文简述通过 Java 动态代理机制实现关系数据与 POJO 对象的映射。

代理

静态代理

静态代理其实就是指设计模式中的代理模式。
代理模式为其他对象提供一种代理以控制对这个对象的访问。

静态代理模式在增强现有的接口业务功能方面有很大的优点,但是大量使用这种静态代理,会使我们系统内的类的规模增大,并且不易维护。

动态代理

为了解决静态代理的问题,引入动态代理的概念,在编译时或者运行时,可以在需要代理的地方动态生成代理,减轻代理类和类在系统中冗余的问题。

Java 动态代理基于经典代理模式,引入了一个 InvocationHandler,InvocationHandler 负责统一管理所有的方法调用。

InvocationHandler

InvocationHandler 接口定义:

1
2
3
4
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}

每一个动态代理类都必须要实现 InvocationHandler 这个接口,通过代理类的实例调用一个方法时,这个方法的调用就会被转发为由 InvocationHandler 这个接口的 invoke 方法来进行调用。

Proxy

Proxy 这个类的作用就是用来动态创建一个代理对象的类,它提供了许多的方法,但是我们用的最多的就是 newProxyInstance 这个方法,可以获得一个动态的代理对象:

1
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,  InvocationHandler h)  throws IllegalArgumentException

实现

参照 mybaits 的用法实现基本的映射能力。

注解

首先定义了三个注解,一个作用在类上 DaoMapper 作用在类上标记这是一个映射类,然后定义注解 Selector 作用在方法上标记查询作用,定义注解 Param 作用在参数上为预编译位的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DaoMapper {
}

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Selector {
String value();
}

@Documented
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Param {
String value();
}

定义一个实体类,与数据库的表字段映射上。增强 feature 可以自动做驼峰转换,这里没有实现。

1
2
3
4
5
6
7
8
9
@Data
public class BaseLineModel {
public static final String TABLE = "baseline";

private Integer id;
private String report_name;
private Integer report_period;
private LocalDateTime creation_date;
}

定义dao层接口,加上注解

1
2
3
4
5
6
7
@DaoMapper
public interface BaseLineDao {

@Selector("select * from "+ BaseLineModel.TABLE +" where report_name = #{reportName}")
BaseLineModel select(@Param("reportName") String report_name);
}

JDBC OP

做到一个很简单的 JDBC 操作工具类,字段映射处理也写到了这里。实现了查询操作,将入参 sql template 以及参数按顺序传入,生成 prepareStatement 后执行,再将返回结果映射到 model 对象。这里的连接池管理、自动重连、配置管理等增强 features 非重点,不做实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 查询
* @param clazz model类
* @param sql
* @param params
* @param <T>
* @return
*/
public <T> T query(Class<T> clazz, String sql, Object... params) throws SQLException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
Object model = clazz.newInstance();
try (Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/cat", "root", "123456")) {
PreparedStatement statement = conn.prepareStatement(sql);
int flag = 1;
for (Object obj : params) {
setValue(statement, flag, obj);
flag++;
}
ResultSet resultSet = statement.executeQuery();
resultSet.afterLast();
resultSet.previous();
fullRes(resultSet, model);
}
return (T) model;
}

映射函数,通过自动寻找 setter 方法填充结果,这里只实现了三种字段。

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
private static void fullRes(ResultSet resultSet, Object model) throws SQLException, InvocationTargetException, IllegalAccessException, NoSuchMethodException {
Field[] declaredFields = model.getClass().getDeclaredFields();
for (Field field : declaredFields) {
String fieldName = field.getName();
if (fieldName.toUpperCase().equals(fieldName)) {
continue;
}
String setFuncName = "set" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
String fieldType = field.getGenericType().toString();

Object object = resultSet.getObject(fieldName);
if (fieldType.equals("class java.lang.String")) {
Method m = model.getClass().getMethod(setFuncName, String.class);
m.invoke(model, object);
} else if (fieldType.equals("class java.lang.Integer")) {
Method m = model.getClass().getMethod(setFuncName, Integer.class);
m.invoke(model, object);
} else if (fieldType.equals("class java.time.LocalDateTime")) {
Method m = model.getClass().getMethod(setFuncName, LocalDateTime.class);
if (object instanceof Timestamp) {
object = ((Timestamp) object).toLocalDateTime();
}
m.invoke(model, object);
}
}
}

动态代理部分

定义一个 MapperMethod 类,实例化的时候提取接口方法的注解信息解析成 JDBC 需要的参数以及记录接口方法的返回对象, 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
34
35
36
37
38
39
40
41

public class MapperMethod<T> {
private String sql;
private Class<?> resType;
private int[] paramsIndex;


public MapperMethod(Method method) {
this.resType = method.getReturnType();
String sourceSql = method.getAnnotation(Selector.class).value();
Parameter[] parameters = method.getParameters();
int flag = 0;
this.paramsIndex = new int[parameters.length];
for (Parameter parameter: parameters) {
String paramName = parameter.getAnnotation(Param.class).value();
String paramFullName = String.format("#{%s}", paramName);
int indexOf = sourceSql.indexOf(paramFullName);
this.paramsIndex[flag] = indexOf;
flag++;
this.sql = sourceSql.replace(paramFullName, "?");
}
}

public Object execute(Object[] objects) {
JdbcUtil jdbcUtil = new JdbcUtil();
try {
return jdbcUtil.query(this.resType, this.sql, objects);
} catch (SQLException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
}
return null;
}
}

定义动态代理类,在实例化的时候记录代理接口,以及代理方法类缓存,调用接口的时候会被动态代理到 invoke 函数执行,然后交由 MapperMethod 代理方法实例执行。

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
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;

public class MapperProxy<T> implements InvocationHandler {

private final Class<T> mapperInterface;

private final Map<Method, MapperMethod> methodCache;

public MapperProxy(Class<T> mapperInterface, Map<Method, MapperMethod> methodCache) {
this.mapperInterface = mapperInterface;
this.methodCache = methodCache;
}

@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
MapperMethod mapperMethod = cachedMapperMethod(method);
return mapperMethod.execute(objects);
}

private MapperMethod cachedMapperMethod(Method method) {
MapperMethod mapperMethod = methodCache.get(method);
if (Objects.isNull(mapperMethod)) {
mapperMethod = new MapperMethod(method);
methodCache.put(method, mapperMethod);
}
return mapperMethod;
}
}

最后代理工厂类,接收被 DaoMapper 作用的接口,并通过 newInstance 方法创建代理类实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MapperProxyFactory<T> {

private final Class<T> mapperInterface;

private Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<>();

public MapperProxyFactory(Class<T> mapperInterface) {
if (Objects.isNull(mapperInterface.getAnnotation(DaoMapper.class))) {
throw new RuntimeException("缺少注解 DaoMapper");
}
this.mapperInterface = mapperInterface;
}


public T newInstance() {
final MapperProxy<T> mapperProxy = new MapperProxy<>(mapperInterface, methodCache);
return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy);
}
}

执行,创建一个代理工厂,然后创建 BaseLineDao 的代理对象, 调用 select 方法,实际上调用到代理对象的 invoke 方法,然后交由 mapperMethod.execute 方法执行:

1
2
3
4
5
6
public static void main(String[] args) {
MapperProxyFactory mapperProxyFactory = new MapperProxyFactory(BaseLineDao.class);
BaseLineDao baseLineDao = (BaseLineDao) mapperProxyFactory.newInstance();
BaseLineModel test1 = baseLineDao.select("TEST1");
System.out.println(test1);
}

扩展

TODO:

  1. Java动态代理与 cglib 动态代理的异同点。
  2. 动态代理的实现原理。

总结

通过这个个简单的实践,了解了 Java 动态代理的使用方法以及对象关系数据的映射处理。

参考

https://zhuanlan.zhihu.com/p/60805342
https://www.zhihu.com/question/20794107/answer/658139129

锁解决的问题是并发操作引起的脏读、数据不一致问题。

基本原理

volatile

在Java中允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保使用排它锁来单独获得这个变量,Java中提供了 volatile,使之在多处理器开发中保证变量的可见性,当一个线程改变了共享变量,另一个线程能够及时读到这个修改的值。恰当的使用它会比 synchronized 成本更低,因为不会引起上下文的切换和调度。

synchronized

通过锁机制实现同步,在Java中每一个对象都可以作为锁,有以下三种形式:

  • 对于普通同步方法,锁的是当前实例对象。
  • 对于静态同步方法,所得是当前类 class 对象。
  • 对于同步方法块,锁的是括号内指定的对象。

为了减少获得锁和释放锁带来的性能消耗,Java SE 1.6 引入了偏向锁和轻量级锁。偏向锁 的核心思想是:如果一个线程获得了锁,就进入偏向模式,当这个线程再次请求锁时,如果没有其它线程获取过该锁,无需再做任何同步操作,可以节省大量锁申请的操作,来提高性能。如果偏向锁获取失败,会通过 轻量级锁 的方式获取,如果获取成功则进入临界区,如果失败则表示有其它线程争夺到锁,当前线程锁请求会膨胀为 重量级锁

锁粗化 是指在遇到一连串连续的对同一个锁不断的进行请求和释放的操作时,会把所有的锁操作整合成对锁的一次请求,减少锁请求的同步次数。

锁消除 是指在编译期,通过对上下文的扫描,去除不可能存在共享资源竞争的锁。

自旋锁 是指在锁膨胀后,避免线程真正的在操作系统层面被挂起,通过对线程做几个空循环,以期望在这之后能获取到锁,顺利的进入临界区,如果还获取不到,则会真正被操作系统层面挂起。

CAS

指的是比较并交换,它是一个原子操作,比较一个内存位置的值并且只有相等时修改这个内存位置的值并更新值,保证新的值总是基于最新的信息计算的。在 JVM 中 CAS 操作是利用处理器提供的 CMPXCHS 指令实现。是实现我们平时所说的自旋锁或乐观锁的核心操作。

优点是竞争小的时候使用系统开销小;对应缺点是循环时间长开销大、ABA问题、只能保证一个变量的原子操作。

ABA 问题

问题产生原因是两个线程处理的时间差导致,具体如下图:

解决 ABA 问题可以增加一个版本号,在每次修改值的时候增加一个版本号。

产生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static AtomicReference<Integer> atomicReference = new AtomicReference<Integer>(100);

public static void main(String[] args) {
new Thread(() -> {
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
},"t1").start();

new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicReference.compareAndSet(100, 2019) + "\t修改后的值:" + atomicReference.get());
},"t2").start();
}

解决:

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
private static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<Integer>(100,1);

public static void main(String[] args) {
new Thread(() -> {
System.out.println("t1拿到的初始版本号:" + atomicStampedReference.getStamp());

//睡眠1秒,是为了让t2线程也拿到同样的初始版本号
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedReference.compareAndSet(100, 101,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
atomicStampedReference.compareAndSet(101, 100,atomicStampedReference.getStamp(),atomicStampedReference.getStamp()+1);
},"t1").start();

new Thread(() -> {
int stamp = atomicStampedReference.getStamp();
System.out.println("t2拿到的初始版本号:" + stamp);

//睡眠3秒,是为了让t1线程完成ABA操作
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("最新版本号:" + atomicStampedReference.getStamp());
System.out.println(atomicStampedReference.compareAndSet(100, 2019,stamp,atomicStampedReference.getStamp() + 1) + "\t当前 值:" + atomicStampedReference.getReference());
},"t2").start();
}