首页
Preview

我的 Go 清洁架构应用程序

我几个月前完全转向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生成并提交到此目录中的

https://github.com/sadensmol/article_my_clean_architecture_go_application/tree/master/db/gen/account/public

请注意,我们始终应该提交我们的生成代码和项目!

我们的应用程序中有两个用例

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的更多信息

Language Guide (proto 3)

用于生成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适配器

gRPC-Gateway

这是我为初步启发而使用的好文章:

Clean Architecture with GO

译自:https://medium.com/@sadensmol/my-clean-architecture-go-application-e4611b1754cb

版权声明:本文内容由TeHub注册用户自发贡献,版权归原作者所有,TeHub社区不拥有其著作权,亦不承担相应法律责任。 如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

点赞(0)
收藏(0)
菜鸟一只
你就是个黄焖鸡,又黄又闷又垃圾。

评论(0)

添加评论