我几个月前完全转向Go技术栈,但在架构代码和项目结构方面遇到了很多问题。我研究了多个开源项目,与多个Go开发人员交流,最终找到了一些模板和方法,我正在为所有新项目使用它们。也许这对所有遇到相同问题的人都有帮助!
我将创建一个简单的帐户管理API,并尝试在Go应用程序中应用我的干净架构视野。
什么是干净的架构
这是一种结构化应用程序的方式,使其更简单、更易于开发/扩展/支持和理解。干净的架构通常与其他一些东西有关。
良好的项目结构
当你的项目结构易于理解和遵循时。当你知道代码的每个部分位于何处时。
这是 我在GitHub上的演示项目 的链接。
项目包括以下目录和文件:
api - 这是所有protobuf模式和相关生成(包括DTO)的位置。这与我们应用程序的适配器层有关。我们在此目录中有一个Makefile,以将其与主Makefile分离,并防止开发人员过于频繁地重新生成API。
cmd - 这是我们的主要应用程序所在的位置。
infrastructure - 这是我们有关环境的docker、环境设置的位置。此外,这也是放置IaaC配置、helm文件和其他环境配置的好地方。
internal - 包含应用程序代码。
tests - 用于集成测试。我认为在一个大项目中,只有一个集成测试目录会有所帮助,因为很难将单元测试与集成测试分开。
低耦合和高内聚
干净架构的另一个关键是你的层之间和类型之间的低耦合和高内聚。
应用程序通常分成不同的部分/层。如果这些层之间耦合度低,那么它们就更容易开发/扩展/支持和测试。它们更容易理解(你不需要了解关系,而只需要了解当前文件/部分/层)。分层架构还有助于将相同的内容放在一个层中,并提供高内聚性,将所有相关内容分组。
在基于微服务的架构中,我们通常按域拆分代码。这个示例只涵盖了帐户领域。因此,我们将根据层架构我们的代码结构进行架构设计。
API
API优先方法
我决定在设计我的应用程序时采用API优先方法。API是你系统的单一真相和接口。
由于代码经常由许多人更改,你可能会意外更改某些内容,从而可能破坏你的API。如果API被设计为单个文件/文档,则无意间更改的机会较小。
由于我们使用Go,因此我决定使用gRPC和protobuf编写API,然后从中生成相关代码,并且对于某些外部客户端,我将生成swagger。我不会在这里设置swagger,你可以使用任何OpenAPI客户端读取规范。
这是自动生成的swagger文件的示例,你可以在github上查看它
API能够处理grpc和http请求,并基于grpc-gateway。在更大的系统中,可以使用grpc进行微服务之间的通信,而可以使用http API用于外部客户端。
API与我们应用程序的适配器层相关,并包含我们用于与系统交互的DTO。DTO与我们系统中的域模型无关,可能不同。
版本控制
API应从一开始就进行版本控制,这可以帮助避免以后在项目架构方面出现问题。我们从第一个版本v1开始。
领域
我们架构的核心是我们的领域 - 我们编写代码以处理的业务领域。
域模型不依赖于任何其他圆圈!但是,由于它是我们架构的核心元素,外部层可以引用域模型。
我看到许多开发人员选择这种方式,他们将VO、DB实体、DTO、系统之间的所有接口放在域层,并使该层在整个应用程序中共享。我认为这种方法与共享状态的方式一样糟糕。由于DB实体仅与存储库层(位于适配器内部)相关,因此我们不能从内部层共享对它们的访问权限。
处理程序DTO和接口也是一样的。如果我们从服务层使用存储库,则应该将存储库接口放置在服务层中 - 尽可能靠近消费者(服务本身)。
我们的领域基于无行为的领域模型 - 没有行为的模型,所有行为、业务规则和验证器都移动到更高的服务层。
数据优先方法
我们将与API相同的方法用于我们的数据层 - 我们将直接在数据库中指定我们的数据,并将其用作通道的来源。这将帮助我们摆脱在数据层上意外更改某些结构时出现的问题。要迁移数据,我们将使用db迁移和初始脚本。
这也将在测试中帮助我们,因为我们可以在测试中制作我们的fixture,而这不会影响我们的域对象。
用例
对我来说,这类似于命令模式 - 它应该负责执行系统上要执行的某些操作。
用例不能与其他“用例”交互。但是,它们可以调用多个需要执行其应用程序逻辑的服务。
例如,如果你需要更新用户角色,则可能需要调用UserService,然后调用UserRolesService。
服务
服务位于用例的内部圆圈中。这个层的主要思想是为你的应用程序级业务逻辑(用例)和企业级业务逻辑(存储库)之间提供粘合剂。服务是一个很好的地方,可以共享多个用例之间的公共逻辑 - 这将避免大量的“用例”和复制/粘贴。
服务不应与其他服务交互,应该简单。
服务围绕领域和特定于领域的组织。在我们的示例中,我们将只有一个服务与帐户相关。
在这个示例中,服务看起来只是围绕存储库的一个包装器,但这个应用程序非常基础。在大型项目/应用程序中,此服务可能包含与你的域模型相关的一些附加业务逻辑。
适配器
这是我们架构的外圈。这是一个接口级别 - 在这里,我们设置控制器 - web/grpc服务器,设置处理程序,并使用此层通过存储库连接到数据库。本层的控制器是入站请求的适配器。从基础架构层的调用直接到达控制器层。在这个例子中,控制器被分为API目录和internal/adapters
,但它们处于同一架构层,所以你可以在这里轻松使用grpc请求/响应DTO。
在这里,我们进行所有的请求/响应验证,并将其传递给将处理它的用例。
响应转换和验证也在这个层次上完成。
控制器层需要知道它可以用于进一步请求处理的所有用例。
层间通信
我们需要为应用程序中的所有代码支持适当的分层。请求和响应应该变成控制器(适配器)层,数据库实体应该变成存储库(适配器)层。如果服务层需要访问存储库,则相关接口应该放在服务层上。
这里的主要规则是——所有关系都应该向内部走——意味着外部层知道内部层,但反过来则不行。我们不会将结构体从外部层推向内部层。是的,它们大多数情况下具有相同的数据,但它们的行为可能不同。结构体应该只在它的层内工作,不应该被传递到内部层!外部层只需要纯数据,我们的目标是从内部传递只有原始/纯数据的数据到外部。
这就是所谓的“依赖规则”——内圆应该对外圆一无所知——这意味着我们应该在存储库中将数据库实体转换为域(因为存储库可以了解域对象,但服务不应该了解存储库的详细信息!)。同样,外圆中使用的数据格式不应该被内圆使用,尤其是如果这些格式是由外圆的框架生成的。我们不希望外圆中的任何东西影响内圆。
如我上面提到的——域层在所有层之间共享。
每一层都应该包含它所需要的一切!
请求处理的前向路径总是从外部到域。我们收到请求——处理用例
。如果我们需要一些持久性,那么就用服务
层进行这些更改。
但是当我们需要访问外部圆时,我们将通过接口(低耦合)来完成。我们只在需要时创建接口,并在消费方创建它们。
测试
在干净的架构中,每个层都是独立的,我们可以使用单元测试独立测试它。集成测试放置在tests
文件夹中,以将其与单元测试分开(因为它们需要启动基础设施)。
我们的Makefile包含up
脚本,用于在本地启动应用程序并应用数据库迁移和fixture。这是集成测试的先决条件——我们启动独立服务以及数据库,然后启动我们的集成测试。up
脚本包含实时重新加载功能,因此我们可以与测试同时开发主应用程序。
让我们开始
我们将使用buf生成我们的API
这是我们的.proto定义
syntax = "proto3";
package me.sadensmol.article_my_clean_architecture_go_application.contract.v1;
import "google/protobuf/timestamp.proto";
import "google/api/annotations.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {version: "1.0"};
external_docs: {
url: "http://localhost:8081";
}
schemes: HTTP;
};
message Account {
int64 id = 1;
string name = 2;
AccountStatus status = 3;
google.protobuf.Timestamp opened_date = 4;
google.protobuf.Timestamp closed_date = 5;
AccessLevel access_level = 6;
}
enum AccountStatus {
ACCOUNT_STATUS_UNKNOWN = 0;
ACCOUNT_STATUS_NEW = 1;
ACCOUNT_STATUS_OPEN = 2;
ACCOUNT_STATUS_CLOSED = 3;
}
enum AccessLevel {
ACCOUNT_ACCESS_LEVEL_UNKNOWN = 0;
ACCOUNT_ACCESS_LEVEL_FULL_ACCESS = 1;
ACCOUNT_ACCESS_LEVEL_READ_ONLY = 2;
ACCOUNT_ACCESS_LEVEL_NO_ACCESS = 3;
}
message CreateAccountRequest {
string name =1;
AccessLevel accessLevel =2;
}
message CreateAccountResponse {
int64 id = 1;
}
message GetAccountRequest {
int64 id =1;
}
message GetAccountResponse {
int64 id =1;
string name = 2;
AccountStatus status = 3;
AccessLevel accessLevel =4;
google.protobuf.Timestamp opened_date = 5;
google.protobuf.Timestamp closed_date = 6;
}
service AccountService {
rpc Create(CreateAccountRequest) returns (CreateAccountResponse) {
option (google.api.http) = {
post: "/api/v1/account"
body: "*"
};
}
rpc GetById(GetAccountRequest) returns (GetAccountResponse) {
option (google.api.http) = {
get: "/api/v1/account/{id}"
};
}
}
服务器设置在main.go中完成
在这里,我们创建一个数据库连接,并设置所需的用例和服务/存储库。
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/adapters/controllers"
handler "github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/adapters/repositories"
"log"
"net"
"net/http"
"os"
"github.com/golang/glog"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
gw "github.com/sadensmol/article_my_clean_architecture_go_application/api/proto/v1"
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/services"
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/usecases"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
grpcServerEndpoint = flag.String("grpc-server-endpoint", "localhost:9090", "gRPC server endpoint")
)
func main() {
flag.Parse()
defer glog.Flush()
err := godotenv.Load("infrastructure/local/.env")
if err != nil {
log.Fatal("Error loading .env file")
}
var connectString = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := sql.Open("postgres", connectString)
if err != nil {
log.Fatalf("Failed to connect to database %v", err)
}
defer db.Close()
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatalln("Failed to listen:", err)
}
s := grpc.NewServer()
accountRepository := handler.NewAccountRepository(db)
accountService := services.NewAccountService(accountRepository)
accountUsecases := usecases.NewAccountUsecases(accountService)
accountHandler := controllers.NewAccountHandler(accountUsecases)
gw.RegisterAccountServiceServer(s, accountHandler)
// Serve gRPC server
log.Println("Serving gRPC on 0.0.0.0:9090")
go func() {
log.Fatalln(s.Serve(lis))
}()
// Register gRPC server endpoint
// Note: Make sure the gRPC server is running properly and accessible
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
err = gw.RegisterAccountServiceHandlerFromEndpoint(ctx, mux, *grpcServerEndpoint, opts)
if err != nil {
log.Fatalln("Failed to listen:", err)
}
gwServer := &http.Server{
Addr: ":8090",
Handler: mux,
}
log.Println("Serving gRPC-Gateway on http://0.0.0.0:8090")
log.Fatalln(gwServer.ListenAndServe())
}
Db实体及相关代码是通过go-jet生成并提交到此目录中的
请注意,我们始终应该提交我们的生成代码和项目!
我们的应用程序中有两个用例
package usecases
import (
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/domain"
)
func (a *AccountUsecases) Create(account domain.Account) (*domain.Account, error) {
return a.accountService.Create(account)
}
package usecases
import (
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/domain"
)
func (a *AccountUsecases) GetByID(ID int64) (*domain.Account, error) {
return a.accountService.GetByID(ID)
}
它们只是围绕用于与存储库交互的帐户服务的包装器
package services
import "github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/domain"
type AccountService struct {
accountRepository AccountRepository
}
type AccountRepository interface {
Create(account domain.Account) (*domain.Account, error)
GetByID(ID int64) (*domain.Account, error)
}
func NewAccountService(accountRepository AccountRepository) *AccountService {
return &AccountService{accountRepository: accountRepository}
}
func (s *AccountService) Create(account domain.Account) (*domain.Account, error) {
return s.accountRepository.Create(account)
}
func (s *AccountService) GetByID(ID int64) (*domain.Account, error) {
return s.accountRepository.GetByID(ID)
}
package repositories
import (
"database/sql"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"github.com/sadensmol/article_my_clean_architecture_go_application/db/gen/account/public/model"
"github.com/sadensmol/article_my_clean_architecture_go_application/db/gen/account/public/table"
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/domain"
)
type AccountRepository struct {
db *sql.DB
}
func NewAccountRepository(db *sql.DB) *AccountRepository {
return &AccountRepository{db: db}
}
func (a *AccountRepository) Create(account domain.Account) (*domain.Account, error) {
var savedAccount model.Account
err := table.Account.INSERT(table.Account.Name, table.Account.AccessLevel).
VALUES(account.Name, account.AccessLevel).
RETURNING(table.Account.AllColumns).Query(a.db, &savedAccount)
if err != nil {
return nil, err
}
return RepositoryModelAccount(savedAccount).toDomain(), nil
}
func (a *AccountRepository) GetByID(ID int64) (*domain.Account, error) {
var savedAccounts []model.Account
err := table.Account.SELECT(table.Account.AllColumns).
WHERE(table.Account.ID.EQ(postgres.Int(ID))).
Query(a.db, &savedAccounts)
if err != nil {
return nil, err
}
if len(savedAccounts) == 0 {
return nil, nil
}
if len(savedAccounts) > 1 {
return nil, fmt.Errorf("GetByID returns non unique result")
}
return RepositoryModelAccount(savedAccounts[0]).toDomain(), nil
}
入口点是适配器层上的控制器
package controllers
import (
"context"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/known/timestamppb"
"log"
contractv1 "github.com/sadensmol/article_my_clean_architecture_go_application/api/proto/v1"
"github.com/sadensmol/article_my_clean_architecture_go_application/internal/account/domain"
)
type AccountHandler struct {
accountUsecases AccountUsecases
}
type AccountUsecases interface {
Create(account domain.Account) (*domain.Account, error)
GetByID(ID int64) (*domain.Account, error)
}
func NewAccountHandler(accountUsecases AccountUsecases) *AccountHandler {
return &AccountHandler{accountUsecases: accountUsecases}
}
func (h *AccountHandler) Create(ctx context.Context, request *contractv1.CreateAccountRequest) (*contractv1.CreateAccountResponse, error) {
log.Println("create was called!!!")
account, err := h.accountUsecases.Create(domain.Account{
Name: request.Name,
Status: domain.AccountStatusNew,
AccessLevel: AccountHandlerAccessLevel(request.AccessLevel).ToDomain(),
})
if err != nil {
log.Fatalf("Error occurred %v", err)
}
return &contractv1.CreateAccountResponse{Id: account.ID}, nil
}
func (h *AccountHandler) GetById(ctx context.Context, request *contractv1.GetAccountRequest) (*contractv1.GetAccountResponse, error) {
account, err := h.accountUsecases.GetByID(request.GetId())
if err != nil {
log.Fatalf("Error occurred %v", err)
}
if account == nil {
return nil, protoregistry.NotFound
}
return &contractv1.GetAccountResponse{
Id: account.ID,
Name: account.Name,
Status: AccountStatusFromDomain(account.Status),
AccessLevel: AccountAccessLevelFromDomain(account.AccessLevel),
OpenedDate: timestamppb.New(account.OpenedAt),
ClosedDate: func() *timestamppb.Timestamp {
if account.ClosedAt != nil {
return timestamppb.New(*account.ClosedAt)
} else {
return nil
}
}(),
}, nil
}
这种方法帮助我快速制作应用程序,并在以后没有任何问题的情况下进行维护/支持。
如果你对这个故事的下一部分感兴趣,请告诉我!并且列出你想要我在那里涵盖的问题列表!
谢谢阅读。
第二部分的进一步计划(如果有人感兴趣)
- 添加基于上下文的事务支持
- 移动到更好的http错误处理程序
- 添加linters和.editorconfig
- 与.devcontainers的集成
- 添加适当的日志记录和监控
- 将API重构为pkg文件夹并通过外部库访问它
- 添加其他微服务并显示它们之间的集成
- 添加在测试期间调试主应用程序的能力
资源
有关protobuf的更多信息
用于生成grpc服务和swagger openapi文档的buf cli
GitHub - bufbuild/buf: A new way of working with Protocol Buffers.
受Java的JOOQ启发的真正不错的库(是的,我喜欢它,也在Go世界中错过了它)
GitHub - go-jet/jet: Type safe SQL builder with code generation and automatic query result data…
Grpc网关允许你在GRPC服务上拥有http适配器
这是我为初步启发而使用的好文章:
译自:https://medium.com/@sadensmol/my-clean-architecture-go-application-e4611b1754cb
评论(0)