2018上海KubeCon
Kubernetes的全球盛会KubeCon将于11月13日~11月15日在中国上海隆重举行,此论坛汇集了众多在开源和云原生领域有卓越贡献的应用人员和技术专家。大会吸引了超过5000名行业精英前来参会,大家齐聚一堂相互分享经验,聚焦创新,并讨论云原生计算的未来。KubeCon + CloudNativeCon中国论坛将召开100多个分组会议,包括技术会议、深度学习、案例研究等。现在通过容器时代专属报名通道报名可以享受超大折扣哦,详情请戳此处链接:【容器时代粉丝专属福利】KubeCon + CloudNativeCon门票惊喜折扣
我想告诉你的是什么
目前简洁架构已是众所周知。然而,我们可能无法很好的知道具体实现的细节。
所以我尝试使用gRPC编写一个具有简洁架构意识的例子。
在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目录中:
% tree
.
├── rpc.go
└── v1.0
├── protocol
│ ├── user_service.pb.go
│ └── user_service.proto
├── user_service.go
└── v1.go
protocol 目录包含协议缓冲区 DSL文件(user_service.proto)及产生的RPC服务代码(user_service.pb.go)。
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容器。
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与具体的用例实现相结合。所以我们可以在一个注册表中替代任何用例的具体实现。
谢谢您阅读本文,如果您有任何建议和改善,欢迎反馈。
容器时代志愿者招募
如果你对技术懵懵懂懂,想要入门却不知从何下手;
如果你求知若渴,想要学习更多技术、思想;
如果你对于技术有着一种狂热的喜爱并且热爱开源,以其为信仰。
容器时代志愿编辑
志愿内容
公众号运营 —— 比如晨读文章推荐、周推荐等;(特别欢迎在校大学生)
翻译 —— 容器生态圈相关教程、文章、资讯等的翻译;
点击阅读原文即可加入,加入之后还有神秘福利等着你呦~
译者:米线
校对:立尧
编辑:米线
有疑问加站长微信联系(非本文作者)