我们的课程到这里已经基本结束,在整个课程中大家从项目初始化时敲下的第一行命令开始咱们一起经历了怎么从零搭建出一个好用的项目框架。这里我们花了大量时间来研究怎么把项目的配置化、可观测、可追踪做好。
同时针对Go本身相对简易的error机制我们做了最大限度的封装,让它在保持Go error 风格的同时能最大限度的拥有错误原因链包装、发生位置记录等功能。
我们还为项目中错误的统一管理制定了标准,通过这套标准既能做好错误码的规划,也能防止项目里到处都散落着硬编码的错误码。
之后我又带大家一起探究了想要用我们自己搭建好的项目实际地去做业务开发时应该怎么做项目分层,不同逻辑的代码应该写在哪个代码层里,我们后续的项目业务开发的实战就是这些理论的不断应用和实践。
上面列举的这些东西,无论项目的架构是单体还是微服务都需要做好,微服务里的每个项目也需要满足这些标准,否则你把单体项目拆成了微服务维护起来只会更困难。
那么我们接下来聊一聊假如要把我们的项目拆成微服务,要做哪些工作。
本文节选自我的专栏《Go项目搭建和整洁开发实战》内容无删减,扫码订阅专栏可解锁其他章节。

订阅后除了加入实战项目外,还可加入专属读者群一起学习。
微服务该怎么拆
使用微服务架构首先要考虑的一点是,业务里到底应该有哪些服务或者说我有一个单体服务我该怎么把它拆成微服务呢?
这一点要搞不清楚,大概率拆了也白拆,或者是纯纯给自己增加工作量。这几年我见过有很多走极端的拆法,有的恨不得一个两个接口就给你整个微服务。有的则是从一个单体变成了几个小一点的单体,也不管服务注册发现什么的,直接上域名给你相互调用。
那么微服务到底该怎么拆呢?其实没有特别统一的标准,还是靠大家对业务的理解,一个比较常用的方法是对业务进行领域分析后按照子域进行拆分。
这一聊就又聊到领域驱动设计了,很多人看到DDD就嗤之以鼻,不过嗤之以鼻的应该是开发阶段的繁琐,设计阶段它的一些思路还是很值得借鉴的,或者说它的思路应该是符合事物发展规律的,你在完全不了解领域驱动设计这个概念前大概率也是按着类似的思路做软件设计的,只不过老外把方法论提炼出来宣传的早,所以他们拿到了命名权。
如果我们把子域按照价值维度划分,可划分为:核心域、通用域和支撑域
下面是一个敏捷项目管理产品的领域划分(图片取自 IDDD 一书)

核心域:决定产品和公司核心竞争力的子域,它是业务成功的主要因素和公司的核心竞争力。举个例子来说:****公司是卖货的,那卖货就是你们与其它竞争对手的关键竞争环节。这就是核心域,就是核心业务。
支撑域:公司在线卖货,但是用户在线支付不灵,那公司也发展不起来,所以支付子领域、库存管理子领域就是支撑域
通用域:没有太多个性化的诉求,同时被多个子域使用的通用功能的子域-- 比如身份认证角色管理子域、(各种基础服务?)。如果上升到整个公司的大业务领域,通用域会被每个业务的领域使用。
我们可以通过DDD的方式定义子域,并把子域对应为每一个服务。

也就是说每个服务应该有自己的领域模型,不能是一个接口就给整个微服务,一个业务整上几百个服务,这里我不是开玩笑,我真见过,五个人维护上百个服务,我们有的公司的屎山的难度真是太高了。
以上内容参考自实现领域驱动设计(IDDD)和微服务架构设置模式两本书,也是我推荐大家有时间拿来看的,只看第二本也够,第一本IDDD翻译过于晦涩。两本设计到编程设计的部分都用Java语言写的,不过大家看起来应该没有难度。
微服务的技术架构
微服务的技术架构一般是什么样的呢?一般是业务网关负责与客户端进行对接,网关层会把请求转发给内部的服务,此外因为拆分了微服务就不可避免地需要使用消息队列进行业务事件的同步从而达到数据的最终一致性。
针对Go技术栈来说最简单的实现微服务架构的方式是使用 Gin / Echo 之类的Web框架做业务网关,内部的各个业务子域的服务使用gRPC,服务发现和注册可以使用gRPC自带的 Etcd naming 组件通过Etcd完成微服务的注册和发现。
这里给大家一个架构图参考,当然这个架构图是当年我刚转Go开发时,组长老大哥画的,项目也造就关停了,这里只供大家参考和理解简单的微服务架构是什么样的。

我一般喜好在微服务中有一个专门处理接收事件消息然后根据事件类型进行内部服务调用的event服务,这样做的好处是其他RPC服务在启动时会更简单,不用监听消息队列。当然这么做肯定是比每个服务都监听消息要多一次RPC请求的,所以这一条不构成建议,大家可以按自己的偏好来。
把项目扩展成微服务要做哪些工作
下面说一下假如要做微服务,我们专栏中学到的技能要做哪些更新才能让它适配微服务。
日志组件增加服务名
从单体架构变成微服务架构,日志如果不能串联和归因那维护起来肯定会比使用单体架构时还要难上几倍。我们的项目里封装的 logger 已经做了日志的trace 和 span 的埋点,直接用到微服务架构下肯定是没问题的。
不过还是需要增加一些服务标示,这些标识主要是收集日志的日志组件对日志做分类用,做运维的同学应该会比较关注这些字段。
这个不难改,我们只需要在项目logger 的初始化方法中加上它即可。
func New(ctx context.Context) *logger {
var traceId, spanId, pSpanId string
if ctx.Value("traceid") != nil {
traceId = ctx.Value("traceid").(string)
}
if ctx.Value("spanid") != nil {
spanId = ctx.Value("spanid").(string)
}
if ctx.Value("psapnid") != nil {
pSpanId = ctx.Value("pspanid").(string)
}
return &logger{
ctx: ctx,
traceId: traceId,
spanId: spanId,
pSpanId: pSpanId,
perfix: config.App.Name // 增加日志的服务名前缀
_logger: _logger,
}
}
gRPC 服务间怎么传递追踪参数
大家都知道我们日志里的trace_id, span_id 这些追踪参数在Http 的API接口调用中是通过 Header 往下继续传递的,那如果是网关调内部的RPC服务该怎么把它们继续传递下去呢?
其实跟发HTTP请求可以配置HTTP客户端携带 Header 和 Context 一样,RPC客户端也支持类似功能。以 gRPC 服务为例,客户端调用RPC 方法时,在可以携带的元数据里设置这些追踪参数。
traceID := ctx.Value("trace-id").(string)
traceID := ctx.Value("span-id").(string)
md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
// 新建一个有 metadata 的 context
ctx := metadata.NewOutgoingContext(context.Background(), md)
// 单向的 Unary RPC
response, err := client.SomeRPCMethod(ctx, someRequest)
gRPC 的服务端的处理方法里,可以再通过 metadata 把元数据里存储的追踪参数取出来。
func (s server) SomeRPCMethod(ctx context.Context, req *xx.someRequest) (reply *xx.SomeReply, err error) {
remote, _ := peer.FromContext(ctx)
remoteAddr := remote.Addr.String()
// 生成本次请求在当前服务的 spanId
spanID := utils.GenerateSpanID(remoteAddr)
traceID, pSpanID := "", ""
md, _ := metadata.FromIncomingContext(ctx)
if arr := md["xx-tranceid"]; len(arr) > 0 {
traceID = arr[0]
}
if arr := md["xx-spanid"]; len(arr) > 0 {
pSpanID = arr[0]
}
return
}
当然如果我们每个客户端调用和RPC 服务方法里都这么搞一遍得累死,gRPC 里也有类似全局路由中间件的概念,叫拦截器,我们可以把追踪参数传递这部分逻辑封装在客户端和服务端的拦截器里。
gRPC客户端拦截器
func UnaryClientInterceptor(ctx context.Context, ... , opts ...grpc.CallOption) error {
md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
mdOld, _ := metadata.FromIncomingContext(ctx)
md = metadata.Join(mdOld, md)
ctx = metadata.NewOutgoingContext(ctx, md)
err := invoker(ctx, method, req, reply, cc, opts...)
return err
}
// 调用gRPC服务
conn, err := grpc.Dial(*address, grpc.WithInsecure(),grpc.WithUnaryInterceptor(UnaryClientInterceptor))
gRPC服务端拦截器
func UnaryServerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
remote, _ := peer.FromContext(ctx)
remoteAddr := remote.Addr.String()
spanID := utils.GenerateSpanID(remoteAddr)
// set tracing span id
traceID, pSpanID := "", ""
md, _ := metadata.FromIncomingContext(ctx)
if arr := md["xx-traceid"]; len(arr) > 0 {
traceID = arr[0]
}
if arr := md["xx-spanid"]; len(arr) > 0 {
pSpanID = arr[0]
}
// 把 这些ID再搞到 ctx 里,其他两个就省略了
ctx := Context.WithValue(ctx, "traceId", traceId)
resp, err = handler(ctx, req)
return
}
微服务如何保持数据一致性
上面聊怎么做微服务拆分时我们聊到过,业务领域中的每个子域可以映射为一个服务,每个服务有都自己的领域模型。
微服务架构的一个关键特性是每个服务之间都是松耦合的,仅通过 API 或者消息进行通信,这就要求每个服务都拥有自己的私有数据库,不能是一个服务能连多个库,如果那样就当于把自己的领域全都暴露出去了。
那业务规则要求一个操作修改多个库内的数据实例该怎么办?
一个合格的微服务,不能在一个业务逻辑中修改自己服务的数据库的同时又去调用API修改其他服务的数据库,而应该借助事件消息,完成数据的最终一致性。
上面这一条呢,说是这么说,据我观察很多时候不是这样的,所以大家可以把它视做一个参照物,但不用把它奉为圭臬,必须完全遵守。尤其是一些有历史的老业务,数据库本就不好按领域做拆分。
那上面聊了如果一个业务操作对A服务的数据做了变更后还需要对B服务里的数据做变更,这个时候我们应该通过事件消息完成服务B的变更。
事件消息也就是发消息队列对吧,虽然微服务保证的是最终一致性,但我们还是要保证服务内的变更和发送事件消息这两个操作的原子性的。这个该怎么做呢?
这个就涉及到事件的存储和发布方式了,通常的做法是首先在本地有一个Events 表,记录所有领域对象发布的领域事件。
CREATE TABLE `tbl_events` (
`event_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`event_type` varhcar(100) NOT NULL COMMENT '事件名称',
`entity_id` bigint(20) unsigned NOT NULL COMMENT '聚合的标识,userId orderId 等数据'
`entity_type` varhcar(100) NOT NULL COMMENT '聚合类型',
`event_data` varchar(50000) NOT NULL COMMENT '事件Body'
`occurred_on` datetime NOT NULL COMMENT '事件发布时间',
`publis_state` tinyint(3) unsinged NOT NULL COMMENT '发布状态'
)

我们在向消息队列发布事件消息时,要在同一个数据库事务中先写Events表再把消息发送给队列,如果队列发布失败,在表中记录发布失败后再退出事务。
这里也可以所有服务公用一个事件库,Events表按照某些维度做垂直拆表,因为Events设计地是通用的不会暴露服务的领域,所以我个人认为多个服务公用一个也没啥关系。
这样做的话除了能保证数据的最终一致性外,还有利于我们做好事件溯源,比如一个订单的状态在订单表中只能体现它当前的状态,它经过了哪些状态变更是没法查到的。
有了这个Events表后,我们做事件溯源或者审计类的需求时就会好做很多。比如类似 “给7 天前签到的用户发消息”、“把某个商品添加到购物车又删除的用户发促销红包”等功能,通过分析事件流里的数据会很容易实现。
微服务的一些参考资料
用了微服务后,再用本地配置可能就不那么顺手了,大部分时候需要远程配置中心,Go的微服务项目用Etcd做配置中心很简单,在项目里直接使用etcd 提供的包去读配置数据,当然也可以借助Viper 库用 Etcd 作为它配置的Provider,这部分内容参考:
Go的微服务可能是没办法绕开 gRPC的,所以如果上面有些东西你无法理解,可以先学习一下gRPC 方面的基础。这里我推荐一个我看过觉得不错的教程
如果你觉得自己写的微服务稳定性没有保障(公司的服务器运维能力不强的时候确实是这样的)可以直接用一些大公司开源的框架,比如腾讯的北极星 https://polarismesh.cn/,它的优点是你可以把它集成到自己的Go 项目或者 gRPC项目中去,不用非从头学一个类似 go-zero,kratos这样的框架。
总结
因为我们的专栏的重点不是微服务架构设计与开发,所以这里我也尽可能地多提供给大家一些我知道做微服务架构的方法,以及做了微服务后项目基础组件需要做的调整和需要重点关注的事情。
虽然这部分没有实战代码与之对应,但我相信如果你真的需要做微服务架构的时候,通过我这里提供的信息肯定是会能减少一些花费在调研和摸索上的时间精力的。
想了解怎么用Go做好项目的开发和设计,以及搭建出一个实用、适合自己的Go项目的基础框架,欢迎扫下方二维码订阅专栏。

《Go项目搭建和整洁开发实战》专栏分为五大部分,重点章节如下

第一部分介绍让框架变得好用的诸多实战技巧,比如通过自定义日志门面让项目日志更简单易用、支持自动记录请求的追踪信息和程序位置信息、通过自定义Error在实现Go error接口的同时支持给给错误添加错误链,方便追溯错误源头。
第二部分:讲解项目分层架构的设计和划分业务模块的方法和标准,让你以后无论遇到什么项目都能按这套标准自己划分出模块和逻辑分层。后面几个部分均是该部分所讲内容的实践。
第三部分:设计实现一个套支持多平台登录,Token泄露检测、同平台多设备登录互踢功能的用户认证体系,这套用户认证体系既可以在你未来开发产品时直接应用
第四部分:商城app C端接口功能的实现,强化分层架构实现的讲解,这里还会讲解用责任链、策略和模版等设计模式去解决订单结算促销、支付方式支付场景等多种多样的实际问题。
第五部分:单元测试、项目Docker镜像、K8s部署和服务保障相关的一些基础内容和注意事项。
扫描上方二维码或者访问 https://xiaobot.net/p/golang 即刻订阅
此外想更详细地了解专栏内容,咨询专栏优惠,都可以添加下面我的微信