持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情
今天接着上一篇 解析 Golang 依赖注入经典解决方案 uber/fx 理论篇 继续介绍 uber/fx,我们将会通过 cloudwego社区的Kitex RPC 框架,创建一个实际的项目,并使用 uber/fx 来实现依赖注入。相关的概念这里我们不再重复,对 fx 的定位,能力不清楚的同学可以复习一下理论篇的内容。
相信大家参考真实项目源码,更能理解 fx 提供的强大能力,方便上手使用。源码在learnfx,欢迎自取。
Talk is cheap, show me the code.
项目背景
鉴于这是一个重在展示 DI 能力的项目,我们不希望里面包含太多业务逻辑。整个 learnfx 项目希望包含一个 RPC 的 server 和 client。
核心的实体是 Item,这是一个泛化的概念,包含可见性,每个 Item 都会包含唯一标识 ID,名称,描述,以及对其可见的用户列表(以 int64 的 UserID 来代表)。
我们希望实现两种能力:
- 提供接口创建一个 Item 并落库;
- 基于一个 UserID 和一批 ItemID,过滤出来当前用户可见的 Item 列表。
项目 server 的接口我们通过 Thrift IDL 来进行定义:
namespace go learn.fx.item
struct FilterVisibleItemsReq {
1: required i64 UserID
2: required list<i64> ItemIDs
}
struct FilterVisibleItemsResp {
1: required list<i64> VisibleItemIDs
}
struct Item {
1: required i64 ID
2: required string Name
3: required string Desc
4: required list<i64> VisibleUsers
}
struct CreateItemReq {
1: required Item Item
}
struct CreateItemResp {
1: required i64 ID
}
service LearnFxService {
CreateItemResp CreateItem(1: CreateItemReq req)
FilterVisibleItemsResp FilterVisibleItems(1: FilterVisibleItemsReq req)
}
ok,接口描述清楚了,那我们是不是要开整 Kitex 了?
No!先别慌着考虑 RPC 的事。做任何一个项目,先思考的一定是你的领域模型,你的核心能力。至于这是个 RPC 服务,还是个 HTTP 服务,亦或者需要通过 MQ 来体现能力,都是细节。这是最外层的事。
我们的 learnfx 项目会遵循整洁架构(Clean Architecture)的思路,从 domain 层开始定义模型和接口,infrastructure 层进行实现,并在最外层实现一个 handler 提供能力。然后我们才会考虑怎样通过 RPC 框架来暴露出来接口。
区分清楚什么是本质,什么是细节,这一点很重要。
domain
鉴于我们的模型非常简单,这里其实更多的是展示一下一个比较规范的架构分层。
首先在项目根目录 mkdir domain,新建一个 domain package,这是我们的起点(用什么存储,用什么框架,都是细节)
我们希望在 domain 中定义好业务的领域模型,业务的存储接口,业务的对外能力。所以,我们把 domain 包拆分为三个子包:
- entity: 核心领域模型(贫血或者充血都可以,看业务团队的能力,做好一个充血模型并不简单,需要很好的设计。建议大家一开始先做成贫血的,如有需要再慢慢补充能力,做成充血的)。在这次项目背景下,其实非常简单,我们只需要维护一个最简单的 Item 结构体即可:
package entity
type Item struct {
ID int64
Name string
Desc string
VisibleUsers []int64
}
- repository: 模型的仓库,CRUD 方法的接口定义。本次我们希望提供创建 Item 的能力,以及批量通过 ID 查询 Item 的能力,定义接口如下:
package repository
import (
"context"
"github.com/ag9920/learnfx/domain/entity"
)
type ItemRepo interface {
CreateItem(ctx context.Context, item *entity.Item) (int64, error)
BatchQueryItemByID(ctx context.Context, itemIDs []int64) ([]*entity.Item, error)
}
- service: 领域方法的接口定义,以及实现。把领域层做厚,应用层做薄是很重要的,我们的领域方法希望暴露两个能力:
- 创建一个新的 Item;
- 根据 UserID 和一批 ItemID 过滤出用户可见的 Item。
package service
import (
"context"
"fmt"
"time"
"github.com/ag9920/learnfx/domain/entity"
"github.com/ag9920/learnfx/domain/repository"
)
type ItemDomainService interface {
CreateItem(ctx context.Context, item *entity.Item) (int64, error)
FilterVisibleItems(ctx context.Context, itemIDs []int64, userID int64) ([]int64, error)
}
type ItemDomainServiceImpl struct {
ItemRepo repository.ItemRepo
}
var _ ItemDomainService = new(ItemDomainServiceImpl)
func NewItemDomainServiceImpl(repo repository.ItemRepo) ItemDomainService {
return &ItemDomainServiceImpl{
ItemRepo: repo,
}
}
func (i *ItemDomainServiceImpl) CreateItem(ctx context.Context, item *entity.Item) (int64, error) {
if item.ID == 0 {
item.ID = time.Now().Unix()
}
return i.ItemRepo.CreateItem(ctx, item)
}
func (i *ItemDomainServiceImpl) FilterVisibleItems(ctx context.Context, itemIDs []int64, userID int64) ([]int64, error) {
items, err := i.ItemRepo.BatchQueryItemByID(ctx, itemIDs)
if err != nil {
return nil, fmt.Errorf("ItemRepo.BatchQueryItemByID failed, err=%w", err)
}
result := make([]int64, 0, len(items))
for _, item := range items {
var isVisible bool
for _, visibleUserID := range item.VisibleUsers {
if userID == visibleUserID {
isVisible = true
break
}
}
if isVisible {
result = append(result, item.ID)
}
}
return result, nil
}
infra
domain 层定义存储接口,infra 层提供具体接口的实现,这样完成解耦,就可以做到我们一期基于 domain 定义的能力来开发,依赖接口,完全想清楚了再实现细节。无论你用 MySQL, PostgreSQL,ES, Redis,甚至内存,这里都是可以替换的。你的业务的能力不应该强依赖某个存储组件或框架。
为此,我们需要实现 domain/repository 下的接口定义:
type ItemRepo interface {
CreateItem(ctx context.Context, item *entity.Item) (int64, error)
BatchQueryItemByID(ctx context.Context, itemIDs []int64) ([]*entity.Item, error)
}
ok,鉴于我们只是 demo 一下依赖注入,item 的存储这里不希望搞的太复杂,直接用内存里一个 map 来存即可。
首先我们还是新建一个 infra 目录,并在其中新建一个 map_store.go 文件,实现我们的底层 map 存储:
package infra
import (
"github.com/ag9920/learnfx/domain/entity"
)
type ItemMapStore map[int64]*entity.Item
func NewItemMapStore() ItemMapStore {
return make(map[int64]*entity.Item)
}
func (s ItemMapStore) StoreItem(item *entity.Item) (id int64, err error) {
s[item.ID] = item
return item.ID, nil
}
func (s ItemMapStore) FindItemByID(id int64) *entity.Item {
return s[id]
}
这里引用了 domain entity 中对 Item 的定义,事实上,我们通常会专门定义一个 Persistent Object,作为我们通过 ORM 往底层关系型数据库存储的模型,这里比较简单,就直接依赖了。
domain 不应该感知到外层的逻辑,但 infra 是对 domain 的实现,这里是可以感知的,符合从同心圆内指向外的原则。
下面我们还需要实现 ItemRepo 接口,我们继续在 infra 包内新建 repo_impl.go 文件:
package infra
import (
"context"
"github.com/ag9920/learnfx/domain/entity"
"github.com/ag9920/learnfx/domain/repository"
)
type ItemRepoImpl struct {
ItemStore ItemMapStore
}
var _ repository.ItemRepo = new(ItemRepoImpl)
func NewItemRepoImpl(store ItemMapStore) repository.ItemRepo {
return &ItemRepoImpl{
ItemStore: store,
}
}
func (i *ItemRepoImpl) CreateItem(ctx context.Context, item *entity.Item) (int64, error) {
return i.ItemStore.StoreItem(item)
}
func (i *ItemRepoImpl) BatchQueryItemByID(ctx context.Context, itemIDs []int64) ([]*entity.Item, error) {
result := make([]*entity.Item, 0, len(itemIDs))
for _, itemID := range itemIDs {
item := i.ItemStore.FindItemByID(itemID)
if item != nil {
result = append(result, item)
}
}
return result, nil
}
这样就能使用 ItemRepoImpl 这个结构来作为 ItemRepo 依赖返回。同时它也依赖了我们前面定义的 ItemMapStore。随后我们会展示怎样实现依赖注入。
handler
有了 domain 和 infra,其实我们就可以依赖 domain service 的能力来提供服务了,所以,我们在项目最外层新建一个 handle.go 文件,用来放对外接口:
type LearnFxService interface {
CreateItem(ctx context.Context, req *CreateItemReq) (r *CreateItemResp, err error)
FilterVisibleItems(ctx context.Context, req *FilterVisibleItemsReq) (r *FilterVisibleItemsResp, err error)
}
这里的 LearnFxService 是一开始我们对服务能力的定义。实现起来也很简单,核心逻辑都在 domain service 提供好了:
type LearnFxServiceImpl struct {
ItemDomainService service.ItemDomainService
}
func NewLearnFxServiceImpl(s service.ItemDomainService) item.LearnFxService {
return &LearnFxServiceImpl{
ItemDomainService: s,
}
}
func (i *LearnFxServiceImpl) FilterVisibleItems(ctx context.Context, req *item.FilterVisibleItemsReq) (*item.FilterVisibleItemsResp, error) {
visibleItemIDs, err := i.ItemDomainService.FilterVisibleItems(ctx, req.ItemIDs, req.UserID)
if err != nil {
return nil, err
}
resp := &item.FilterVisibleItemsResp{
VisibleItemIDs: visibleItemIDs,
}
return resp, nil
}
func (i *LearnFxServiceImpl) CreateItem(ctx context.Context, req *item.CreateItemReq) (resp *item.CreateItemResp, err error) {
itemID, err := i.ItemDomainService.CreateItem(ctx, &entity.Item{
ID: req.Item.ID,
Name: req.Item.Name,
Desc: req.Item.Desc,
VisibleUsers: req.Item.VisibleUsers,
})
if err != nil {
return nil, err
}
resp = &item.CreateItemResp{
ID: itemID,
}
return resp, nil
}
Kitex
好了,现在我们有项目的骨架了,也有外层的 LearnFxServiceImpl 作为对 LearnFxService 接口的实现。
接下来,我们基于 Kitex 的脚手架来生成框架所需的代码即可。这部分不是我们的重点,对 Kitex 不熟悉的同学可以参照 Getting Started 了解一下。
- 安装 kitex codegen 工具
go install github.com/cloudwego/kitex/tool/cmd/kitex@latest
- 安装 thriftgo
go install github.com/cloudwego/thriftgo@latest
-
将一开始定义的 Thrift IDL 放到项目的根目录:learn_fx.thrift
-
在 learnfx 项目根目录,执行 kitex 命令,根据 IDL 来生成定义。
kitex -service learn.fx.item learn_fx.thrift
你会发现,项目里多了 build.sh,以及 kitex_gen 目录,这里就有我们的 item 以及各个接口定义。
server
我们需要使用"github.com/ag9920/learnfx/kitex_gen/learn/fx/item/learnfxservice" 中的learnfxservice.NewServer 方法来生成一个 server.Server 对象,并调用 Run 方法,启动 server。
如果随后需要停止,同样是调用 server.Server 对象的 Stop 方法来实现。
这里我们暂时不用太在意,随后 fx 注入的部分会重点看一下怎么做。
client
项目根目录新建一个 client 包,由于只是 demo 一下 client 能够运行,这里并不是必须的,大家也可以在其他地方实现 client,新建一个 main.go,填充以下内容:
package main
import (
"context"
"log"
"time"
"github.com/cloudwego/kitex/client"
"github.com/ag9920/learnfx/kitex_gen/learn/fx/item"
"github.com/ag9920/learnfx/kitex_gen/learn/fx/item/learnfxservice"
)
func main() {
client, err := learnfxservice.NewClient("learn fx client", client.WithHostPorts("[::1]:8888"))
if err != nil {
log.Fatal(err)
}
// insert item
req := &item.CreateItemReq{
Item: &item.Item{
ID: 9324359,
Name: "test name",
Desc: "test desc",
VisibleUsers: []int64{
1234,
},
},
}
resp, err := client.CreateItem(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Default().Printf("create item id=%v", resp.ID)
// query visible item
for {
req := &item.FilterVisibleItemsReq{
UserID: 1234,
ItemIDs: []int64{
9324359,
23435423,
},
}
resp, err := client.FilterVisibleItems(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println(resp)
time.Sleep(time.Second)
}
}
这样我们就实现了个简易的 learnfx 服务的客户端。
在 client 包下执行 go run main.go 就能向本机 8888 端口的 server 发出请求。
fx 注入
好了,前面说了这么多,都是为了最后一步做铺垫。
我们有 LearnFxServiceImpl 作为整体服务的实现,有 ItemDomainServiceImpl 作为 ItemDomainService 的实现,以及 ItemRepoImpl 作为 ItemRepo 的实现。
怎样使用 fx 串联起来呢?
1. 补充构造器
理论篇里面我们说过,fx 是基于构造器来提供能力的,所以我们必须保证所有涉及到的依赖,都有对应的构造器。这里就直接在各个结构定义的地方补充即可:
func NewLearnFxServiceImpl(s service.ItemDomainService) item.LearnFxService {
return &LearnFxServiceImpl{
ItemDomainService: s,
}
}
func NewItemDomainServiceImpl(repo repository.ItemRepo) ItemDomainService {
return &ItemDomainServiceImpl{
ItemRepo: repo,
}
}
func NewItemRepoImpl(store ItemMapStore) repository.ItemRepo {
return &ItemRepoImpl{
ItemStore: store,
}
}
type ItemMapStore map[int64]*entity.Item
func NewItemMapStore() ItemMapStore {
return make(map[int64]*entity.Item)
}
注意,需要什么依赖,就传入什么依赖,最底层的 ItemMapStore 直接就可以构建。
此外需要注意,Kitex 需要我们构建的 server.Server 也可以通过 fx.Provide 来提供依赖:
func NewRpcServer(rpcSrv item.LearnFxService) server.Server {
return learnfxservice.NewServer(rpcSrv)
}
2. 新建 fx.Module 管理
我们完全可以直接 fx.Provide,但一旦项目复杂,这样会导致代码可读性很差。所以,我们基于 domain 和 infra 都新建自己的 module.go 收敛 fx 的依赖。
- domain
package domain
import (
"go.uber.org/fx"
"github.com/ag9920/learnfx/domain/service"
)
var Module = fx.Module("domain",
fx.Provide(service.NewItemDomainServiceImpl),
)
- infra
package infra
import "go.uber.org/fx"
var Module = fx.Module("infra",
fx.Provide(NewItemMapStore),
fx.Provide(NewItemRepoImpl),
)
3. 通过 hook 实现服务触发器
目前结合 domain 和 infra 的两个 module,我们的 fx 容器类似这样:
fx.New(
domain.Module,
infra.Module,
fx.Provide(NewRpcServer, NewLearnFxServiceImpl),
).Run()
但有没有发现问题?
缺了 Invoker!
这些 Provider 都是懒加载的,只是这样 Provide 出来是不会触发运行的,即便调用了 Run() 也没用。
我们需要一个 invoke 函数,并通过 fx 应用的 OnStart hook 来启动 Kitex RPC server:
func startServer(svr server.Server, lc fx.Lifecycle) {
// 通过lifecycle异步启动,不然invoke执行在onstart之前
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
fmt.Println("start rpc server")
// 异步启动避免阻塞fx启动,依赖srv.Run的panic
go func() {
if err := svr.Run(); err != nil {
fmt.Printf("fail to run server: %v", err)
panic(err)
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
fmt.Println("shutdown rpc server")
return svr.Stop()
},
})
}
这里我们先新建了 startServer 函数,从 fx 接收一个 Lifecycle 对象,这里可以 Append 我们指定的钩子函数。
逻辑很简单,我们希望 fx 在注入完依赖后,调用 server.Server 的 Run 方法启动,在 fx 应用结束,或者依赖有问题的时候,关闭 server,这部分逻辑收在了 OnStop hook 中。
4. 通过 fx.New 创建容器
更新下根目录 main.go 里面 fx 容器的启动逻辑如下:
package main
import (
"go.uber.org/fx"
"github.com/ag9920/learnfx/domain"
"github.com/ag9920/learnfx/infra"
)
func main() {
fx.New(
domain.Module,
infra.Module,
fx.Provide(NewRpcServer, NewLearnFxServiceImpl),
fx.Invoke(startServer),
).Run()
}
这样就够了,不需要更多代码。
当运行服务时,fx.New 会遍历所有 Provider 和 Invoker,明确需要哪些依赖,构建 dependency graph,最终构建出来一个依赖完备的 server.Server 传给我们的 Invoker,也就是 startServer 函数,启动整个应用。
验证效果
我们在根目录执行 ./build.sh 对项目进行编译,在生成 output 产物后。
执行 ./output/bootstrap.sh ./output 命令,观察 console 输出:
$ ./output/bootstrap.sh output
[Fx] PROVIDE server.Server <= main.NewRpcServer()
[Fx] PROVIDE item.LearnFxService <= main.NewLearnFxServiceImpl()
[Fx] PROVIDE service.ItemDomainService <= github.com/ag9920/learnfx/domain/service.NewItemDomainServiceImpl() from module "domain"
[Fx] PROVIDE infra.ItemMapStore <= github.com/ag9920/learnfx/infra.NewItemMapStore() from module "infra"
[Fx] PROVIDE repository.ItemRepo <= github.com/ag9920/learnfx/infra.NewItemRepoImpl() from module "infra"
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE main.startServer()
[Fx] HOOK OnStart main.startServer.func1() executing (caller: main.startServer)
start rpc server
[Fx] HOOK OnStart main.startServer.func1() called by main.startServer ran successfully in 3.791µs
[Fx] RUNNING
2022/10/13 21:33:30.428283 server.go:81: [Info] KITEX: server listen at addr=[::]:8888
这里可以看到,我们的 server.Server,item.LearnFxService,service.ItemDomainService,infra.ItemMapStore,repository.ItemRepo 这些依赖都被成功注入。
invoke 方法也在 start 的时候被调用,启动了 KITEX server。
下面我们进入 client 目录,执行 go run main.go 测试一下:
$ go run main.go
2022/10/13 21:33:42 create item id=9324359
2022/10/13 21:33:42 FilterVisibleItemsResp({VisibleItemIDs:[9324359]})
2022/10/13 21:33:43 FilterVisibleItemsResp({VisibleItemIDs:[9324359]})
2022/10/13 21:33:44 FilterVisibleItemsResp({VisibleItemIDs:[9324359]})
2022/10/13 21:33:45 FilterVisibleItemsResp({VisibleItemIDs:[9324359]})
^Csignal: interrupt
可以看到,我们创建的 item 在随后被查询出来,符合预期。
现在我们通过 ctrl+c 来停止 server,看看会不会触发 OnStop 回调。
^C[Fx] INTERRUPT
[Fx] HOOK OnStop main.startServer.func2() executing (caller: main.startServer)
shutdown rpc server
[Fx] HOOK OnStop main.startServer.func2() called by main.startServer ran successfully in 2.827µs
完美符合预期,可以看到这里调用了 startServer 中的 hook 定义。
总结
fx 的这两篇介绍到此就结束了,整体来看,fx 的 DI 能力是完全够用的,而且配合 lifecycle 的能力,使得 fx 和我们的业务应用更能贴合在一起。建议大家 clone 下来我们的实战仓库,自己体验一下。
也欢迎大家补充更多高阶用法。这篇实战限于篇幅只介绍了比较基础的能力。事实上我们还可以体验更多,比如同类型多次注入用到的 Annotate 能力,fxtest 和 Populate 对 mock 的支持。
感谢阅读!欢迎评论区交流!