整洁架构是现现在是很是知名的架构了。然而咱们也许并不太清楚实现的细节。 所以我试着创造一个有着整洁架构的使用 gRPC 的 Go 项目。前端
这个小巧的项目是个用户注册的例子。请随意在本文下面回复。android
8am 基于整洁架构,项目结构以下。ios
% tree
.
├── Makefile
├── README.md
├── app
│ ├── domain
│ │ ├── model
│ │ ├── repository
│ │ └── service
│ ├── interface
│ │ ├── persistence
│ │ └── rpc
│ ├── registry
│ └── usecase
├── cmd
│ └── 8am
│ └── main.go
└── vendor
├── vendor packages
|...
复制代码
最外层目录包括三个文件夹:git
整洁架构有一些概念性的层次,以下所示:github
一共有 4 层,从外到内分别是蓝色,绿色,红色和黄色。我把应用目录表示为除了蓝色以外的三种颜色:golang
整洁架构最重要的就是让接口穿过每一层。数据库
在我看来, 实体层就像是分层架构里的领域层。 所以为了避变和领域驱动设计里的实体概念弄混,我把这一层叫作应用/领域层。后端
应用/领域包括三个包:缓存
我将会解释每个包的实现细节。bash
模型包含以下用户聚合:
这并非真正的聚合,可是我但愿大家能够未来在本地运行的时候,加入各类各样的实体和值对象。
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 获取了系统里全部被保存的用户。Save 则是把用户保存到系统中。我再次强调,这一层不该该知道对象被保存或者序列化到哪里了。
服务层是不该该包含在模型层中的业务逻辑集合。举个例子,该应用不容许任何已经存在的邮箱地址注册。若是这个验证在模型层作,咱们就发现以下的错误:
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 依赖两个包。UserRepository 接口和 service.UserService 结构体。当使用者初始化用例时,这两个包必须被注入。一般这些依赖都是经过依赖注入容器解决,这个后文会提到。
ListUser 这个用例会取到全部已经注册的用户,RegisterUser 用例是若是一样的邮箱地址没有被注册的话,就用该邮箱把新用户注册到系统。
有一点要注意,User 不一样于 model.User. model.User 也许包含不少业务逻辑,可是其余层最好不要知道这些具体逻辑。因此我为用例 users 定义了 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 接口,关系型数据库的存储库或者其余接口的边界的具体对象。在本例中,我加了两个具体物件,内存存取器和 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
}
复制代码
这是存储库的具体实现。若是咱们想要把用户保存到数据库或者其余地方的话,须要实现一个新的存储库。尽管如此,咱们也不须要修改模型层。这太神奇了。
User 只在这个包里定义。这也是为了解决不一样层之间解封业务逻辑的问题。
type User struct {
ID string
Email string
}
复制代码
我认为 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 仅依赖用例接口。
若是你想使用其它层(如:GUI)的用例,你能够按照你的方式实现这个接口。
v1.go
是使用依赖注入容器的对象依赖性解析器:
func Apply(server *grpc.Server, ctn *registry.Container) {
protocol.RegisterUserServiceServer(server, NewUserService(ctn.Resolve("user-usecase").(usecase.UserUsecase)))
}
复制代码
v1.go
把从 registry.Container 取回的包应用在 gRPC 服务上。
最后,让咱们看看依赖注入容器的实现。
注册是解决对象依赖性的依赖注入容器。 我用的依赖注入容器是 github.com/sarulabs/di…
sarulabs/di: go (golang) 的依赖注入容器。请注册 GitHub 帐号来为 sarulabs/di 开发作贡献
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
和具体的用例实现联系起来。这样咱们只要在一个地方注册,就能够替换掉任何用例的具体实现。
感谢你读完了这篇入门。欢迎提出宝贵意见。若是你有任何想法和改进建议,请不吝赐教!
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。