我发现一个结构良好的项目会随着应用程序的范围和复杂性的增长而大有帮助。
虽然对于像这样的小项目可能不太相关,但养成习惯需要不断的练习和改进,所以我尽可能地遵循相同的做法。
Mono 与单独的存储库通常,我非常喜欢在完全独立的存储库中保存单独的服务。
它使构建管道不那么复杂(无需监视路径),并且在每个服务上下文之间保持明确定义的线路。
但是,出于本系列的目的,我将使用单个单一存储库运行。这有几个原因
-
整个项目都是开源的,所以对于任何想要在本地运行应用程序的人来说,只需一个 git clone 命令就可以为大家省去很多麻烦
-
我一直坚持独立存储库的想法,但从来没有真正努力过单一存储库并理解随之而来的好处。
所以,我计划我的结构是这样的。
+-- README.md
+-- docker-compose.yml
+-- docs 包含有关整个项目的任何文档
+-- 源代码
| +-- 团队服务
| +-- | +-- 域_包含域实体_
| +-- | +-- 用例 _ 包含领域用例
| +-- | +-- 接口 包含域中的接口
| +-- | +-- 基础设施_包含任何基础设施代码(HTTP/数据库等)_
| +-- | +-- docs _ 包含特定于服务的文档 _
| +--- 夹具服务
这可能会随着时间的推移而改变和扩大,但从长远来看,有一个明确定义的起点应该会有所帮助。对于我自己的理智和查看回购协议的其他人。
_我对项目布局的想法结合了 Bob Martin 叔叔关于清洁架构的书和来自这个github repo 的详细信息 Go 最佳实践
测试驱动开发与往常一样,我将在开发所有这些服务时尝试遵循最佳 TDD 实践。
虽然这可能会增加 Go 的学习曲线,但从一开始就构建这些最佳实践将构成我 GoLang 知识的坚实支柱。
入门
这似乎是合乎逻辑的,我的应用程序的最佳起点是团队服务。
没有团队,整个系统并没有太多用处。
最基本形式的团队服务只是围绕某种数据存储的 CRUD API 包装器。坚持使用新技术并做好云准备的计划,我将使用 Amazon DynamoDB 作为我的数据存储(此处为ADR)。
在研究 Go 用于基于微服务的应用程序时,GoKit似乎非常合适,因此它将成为我的程序结构的基础(ADR Here)。
团队
实体
因此,任何数据存储类型 API 的基础都是存储模型本身。团队是任何数据对象的基础聚合。
球队会有球员,但就球队服务而言,球员不能是他们自己的实体。所以这给出了一个相当简单的基本模型
// Team is the central class in the domain model.
type Team struct {
ID string `json:"id"`
Name string `json:"teamName"`
Players map[string]*Player `json:"players"`
}
// Player holds data for all the players that a team has
type Player struct {
Name string `json:"name"`
Position string `json:"position"`
}
我完全期望对象属性会随着时间的推移而扩展,但作为一个基本的功能模型,这很好。
让我们一起做一些测试
所以,让我们编写一个测试来创建一个新团队,然后将一些玩家添加到团队中。
首先,我将以下测试添加到 domain_test.go 文件中。当我从数据库加载一支球队时,我希望能够将一名球员添加到该球队。
func TestCanAddValidPlayerToTeam(t *testing.T) {
team := &Team{}
team.AddPlayer(&Player{
Name: "James",
Position: "GK",
})
if len(team.Players) < 1 {
t.Fatalf("Player not added")
}
}
立即使用
go test
由于该方法尚未实现,因此引发错误。然后,我可以通过将以下代码添加到 team.go 文件来消除错误。
// AddPlayerToTeam adds a new player to the specified team.
// AddPlayer adds a player to a team.
func (team *Team) AddPlayer(player *Player) error {
team.Players = append(team.Players, player)
return nil
}
我们还需要检查团队中是否已经存在一名球员,以及他们是否只是返回该球员而不是添加副本。为了检查这一点,我将再添加一个测试。
func TestCanAddValidPlayerToTeam_DuplicatePlayer_ShouldThrowError(t *testing.T) {
team := &Team{}
firstPlayerAddResult := team.AddPlayer(&Player{
Name: "James Eastham",
Position: "GK",
})
secondPlayerAddResult := team.AddPlayer(&Player{
Name: "James Eastham",
Position: "GK",
})
if firstPlayerAddResult != nil || secondPlayerAddResult == nil {
t.Fatalf("Second add of the same name should throw an error")
}
}
运行测试会返回一个失败并显示“已添加重复播放器”的消息。
为了解决这个问题,我将把我的 AddPlayerToTeam 方法更新为
// AddPlayerToTeam adds a new player to the specified team, if the player exists the existing player is returned.
func (team *Team) AddPlayerToTeam(playerName string, position string) (*Player, error) {
for _, v := range team.Players {
if v.Name == playerName {
return v, nil
}
}
player := &Player{
Name: playerName,
Position: position,
}
team.Players = append(team.Players, player)
return player, nil
}
从那里,我添加了很多测试方法来验证已添加的播放器对象的不同部分。这使 domain.go 文件看起来像这样。
package domain
import "errors"
var validPositions = [...]string{
"GK",
"DEF",
"MID",
"ST",
}
// ErrInvalidArgument is thrown when a method argument is invalid.
var ErrInvalidArgument = errors.New("Invalid argument")
// TeamRepository handles the persistence of teams.
type TeamRepository interface {
Store(team Team)
FindById(id string) Team
Update(team Team)
}
// PlayerRepository repository handles the persistence of players.
type PlayerRepository interface {
Store(player Player)
FindById(id string) Player
Update(player Player)
}
// Team is a base entity.
type Team struct {
ID string `json:"id"`
Name string `json:"teamName"`
Players []*Player `json:"players"`
}
// Player holds data for all the players that a team has.
type Player struct {
Name string `json:"name"`
Position string `json:"position"`
}
// AddPlayer adds a player to a team.
func (team *Team) AddPlayer(player *Player) error {
if len(player.Name) == 0 {
return ErrInvalidArgument
}
for _, v := range team.Players {
if v.Name == player.Name {
return ErrInvalidArgument
}
}
if len(player.Position) == 0 {
return ErrInvalidArgument
}
isPositionValid := false
for _, v := range validPositions {
if v == player.Position {
isPositionValid = true
break
}
}
if isPositionValid == false {
return ErrInvalidArgument
}
team.Players = append(team.Players, player)
return nil
}
用例
该领域最简单的用例之一是创建一个新团队。坚持我们的 TDD 原则,让我们编写一个测试来做到这一点。
func TestCanCreateTeam(t *testing.T) {
teamInteractor := createInMemTeamInteractor()
team := &CreateTeamRequest{
Name: "Cornwall FC",
}
createdTeamID := teamInteractor.Create(team)
if len(createdTeamID) == 0 {
t.Fatalf("Team has not been created")
}
}
func createInMemTeamInteractor() *TeamInteractor {
teamInteractor := &TeamInteractor{
TeamRepository: &mockTeamRepository{},
}
return teamInteractor
}
让我们通过这个测试
// Team holds a reference to the team data.
type CreateTeamRequest struct {
Name string
}
// CreateTeam creates a new team in the database.
func (interactor *TeamInteractor) CreateTeam(team *CreateTeamRequest) (string, error) {
newTeam := &domain.Team{
Name: team.Name,
}
createdTeamID := interactor.TeamRepository.Store(newTeam)
return createdTeamID, nil
}
很好很简单,我们将用例 CreateTeamRequest 结构映射到 Team 的域版本。现在,拥有两个具有相同属性的完全独立的对象似乎有点过分了。但是坚持 CleanArchitecture 的“规则”,用例层使用不同的类型可以消除任何类型的耦合。
封装业务逻辑
在实体层和用例层之间,这为我提供了构建应用程序所需的一切。
正如鲍勃·马丁本人所说,其他一切都只是一个细节。
我坚信,如果正确遵循干净架构的原则,应该能够构建大量应用程序,而无需考虑数据库、Web 服务、HTTP 或任何外部框架。
就是说,因为我不耐烦并且我正在尝试学习 Go,同时也坚持最佳实践,所以我继续前进并添加了一个 REST 层。
HTTP层
有四个文件提供了一个非常基本的 HTTP 服务器。他们是:
-
transport.go transport 保存端点本身的详细信息,以及将入站请求转换为服务可以理解的内容(解析请求正文等)
-
endpoint.go _ endpoint 包含每个端点应该如何操作的实际实现 _
-
main.go main 是所有类中最脏的。它构建用于依赖注入的所有必需对象
-
infrastructure/repositories.go repositories 拥有一个非常基本的内存“数据库”。初始化时,会创建一个空的团队对象数组并用于保存任何入站请求
因此,我们有了一个非常简单的团队服务实现,其中包含一些基本的 HTTP 功能。
在接下来的一周里,我将构建团队服务的内部结构,同时尽可能远离细节。
这对我来说是一次巨大的学习之旅(Go 与 C# 非常不同),所以如果有人发现我可以做得更好的任何事情,我将非常感谢您的意见。