Go语言的简洁架构

2018上海KubeCon

我想告诉你的是什么

目前简洁架构已是众所周知。然而,我们可能无法很好的知道具体实现的细节。

所以我尝试使用gRPC编写一个具有简洁架构意识的例子。

hatajoe/8am

在Github上创建了一个账户用于开发hatajoe/8am。详见:

https://github.com/hatajoe/8am

1

这个小型项目表示用户注册的例子,请随意回复任何内容。

项目结构

8am是基于简介架构的,这个项目的结构如下:

% tree

.

├── Makefile

├── README.md

├── app

│ ├── domain

│ │ ├── model

│ │ ├── repository

│ │ └── service

│ ├── interface

│ │ ├── persistence

│ │ └── rpc

│ ├── registry

│ └── usecase

├── cmd

│ └── 8am

│ └── main.go

└── vendor

├── vendor packages

|...

顶层目录包含3个分支:

app:应用包的根目录

cmd:main包目录

vender:几个依赖包目录

简洁架构有几个概念层如下:

简洁架构有4层,蓝色层、绿色层、红色层和黄色层,顺序如上所示,除了蓝色代表了app目录外,这些层分别代表了:

接口:绿色层

用例:红色层

域:黄色层

关于简洁架构最重要的事就是编写访问层之间的接口。

实体——黄色层

IMO, 实体层在结构层次中更像是domain层。所以我命名这个层为app/domain是为了防止与DDD实体混淆。

app/domain有三个包:

模型:包含聚合、实体和值对象

仓库:聚合的仓库接口

服务:包含依赖于各种模型的应用服务

我解释下对于每个包的执行细节:

模型

模型的用户聚合如下所示:

这里不是实际的聚合,前提是各种实体和值对象将在未来添加。

package model

type User struct {

id string

email string

}

func NewUser(id, email string) *User {

return &User{

id: id,

email: email,

}

}

func (u *User) GetID() string {

return u.id

}

func (u *User) GetEmail() string {

return u.email

}

聚合是事务为了保持他们业务规则的一致性的边界。因此每个聚合会存在一个对应的仓库。

仓库

在仓库层,仓库只是一个接口,是因为仓库无需知道持久化实现的细节。但持久化也是仓库层的重要本质。

用户聚合仓库的实现是:

package repository

import "github.com/hatajoe/8am/app/domain/model"

type UserRepository interface {

FindAll() ([]*model.User, error)

FindByEmail(email string) (*model.User, error)

Save(*model.User) error

}

FindAll获取系统保留的所有用户,持久化保存到系统中。我再说一遍,这一层不应该知道对象在何处保存或序列化。

服务

服务层用于收集业务逻辑,这些业务逻辑不包含在模型中。例如,应用不允许注册存在的邮件地址。如果模型具有此验证,我们会感觉到如下的一些错误:

func (u *User) Duplicated(email string) bool {

// Find user by email from persistence

layer...

}

Duplicated 函数与User模型不相关。为了解决这个,我们可以像下面这样添加服务层:

type UserService struct {

repo repository.UserRepository

}

func (s *UserService) Duplicated(email string) error {

user, err := s.repo.FindByEmail(email)

if user != nil {

return fmt.Errorf("%s already exists",

email)

}

if err != nil {

return err

}

return nil

}

实体通过其它层包含业务逻辑和接口。业务逻辑应该被包含在模型和服务层中,而不应该依赖其他层。如果我们需要访问任何其他层,我们应该使用仓库接口。通过这样的反向依赖,可以使包独立,获得更好的测试和维护。

用例-红色层

用例是应用的一次操作单元。

在8am中,用户列表和用户注册均被定义为用例。

这些用例被如下的接口所代表:

type UserUsecase interface {

ListUser() ([]*User, error)

RegisterUser(email string) error

}

为什么是接口?这是因为用例被使用于接口层——绿色层。如果要在层之间进行访问,我们应该始终定义接口来实现。

UserUsecase的实现很简单,如下:

type userUsecase struct {

repo repository.UserRepository

service *service.UserService

}

func NewUserUsecase(repo repository.UserRepository,

service *service.UserService) *userUsecase {

return &userUsecase {

repo: repo,

service: service,

}

}

func (u *userUsecase) ListUser() ([]*User, error) {

users, err := u.repo.FindAll()

if err != nil {

return nil, err

}

return toUser(users), nil

}

func (u *userUsecase) RegisterUser(email string) error

{

uid, err := uuid.NewRandom()

if err != nil {

return err

}

if err := u.service.Duplicated(email); err != nil

{

return err

}

user := model.NewUser(uid.String(), email)

if err := u.repo.Save(user); err != nil {

return err

}

return nil

}

userUsercase 依赖于两个包,接口repository.UserRepository 和 结构体*service.UserService 。当使用用例的用户,初始化用例时必须引用这两个包。这些独立性通常通过DI容器解决,这将写在后续的条目中。

ListUser用例获取所有注册的用户,如果用户没有被相同的email地址注册时,用RegisterUser将此用户注册到系统中。

一个要点,User不是model.User。model.User可能有很多种业务知识,但是其他层无法知道这些。所以我为用例的用户定义DAO来概括这些知识。

type User struct {

ID string

Email string

}

func toUser(users []*model.User) []*User {

res := make([]*User, len(users))

for i, user := range users {

res[i] = &User{

ID: user.GetID(),

Email: user.GetEmail(),

}

}

return res

}

所以,为什么你认为服务用作具体的实现而不是使用接口?这是因为服务不依赖于其他层。相反的,仓库在各层间访问,依赖于服务的细节不被其他层所知道而实现的,所以仓库被定义为接口。我认为这在架构中是最重要的事情。

接口——绿色层

这一层体现的是具体的对象,如API端点处理程序、RDB的仓库或其他边界的接口。在这种情况下,我添加了2个具体的对象,内存存储访问器和gRPC 服务。

内存存储访问器

我添加了具体的用户仓库作为内存存储访问器。

type userRepository struct {

mu *sync.Mutex

users map[string]*User

}

func NewUserRepository() *userRepository {

return &userRepository{

mu: &sync.Mutex{},

users: map[string]*User{},

}

}

func (r *userRepository) FindAll() ([]*model.User,

error) {

r.mu.Lock()

defer r.mu.Unlock()

users := make([]*model.User, len(r.users))

i := 0

for _, user := range r.users {

users[i] = model.NewUser(user.ID, user.Email)

i++

}

return users, nil

}

func (r *userRepository) FindByEmail(email string)

(*model.User, error) {

r.mu.Lock()

defer r.mu.Unlock()

for _, user := range r.users {

if user.Email == email {

return model.NewUser(user.ID, user.Email),

nil

}

}

return nil, nil

}

func (r *userRepository) Save(user *model.User) error

{

r.mu.Lock()

defer r.mu.Unlock()

r.users[user.GetID()] = &User{

ID: user.GetID(),

Email: user.GetEmail(),

}

return nil

}

这是仓库的具体实现。如果需要在RDB或其他中保存用户,则需要其他的实现方式。但即使在这种情况下,我们不需要改变模型层。模型层依赖于独立的仓库接口,而不关心实现细节。这真惊人。

User被定义为仅在此包适用。这也是为了解决拆分层之间的关系。

type User struct {

ID string

Email string

}

gRPC 服务

我认为gRPC服务也包含在接口层内。

gRPC被定义在如下的app/interface/rpc目录中:

user_service.go 是包装gRPC的端点处理程序:

type userService struct {

userUsecase usecase.UserUsecase

}

func NewUserService(userUsecase usecase.UserUsecase)

*userService {

return &userService{

userUsecase: userUsecase,

}

}

func (s *userService) ListUser(ctx context.Context, in

*protocol.ListUserRequestType)

(*protocol.ListUserResponseType, error) {

users, err := s.userUsecase.ListUser()

if err != nil {

return nil, err

}

res := &protocol.ListUserResponseType{

Users: toUser(users),

}

return res, nil

}

func (s *userService) RegisterUser(ctx

context.Context, in *protocol.RegisterUserRequestType)

(*protocol.RegisterUserResponseType, error) {

if err :=

s.userUsecase.RegisterUser(in.GetEmail()); err != nil

{

return &protocol.RegisterUserResponseType{},

err

}

return &protocol.RegisterUserResponseType{}, nil

}

func toUser(users []*usecase.User) []*protocol.User {

res := make([]*protocol.User, len(users))

for i, user := range users {

res[i] = &protocol.User{

Id: user.ID,

Email: user.Email,

}

}

return res

}

userService 仅依赖于用例接口。

如果你想从其他层中(如CUI)使用用例,你可以实现你想要的接口。

v1.go用于解决使用DI容器的对象依赖关系:

func Apply(server *grpc.Server, ctn

*registry.Container) {

protocol.RegisterUserServiceServer(server,

NewUserService(ctn.Resolve("user-usecase").

(usecase.UserUsecase)))

}

v1.go应用包被从*registry.Container到gRPC服务中检索。

最后,让我们看一下DI容器的实现。

注册表

注册表就是DI容器,用于解决对象间的依赖。

我曾使用github.com/sarulabs/di作为DI容器。

sarulabs/di

go语言(golang)中的依赖注入容器,在Github上创建一个账户用于开发sarulabs/di,详见:

https://github.com/sarulabs/di

1

github.com/surulabs/di可以被随意使用:

type Container struct {

ctn di.Container

}

func NewContainer() (*Container, error) {

builder, err := di.NewBuilder()

if err != nil {

return nil, err

}

if err := builder.Add([]di.Def{

{

Name: "user-usecase",

Build: buildUserUsecase,

},

}...); err != nil {

return nil, err

}

return &Container{

ctn: builder.Build(),

}, nil

}

func (c *Container) Resolve(name string) interface{} {

return c.ctn.Get(name)

}

func (c *Container) Clean() error {

return c.ctn.Clean()

}

func buildUserUsecase(ctn di.Container) (interface{},

error) {

repo := memory.NewUserRepository()

service := service.NewUserService(repo)

return usecase.NewUserUsecase(repo, service), nil

}

上面的例子,我通过使用buildUserUsecase函数将字符串user-usecase与具体的用例实现相结合。所以我们可以在一个注册表中替代任何用例的具体实现。

谢谢您阅读本文,如果您有任何建议和改善,欢迎反馈。

容器时代志愿者招募

如果你对技术懵懵懂懂,想要入门却不知从何下手;

如果你求知若渴,想要学习更多技术、思想;

如果你对于技术有着一种狂热的喜爱并且热爱开源,以其为信仰。

志愿者计划 JOIN US

容器时代志愿编辑

志愿内容

公众号运营 —— 比如晨读文章推荐、周推荐等;(特别欢迎在校大学生)

翻译 —— 容器生态圈相关教程、文章、资讯等的翻译;

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181106B14RSJ00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

同媒体快讯

扫码关注云+社区

领取腾讯云代金券