1

作者|宋瑞国(尘醉)
来源|尔达 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.yaml
helloworld:
    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 protocol
gohub protoc protocol *.proto 

其中 protocol/pb 为协议代码,protocol/client 为客户端代码

第三步,有了协议代码,必然需要去实现对应的服务接口,通过 gohub 生成接口实现的代码模版:

cd server/helloworld
gohub 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)进入交流群讨论,或者直接点击下方链接了解更多!


erda_terminus_io
35 声望6 粉丝