[翻译]在 Go 应用中使用简明架构(4)

原文在此,续……

——–翻译分隔线——–

在 Go 应用中使用简明架构(4)

接口层

关于这点,必须说,所有东西都得有编码智慧,不论是真实的商业还是我们的应用用例。让我们看看对于接口层的代码这意味着什么。不像在各个内部层次中,所有代码都属于一个逻辑,接口层是由若干独立的部分构建而成。因此,我们将这个层次的代码拆分为若干个文件。

由于我们的商店要通过 Web 访问,就从 Web 服务开始吧:

package interfaces

import (
	"fmt"
	"io"
	"net/http"
	"strconv"
	"usecases"
)

type OrderInteractor interface {
	Items(userId, orderId int) ([]usecases.Item, error)
	Add(userId, orderId, itemId int) error
}

type WebserviceHandler struct {
	OrderInteractor OrderInteractor
}

func (handler WebserviceHandler) ShowOrder(res http.ResponseWriter, req *http.Request) {
	userId, _ := strconv.Atoi(req.FormValue("userId"))
	orderId, _ := strconv.Atoi(req.FormValue("orderId"))
	items, _ := handler.OrderInteractor.Items(userId, orderId)
	for _, item := range items {
		io.WriteString(res, fmt.Sprintf("item id: %d\n", item.Id))
		io.WriteString(res, fmt.Sprintf("item name: %v\n", item.Name))
		io.WriteString(res, fmt.Sprintf("item value: %f\n", item.Value))
	}
}

由于各个 Web 服务看起来都差不多,所以这里并没有实现所有的。在实际的应用中,添加商品到订单和管理用途的展示订单也应当作为 Web 服务。

关于这段代码的作用,最为明显的就是它没有做太多工作!接口层,正确的设计就是简单,这是因为他们的主要任务是在不同层之间传输数据。这是要点。实际情况是这段代码其实无法识别某个 HTTP 请求是来自于用例层的。

再次强调,注入是为了用来处理依赖。OrderInteractor 在生产环境可能实际上是 usecases.OrderInteractor,这意味着在单元测试的时候可以被替换,让 Web 服务在模拟环境下被测试。也就是说单元测试可以仅仅测试处理 Web 服务本身的这部分行为(在调用 OrderInteractor.Items 的时候是使用“userId”作为第一个请求参数吗?)。

讨论一下完整的 Web 服务可能的样子是值得的。这里没有对身份进行验证,确信请求提供的 userId 是合法的——在真实应用中,Web 服务处理程序可能需要从会话中获取请求的用户,例如从 cookie 中获得。

呃,等等,我们已经有了客户或者用户,现在又有了会话和 cookie?或多或少,总有些什么是一样的东西吧?

当然,或多或少,这就死要点。它们存在于不同的概念层次中。cookie 是非常底层的机制,处理那些在浏览器内存和 HTTP 头的字节包。会话就更加的抽象了,是用于确定不同的无状态请求属于某个客户端的概念——而 cookie 是具体的实现形式。

用户是相当高的层次——一个非常抽象的说法“一个可以被确认的、与应用进行交互的人”——用会话来作为具体的实现形式。最后,就是关于客户,这个实体可以被认为是纯粹的商业术语——用用户来……好吧,你应该懂的。

我建议让这些明确成为不同的东西,而不是面对由于在不同的概念层次使用使用相同的表达而带来的痛苦。当在会话的传输机制上,需要使用 SSL 客户端验证来代替 cookie 时,只需要在基础层的底层实现上引入一个新的库来进行验证,并且需要修改接口层的代码,以便确保会话使用这些底层的 HTTP 细节——而用户和客户并不知道这些变化。

在接口层,同样有用于从用例层的数据结构创建 HTML 响应的代码。在真实的应用中,这可能是通过一个在基础层中的模板库来完成的。

现在来看看我们应用的最后一块砖:持久化。我们已经有了可以工作的领域模型,有了让领域生效的用例,并且实现了允许用户通过 web 访问我们应用的接口。现在,剩下的全部工作就是将商业和应用数据保存到硬盘中,然后我们就准备好 IPO 了。

为了完成这个,就需要实现领域和用例层的抽象的存储接口。由于存储一边是底层的数据库,一边是高层的业务,所以这个实现属于接口层。存储的任务就是从其中一个传递给另一个。

鉴于其用途,对于接口层以及更低层次来说,某些存储的实现可能是受到限制的,例如编写运行时的纯内存的对象缓存,或者为了单元测试模拟一个存储。而大多数真实世界的存储都需要同外部的持久化机制进行通讯,例如数据库,也可能使用库来处理底层连接和查询细节——这些是在系统的基础层。因此,跟在其他层次一样,我们需要确保不会违反依赖原则。

这不是说存储是数据库透明的!它必然会知道要跟 SQL 数据库通讯。但是它只需要关心高层次,或者说,“逻辑”方面的内容。从这个表获取数据,将数据放到那个表。低层次,或者说“物理”方面,不在这个范围内——例如从网络连接到数据库,使用从库读主库写,处理超时等等,这都是基础层的破事儿。

换句话说,我们的存储更像是恰当的使用一个高层次接口,而将那些讨厌的基础细节加以隐藏,并且只是决定将哪些 SQL 发给服务器,就这样,就可以工作了。

现在在 src/interfaces/repositories.go 中建立这个接口:

type DbHandler interface {
  Execute(statement string)
  Query(statement string) Row 
}

type Row interface {
  Scan(dest ...interface{})
  Next() bool
}

这确实是一个很有限的接口,不过它有了存储所需要的所有的操作:增、删、查、改行记录。

在基础层,将实现一些胶水代码,使用 sqlite3 库来和实际的数据库进行通讯,以便满足这个接口。不过首先,还是先完整实现存储:

$GOPATH/src/interfaces/repositories.go

package interfaces

import (
	"domain"
	"fmt"
	"usecases"
)

type DbHandler interface {
	Execute(statement string)
	Query(statement string) Row
}

type Row interface {
	Scan(dest ...interface{})
	Next() bool
}

type DbRepo struct {
	dbHandlers map[string]DbHandler
	dbHandler  DbHandler
}

type DbUserRepo DbRepo
type DbCustomerRepo DbRepo
type DbOrderRepo DbRepo
type DbItemRepo DbRepo

func NewDbUserRepo(dbHandlers map[string]DbHandler) *DbUserRepo {
	dbUserRepo := new(DbUserRepo)
	dbUserRepo.dbHandlers = dbHandlers
	dbUserRepo.dbHandler = dbHandlers["DbUserRepo"]
	return dbUserRepo
}

func (repo *DbUserRepo) Store(user usecases.User) {
	isAdmin := "no"
	if user.IsAdmin {
		isAdmin = "yes"
	}
	repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO users (id, customer_id, is_admin)
	                                    VALUES ('%d', '%d', '%v')`,
	                                    user.Id, user.Customer.Id, isAdmin))
	customerRepo := NewDbCustomerRepo(repo.dbHandlers)
	customerRepo.Store(user.Customer)
}

func (repo *DbUserRepo) FindById(id int) usecases.User {
	row := repo.dbHandler.Query(fmt.Sprintf(`SELECT is_admin, customer_id
	                                         FROM users WHERE id = '%d' LIMIT 1`,
	                                         id))
	var isAdmin string
	var customerId int
	row.Next()
	row.Scan(&isAdmin, &customerId)
	customerRepo := NewDbCustomerRepo(repo.dbHandlers)
	u := usecases.User{Id: id, Customer: customerRepo.FindById(customerId)}
	u.IsAdmin = false
	if isAdmin == "yes" {
		u.IsAdmin = true
	}
	return u
}

func NewDbCustomerRepo(dbHandlers map[string]DbHandler) *DbCustomerRepo {
	dbCustomerRepo := new(DbCustomerRepo)
	dbCustomerRepo.dbHandlers = dbHandlers
	dbCustomerRepo.dbHandler = dbHandlers["DbCustomerRepo"]
	return dbCustomerRepo
}

func (repo *DbCustomerRepo) Store(customer domain.Customer) {
	repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO customers (id, name)
	                                    VALUES ('%d', '%v')`,
	                                    customer.Id, customer.Name))
}

func (repo *DbCustomerRepo) FindById(id int) domain.Customer {
	row := repo.dbHandler.Query(fmt.Sprintf(`SELECT name FROM customers
	                                         WHERE id = '%d' LIMIT 1`,
	                                         id))
	var name string
	row.Next()
	row.Scan(&name)
	return domain.Customer{Id: id, Name: name}
}

func NewDbOrderRepo(dbHandlers map[string]DbHandler) *DbOrderRepo {
	dbOrderRepo := new(DbOrderRepo)
	dbOrderRepo.dbHandlers = dbHandlers
	dbOrderRepo.dbHandler = dbHandlers["DbOrderRepo"]
	return dbOrderRepo
}

func (repo *DbOrderRepo) Store(order domain.Order) {
	repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO orders (id, customer_id)
	                                    VALUES ('%d', '%v')`,
	                                    order.Id, order.Customer.Id))
	for _, item := range order.Items {
		repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO items2orders (item_id, order_id)
		                                    VALUES ('%d', '%d')`,
		                                    item.Id, order.Id))
	}
}

func (repo *DbOrderRepo) FindById(id int) domain.Order {
	row := repo.dbHandler.Query(fmt.Sprintf(`SELECT customer_id FROM orders
	                                         WHERE id = '%d' LIMIT 1`,
	                                         id))
	var customerId int
	row.Next()
	row.Scan(&customerId)
	customerRepo := NewDbCustomerRepo(repo.dbHandlers)
	order := domain.Order{Id: id, Customer: customerRepo.FindById(customerId)}
	var itemId int
	itemRepo := NewDbItemRepo(repo.dbHandlers)
	row = repo.dbHandler.Query(fmt.Sprintf(`SELECT item_id FROM items2orders
	                                        WHERE order_id = '%d'`,
	                                        order.Id))
	for row.Next() {
		row.Scan(&itemId)
		order.Add(itemRepo.FindById(itemId))
	}
	return order
}

func NewDbItemRepo(dbHandlers map[string]DbHandler) *DbItemRepo {
	dbItemRepo := new(DbItemRepo)
	dbItemRepo.dbHandlers = dbHandlers
	dbItemRepo.dbHandler = dbHandlers["DbItemRepo"]
	return dbItemRepo
}

func (repo *DbItemRepo) Store(item domain.Item) {
	available := "no"
	if item.Available {
		available = "yes"
	}
	repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO items (id, name, value, available)
	                                    VALUES ('%d', '%v', '%f', '%v')`,
	                                    item.Id, item.Name, item.Value, available))
}

func (repo *DbItemRepo) FindById(id int) domain.Item {
	row := repo.dbHandler.Query(fmt.Sprintf(`SELECT name, value, available
	                                         FROM items WHERE id = '%d' LIMIT 1`,
	                                         id))
	var name string
	var value float64
	var available string
	row.Next()
	row.Scan(&name, &value, &available)
	item := domain.Item{Id: id, Name: name, Value: value}
	item.Available = false
	if available == "yes" {
		item.Available = true
	}
	return item
}

你会说:不只一个人认为,这是糟糕的代码!许多重复,没有错误处理,还有一股怪味儿。不过这个指南的要点既不是代码样式,也不是设计模式——这是关于应用的架构的,因此我希望这些随意编写的简单代码是直白,容易理解的,无关优雅和精明——噢,当然,我还是个 Go 的初学者,你们看到了。

值得注意的是,在每个存储中都有 dbHandlers map,这样就可以不放弃依赖注入的前提下让存储之间相互调用。如果某个存储使用了与其他不同的 DbHandler 实现,那么这些用到其他存储的存储也不需要明确知道谁用的什么;这也算是某种穷人的依赖注入容器吧。

让我们进一步解释下一个有趣的方法,DbUserRepo.FindById()。在我们的架构中,这是个很好的示例,说明了接口所做的一切就是从一个层次向另一个层次传递数据。FindById 从数据库读取行记录,并且生成领域和用例实体。我故意让数据库中的 User.IsAdmin 属性比正常更复杂了一些,用“yes”和“no”的 varchar 在数据库中进行排序。在用例实体 User 中,它被表达为一个布尔值。对这些不同的表达进行转换是存储的主要工作。

User 实例有一个 Customer 属性,这是一个领域实体;User 存储直接使用了 Customer 存储来获取它需要的实体。

现在不难想像当应用增长时,架构是如何为我们提供帮助的了。通过遵循依赖原则,可以对实体的细节进行重构而无需变更实体本身。我们可能会将 User 实体的拆分到多个表中,存储需要处理这些细节,从多个表中获取数据,放到一个实体里,但是存储的用户并不知道这些情况。

——–翻译分隔线——–

这篇东西拖得太久了,争取在 2012 年内完成吧……

Leave a comment

Your email address will not be published. Required fields are marked *