在看如何使用 Go 构建一个具有高度可扩展性、可靠性和易维护性的大型项目之前,先通过 Kubernetes
的项目结构来看他是如何组织容器编排这一系列功能模块的。
Kubernetes 代码布局
下表为 kubernetes 主要的一级目录列表及其主要功能,接下来我们将逐一讲解这几个目录的存放功能。
源码目录 | 说明 |
---|---|
api | 存放接口协议 |
build | 构建应用相关的代码 |
cmd | 各个应用的 main 入口 |
pkg | 各组件的主要实现 |
staging | 各组件之间相互依赖的代码暂存 |
api
存放了 OpenAPI 、Swagger的文件,包含了JSON 和 Protocol 的定义。
build
存放了构建 Kubernetes
项目的脚本,包含了构建 K8s
各个组件以及需要的镜像,如 pause
程序。
cmd
cmd
目录存放项目构建可执行文件的 main
包源文件。如果有多个可执行文件需要构建的话,则可以将每个可执行文件放在单独的子目录中。我们看 kubernetes
的 cmd
目录下的具体文件夹的子目录内容。
- cmd 各个应用的 `main` 方法
- kube-proxy 负责网络相关规则的应用
- kube-apiserver 负责公开k8s的api,处理接受请求。提供了各种资源(Pod,replicaSet,Service)的CURD
- kube-controller-manager
- kube-scheduler 负责监视新创建的Pod并选择节点给Pod运行
- kubectl 访问集群的命令行工具
可以看到这个目录底下, k8s
中我们所熟悉的 kube-proxy
、 kube-apiserver
的组件都可以在这里找到。
pkg
pkg
目录存在自身需要使用依赖的包和项目导出的包。
- pkg 各组件的主要实现
- proxy: 网络代理的实现
- kubelet: 维护Node的Pod
- cm: 容器管理,如 cgroups
- stats :资源占用情况,由`cAdvisor` 实现
- scheduler: Pod调度的实现
- framework
- controlplane:控制平面
- apiserver
staging
staging
目录的包以软链接的形式连接到 k8s.io
里面。首先,因为 kubernetes
项目十分庞大,这样可以避免了仓库分散而产生的开发障碍,能够让所有代码在一个 pull request
中提交和评审。通过这种方式,保证了模块的独立性,又保障了代码主仓库的代码完整性。
同时,通过 go mod
中 replace
的方式,不需要为每一个依赖的代码打 tag
,简化了版本管理和发布流程。
如果不这么做,我们用 monorepo
的方式,即我们把 staging
下面的代码都拆分成独立仓库。在 kubernetes
主仓库中,所依赖这些子仓库的代码发生变动时,我们需要在子仓库提交后,先发布一个新的 tag
,然后在 go mod
中替换旧的 tag
再进行开发。这样无疑是增加了整体的开发成本。
所以,staging
目录的包以软链接的形式连接到主仓库里面,可以有效简化版本管理和发布流程。
与 Standard Go Project Layout 的对比
internal
目录则是用于不想导出给外面使用的包。在 go
中, internal
的原理是能够在自身项目中正常使用的同时,又保证不会让外部项目看到。
然而,k8s
中不存在 internal
的目录,这是因为 Kubernetes 项目最早是在 2014 年左右开始开发的,而 internal
目录的概念在 Go 语言中是从 Go 1.4(2014 年末发布)之后才引入的。在 Kubernetes 项目初期的开发中,还没有形成大规模使用 internal
的设计惯例,后续也没有进行大规模的重构来引入它。
同时,Kubernetes 的设计目标之一是模块化和解耦,它通过明确的包组织和代码结构来实现封装,不需要通过 internal
包来限制包的访问。
看到这里,我们已经了解了构建一个项目的标准一级目录结构。
Go 没有像 java 有标准的目录框架,带来的问题就是不同项目入手的时候都需要去习惯对应的代码结构,可能同一个团队都会存在不同的结构,这对于新人理解项目是会带来很大的阻碍。
既然存在阻碍,协作就会比较困难。一个统一一级目录结构能让我们接手一个项目的时候,能有统一的入口去快速找到代码,让大家协作开发时提高开发效率,减少对代码存放位置的纠结和困惑。
可是,仅仅代码一级目录的结构统一就能构建完美的大型项目吗?答案当然是否定的。
仅靠统一的目录结构,并不能一劳永逸地解决代码日渐腐化直至混乱的问题。 良好的设计理念,才能在项目日渐膨胀的同时,保持程序设计脉络的清晰。
声明式的理念设计
声明式 API 贯穿着 Kubernetes
的整个代码设计,防止陷入过程化编程。
比如在改变资源的状态的时候,**应该告诉 k8s 期望的状态,而不是告诉k8s应该怎么做。**这也是 kublet rolling-update
被淘汰的原因,它的设计是微观管理了整个 pod
更新的动作。
我们通过告知 Kubernetes
期望状态后, kubelet
可以根据期望状态去做出自己相应的动作,外部无需过多干涉。
看到这里是不是会有疑问,声明式 API 为什么能够保证项目膨胀的时候保持模块清晰?这不是用户在使用 Kubernetes
时,感知到的吗,这怎么跟内部设计扯上关系?
我们设计接口的时候,如果将操作过程都暴露给用户,让他一步一步的去干涉我们的 Pod
更新,那我们在这个过程设计出来的模块,只能是面向过程去做的。这样我们的代码模块就很难做到清晰了,因为这里面耦合了许多用户的操作过程。
而通过声明式 API,我们告诉了 k8s
期望的状态后,集群内部可以通过多个组件的协调,最终来达到我们的状态,这样用户不需要知道内部是如何更新的,还能够在需要引入更多协作插件的时候,直接增加模块,而不需要再暴露 API
让用户来操作。
cAdvisor
对 k8s 部署的资源进行监控,收集容器资源的指标内容,它独立进行,并不依赖于外部组件;然后控制器把这些指标跟用户声明的指标进行对比,来看是否达到扩缩容的条件。
由于模块的独立, cAdvisor
只要专注于把监控指标获取并返回即可,而不需要关心这些指标的实际用途,是被用来作为观测指标?还是自动扩缩容的依据?
这也是我们在设计不同任务组件的原则,要明确所需完成的需求;在传递信息时,只关注输入和输出;至于内部如何实现可以进行内聚,不透传给外部,使外部业务使用时,尽可能简单。
避免过度设计
过度的工程设计往往比工程设计不足还要糟糕。
最早的 Kubernetes 版本是 0.4,其中的网络部分,最开始官方的实现方式就是 GCE 执行 salt 脚本创建 bridge,其他环境的推荐的方案是 Flannel 和 OVS。
随着 Kubernetes 发展起来之后,Flannel 在有些情况下就不够用了,15 年左右社区里 Calico 和 Weave 冒了出来,基本解决了网络问题,Kubernetes 就更不需要自己花精力来做这件事了,所以推出了 CNI,来做网络插件的标准化。
可以看到 Kubernetes 在最开始的设计中也不是一步到位,而是随着问题的不断出现,不断去推出新的设计来适应各个环境的变化。
最开始启动项目的时候,**依赖关系相对比较清晰。所以,在工程设计之初并不会遇到循环依赖的场景,**但是随着项目的发展则会逐渐出现这个问题。产品需求上的功能会导致代码设计上的交叉引用。
尽管在开始做之前,我们尽可能了解所有的业务背景和所需要解决的问题,但是随着产品功能的变动和程序迭代,也会出现一些新的问题。 我们能做的是,在设计时,留意模块设计和依赖关系的整理,尽可能将现有功能做到内聚,在后续增加抽象时,不至于需要推翻以前的所有代码,出现「重构」式的改动。
为了“拓展性”,而过度设计程序的可拓展的,为了设计而设计的情况的出现。
下面我们通过一个电商业务设计场景来看设计的演进。
首先系统有两个模块:
- 订单模块:负责处理订单创建、支付、状态更新等操作。它依赖于用户模块来获取用户信息(如用户的配送地址、联系方式等)。
- 用户模块:负责管理用户的信息、注册、登录和用户数据的存储。它不依赖于订单模块。
在这个初始设计中,依赖是单向的:订单模块
依赖于用户模块
。
那我们编码的时候就没必要预先做抽取设计,很多项目在最开始并不能预知到自己成功还是失败,花过多精力去设计,不仅仅是在产品发布周期上不允许,在技术上也可能因为产品做了大的概念变动导致过度的设计成为后面修改的绊脚石。
随着需求的发展,出现了以下新需求:平台需要根据用户的购买历史(订单记录)来推荐个性化的商品。
为了实现个性化推荐,用户模块
现在需要调用订单模块
的接口来获取用户的订单历史。
这样,依赖关系变成了:
- 订单模块依赖于用户模块来获取用户信息。
- 用户模块依赖于订单模块来获取用户的订单历史。
这种变化导致了循环依赖:订单模块
依赖于用户模块
,同时用户模块
也依赖于订单模块
。
为了解决循环依赖,可以考虑以下几种方案:
拆分模块职责:引入一个新的模块,如推荐模块
,专门负责处理个性化推荐逻辑。推荐模块
可以从用户模块
和订单模块
分别获取需要的数据,避免它们之间的直接相互依赖。
我们通过对模块进行抽取解决了用户模块跟订单模块耦合的问题。
但是这里有产生了一个新的需求,用户在活动期间购买了一些活动商品,产品经理希望监测到这种订单的产生后,推荐模块能马上感知到,并做出配套的参加活动商品的推荐,例如用户买了打折的运动手表,这个时候我们如果同时推荐有打折的蓝牙运动耳机,是不是用户复购率就会更高呢?
这个时候产生订单后订单模块去调用推荐模块传入数据这种显然是不可取的,因为原先推荐模块已经依赖订单模块获取用户购买数据,已经有单向的依赖关系,我们再让订单模块去调用推荐模块的话,这显然就形成了循环依赖。
那这里怎么能够实现快速让推荐模块感知到订单的变化呢?就需要通过事件驱动来实现。
使用事件驱动的方式,订单模块
在用户下单时触发一个事件,而 推荐模块
订阅该用户订单产生的事件。这样两个模块之间不需要直接调用对方的接口,而是通过事件传递数据。
推荐模块获取到数据之后可以马上重新开始训练新的推荐模型并给用户推荐关联商品。
从上面的例子可以看到,目前企业级应用的一大难点:业务领域建模。
在建模方面,更多的是一个随着需求不断进化优化设计的过程。
上面说到的用户、订单和推荐模块,也是大部分 TOC
产品演进过程中会遇到的内容。
如何在演进的过程中不断的优化我们的模块设计和代码结构,提高我们的迭代速度,是我们需要不断去探索和思考的。
小结
我们来回顾一下这篇文章的内容。
- 在构建大型项目时,统一的目录结构能提高协作效率,但良好的设计理念才是维持项目清晰度和扩展性的关键。
Kubernetes
声明式 API 可以帮助模块保持独立,避免过程化编程的陷阱。- 项目的设计应根据实际需求逐步演进,避免过度设计。
- 关注模块职责和依赖的合理拆分,通过事件驱动解决模块间的耦合问题。