Golang六边形架构源码和分析


维护软件的成本比开发软件的成本高得多。为了使维护软件具有成本效益,我们需要一种架构,使每个开发人员都能轻松理解代码库的每个部分并鼓励并行工作,因为大型软件通常有一个团队在背后支持。

六边形架构旨在通过提供清晰的关注点分离来实现这些目标,使您能够专注于业务逻辑 - 是什么使您的应用程序独一无二,同时保持基础设施和外部系统易于交换。此外,该架构无缝地执行 SOLID 原则,使其成为开发人员手中的强大工具。

在本节中,我将通过在证券交易所模拟应用程序中实现下单用例来展示我对 Golang 中六角形架构的经过验证的解释。完整的源代码可以在https://gitlab.com/briannqc/backend-101找到。

代码结构:

.
├── main.go
├── application
│   ├── domain
│   ├── service
│   │   ├── order_place_service.go
│   │   ├── order_cancel_service.go
│   │   ├── stock_list_service.go
│   │   └── stock_delist_service.go
│   └── port
│       ├── in
│       └── out
└── adapter
    ├── in
    │   ├── web
    │   │   ├── order_place_handler.go
    │   │   ├── order_cancel_handler.go
    │   │   ├── stock_list_handler.go
    │   │   └── stock_delist_handler.go
    │   ├── grpc
    │   ├── graphql
    │   └── kafkaconsumer
    └── out
        ├── downstream1
        ├── downstream2
        ├── mysql
        ├── rabbitmq
        └── kafkaproducer


域实体:Order 和 OrderBook
领域实体始终是六角架构中任何功能的起点。对于证券交易所应用程序,我们需要定义Order实体来表示股票订单并OrderBook跟踪和匹配未完成的订单。虽然 Order 仅具有状态,但 OrderBook 确实具有状态和行为。

应用服务:PlaceOrderService
PlaceOrderService位于域实体之后。它首先验证输入,然后在尝试匹配之前保留顺序。这是因为,如果在后续步骤中出现问题,协调用例可以稍后尝试匹配持久化的订单。我们通常将最重要的步骤移至服务顶部。如果用例是可重试的,我们也可能赞成幂等步骤。然而,情况并非总是如此,工程师应该向产品负责人澄清每个用例中的步骤顺序。或许,我们可以与他们分享我们的理解并请他们审查。

输入端口:PlaceOrderUseCase
PlaceOrderUseCase输出端口是一个接口,抽象的PlaceOrderService。

package in

type PlaceOrderUseCase interface {
  PlaceOrder(ctx context.Context, symbol string, quantity uint, priceCent uint64, side string) (string, error)
}

输出端口:UpsertOrderPort 端口、GetOrderBookPort 端口和 ReportExecutionPort 端口
要为 PlaceOrderService 提供服务,我们需要以下 3 个输出端口:

package out

// UpsertOrderPort 将订单插入和更新到持久性存储中。
// 存储中插入和更新订单。不过,它没有定义应使用哪个数据库。
type UpsertOrderPort interface {
  InsertOrder(context.Context, domain.Order) error
  UpdateAllOrders(context.Context, ...domain.Order) error
}

// GetOrderBookPort 提供了检索订单的方法、
// 例如,在内存或缓存中。
type GetOrderBookPort interface {
  GetOrderBook(context.Context) (*domain.OrderBook, error)
}

// ReportExecutionPort 报告订单执行情况,可以是
// 从电子邮件、推送通知到甚至物理邮件。
// 然而,端口本身并没有定义它。
type ReportExecutionPort interface {
  ReportExecution(context.Context, ...domain.Order)
}

如果您正在使用新建应用程序的最初几个服务,您可能需要创建大量新的输出端口。不过,创建的端口越多,在后续服务中重用大部分端口的可能性就越大。

输入和输出适配器
输入适配器:下订单处理程序

在本示例中,我们公开了 REST API,因此创建了 web.PlaceOrderHandler。该处理程序有一个 PlaceOrderUseCase 输入端口,负责映射从该端口输入和输出的 HTTP 请求/响应。

package web

type PlaceOrderHandler struct {
  uc in.PlaceOrderUseCase
}

// HandlePlaceOrder 处理下订单的 HTTP 请求。
// 方法名必须是长格式,但也可以缩短为 "Handle"。
// 由于其外层作用域的原因,它可以缩短为 "Handle"(处理)。
// 公制发射器使用函数名来区分端点。
func (h *PlaceOrderHandler) HandlePlaceOrder(c *gin.Context) {
  req := struct {
    Symbol   string
    Quantity uint
    Price    float64
    Side     string
  }{}
  if err := c.ShouldBindJSON(&req); err != nil {
    resp := newErrorResponse(
     
"ERR1001",
     
"Request body is not a proper JSON",
     
"Your place order request has been received at our end, but we could not understand it due to improper format. Please modify your request following our API spec and try again. If the issue keeps happening, contact our Customer Support.",
    )
    c.JSON(http.StatusBadRequest, resp)
    return
  }

  id, err := h.uc.PlaceOrder(c, req.Symbol, req.Quantity, uint64(req.Price*100), req.Side)
  if err != nil {
    c.JSON(statusCodeOf(err), errorResponseOf(err))
    return
  }
  resp := newSuccessfulResponse(
   
"Order is placed successfully",
    gin.H{
"id": id},
  )
  c.JSON(http.StatusAccepted, resp)
}

输出适配器
在本例中,为了方便起见,我们使用内存 SQLite。它还可以实现此类输出端口的其他同级端口,如 out.GetOrderPort 和 out.DeleteOrderPort。

在实际应用中,我们肯定不会使用内存 SQLite,而会使用 MySQL、OracleDB 或 PostgreSQL。

这种特定技术的适配器应该与 sqlite.OrderPersistenceAdapter 几乎相似。

最后实现适配器可以让我们远离 "数据库驱动设计"。

package sqlite

type OrderPersistenceAdapter struct {
  db *sqlx.DB
}

func (a *OrderPersistenceAdapter) InsertOrder(ctx context.Context, order domain.Order) error {
  tx, err := a.db.Beginx()
  if err != nil {
    return err
  }
  _, err = tx.ExecContext(
    ctx,
    `INSERT INTO stock_order(id, symbol, quantity, price_cent, side, status) VALUES(?, ?, ?, ?, ?, ?);`,
    order.ID(),
    string(order.Symbol()),
    order.Quantity(),
    uint64(order.Price()),
    string(order.Side()),
    string(order.Status()),
  )
  if err != nil {
    return err
  }
  return tx.Commit()
}

func (a *OrderPersistenceAdapter) UpdateAllOrders(ctx context.Context, orders ...domain.Order) error {
  tx, err := a.db.Beginx()
  if err != nil {
    return err
  }
  for _, order := range orders {
    // [Omitted] Update order
  }
  return tx.Commit()
}

OrderBookAdapter 和 ReportExecutionAdapter 实现了各自的输出端口:GetOrderBookPort 和 ReportExecutionPort。

在本示例中,它们的实现只是玩具,尽管它们仍然是完全合格的输出适配器。

在现实中,开发依赖于其他应用程序的应用程序的情况并不少见,而这些应用程序的构造甚至是未启动的。

六边形架构使我们能够在开发应用程序时先于其依赖程序,并将输出适配器的集成工作降到最低。

package mem

type OrderBookAdapter struct {
  ob *domain.OrderBook
}

func NewOrderBookAdapter() *OrderBookAdapter {
  return &OrderBookAdapter{ob: domain.NewOrderBook()}
}

func (a *OrderBookAdapter) GetOrderBook(ctx context.Context) (*domain.OrderBook, error) {
  return a.ob, nil
}
package email

type ReportExecutionAdapter struct {}

func (a *ReportExecutionAdapter) ReportExecution(ctx context.Context, orders ...domain.Order) {
  for _, o := range orders {
    log.Printf("Sending execution report email for order id: %v, status: %v\n", o.ID(), o.Status())
  }
}

最后,将它们连接起来
最后一步是将所有这些组件连接起来,使它们联机。正如你所看到的,我们写了很多代码,但还没有一个上线。这其实是件好事。

试想一下,当您实现一个新的应用程序接口时,您可以将大量代码拆分成更小的 "非活动 "合并请求,例如,一个用于域实体,一个用于应用程序服务和端口,一个用于输入适配器,一个用于输出适配器,最后一个用于布线,这样,您的 MR 将比拥有数百个 LOC 的单个 MR 更快地完成合并。

package main

func main() {
  engine := gin.Default()
  v1Api := engine.Group("/api/v1")

  sqliteDB := sqlx.MustOpen(
"sqlite3", ":memory:")
// 在实际应用中,我们几乎不会在这里创建表格、
 
// 在实际应用中,我们几乎不在这里创建表格,而是使用数据库迁移工具,如 flyway 或 golang-migrate
  sqliteDB.MustExec(`CREATE TABLE stock_order (id TEXT PRIMARY KEY, symbol TEXT, quantity INT, price_cent INT, side TEXT, status TEXT);`)

  orderPersistenceAdapter := sqlite.NewOrderPersistenceAdapter(sqliteDB)
  orderBookAdapter := mem.NewOrderBookAdapter()
  reportExecutionAdapter := email.NewReportExecutionAdapter()

  placeOrderService := service.NewPlaceOrderService(
    domain.GenerateOrderID,
    orderPersistenceAdapter,
    orderBookAdapter,
    reportExecutionAdapter,
  )
  placeOrderHandler := web.NewPlaceOrderHandler(placeOrderService)
  v1Api.POST(
"/orders", placeOrderHandler.HandlePlaceOrder)
  engine.Run(
":8080")
}

总结
尽管如此,软件工程中并不存在放之四海而皆准的解决方案。如果你正在开发一个简单的 CRUD 或数据服务,六边形架构很可能会成为你的开销。正如我所提到的,在大刀阔斧地改变之前,最好先用玩具项目或项目的一小部分进行尝试。归根结底,在工具箱中多一个工具将有助于你在遇到问题时选择合适的工具。