มาลอง Code style Hexagonal กัน
มาลองเขียน go style Hexagonal กัน
เริ่ม ต้น setup project ก่อน ในหัวข้อนี้เราจะใช้ Fiber และ Gorm ที่เคยร่ำเรียนมาจากก่อนหน้านี้
- Fiber เป็นตัวแทนของฝั่งการรับ HTTP Request ที่ส่งมาจาก Client ของ User
- Gorm เป็นตัวแทนของฝั่งการส่งข้อมูลเข้า Database ที่ทำต่อหลังจากได้รับข้อมูลจาก Business Logic
** เพื่อให้เกิดความเข้าใจที่ถ่องแท้มากขึ้น ขอให้รู้จัก 2 ตัวนี้มาก่อนที่จะเริ่ม code เรื่องนี้
เราจะลองทำ Diagram หน้าเมื่อกี้ (ส่วนของการ Create Order) ออกมาเป็น code กันบ้าง
โดยเราจะทำการวาง Structure กันตามนี้
.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── main.go
1. Port
Structure ที่เกี่ยวข้องจะมี 3 file นี้
.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── go.mod
├── go.sum
├── main.go
สิ่งแรกที่เรามีการประกาศออกมาก่อนคือ struct สำหรับการเก็บข้อมูล Order ไว้ภายใน core
โดย โครงสร้างนี้จะมีการเรียกใช้งานในทุกส่วนของ application เพื่อให้ทุกคนสามารถเข้าถึงโครงสร้างข้อมูลหน้าที่เหมือนกันออกมาได้
ที่ order.go
ก็จะมีหน้าประมาณนี้
package core
type Order struct {
ID uint˝
Total float64
}
โดย Port จะมีทั้งหมด 2 ตัวที่ต้อง implement คือ
- Primary Port (ส่วนที่ต้องรับจากภายนอก) = เก็บไว้ที่
order_service.go
โดย
- implement
OrderService
เป็น Port ระบุการเชื่อมต่อเอาไว้ว่า ถ้าจะส่งข้อมูลเข้ามาต้องส่งข้อมูล order หน้าตาแบบไหนมา - โดยเราจะสร้าง
order.go
สำหรับการเก็บ schema ของ data เอาไว้ เพื่อใช้สำหรับกำหนด spec ทั้ง Port, Adapter, Business Logic ว่า data ที่ใช้สื่อสารกันมีหน้าตาเป็นแบบไหน
ที่ไฟล์ order_service.go
สำหรับ Primary Port ก็จะมีหน้าตาประมาณนี้
package core
// Primary port
type OrderService interface {
CreateOrder(order Order) error
}
- Secondary Port (ส่วนที่จะต้องส่งต่อไปยังส่วนของ database) = เก็บไว้ที่
order_repository.go
โดย
- implement
OrderRepository
สำหรับ spec ข้อมูลสำหรับการรับข้อมูลเพื่อไป save ลง database (ซึ่งก็คือข้อมูล order)
ที่ไฟล์ order_repository.go
สำหรับ Secondary Port ก็จะมีหน้าตาประมาณนี้
package core
// Secondary port
type OrderRepository interface {
Save(order Order) error
}
2. Business Core logic
Structure ที่เกี่ยวข้องจะมี 1 file นี้
.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── go.mod
├── go.sum
├── main.go
โดย เราจะทำการสร้าง function สำหรับจัดการ Order ขึ้นมา โดยใช้ function จาก Repository มาจัดการผูก logic (เป็น business function ขึ้นมา) สำหรับจัดการตัว Order
หน้าตา order_service.go
ก็จะเป็นประมาณนี้เพิ่มขึ้นมา
type orderServiceImpl struct {
repo OrderRepository
}
func NewOrderService(repo OrderRepository) OrderService {
return &orderServiceImpl{repo: repo}
}
func (s *orderServiceImpl) CreateOrder(order Order) error {
if order.Total <= 0 {
return errors.New("total must be positive")
}
// Business logic...
if err := s.repo.Save(order); err != nil {
return err
}
return nil
}
โดย
- ตัว struct
orderServiceImpl
จะทำการเก็บ logic ของการจัดการ Order เอาไว้ ชื่อCreateOrder(order Order)
โดยจะทำการคุยกับOrderRepository
(ที่จะส่งเข้ามาจาก Adapter อีกทีตาม spec ที่ Port กำหนด) เพื่อทำการไปคุยให้สร้าง Order จาก Database ออกมา - ส่วน function
NewOrderService
ไว้สำหรับการ instance ตัวแปรตอนที่มีการสร้างตัวแปรตาม struct ของorderServiceImpl
(เป็นตัวที่ใช้รับ Repository ที่ส่งจาก adapter เข้ามา)
3. Adapter
Structure ที่เกี่ยวข้องจะมี 2 file นี้
.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── go.mod
├── go.sum
├── main.go
โดย Adapter จะมีทั้งหมด 2 ตัวที่ต้อง implement คือ
- Primary Adapter (adapter สำหรับการ handle input ที่เข้ามา) = เก็บไว้ที่
http_adapter.go
- ฝั่ง Adapter ทำการสร้าง struct
HttpOrderHandler
ขึ้นมา โดยทำการ implement ตาม Primary port (ซึ่งก็คือOrderService
ที่สร้างไว้) - โดย Adapter นี้มีหน้าที่ในการ แปลง HTTP Request ที่เข้ามา เพื่อทำการเตรียมส่งเข้า
OrderService
(เพื่อให้OrderService
ทำการส่งต่อไปยัง Business logic ต่อ) ซึ่งโจทย์ของ Adapter นั้นมีเพียงแค่ปั้น data ให้ตรงตาม spec ของOrderService
เท่านั้น (เหมือนได้ข้อมูลตรงกัน ที่เหลือก็เป็นหน้าที่ของ Port ที่ต้องไปจัดการต่อเอง)
หน้าตา http_adapter.go
ก็จะเป็นประมาณนี้ (โดยจะมีการเรียกใช้ service ของ Fiber เพิ่มขึ้นมา)
package adapters
import (
"fmt"
"mike/core"
"github.com/gofiber/fiber/v2"
)
// Primary adapter
type HttpOrderHandler struct {
service core.OrderService
}
func NewHttpOrderHandler(service core.OrderService) *HttpOrderHandler {
return &HttpOrderHandler{service: service}
}
func (h *HttpOrderHandler) CreateOrder(c *fiber.Ctx) error {
var order core.Order
if err := c.BodyParser(&order); err != nil {
fmt.Println(err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
if err := h.service.CreateOrder(order); err != nil {
// Return an appropriate error message and status code
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(order)
}
จาก code
- มี
HttpOrderHandler
สำหรับเป็น struct กำหนดรูปแบบของ instance ของ Primary Adapter ที่จะต้องมีการส่งเข้ามา NewHttpOrderHandler
ทำการ instance โดยรับ service ที่จะทำการส่งต่อเข้ามา (ในทีนี้คือOrderService
)- ส่วน
CreateOrder
ก็จะเป็น method ของHttpOrderHandler
เพื่อให้เรียกใช้สำหรับ endpoint ของการCreateOrder
(สร้างขึ้นมาเพื่อให้ client ที่มีการเรียกใช้เป็น Primary Adapter สามารถเรียกใช้งานได้)
- Secondary Adapter (adapter สำหรับการส่งต่อข้อมูลเข้า database) = เก็บไว้ที่
gorm_adapter.go
- ฝั่ง Adapter ทำการสร้าง struct
GormOrderRepository
เป็น Secondary adapter โดยทำการ implement ตามOrderRepository
(ที่เป็น interface ของ Secondary port) และทำการเก็บ logic ที่ใช้สำหรับการพูดคุยกับ database เอาไว้ - ซึ่งในที่นี้คนที่ทำหน้าที่ช่วยคุยใน Secondary adapter ก็คือ library
GORM
จะเป็นคนไป คุยกับ database ให้โดยการแปลงข้อมูลที่ส่งมา (โดยเรียกใช้งานจาก logic ของ Business logic) ทำการส่งเข้า database โดยตรงไป
หน้าตา gorm_adapter.go
ก็จะประมาณนี้
package adapters
import (
"gorm.io/gorm"
"mike/core"
)
// Secondary adapter
type GormOrderRepository struct {
db *gorm.DB
}
func NewGormOrderRepository(db *gorm.DB) core.OrderRepository {
return &GormOrderRepository{db: db}
}
func (r *GormOrderRepository) Save(order core.Order) error {
if result := r.db.Create(&order); result.Error != nil {
// Handle database errors
return result.Error
}
return nil
}
จาก code
- มี struct
GormOrderRepository
ที่จะทำการเก็บ function ของการทำงาน database ไว้ (ซึ่งก็คือ methodSave()
) NewGormOrderRepository
function สำหรับการ instance ตัว GormOrderRepository โดยทำการรับ db (ซึ่งก็คือ database ที่จะทำการเชื่อมต่อ) เข้ามาผ่าน struct ที่มีการกำหนด spec เอาไว้
รวมทุกอย่างเข้าด้ วยกัน
จากทั้งหมดที่เรา implement มา
.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── main.go
กลับมาที่ main.go
เรามาทำการเรียกใช้ผ่าน main.go
กัน โดยที่ main.go
สิ่งที่เราจะทำคือ
- ทำการกำหนด config database (ในทีนี้ขอใช้
sqlite
เพื่อความรวดเร็วในการ implement) และ fiber (port สำหรับการ run http server) - นำตัวแปร config ของ database ไล่ใส่ไปตั้งแต่
Secondary Adapter (ผ่าน Port)
>Business logic (Service)
>Primary Adapter (ผ่าน Port)
โดยถ้าเทียบกับโจทย์ที่เรา implement ไปก็จะเป็นGormOrderRepository
>OrderService
>HttpOrderHandler
code ก็จะมีหน้าตาประมาณนี้
package main
import (
"github.com/gofiber/fiber/v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"mike/adapters"
"mike/core"
)
func main() {
app := fiber.New()
// Initialize the database connection
db, err := gorm.Open(sqlite.Open("orders.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// Migrate the schema
db.AutoMigrate(&core.Order{})
// Set up the core service and adapters
orderRepo := adapters.NewGormOrderRepository(db)
orderService := core.NewOrderService(orderRepo)
orderHandler := adapters.NewHttpOrderHandler(orderService)
// Define routes
app.Post("/order", orderHandler.CreateOrder)
// Start the server
app.Listen(":8000")
}
เมื่อลอง run project ด้วย go run main.go
ดู ก็จะสามารถ run ผลลัพธ์ fiber ออกมาได้
และนี่ก็คือตัวอย่างของการ implement hexagonal architecture อย่างง่าย ที่เหลือ
- ถ้ามีส่วน business logic เพิ่มขึ้น = ต้องเพิ่ม service, data structure
- ถ้ามีส่วนต้องเพิ่ม spec ในการรับข้อมูล = ต้องเพิ่ม Port
- ถ้ามีส่วนต้องเปลี่ยน adapter ไปใช้อีกตัวหนึ่ง = ต้องเปลี่ยน Adapter (หรือสามารถเพิ่ม Adapter เพื่อใช้ตาม spec เดียวกันออกมาได้)