导读:
Erda Infra 微服务框架是从 Erda 项目演进而来,并且完全开源。Erda 基于 Erda Infra 框架完成了大型复杂项目的构建。本文将全面、深入地剖析 Erda Infra 框架的架构设计以及如何使用。
Erda Infra: https://github.com/erda-project/erda-infra
Erda: https://github.com/erda-project/erda
背景
在互联网技术高速发展的浪潮中,众多的大型系统慢慢从单体应用演变为微服务化系统。
单体应用
单体应用的优势是开发快速、部署简单,我们不需要考虑太多就能快速构建出应用,很快地上线产品。
然而,随着业务的发展,单体程序慢慢变得复杂混乱,非常容易改出 bug,体积也变得越来越大,当业务量上来的时候,很容易崩溃。
微服务架构
大型系统往往采用微服务架构,这种架构把复杂的系统拆分成了多个服务,微服务之间松耦合、微服务内部高内聚。
同时,微服务架构也带来了一些挑战。服务变多,对整个系统的稳定性是一种挑战,比如:该如何处理某个服务挂了的情况、服务之间如何通讯、如何观测系统整体的状况等。于是,各种各样的微服务框架诞生了、采用各种技术来解决微服务架构带来的问题,Spring Cloud 就是一个 Java 领域针对微服务架构的一个整合性的框架。
云平台
Spring Cloud 提供了许多技术解决方案,然而对于企业来说,运维成本还是很高。企业需要维护各种中间件和众多的微服务,于是出现了各种各样的云服务、云平台。
Erda (https://github.com/erda-project/erda) 是一个针对企业软件系统在开发阶段和运维阶段进行全生命周期管理、一站式的 PaaS 平台,在各个阶段都能够解决微服务带来的各种问题。
Erda 本身也是一个非常大的系统,它采用微服务架构来设计,同样面临着微服务架构带来的问题,同时对系统又提出了更多的需求,我们希望实现:
系统高度模块化
系统具有高扩展性
适合多人参与的开发模式
同时支持 HTTP、gRPC 接口、能自动生成 API Client 等
另一方面,Erda 的开发语言是 golang,在云原生领域,golang 是一个主流的开发语言,特别适合开发基础的组件,Docker、Kubernetes、Etcd、Prometheus 等众多项目也都选用 golang 开发。不像 Spring Cloud 在 Java 中的地位,在 golang 的生态圈里,没有一个绝对霸主地位的微服务框架,我们可以找到许多 web 框架、grpc 框架等,他们提供了很多工具,但不会告诉你应该怎么去设计系统,不会帮你去解耦系统中的模块。
基于这样的背景,我们开发了 Erda Infra 框架。
Erda Infra 微服务框架
一个大的系统,一般由多个应用程序组成,一个应用程序包含多个模块,一般的应用程序结构如下图所示:
这样的结构存在一些问题:
代码耦合:一般会在程序最开始的地方,读取所有的配置,初始化所有模块,然后启动一些异步任务,而这个集中初始化的地方,就是代码比较耦合的地方之一。
依赖传递:因为模块之间的依赖关系,必须得按照一定的顺序初始化,包括数据库 Client 等,必须得一层层往里传递。
可扩展性差:增删一个模块,并不那么方便,也很容易影响到其他模块。
不利于多人开发:如果一个应用程序里的模块是由多人负责开发的,那么也很容易互相影响,调试一个模块,也必须得启动整个应用程序里的所有模块。
接下来我们通过几个步骤来解决这些问题。
构建以模块驱动的应用程序
我们可以将整个系统拆分为一个个小的功能点,每一个小的功能点对应一个微模块。整个系统像拼图、搭积木一样,自由组合各种功能模块为一个大的模块作为独立的应用程序。
这也意味着我们无需担心整个系统的服务过多、过于分散,只需要专注于功能本身的拆分。微服务不仅存在于跨节点的多进程之间,也同样存在于一个进程内。
我们利用 Erda Infra 框架来定义一个模块:
package exampleimport ("context""fmt""time""github.com/erda-project/erda-infra/base/logs""github.com/erda-project/erda-infra/base/servicehub"
)// Interface 以接口的形式,对外提供本模块的功能
type Interface interface {Hello(name string) string
}// config 声明式的配置定义
type config struct {Message string `file:"message" flag:"msg" default:"hi" desc:"message to print"`
}// provider 代表一个模块
type provider struct {Cfg *config // 框架会自动注入Log logs.Logger // 框架会自动注入
}// Init 初始化模块。可选,如果存在,会被框架自动调用
func (p *provider) Init(ctx servicehub.Context) error {p.Log.Info("message: ", p.Cfg.Message)return nil
}// Run 启动异步任务。可选,如果存在,会被框架自动调用
func (p *provider) Run(ctx context.Context) error {tick := time.NewTicker(3 * time.Second)defer tick.Stop()for {select {case <-tick.C:p.Log.Info("do something...")case <-ctx.Done():return nil}}
}// Hello 实现接口
func (p *provider) Hello(name string) string {return fmt.Sprintf("hello %s", p.Cfg.Message)
}func init() {// 注册模块servicehub.Register("helloworld", &servicehub.Spec{Services: []string{"helloworld-service"}, // 代表模块的服务列表Description: "here is description of helloworld",ConfigFunc: func() interface{} { return &config{} }, // 配置的构造函数Creator: func() servicehub.Provider { // 模块的构造函数return &provider{}},})
}
当我们定义了很多个这样的功能模块后,可以通过一个 main 函数来启动模块:
package mainimport ("github.com/erda-project/erda-infra/base/servicehub"_ ".../example" // your package import path
)func main() {servicehub.Run(&servicehub.RunOptions{ConfigFile: "example.yaml",})
}
然后,通过一份配置文件 example.yaml 来确定我们启动哪些模块:
# example.yaml
helloworld:message: "erda"
提示:当然这里也可以内置配置,可以参考 servicehub.RunOptions 里的定义。
这种方式的优点有以下几点:
面向微模块编程,只需要关心自身的功能,更容易做到高内聚、低耦合。
声明式的配置定义,无需关心配置读取的步骤,框架实现多种配置的读取。
无需关心其他模块如何初始化,也无需关心整个应用的初始化顺序,只需要专注自身的初始化步骤。
异步任务的管理,框架会处理 进程信号,优雅的关闭模块任务。
系统高度可配置,任意模块都可独立配置,并且可以单独启动某个模块进行调试。
模块间的依赖
正如微服务所面临的问题之一,服务之间有着复杂的调用,客观上存在着依赖关系。我们将功能模块化之后,该如何解决模块之间的依赖关系。
Erda Infra 给我们提供了依赖注入的方式,在介绍依赖注入之前,我们先了解一些概念:
Service,代表一种可以供其他模块或其他系统使用的功能。
Provider,代表服务的提供者,提供 0 个或多个服务,相当于一个模块,一组服务集合。
一个 Provider 可以依赖 0 个或多个 Service。
我们可以在 Provider 上定义所依赖的 Service 类型作为一个字段,框架会自动将依赖的 Service 实例注入。
例如,我们定义一个模块 2来引用上一节定义的 helloword 模块所提供的 helloworld-service 服务:
package example2// 以下省略号非关键代码import ("github.com/erda-project/erda-infra/base/servicehub"".../example" // your package import path
)type provider struct {Example example.Interface `autowired:"helloworld-service"` // 框架会自动注入实例
}func (p *provider) Init(ctx servicehub.Context) error {p.Example.Hello("i am module 2")return nil
}func init() {// 注册模块 ...
}
可以思考一下,为什么不是 Provider 之间直接依赖,而是通过 Service 依赖?因为相同的 Service 可以由多个不同实现的 Provider 提供。
正如我们依赖一个接口,而非具体实现类一样,我们可以将依赖的 Service 接口类型定义在一个公共的地方,由 不同的 Provider 实现来接口,调用者无需关心具体实现,我们可以通过配置来切换不同的实现。这样可以做到模块之间的解耦。
框架可以通过 autowired 声明的 Service 名称来进行依赖注入,也可以通过接口类型来进行依赖注入,具体可以参考 Erda Infra 仓库里的例子。
框架会分析模块之间的依赖关系,来确定每个模块的初始化顺序,所以,当我们编写一个模块的时候,也就无需关心整个程序里所有模块的初始化顺序了。
构建跨进程的 HTTP + gRPC 服务
当解决了模块之间的依赖关系后,我们接下来考虑如何跨进程通讯的问题。
所谓天下大势,合久必分,分久必合,我们经常会因为一些架构调整或其他原因,需要将部分功能模块迁移到另外的应用程序里,又或者是将一个大的应用程序拆分为多个小的程序。另一方面,要实现整个系统的所有小功能任意组合为一个大模块,必然也涉及到跨进程的通讯。
好在我们可以进行面向接口的编程,模块之间的依赖是通过 service 接口依赖的,那么这个接口就有可能是本地的模块,也有可能是远程的模块。
Erda Infra 可以做到模块之间的解偶,也能解决跨进程通讯的问题,框架通过定义 ProtoBuf API 的方式,为模块提供同时支持 HTTP 和 gRPC 接口的能力:
框架也提供了 cli 工具,来帮助我们生成相关的代码:
下面我们来看一个例子。
第一步,创建一个 greeter.proto 文件,定义一个 GreeterService 服务:
syntax = "proto3";package erda.infra.example;
import "google/api/annotations.proto";
option go_package = "github.com/erda-project/erda-infra/examples/service/protocol/pb";// the greeting service definition.
service GreeterService {// say hellorpc SayHello (HelloRequest) returns (HelloResponse) {option (google.api.http) = {get: "/api/greeter/{name}",};}
}message HelloRequest {string name = 1;
}message HelloResponse {bool success = 1;string data = 2;
}
第二步,通过 Erda Infra 提供的 gohub 工具,可以编译出相关的协议代码和 Client 模块。
cd protocol
gohub protoc protocol *.proto
其中 protocol/pb 为协议代码,protocol/client 为客户端代码
第三步,有了协议代码,必然需要去实现对应的服务接口,通过 gohub 生成接口实现的代码模版:
cd server/helloworld
gohub protoc imp ../../protocol/*.proto
greeter.service.go 文件内容如下:
package exampleimport ("context""github.com/erda-project/erda-infra/examples/service/protocol/pb"
)type greeterService struct {p *provider
}func (s *greeterService) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloResponse, error) {// TODO: 编写业务逻辑return &pb.HelloResponse{Success: true,Data: "hello " + req.Name,}, nil
}
如此一来,就可以开始在模块里编写我们的业务逻辑了。
我们编写好接口的实现后,就同时拥有了 HTTP 和 gRPC 接口,当然这两种接口是可以选择性暴露的。
那么,刚编写好的模块如何被其他模块所引用呢?
来看下面的例子:
package callerimport ("context""time""github.com/erda-project/erda-infra/base/logs""github.com/erda-project/erda-infra/base/servicehub""github.com/erda-project/erda-infra/examples/service/protocol/pb"
)type config struct {Name string `file:"name" default:"recallsong"`
}type provider struct {Cfg *configLog logs.LoggerGreeter pb.GreeterServiceServer // 由 本地模块 或 远程模块 提供
}// 调用 GreeterService 服务的例子
func (p *provider) Run(ctx context.Context) error {tick := time.NewTicker(3 * time.Second)defer tick.Stop()for {select {case <-tick.C:resp, err := p.Greeter.SayHello(context.Background(), &pb.HelloRequest{Name: p.Cfg.Name,})if err != nil {p.Log.Error(err)}p.Log.Info(resp)case <-ctx.Done():return nil}}
}func init() {servicehub.Register("caller", &servicehub.Spec{Services: []string{},Description: "this is caller example",Dependencies: []string{"erda.infra.example.GreeterService"},ConfigFunc: func() interface{} {return &config{}},Creator: func() servicehub.Provider {return &provider{}},})
}
其中 pb.GreeterServiceServer 是一个由 ProtoBuf 文件生成的接口,调用者无需关心该接口的实现是由本地模块提供还是远程模块提供,这可以通过配置文件来确定。
当它由本地模块提供实现时,会通过接口调用到本地的实现函数;当它是由远程模块提供时,会通过 gRPC 来调用。
例子完整代码:https://github.com/erda-project/erda-infra/tree/master/examples/service。
模块通用化
Erda Infra 提供了许多现成的通用模块,开箱即用。
以上通用模块中,httpserver 这个模块提供了类似于 Spring MVC 中 Controller 的效果,可以写任意参数的处理函数,而不是固定的 http.HandlerFunc 形式。
每个程序可能都需要 health、pprof 等接口,我们只需导入相应的模块,就能拥有这些接口。
另外,开发者们也能开发更多的、分布在不同仓库里的通用业务模块,供其他业务系统使用,能很大程度上提高功能模块的复用性。
总结
Erda Infra 是一个能够快速构建以模块驱动的系统框架、能够解决微服务带来的许多问题。将来,也会有更多的通用模块,来解决不同场景下的问题,能够更大程度地提高开发效率。
关于 Erda 如果你有更多想要了解的内容,欢迎添加小助手微信(Erda202106)进入交流群讨论,或者直接点击下方链接了解更多!
Erda Github 地址:https://github.com/erda-project/erda
Erda Cloud 官网:https://www.erda.cloud/
更多精彩内容,也可扫码也可以加入 GoCN 的大家族哟~
如果群满的话也可以加微信号:gopherWang 加入组织~