在本文中,我们将讨论如何在Go语言的后端项目中实现干净的架构。
项目链接:Go后端干净的架构 https://github.com/amitshekhariitbhu/go-backend-clean-architecture。
在创建这个项目之前,我查看了GitHub上与Go干净架构相关的20多个项目。感谢所有这些项目,我从中学到了很多。正如我一直说的:
“学习编程最好的方法就是写代码。但要写出好的代码,你也必须阅读好的代码。养成读好代码的习惯。你可以在GitHub上找到许多开源项目并开始阅读。”
然后,在实现部分中,我结合了所有的想法、经验和从这些项目中学到的东西来创建这个项目。像往常一样,我很乐意听取有关我的项目的反馈。这对每个人,尤其是我自己,都非常有帮助。
由于我已经实现了干净的架构,因此在项目中创建了以下层:
- Router
- Controller
- Usecase
- Repository
- Domain
下面是用Go语言编写的后端项目的完整架构:
** 为什么我们要在项目中考虑Clean Architecture
呢?**
Clean Architecture
为软件应用程序提供了一系列好处,比如:
- 独立于框架:如果必要,更容易用另一个包替换一个包,因为所有东西都是解耦的。例如,我们可以更改我们使用的数据库包,或者如果需要,添加另一个包。
- 高度可测试:更容易编写测试。我已经为用例、存储库和控制器层编写了测试。
- 添加新功能变得容易。
- 容易修改代码以进行任何所需的更改。
由于此项目遵循干净的架构原则,因此你可以非常容易地使用最适合你要求的包替换它们。但是,我使用的主要包如下:
- gin:Gin是用Go(Golang)编写的HTTP Web框架。它具有类似Martini的API,但性能更好-高达40倍。如果你需要惊人的性能,那么就用Gin吧。
- mongo go driver:MongoDB的官方Golang驱动程序。
- jwt:JSON Web Token是一种开放的、行业标准的RFC 7519方法,用于在两个方之间安全地表示声明。用于访问令牌和刷新令牌。
- viper:用于从“.env”文件加载配置。带有尖牙的Go配置。查找、加载和取消解组JSON、TOML、YAML、HCL、INI、envfile或Java属性格式的配置文件。
- bcrypt:bcrypt包实现了Provos和Mazières的bcrypt自适应哈希算法。
- testify:一个工具包,具有常见的断言和模拟,可以与标准库很好地配合使用。
- mockery:用于测试中的Golang模拟代码自动生成器。
- 你还可以在
go.mod
中查看更多包。
现在,让我们从路由器开始讨论所有创建的层。
Router
首先,请求到达路由器。然后,将其分成两个路由器,如下所示:
- 公共路由器:所有公共API都应通过此路由器。
- 受保护的路由器:所有私有API都应通过此路由器。
公共 API请求流程:
私人 API请求流程:
在两个路由器之间添加了一个中间件来检查访问令牌的有效性。因此,带有无效访问令牌的私有请求根本不应到达受保护的路由器。
然后,它分发到相应的路由器。你可以查看以下代码以了解:
package route
import (
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/api/middleware"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/bootstrap"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo"
"github.com/gin-gonic/gin"
)
func Setup(env *bootstrap.Env, timeout time.Duration, db mongo.Database, gin *gin.Engine) {
publicRouter := gin.Group("")
// All Public APIs
NewSignupRouter(env, timeout, db, publicRouter)
NewLoginRouter(env, timeout, db, publicRouter)
NewRefreshTokenRouter(env, timeout, db, publicRouter)
protectedRouter := gin.Group("")
// Middleware to verify AccessToken
protectedRouter.Use(middleware.JwtAuthMiddleware(env.AccessTokenSecret))
// All Private APIs
NewProfileRouter(env, timeout, db, protectedRouter)
NewTaskRouter(env, timeout, db, protectedRouter)
}
然后,路由器将调用其相应的控制器。为了调用控制器,我们需要用例,因为控制器依赖于用例。我们还需要存储库,因为用例依赖于存储库。现在,我们有存储库,我们将其传递给用例。之后,我们有了用例,我们将其传递给控制器。最后,我们的控制器已准备好在路由器中使用。示例代码:
package route
import (
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/api/controller"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/bootstrap"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/repository"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/usecase"
"github.com/gin-gonic/gin"
)
func NewTaskRouter(env *bootstrap.Env, timeout time.Duration, db mongo.Database, group *gin.RouterGroup) {
tr := repository.NewTaskRepository(db, domain.CollectionTask)
tc := &controller.TaskController{
TaskUsecase: usecase.NewTaskUsecase(tr, timeout),
}
group.GET("/task", tc.Fetch)
group.POST("/task", tc.Create)
}
每个后端请求最终都由控制器执行。定义一个路由列表,将给定的请求映射到控制器和操作。
Controller
现在,请求已经到达控制器。首先,它将验证请求中的数据。如果有任何无效内容,则返回“400 Bad Request”作为错误响应。如果请求中的所有内容都有效,则将调用用例层执行操作。示例代码:
package controller
import (
"net/http"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type TaskController struct {
TaskUsecase domain.TaskUsecase
}
func (tc *TaskController) Create(c *gin.Context) {
var task domain.Task
err := c.ShouldBind(&task)
if err != nil {
c.JSON(http.StatusBadRequest, domain.ErrorResponse{Message: err.Error()})
return
}
userID := c.GetString("x-user-id")
task.ID = primitive.NewObjectID()
task.UserID, err = primitive.ObjectIDFromHex(userID)
if err != nil {
c.JSON(http.StatusBadRequest, domain.ErrorResponse{Message: err.Error()})
return
}
err = tc.TaskUsecase.Create(c, &task)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, domain.SuccessResponse{
Message: "Task created successfully",
})
}
func (u *TaskController) Fetch(c *gin.Context) {
userID := c.GetString("x-user-id")
tasks, err := u.TaskUsecase.FetchByUserID(c, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, tasks)
}
Usecase
用例层依赖于存储库层。此层使用存储库层执行操作。存储库如何执行操作完全由存储库决定。示例代码:
package usecase
import (
"context"
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
)
type taskUsecase struct {
taskRepository domain.TaskRepository
contextTimeout time.Duration
}
func NewTaskUsecase(taskRepository domain.TaskRepository, timeout time.Duration) domain.TaskUsecase {
return &taskUsecase{
taskRepository: taskRepository,
contextTimeout: timeout,
}
}
func (tu *taskUsecase) Create(c context.Context, task *domain.Task) error {
ctx, cancel := context.WithTimeout(c, tu.contextTimeout)
defer cancel()
return tu.taskRepository.Create(ctx, task)
}
func (tu *taskUsecase) FetchByUserID(c context.Context, userID string) ([]domain.Task, error) {
ctx, cancel := context.WithTimeout(c, tu.contextTimeout)
defer cancel()
return tu.taskRepository.FetchByUserID(ctx, userID)
}
Repository
存储库是用例的依赖项。用例层要求存储库执行操作。存储库层可以选择任何数据库,实际上可以根据要求调用任何其他独立服务。在项目中,存储库层对数据库进行查询以执行由用例层要求的操作。示例代码:
package repository
import (
"context"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type taskRepository struct {
database mongo.Database
collection string
}
func NewTaskRepository(db mongo.Database, collection string) domain.TaskRepository {
return &taskRepository{
database: db,
collection: collection,
}
}
func (tr *taskRepository) Create(c context.Context, task *domain.Task) error {
collection := tr.database.Collection(tr.collection)
_, err := collection.InsertOne(c, task)
return err
}
func (tr *taskRepository) FetchByUserID(c context.Context, userID string) ([]domain.Task, error) {
collection := tr.database.Collection(tr.collection)
var tasks []domain.Task
idHex, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return tasks, err
}
cursor, err := collection.Find(c, bson.M{"userID": idHex})
if err != nil {
return nil, err
}
err = cursor.All(c, &tasks)
if tasks == nil {
return []domain.Task{}, err
}
return tasks, err
}
Domain
在领域层中,我们放置以下内容:
- 请求和响应模型。
- 数据库实体。
- 用例和存储库的接口。
示例代码:
package domain
import (
"context"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
CollectionTask = "tasks"
)
type Task struct {
ID primitive.ObjectID `bson:"_id" json:"-"`
Title string `bson:"title" form:"title" binding:"required" json:"title"`
UserID primitive.ObjectID `bson:"userID" json:"-"`
}
type TaskRepository interface {
Create(c context.Context, task *Task) error
FetchByUserID(c context.Context, userID string) ([]Task, error)
}
type TaskUsecase interface {
Create(c context.Context, task *Task) error
FetchByUserID(c context.Context, userID string) ([]Task, error)
}
领域、模型和实体在控制器、用例和存储库中使用。
现在,我们已经讨论了在此干净架构后端项目中创建的所有层。现在是了解测试的时候了,我已经包括了控制器、用例和存储库层的测试。我使用mockery包生成数据库、存储库和用例的模拟代码。你可以在项目本身的README中找到生成模拟代码的步骤。
存储库测试 由于在项目中,存储库层使用数据库,我已经模拟了数据库,并测试了存储库,如下所示:
package repository_test
import (
"context"
"errors"
"testing"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo/mocks"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestCreate(t *testing.T) {
var databaseHelper *mocks.Database
var collectionHelper *mocks.Collection
databaseHelper = &mocks.Database{}
collectionHelper = &mocks.Collection{}
collectionName := domain.CollectionUser
mockUser := &domain.User{
ID: primitive.NewObjectID(),
Name: "Test",
Email: "test@gmail.com",
Password: "password",
}
mockEmptyUser := &domain.User{}
mockUserID := primitive.NewObjectID()
t.Run("success", func(t *testing.T) {
collectionHelper.On("InsertOne", mock.Anything, mock.AnythingOfType("*domain.User")).Return(mockUserID, nil).Once()
databaseHelper.On("Collection", collectionName).Return(collectionHelper)
ur := repository.NewUserRepository(databaseHelper, collectionName)
err := ur.Create(context.Background(), mockUser)
assert.NoError(t, err)
collectionHelper.AssertExpectations(t)
})
t.Run("error", func(t *testing.T) {
collectionHelper.On("InsertOne", mock.Anything, mock.AnythingOfType("*domain.User")).Return(mockEmptyUser, errors.New("Unexpected")).Once()
databaseHelper.On("Collection", collectionName).Return(collectionHelper)
ur := repository.NewUserRepository(databaseHelper, collectionName)
err := ur.Create(context.Background(), mockEmptyUser)
assert.Error(t, err)
collectionHelper.AssertExpectations(t)
})
}
用例测试 用例依赖于存储库,我已经模拟了存储库,并测试了用例,如下所示:
package usecase_test
import (
"context"
"errors"
"testing"
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain/mocks"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/usecase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestFetchByUserID(t *testing.T) {
mockTaskRepository := new(mocks.TaskRepository)
userObjectID := primitive.NewObjectID()
userID := userObjectID.Hex()
t.Run("success", func(t *testing.T) {
mockTask := domain.Task{
ID: primitive.NewObjectID(),
Title: "Test Title",
UserID: userObjectID,
}
mockListTask := make([]domain.Task, 0)
mockListTask = append(mockListTask, mockTask)
mockTaskRepository.On("FetchByUserID", mock.Anything, userID).Return(mockListTask, nil).Once()
u := usecase.NewTaskUsecase(mockTaskRepository, time.Second*2)
list, err := u.FetchByUserID(context.Background(), userID)
assert.NoError(t, err)
assert.NotNil(t, list)
assert.Len(t, list, len(mockListTask))
mockTaskRepository.AssertExpectations(t)
})
t.Run("error", func(t *testing.T) {
mockTaskRepository.On("FetchByUserID", mock.Anything, userID).Return(nil, errors.New("Unexpected")).Once()
u := usecase.NewTaskUsecase(mockTaskRepository, time.Second*2)
list, err := u.FetchByUserID(context.Background(), userID)
assert.Error(t, err)
assert.Nil(t, list)
mockTaskRepository.AssertExpectations(t)
})
}
控制器测试 控制器依赖于用例,我已经模拟了用例,并测试了控制器,如下所示:
package controller_test
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/api/controller"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain/mocks"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func setUserID(userID string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("x-user-id", userID)
c.Next()
}
}
func TestFetch(t *testing.T) {
t.Run("success", func(t *testing.T) {
mockProfile := &domain.Profile{
Name: "Test Name",
Email: "test@gmail.com",
}
userObjectID := primitive.NewObjectID()
userID := userObjectID.Hex()
mockProfileUsecase := new(mocks.ProfileUsecase)
mockProfileUsecase.On("GetProfileByID", mock.Anything, userID).Return(mockProfile, nil)
gin := gin.Default()
rec := httptest.NewRecorder()
pc := &controller.ProfileController{
ProfileUsecase: mockProfileUsecase,
}
gin.Use(setUserID(userID))
gin.GET("/profile", pc.Fetch)
body, err := json.Marshal(mockProfile)
assert.NoError(t, err)
bodyString := string(body)
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
gin.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, bodyString, rec.Body.String())
mockProfileUsecase.AssertExpectations(t)
})
t.Run("error", func(t *testing.T) {
userObjectID := primitive.NewObjectID()
userID := userObjectID.Hex()
mockProfileUsecase := new(mocks.ProfileUsecase)
customErr := errors.New("Unexpected")
mockProfileUsecase.On("GetProfileByID", mock.Anything, userID).Return(nil, customErr)
gin := gin.Default()
rec := httptest.NewRecorder()
pc := &controller.ProfileController{
ProfileUsecase: mockProfileUsecase,
}
gin.Use(setUserID(userID))
gin.GET("/profile", pc.Fetch)
body, err := json.Marshal(domain.ErrorResponse{Message: customErr.Error()})
assert.NoError(t, err)
bodyString := string(body)
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
gin.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, bodyString, rec.Body.String())
mockProfileUsecase.AssertExpectations(t)
})
}
这就是我们如何在Go语言的后端项目中实现干净的架构。你可以查看项目,我已经在项目本身的README中包括了逐步指南以运行此项目。
项目链接:Go后端干净的架构。
译自:https://amitshekhar.me/blog/go-backend-clean-architecture
评论(0)