写点什么

为构建大型复杂系统而生的微服务框架 Erda Infra

用户头像
尔达Erda
关注
发布于: 5 小时前


作者|宋瑞国(尘醉)

来源|尔达 Erda 公众号


导读:Erda Infra 微服务框架是从 Erda 项目演进而来,并且完全开源。Erda 基于 Erda Infra 框架完成了大型复杂项目的构建。本文将全面、深入地剖析 Erda Infra 框架的架构设计以及如何使用。


背景

在互联网技术高速发展的浪潮中,众多的大型系统慢慢从单体应用演变为微服务化系统。​

单体应用

单体应用的优势是开发快速、部署简单,我们不需要考虑太多就能快速构建出应用,很快地上线产品。​然而,随着业务的发展,单体程序慢慢变得复杂混乱,非常容易改出 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 example
import ( "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 main
import ( _ ".../example" // your package import path "github.com/erda-project/erda-infra/base/servicehub")
func main() { servicehub.Run(&servicehub.RunOptions{ ConfigFile: "example.yaml", })}package main
import ( "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.yamlhelloworld:  message: "erda"
复制代码



提示:当然这里也可以内置配置,可以参考 servicehub.RunOptions 里的定义。


这种方式的优点有以下几点:​


  • 面向微模块编程,只需要关心自身的功能,更容易做到高内聚、低耦合。

  • 声明式的配置定义,无需关心配置读取的步骤,框架实现多种方式的配置读取。

  • 无需关心其他模块如何初始化,也无需关心整个应用的初始化顺序,只需要专注自身的初始化步骤。

  • 异步任务的管理,框架会处理 进程信号,优雅的关闭模块任务。

  • 系统高度可配置,任意模块都可独立配置,并且可以单独启动某个模块进行调试。

模块间的依赖

正如微服务所面临的问题之一,服务之间有着复杂的调用,客观上存在着依赖关系。我们将功能模块化之后,该如何解决模块之间的依赖关系。​Erda Infra 给我们提供了依赖注入的方式,在介绍依赖注入之前,我们先了解一些概念:​


  • Service,代表一种可以供其他模块或其他系统使用的功能。

  • Provider,代表服务的提供者,提供 0 个或多个服务,相当于一个模块,一组服务集合。

  • 一个 Provider 可以依赖 0 个或多个 Service。


我们可以在 Provider 上定义所依赖的 Service 类型作为一个字段,框架会自动将依赖的 Service 实例注入。​例如,我们定义一个模块 2 来引用上一节定义的 helloword 模块所提供的 helloworld-service 服务:​


package example2
// 以下省略号非关键代码
import ( ".../example" // your package import path "github.com/erda-project/erda-infra/base/servicehub")
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 hello rpc 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 protocolgohub protoc protocol *.proto 
复制代码



其中 protocol/pb 为协议代码,protocol/client 为客户端代码​第三步,有了协议代码,必然需要去实现对应的服务接口,通过 gohub 生成接口实现的代码模版:​


cd server/helloworldgohub protoc imp ../../protocol/*.proto
复制代码



greeter.service.go 文件内容如下:


package example
import ( "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 caller
import ( "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 *config Log logs.Logger Greeter 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)进入交流群讨论,或者直接点击下方链接了解更多!​


发布于: 5 小时前阅读数: 7
用户头像

尔达Erda

关注

还未添加个人签名 2021.06.16 加入

还未添加个人简介

评论

发布
暂无评论
为构建大型复杂系统而生的微服务框架 Erda Infra