微服务架构目前仍然是一种主要的开发方式,笔者从2010年开始接触OpenStack,对OpenStack架构略有了解,于是在后期的产品研发中,沿用这一框架。但是随着项目的进行,也逐步发现了该框架中在实际应用中的缺陷,以及入门成本比较陡峭的问题。所以想结合gRPC思路,来分析一下是否有对现有框架优化的可能性。
本文大部分内容出自:https://realpython.com/python-microservices-grpc/,结合了笔者个人经验进行了整理,并非完全的翻译。国内很多网站翻译的版本并非完整版,此篇几乎完整的还原了原文,请关注老孙正经胡说网站关注后续更新。
在完成了通篇的翻译工作后,不禁为作者的细致程度点赞,细节程度“令人发指”,这种匠人精神是值得国内的技术人员学习的。
全文导航
为了方便大家阅读,这里将全部目录进行一下索引,方便大家在老孙正经胡说(https://sunqi.site)中查看相关文章:
- 利用gRPC构建Python微服务(一)——关于微服务
- 利用gRPC构建Python微服务(二)——gRPC基础
- 利用gRPC构建Python微服务(三)——实战Python gRPC
- 利用gRPC构建Python微服务(四)——在Kubernetes中部署
- 利用gRPC构建Python微服务(五)——微服务可观测性
- 利用gRPC构建Python微服务(六)——Python gRPC最佳实践
- 利用gRPC构建Python微服务(七)——AsyncIO和gRPC
前言
微服务架构反映了开发人员在技术迭代中最朴素的愿景,即不把鸡蛋放在一个篮子中。解决随着项目发展,所有代码放在一个项目中时,开发运维成本陡增的问题。同时也提高应用可扩展性,尽可能的降低由于需求的增加而对原有架构重构的风险。
通过该教程,你可以学会:
- 使用Python构建微服务模块,并且通过grpc进行通讯
- 实现中间层(middleware),实现对其他微服务监控
- 如何对中间层和微服务模块进行单元测试和完整性测试
- 将微服务模块部署在Kubernetes集群中
为什么要使用微服务(Microservices)?
假设你要开发一个在线销售图书的网站,你的团队里有几百人,他们都在实现某部分具体的功能,例如管理购物车、推荐、处理交易等功能。
如果按照传统方式实现,将所有的代码写入一个巨大的项目中,所有的研发人员需要耗费时间理解全部代码。同时由于代码间的关联性,测试的时间成本也会被拉长,在版本控制上,代码与数据库结构的一致性也成了管理和开发上的难题。最重要的一点,这样的架构并不适用于快速变化的需求,开发一个新功能的周期将成倍的增加。
基于以上在传统开发中的难点,就应运而生了微服务的理念。将原有模块化开发中的功能模块,拆分成一个一个微服务,微服务通过统一的接口对外通讯,模块内部的变化不会影响其他模块。在示例应用中,可以将购物车,物品清单中等都作为一个微服务模块进行拆分。
模块化开发
假如产品经理需要实现一个功能:图书买二赠一。那么作为研发人员,可以增加一个检查购物车的逻辑,发现购物车有两本以上时,直接减去最便宜的一本书的价格即可实现该功能。
接着又出现了一个新需求,需要对这一活动的效果进行跟踪,由于之前的逻辑是在购物车实现的,开发人员需要在结账时,更新一下关系型数据库中的字段
buy_two_get_one_free_promo = true
接下来,产品经理告知你,该优惠活动每个用户只能享受一次,所以呢你需要在显示页面的时候检查你上面设置的标记,如果用户已经使用过该优惠,则需要隐藏掉宣传的Banner,如果没使用过则需要发邮件告知用户。
随着时间推移,关系型数据库数据量越来越大,所以希望更换共享型数据库(shared database),但是由于这样的标记太多,导致业务逻辑和数据库之间的羁绊越来越严重。这就是为什么不在一个项目中实现所有代码的原因,从长期角度来看,边界非常重要。
交易数据库只能被交易微服务所访问,其他服务访问时都应该通过抽象的接口实现,这样可以控制代码变更后的”爆炸半径“。
灵活性
微服务为代码构建提供了灵活性,一方面每一个微服务模块的开发语言不受限制,另外一方面你还可以灵活的扩展微服务模块。根据应用的特性,你可以将微服务模块运行在不同的硬件上。
稳定性
单点瓶颈问题是单体应用面临稳定性最大的挑战。另外,由于单体应用代码变更而引起的“爆炸半径”往往不可控。
所有权
在大型单体项目中,由于每个人对于整体架构理解不同,所以在实现过程中需要有经验的人进行严格的代码检查,这样让迭代速度变慢。
微服务架构让每个开发工程师只需要关注自己的代码部分的架构,这也降低了增加新功能时对其他模块产生影响的风险。
如何划分“微”服务?
这其实是一个极具争议性的话题,就像Restful一样,并非是一种协议规范,只是一种风格、一种建议。关于这一话题有太多争议性的讨论,甚至有文章说”微服务已死“的论调。其实我们不必纠结于此,“一千个人心中有一千个哈姆雷特”,从结果导向来讲,“黑猫白猫能抓到老鼠就是好猫”。从原文作者角度看,“微”是一种不恰当的命名,而我们重点关注的是应该放在“服务”上。
所以在设计微服务时有以下几点建议:
- 微服务太小会破坏代码的模块化,微服务中的代码实现是有价值的,这类似类的数据和方法之间的关系。所以微服务的范围要适当,不要太大也不要太小,这也是为什么上面提到微服务的拆分因人而异,没有绝对的对与错
- 微服务的开发和测试其实比单体应用更难,因为如果一个开发人员想要开发一个模块,必须要知道如何启动其他模块,这就对CI/CD流程提出了更高的要求,几个微服务模块手动可以启动,但是如果是十几个,则相当耗时耗力
- 微服务的设计是一门艺术,也要结合团队的实际情况,如果你团队有5个人,但是微服务模块有20个,这是相当危险的;如果你的团队负责一个微服务模块,却被其他五个团队共享使用,这也可能会导致问题
- 微服务在拆分时,不要意味的追求“微”,某些微服务模块可能会很大,但是也要注意,如果一个微服务模块做了两件或以上不相关的事情,也说明那个功能实现并不属于这个模块
这是对于在线书店的微服务模块拆分:
- Marketplace:用户访问网站的逻辑
- Cart:用户购物车及购买流程
- Transactions:付款流程和发送收据
- Inventroy:库存管理
- User Account:用户登陆注册、密码修改等
- Reviews:评价管理
这只是一些例子,并不是全部,每个团队的逻辑相对独立,这样更容易控制“爆炸半径”,例如评价系统出现问题,并不会影响购买流程。
微服务架构和单体架构的平衡
在产品研发初期,选择单体架构能够更快速的添加各种功能,快速完成产品。但是随着开发的进行,产品逐渐变得越来越臃肿。使用Python构建微服务架构,短期内会消耗你一定的精力,但是具备很好的扩展性。
典型的硅谷创业周期都是从单体开始,以便企业能够快速迭代。公司可以雇佣更多的工程师后,可以考虑使用微服务架构,但是要注意选择合适的时间点。
关于微服务架构与单体架构的平衡请参考《什么时候开始使用微服务架构》(https://www.youtube.com/watch?v=GBTdnfD6s5Q)
微服务示例
示例需求:
- 定义一个API接口,实现微服务
- 定义两个微服务:
- Marketplace:简单的web程序,向用户展示图书列表
- Recommendations:显示用户可能喜爱的图书的列表
下图展示了服务之间的通讯关系:
用户通过浏览器访问Marketplace微服务,Marketplace微服务将和Recommendations进行通讯。
Recommendations API应该包含以下功能:
- User ID:用户个性化推荐标识,为了简单起见,样例是随机生成的
- Book category:让API增加一些趣味性,添加图书的类目
- Max results:最大推荐数量
返回的结果是一个列表,其中包含:
- Book ID:图书唯一标识
- Boot title:图书名称
我们使用**protocol buffers正式定义API接口,**这个protocol buffers中声明了你的API。Protocol buffers是Google开发的,提供了一种正式的API接口规范。这看起来有点神秘,以下注释部分是对每一行的详细描述:
# 定义了该文件使用proto3协议取代旧的proto2版本
syntax = "proto3";
# 定义图书类目
enum BookCategory {
MYSTERY = 0;
SCIENCE_FICTION = 1;
SELF_HELP = 2;
}
# 定义API请求,user_id和max_results使用了int32类型,而category使用了上面定义的BookCategory类型,可以暂时忽略
message RecommendationRequest {
int32 user_id = 1;
BookCategory category = 2;
int32 max_results = 3;
}
# 定义图书推荐类型
message BookRecommendation {
int32 id = 1;
string title = 2;
}
# 定义了微服务响应,replated关键字代表返回的是BookRecommendation的类型的列表
message RecommendationResponse {
repeated BookRecommendation recommendations = 1;
}
# 可以看做是一个函数,输入为RecommendationRequest,输出为RecommendationResponse
service Recommendations {
rpc Recommend (RecommendationRequest) returns (RecommendationResponse);
}
这里rpc就是远程调用(remote procedure call),类似本地调用函数,但实际可能是在远程服务器运行该函数。
为什么是RPC和Protocol Buffers?
为什么使用这种方式来定义API接口呢?如果你想让一个微服务调用另外一个微服务,最简单的方法是通过HTTP调用,并返回一个Json类型的字符串。你可以使用这种方式,但是使用Protocol Buffers则更有优势。
文档
首先,使用protocol buffers可以让你使用更优雅并且自定义样式的方式定义API。如果使用Json,则你需要在文档中记录包含的字段及其类型。与任何文档一样,你可能面临不准确,或者文档未及时更新的风险。
当你使用protocol buffers进行API定义时,从中生成Python代码。你的代码永远不会和文档不一致,文档是好的,但是代码中的自我文档是更好的方式。
验证
第二点优势,是自动基于类型定义的验证。例如:生成的代码不会接受错误的类型。生成的代码还内置了所有RPC样板文件。
当你使用HTTP和Json构建API时,你需要写一些代码实现请求、发送、校验返回结果等。使用protocol buffers你就像调用本地函数一样,但底层实际上是一个网络调用。
你可以使用基于HTTP和JSON框架的Swagger和RAML获得同样的优势,具体信息可以查阅其他文档。
那么是否有充足的理由使用grpc,而不选择其他的呢?答案仍然是肯定的。
性能
gRPC框架是比传统HTTP请求效率更高。gRPC是在HTTP/2基础上构建的,能够在一个长连接上使用现成安全的方式进行多次并发请求。连接创建相对较慢,所以在多次请求时,一次连接并共享连接以节省时间。gRPC信息是二进制的,并且小于JSON。未来,HTTP/2会提供内置的头部压缩。
gRPC内置了对流式请求和返回的支持。比基础HTTP连接更好的管理网络问题,即使是在长时间断线后,也会自动重连。他还有连接器,你会在本向导后面学习到。你甚至可以实现插件,已经有人做出了这样的Python库。最根本的,你可以完全免费试用这个伟大的架构。
对开发人员友好
相较于REST,很多开发人员更喜欢gRPC,这其中的原因是,你不需要像REST一样使用动词或者资源来定义功能,而是直接使用函数方式。作为一名开发人员,更习惯的是函数思考方式,这也是grpcapi的定义方式。
实现REST API定义功能通常比较麻烦,你首先要确定资源,构造路径,以及使用哪个动词。通常有多个选择,如何嵌套资源,如何使用POST或其他动词。REST和gRPC孰优孰劣又会是一个争论的焦点,但是正如之前所说的,没有最好的技术,只有最适合需求场景的技术方案。
严格来说,protocol buffers是将数据通过序列化方式在两个微服务之间传输。所以,protocol buffers和JSON或XML都是相似的方式组织数据。不同的是,protocol buffers拥有更严格的格式和更压缩的方式在网络上通讯。
另外一方面,RPC架构应该被称为gRPC或者Google RPC。这很像HTTP。但正如上述所言,gRPC实际上是基于HTTP/2构建的。
代码样例
现在正式进入实现部分,我们来看看protoco buffers能做什么?protobufs是简称,后续会大量出现。
正如上面提到的,你可以从protobufs生成Python代码。这个工作是作为grpcio-tools包的一部分。
首先,定义你的初始目录结构:
.
├── protobufs/
│ └── recommendations.proto
|
└── recommendations/
protobufs包含recommendations.proto文件,这是我们上面定义的部分。
你将在recommendations目录生成Python代码。首先,你需要安装grpcio-tools,创建recommendations/requirements.txt
grpcio-tools ~= 1.30
可以创建一个virtualenv环境来运行后续代码,在虚拟环境中安装依赖。
virtualenv venv
source venv/bin/activate
python -m pip install -r requirements.txt
从protobufs生成代码
$ cd recommendations
$ python -m grpc_tools.protoc -I ../protobufs --python_out=. \
--grpc_python_out=. ../protobufs/recommendations.proto
此处将生成两个文件
$ ls
recommendations_pb2.py recommendations_pb2_grpc.py
这些文件包含与API通讯的Python类型和函数。编译器生成客户端代码并调用RPC和Server端代码实现远程调用。我们先来看一下客户端代码部分。
RPC客户端
这里生成的代码可读性并不高
>>> from recommendations_pb2 import BookCategory, RecommendationRequest
>>> request = RecommendationRequest(
... user_id=1, category=BookCategory.SCIENCE_FICTION, max_results=3
... )
>>> request.category
1
protobuf编译器生成了与你的protobuf类型对应的 Python 类型。到目前为止,一切顺利。你还可以看到一些类型的检查字段:
>>> request = RecommendationRequest(
... user_id="oops", category=BookCategory.SCIENCE_FICTION, max_results=3
... )
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'oops' has type str, but expected one of: int, long
如果传入错误字段类型,则抛出TypeError。
在proto3中所有字段是可选的,所以你要校验是否正确设置所有值。如果其中一个未提供,如果为数值类型则默认为0,如果是字符类型默认为空:
>>> request = RecommendationRequest(
... user_id=1, category=BookCategory.SCIENCE_FICTION
... )
>>> request.max_results
0
因为没有设置int字段的默认值,所以返回0。
虽然protobufs为你进行了初步检查,但是仍然需要从自身业务角度验证数据的有效性,其实无论使用何种实现方式,这都是必须要做的。
生成的recommendations_pb2.py文件包含了类型定义,而recommendations_pb2_grpc.py文件则包含了客户端和服务端基本通讯框架。以下就是client在使用时需要的引入:
>>> import grpc
>>> from recommendations_pb2_grpc import RecommendationsStub
引入grpc是为了设置与远程服务的链接。之后导入RPC客户端stub,之所以叫做stub是因为客户端本身并不包含任何功能。只是调用远程服务并传回结果。
如果你查看protobuf的定义,你会看到在service Recommendations的定义最后。protobuf的编译器使用Recommendations,并且追加从客户端的名称Stub,就构成了RecommendationStub
service Recommendations {
rpc Recommend (RecommendationRequest) returns (RecommendationResponse);
}
现在你可以发送RPC请求了
>>> channel = grpc.insecure_channel("localhost:50051")
>>> client = RecommendationsStub(channel)
>>> request = RecommendationRequest(
... user_id=1, category=BookCategory.SCIENCE_FICTION, max_results=3
... )
>>