从 API 设计开始,了解一下 Golang 的新框架 Twirp
本文最初发布于 Medium 网站,经原作者授权由 InfoQ 中文站翻译并分享。
在这篇博文中我想谈谈 API,讲一下针对微服务该如何设计 API。
准备工作:
Golang——https://golang.org/doc/install
Protobuf 编译器——https://grpc.io/docs/protoc-installation/
项目源码在这里:
https://github.com/subzero112233/golang-twirp
直到前些年的时候,构建应用程序的首选方法还是做一个单层且不可分割的单元,用它来处理多个互相关联的任务。这就是单体(Monolith)模式。
这种模式在过去是最常见的,如今许多场景下它依旧有很好的效果,并且业内还有很多应用程序都在用它。
但这种方法有一些缺点:
你的代码库会变得很庞大;
你违反了 SRP 原则——一个组件应该只做一件事,把它做好;
组件之间都是紧密耦合的;
单体架构在开发和产品运营方面都不能很好地扩展——所有东西都是在同一张大饼上运行和开发的;
面临很高的错误、数据泄露和安全漏洞风险——发现了一个安全漏洞?好吧,你的整个应用程序都会受到影响。
如今,市场要求你不惜一切代价快速行动、打破常规、改变方向、扩大规模并随时待命。为了满足这些要求,软件行业开始采用一种新方法——微服务。
微服务为你带来了一个高度可维护和可测试的软件,它是松散耦合和独立部署的。小型团队可以负责一个或多个微服务,并按照自己的节奏,使用自己选择的语言和框架来开发软件。
我认为,选择单体还是微服务应该视具体情况而定。这里我不会探讨这个问题,但不管怎样每种选项都有自己的优缺点。
一家公司有一个 SaaS 产品,并向用户公开了一个 REST API。随着该产品的发展壮大,他们之前一直在使用的单体架构已经无法满足他们的目标了:
现在交付新特性花费的时间太久;
某些组件需要换一种语言才能获得更好的效果;
有一个组件需要扩展,但它的体量比较小,你不想为了它就扩展整个应用程序。
这样的原因还可以有很多。所以他们决定将单体应用拆分为 5 个微服务和一个队列。
stats api 将接收来自不同类型来源的请求,各自请求不同类型的数据。
此外,我们不想给我们的数据库带来太多压力,所以我们在 stats api 和 stats writer 之间放了一个队列,它会以 10 个项目为一组写入数据库。
其他组件会收到诸如“我想对比 Devin Booker 和 Chris Middleton”之类的请求,因此它们必须从数据库中获取数据并做一些高级计算。
这种请求是由用户发起的,必须在几秒钟或更短的时间内返回,因此我们必须让它们保持同步。
开发人员和架构师选择 RESTful API 作为服务之间的通信方式是很常见的,但我想解释为什么 REST 可能是我实在没办法才会考虑的选项之一。
当今最常见的 API 实现是 REST。REST 是 REpresentational State Transfer(表征状态转移)的首字母缩写词。
REST 依赖于一个无状态的客户端 - 服务器协议,其中客户端和服务器是完全分离的(关注点分离)。可以使用缓存来提高网络效率和性能。
REST API 有一个统一的接口,允许应用程序独立演进,而无需应用程序的服务或模型和动作与 API 层本身紧密耦合。
REST API 也是由一些限制组件行为的分层结构组成的,因此每个组件无法看到与其交互的所在层之外的内容。
由于这些原因,REST API 在过去十年中凭借可扩展性、性能和易用性的优势而广受欢迎,几乎所有人都在使用它们。
听起来就该是它了?其实不一定。
除了面向公众的 API 之外,现在的通信完全是内部的、服务到服务的,没有人参与。
当你遇到下列情况时,REST 是一个不错的选择:
需要支持不同类型的客户端:浏览器、手机等;
希望你的请求 - 响应是人类可读的;
需要一个标准的和广泛采用的接口和消息格式;
需要支持大量的语言和库。
但上面这些都不符合我们的情况。
REST API 的代码生成需要你使用第三方工具,并且不受原生支持。这可能会有非常多的局限,例如在 Go 中就没有用于生成完全兼容的 OAS3 客户端的库。
JSON 是迄今为止最流行的 REST API 数据格式,但它有几个限制:
没有模式(schema):我们的数据库有模式,我们的代码编写的时候就保留了一种模式,那么为什么我们的数据格式没有模式呢?也有用于 JSON 的模式验证器,但它们并不常用,并且是作为外部库提供的,还需要额外的代码;
速度:除了浏览器、服务端等用 JS 编写的 JavaScript 原生环境外,JSON 序列化最高可比 protobuf 慢 6 倍。二进制序列化往往比文本序列化更快;
大小:JSON 生成的对象比二进制选项要大;
额外代码:JSON 需要样板代码来序列化 / 反序列化数据,但是你编写的代码越多,出错的概率也就越大。此外,你正在浪费时间编写与业务无关的代码;
数据类型:JSON 仅支持有限数量的数据类型:字符串、数字、布尔值、空值、对象、数组;
向后兼容性:JSON 不向后兼容。
考虑上面的场景,哪种请求方法最适合检索玩家的统计数据呢?
POST /stats/:name
PUT /stats/:name
应该发送哪些标头、查询参数和 / 或请求正文?应该有什么响应?我们如何传达错误?要问的问题太多,要做出的决定也太多了。
开发人员可能需要通读由什么人撰写的 API 文档,并且通常还需要阅读应用程序的代码以了解端点的实际工作方式。
于是我们又要花费很多宝贵的时间。
本质上,RPC 的用途是让一台机器上的程序能够调用网络上另一台机器上的子程序。RPC 更多是关于动作的,而 REST 的重点则在资源上。
RPC 服务可以比 REST 更简单、性能更好,但代价是灵活性和独立性。对于服务到服务的通信来说,这完全不是什么问题。
虽然 Go 中还有其他一些 RPC 框架,但除非我的确没得选,否则我会使用 Twirp,原因如下:
它的设置非常简单,这对我来说最重要;
同时支持 http 1.1 和 http 2.0;
同时支持 Protobuf 和 JSON;
易于调试。
gRPC 应该获得荣誉提名,并且绝对有它自己的用武之地,尤其是当你需要双向流传输、长生命周期连接和客户端负载平衡时。
gRPC 的缺点是你经常会遇到各种问题,需要第三方支持,例如 grpc-gateway、grpc-web 等。
Protobuf 是谷歌编写的一种数据序列化机制,并且越来越流行了。
它是开源的,也是语言和平台中立的。
Protobuf 使用一个二进制传输格式,这意味着它不是人类可读的,但也意味着它会消耗更少的空间和带宽,消耗更少的 CPU。
与其他类型相比,Protobuf 具有以下优势:
有一个模式;
更快更小;
向后兼容;
具有内置的验证和扩展能力;
支持更多的数据类型。
为了对比 Twirp 和 REST,我编写了这个基础应用程序,可以通过 RPC 和 REST 发送 / 检索玩家统计数据。
REST 实现很简单,可以在这里找到,我们就跳过它直接来看 twirp。
首先,我们看看 stats.proto 文件:
syntax = "proto3";
import "google/protobuf/timestamp.proto";
option go_package = "./rpc/stats";
package stats;
service StatsService {
rpc AddStats(AddStatsRequest) returns (AddStatsResponse);
rpc GetStats(GetStatsRequest) returns (GetStatsResponse);}
message Stats {
string player_name = 1;
float minutes = 2;
int32 field_goals = 3;
int32 field_goal_attempts = 4;
int32 three_pointers_made = 5;
int32 three_pointer_attempts = 6;
int32 free_throws_made = 7;
int32 free_throw_attempts = 8;
int32 offensive_rebounds = 9;
int32 defensive_rebounds = 10;
int32 assists = 11;
int32 steals = 12;
int32 blocks = 13;
int32 turnovers = 14;
int32 personal_fouls = 15;
int32 points = 16;
string team = 17;
string opponent = 18;
google.protobuf.Timestamp game_date = 19;
}
message AddStatsRequest {
repeated Stats stats = 1;
}
message GetStatsRequest {
string player_name = 1;
}
message AddStatsResponse {
string status = 1;
}
message GetStatsResponse {
repeated Stats stats = 1;
}
该文件以一种结构化格式对我们的消息建模,在这里我们以请求 - 响应格式定义我们的 RPC 服务。例如:AddStats 函数接收 AddStatsRequest 消息,它本质上是一个 Stats 消息的数组,并返回一个 AddStatsResponse 格式的消息,这里就是一个字符串。
go install github.com/twitchtv/twirp/protoc-gen-twirp
go install google.golang.org/protobuf/cmd/protoc-gen-go
从 proto 文件中生成.go 文件:
确保 $GOPATH 指向你的 golang 安装目录,在我的例子中是 /usr/local/go。
然后运行:
protoc --proto_path=$GOPATH/src:. --twirp_out=../ --go_out=../ rpc/stats/stats.proto
两个新文件:stats.pb.go 和 stats.twirp.go 包含一个客户端和服务器实用程序。
还有一件很重要的事情需要提一下,我们需要在代码中实现 StatsService 接口。
下面是代码(转换部分没在里面,以突出重点):
package twirphandler
import (
"context"
"net/http"
"time"
"github.com/subzero112233/golang-twirp/entity"
"github.com/subzero112233/golang-twirp/rpc/stats"
"github.com/subzero112233/golang-twirp/usecase/playerstats"
"github.com/twitchtv/twirp"
"google.golang.org/protobuf/types/known/timestamppb"
)
type TwirpHandler struct {
Usecase playerstats.UseCase
}
func NewTwirpHandler(usecase playerstats.UseCase) http.Handler {
t := &TwirpHandler{
Usecase: usecase,
}
return stats.NewStatsServiceServer(t)
}
// errors may not be working well. check this by returning an error from the usecase
func (t *TwirpHandler) GetStats(ctx context.Context, input *stats.GetStatsRequest) (*stats.GetStatsResponse, error) {
var stat entity.Stats
stat.PlayerName = input.PlayerName
data, err := t.Usecase.GetStats(stat.PlayerName)
if err != nil {
return &stats.GetStatsResponse{}, twirp.InternalError(err.Error())
}
statsSlice := convertFromEntity(data)
return &stats.GetStatsResponse{Stats: statsSlice}, nil
}
// errors may not be working well. check this by returning an error from the usecase
func (t *TwirpHandler) AddStats(ctx context.Context, input *stats.AddStatsRequest) (*stats.AddStatsResponse, error) {
e := convertToEntity(input.Stats)
err := t.Usecase.AddStats(e)
if err != nil {
return &stats.AddStatsResponse{}, twirp.InternalError(err.Error())
}
return &stats.AddStatsResponse{Status: "success"}, nil
}
我们看看它是如何工作的。
首先,需要启动服务器。
~ $ go run cmd/main.go
现在发送请求。首先是 AddStats,然后是 GetStats。
我创建了一个示例客户端实现来演示请求:
package main
import (
"context"
"fmt"
"net/http"
"github.com/subzero112233/golang-twirp/rpc/stats"
"github.com/twitchtv/twirp"
"google.golang.org/protobuf/types/known/timestamppb"
)
func main() {
endpoint := "http://localhost:8000"
client := stats.NewStatsServiceProtobufClient(endpoint, &http.Client{})
header := make(http.Header)
ctx := context.Background()
ctx, err := twirp.WithHTTPRequestHeaders(ctx, header)
if err != nil {
return
}
var statz []*stats.Stats
statz = append(statz, &stats.Stats{
PlayerName: "Devin Booker",
Minutes: 44,
FieldGoals: 18,
FieldGoalAttempts: 27,
ThreePointersMade: 6,
ThreePointerAttempts: 10,
FreeThrowsMade: 4,
FreeThrowAttempts: 4,
OffensiveRebounds: 0,
DefensiveRebounds: 5,
Assists: 3,
Steals: 1,
Blocks: 0,
Turnovers: 3,
PersonalFouls: 3,
Points: 55,
Team: "Phoenix Suns",
Opponent: "Milwaukee_Bucks",
GameDate: timestamppb.Now(),
})
respAdd, err := client.AddStats(ctx, &stats.AddStatsRequest{
Stats: statz})
if err != nil {
fmt.Println("AddStats returned an error :", err)
}
fmt.Println(respAdd)
respGet, err := client.GetStats(ctx, &stats.GetStatsRequest{
PlayerName: "Nicolas Batum"})
if err != nil {
fmt.Println("GetStats returned an error :", err)
}
fmt.Println(respGet)
}
如你所见,请求发送就像常规函数调用一样!
~ $ go run example/example.go
status:”success”
stats:{player_name:”Reggie Jackson” minutes:32.9 field_goals:14 field_goal_attempts:20 three_pointers_made:4 three_pointer_attempts:7 free_throws_made:6 free_throw_attempts:6 offensive_rebounds:1 defensive_rebounds:3 assists:6 steals:1 turnovers:2 personal_fouls:3 points:38 game_date:{seconds:1625916051}}
还有很酷的一点是我们也可以使用 curl 来发送请求。调试和初始设置时这非常方便:
echo 'player_name:"Jae Crowder"' \
| protoc --encode stats.GetStatsRequest ./rpc/stats/stats.proto \
| curl -s --request POST \
--header "Content-Type: application/protobuf" \
--data-binary @- \
http://localhost:8000/twirp/stats.StatsService/GetStats \
| protoc --decode stats.GetStatsResponse ./rpc/stats/stats.proto
为了测试性能差异,我创建了两个测试文件(rest_test.go、twirp_test.go):
~ $ go test -bench=. -benchtime=5000x
goos: linux
goarch: amd64
pkg: github.com/subzero112233/golang-twirp
cpu: Intel(R) Core(TM) i7–8550U CPU @ 1.80GHz
BenchmarkRestAdd-8 5000 533270 ns/op
BenchmarkRestGet-8 5000 475062 ns/op
BenchmarkTwirpAdd-8 5000 90284 ns/op
BenchmarkTwirpGet-8 5000 91833 ns/op
PASS
ok github.com/subzero112233/golang-twirp 5.966s
在较小的负载上差异可能会小一些,但也足够明显了,意味着必要时还是应该使用 RPC。
感谢阅读。
原文链接:
https://itnext.io/golang-microservices-and-twirp-5ef495278ddf