前言
CNCF 与 Cloud Native 这两个技术词汇最近频频走进了程序员的视野,一切和他能搭上边的软件意味着标准、开放、时尚,也更能俘获技术哥哥们的心;这篇文章不想去带大家重温这个词汇后面的软件体系,笔者觉得单凭用到了这些开源软件,不等于我们自己的软件就已经是 Cloud Native,在使用哑铃和成为肌肉男之间还隔着科学使用和自律锻炼两道工序;在此,笔者想根跟大家聊聊让我们的应用真正变得 Cloud Native 时的理论依据:微服务的十二要素。这篇文章也是先从作者自身项目的角度(一个基于 EDAS 的微服务架构),来阐述对这十二条要素的前两条 —— 仓库(Code Base)与依赖(Dependency)的理解
Code Base 的原文释义是:"一份基准代码,多份部署,基准代码和应用之间总是保持一一对应的关系;不同环境中的相同应用,应该来源于同一份代码"。我的理解有两个:
- 一个应用,产生自同一个仓库。
- 一个仓库,只产生一个应用。
为什么推演出这么两个结论呢?让我们先看一个实际的项目。
为什么是一个应用?
给大家举一个一个仓库包含多个应用的反例,笔者自己的一个项目是一个的微服务的架构,和大部分的微服务架构一样,一开始是由一个单体的应用拆解而来,拆解之后,大致简化成四个服务:微服务网关(Gateway),两个后台服务(UserService, OrderService),后台管理控制台服务(Admin),简单的架构示意图如下:
在拆分的过程一开始为了项目上线的减少风险,将拆分之后的应用都放在了一个 GIT 仓库中进行管理,同时也共用了同一个库。重构之后仓库的目录如下:
~/workspace/java/app/ $ tree -L 2
.
├── README.md
├── service-api # 通用的 API 接口定义
│ ├── userservice-api # 服务 UserService 的声明
│ ├── orderservice-api # 服务 OrderService 的声明
│ ├── rpc-api # 远程服务调用相关的接口声明
│ ├── common-api # UserService 与 OrderService 都依赖的声明
| .....
├── service-impl # 对应 API 的相关具体业务实现
│ ├── userservice-impl
│ ├── orderservice-impl
│ ├── common-impl
| .....
├── web-app # Web 应用工程
│ ├── admin
│ ├── userservice
│ ├── orderservice
│ ├── gateway
一开始这些服务之间的发布和改动彼此都不受影响,这一过程持续了大约两个迭代,随着迭代的不断进行和新人的加入,后来我们线上发现一个很奇怪的现象,每次用户进入刷新订单的地址列表的时候,会伴随这一次用户 Token 的刷新而导致用户被踢出,线上的排查过程在 EDAS 的分布式链路跟踪系统 EagleEye 的帮助下,马上就定位到了出问题的代码:
// User Service 中
public class User {
public void refresh() {
// 刷新登录 token
}
}
// Order Service 中
public class OrderUser extends User {
// 函数少了一个字母,导致 refresh 调用了父类的 refresh
public void refesh() {
// 刷新地址列表
}
}
这个故障,我先邀请大家一起思考一下几个问题:
- 从编码角度,如何避免上述重写的方法因为名字误写造成故障?
- 从设计角度,OrderUser 和 User,是否是继承关系?
- 这个问题的根因是什么?
以上的几个问题中,第一个问题的答案,很多同学都知道,就是使用 Java 自带的 Annotation @Override
,他会自动强制去检查所修饰的方法签名在父子类中是否一致。第二个问题,需要从领域边界来说,这是一个典型的边界划分的问题,即:订单中的用户,和会员登录中的用户,是不是相同的“用户”?会员中的用户,其实只需要关心用户名密码,其他都是这个用户的属性;而订单中的用户,最重要的肯定是联系方式,即一个联系方式,确定一个人。虽然他们都叫做用户,但是在彼此的上下文中,肯定是不一样的概念。所以这里的 OrderUser
和 User
是不能用继承关系的,因为他们就不是一个 "IS A" 的关系。
__仓库共享__,加上没有多加思考的模型,导致依赖混乱;如果两个 User
对象之间代码上能做到隔离,不是那么轻易的产生“关系”,这一切或许可以避免。
为什么是一个仓库?
严格意义上说,一个应用的所有代码都肯定来源于不同的仓库?我们所依赖的三方库如(fastjson, edas-sdk 等)肯定是来源于其他的仓库;这些类库是有确切的名称和版本号,且已经构建好的"制品",这里所说的一个仓库,是指源码级别的“在制品”。可能在很多的项目中不会存在这样的情况,以 GIT 为例,他一般发生在 submodule 为组织结构的工程中,场景一般是啥呢?在我们这个工程中确实是有一个这样的例子:
为了解掉第一个问题,我们决定拆仓库,仓库的粒度按照应用粒度分,同时把 common 相关的都拆到一个叫做 common 仓库中去;业务服务都好说,这里特殊处理的是 admin 应用,admin 是一个后台管理应用,变化频度特别大,需要依赖 UserService 和 OrderService 一大堆的接口。关于和其他仓库接口依赖的处理,这里除了常见的 Maven 依赖方式之外,还有另外一个解决方案就是 git submodule,关于两个方案的对比,我简单罗列在了下表之中:
优点 | 缺点 | |
---|---|---|
Maven 依赖 | 可指定已固化的版本进行依赖 | 必须发布成二方包 |
Submodule 依赖 | 灵活、可直接共享代码库 | 变更不可控 |
我觉得如果这个项目组只有一两个人的时候,不会带来协作的问题;上面的方案随便哪一个都是不需要花太多时间做特殊讨论,挑自己最熟悉最拿手的方案肯定不会有错,所谓小团队靠技术吗,说的就是这么个道理;我们当时是一个小团队,同时团队中也有同学对 submodule 处理过类似的情况,所以方案的选择上就很自然了。
后来随着时间的推移,团队慢慢变大,就发现需要制定一些流程和和规范来约束一些行为,以此保障团队的协作关系的时候;这时候发现之前靠一己之力打拼下来的地盘在多人写作下变得脆弱不堪,尤其是另外一个 submodule 变成一个团队进行维护的时候,submodule 的版本管理几乎不可预期,而且他的接口变动和改动是完全不会理会被依赖方的感受的,因为他也不知道是否被依赖;久而久之,你就会明白什么叫做你的项目被__腐化__了。简单理解__腐化__这个词就是,你已经开始害怕你所做的一切改动,因为你不知道你的改动是否会引来额外的麻烦。从这个角度也可以去理解为什么一门语言设计出来为什么要有 __private__、__public__这些表示范围的修饰词。正因为有这些词的存在,才让你的业务代码的高内聚成为的有可能,小到设计一个方法一个类、再引申到一个接口一个服务、再到一个系统一个仓库,这个原则始终不变。
上述问题带来的解法很简单,就是变成显示依赖的关系,所谓显示依赖是指的两个依赖之间是确定的。什么是确定的?确定 == No Supprise !对,不管什么时候,线上还是线下,我依赖你测试环境的接口返回是一个整数,到了线上,返回的也必须是一个整数、不能变成浮点数。而让确定性变得可行的,不是君子协定;只能是一个版本依赖工具。比如说 Java 中的 Maven 正式的版本依赖。
结语
职责内聚、依赖确定,是我们的应用变得真正 Cloud Native 的前提。没有了这些基本的内功,懂的开源软件再多、对微服务栈再熟悉,也会有各种意想不到的事情出来,试想一下,如果应用的职责到处分散,那到时候扩容到底扩谁呢?如果依赖方变得及其不确定,谁又来为每次发版的不确定的成本买单?Be Cloud Native,请从应用代码托管的住所开始。