Skip to main content

Implementing Hexagonal Architecture

ส่วนนี้จะไม่มีการเขียน logic เพื่อให้ section นี้ไม่ยาวจนเกินไป

ก่อนอื่นการจะเปลี่ยน Architecture แบบชุ่ย(ที่ทำใน Http handle บทความก่อน) ไปเป็น Hexagonal Architecture เราต้องสร้างหลายๆอย่างทำให้โค้ด base เปลี่ยนค่อนข้างเยอะแต่ก็จะขอให้ค่อยๆทำไปในความเร็วของตัวเองครับ

การจะทำ Hexagonal Architecture อย่างที่บอกไปใน introduction เราจำเป็นต้องมี Port ที่เป็น interface ก่อน ซึ่ง interface ในที่นี้คือการกำหนดชื่อของ function รวมถึง parameter และ return statement ด้วย งั้นก่อนอื่นเรามาสร้าง folder เพื่อให้เข้าใจง่ายว่าส่วนไหนคือส่วนไหนของ Hexagonal Architecture กันดีกว่า ซึ่งในที่นี้เราจำเป็นต้องสร้าง 3 folder นั้นคือ Repository, Service, และ Handler

เราจะเริ่มสร้างจากชั้นที่ลึกที่สุดที่เก็บข้อมูลไปถึงชั้นที่เอาข้อมูลที่ประมวณผลแล้วไปแสดงให้ user สามารถเรียกได้ หรือก็คือเราจะสร้าง repository -> service -> handler นั้นเอง

Repository

Screenshot 2565-11-04 at 21.40.34.png

1. สร้างไฟล์ชื่อว่า user.go ขึ้นมาใน folder repository และโค้ดด้านในเราจะเก็บ struct (model ของข้อมูล) และ interface ที่เป็น "Port "นั้นเอง 

package repository

type User struct {
	Id       int64  `json:"id"`
	Email    string `json:"email"`
	Password string `json:"password"`
	Secret   string `json:"secret"`
}

type UserRepository interface {
	CreateUser(email string, password string, secret string) (*User, error)
	CheckUser(email string) (*User, error)
	GetUsers() ([]*User, error)
}

ซึ่ง model พวกนี้สามารถเปลี่ยนแปลงได้ตามที่เราออกแบบไว้ได้เลย แต่ส่วนมากจะนำข้อมูลที่มีอยู่ใน database มาเป็น struct นั้นเอง ส่วน interface ก็สามารถเปลี่ยนไปตามที่เราออกแบบได้เช่นกัน ซึ่ง method พวกนี้จะเป็นการ query, insert, update, หรือ delete ข้อมูลต่างๆที่เป็นขั้นสุดท้ายที่ติดต่อกับ database แต่ใน session นี้ขอให้มีเท่านี้ก่อนครับ

2. สร้างไฟล์ user_db.go ขึ้นมาใน folder เดียวกัน ซึ่งไฟล์นี้จะมาทำหน้าที่เป็น "adapter" ฝั่งที่ติดต่อกับ database ของเรานั่นเอง

package repository

import (
	"database/sql"
)

type userRepositoryDB struct {
	db *sql.DB
}

func NewRepositoryDB(db *sql.DB) userRepositoryDB { // รับ instance database ที่เราจะใช้มาแล้วออกมาเป็น "Adapter" ของ repository
	return userRepositoryDB{db: db}
}

func (u userRepositoryDB) CreateUser(email string, password string, secret string) (*User, error) {
  	// implement me
  	fmt.Print("User Inserted")
	return nil, nil
}
func (u userRepositoryDB) CheckUser(email string) (*User, error) {
	// implement me
  	fmt.Print("User Fetched")
	return nil, nil
}

func (u userRepositoryDB) GetUsers() ([]*User, error) {
	// implement me
  	fmt.Print("Users Fetched")
	return nil, nil
}

ใน file นี้จะมี struct ที่เก็บ instance ของ Database ไว้ด้วยเพื่อที่จะสามารถทำ operation ต่างๆได้ (ในครั้งนี้เราจะใช้ MySQL กัน) ซึ่ง struct นี้จะเป็น private ที่จะไม่สามารถสร้างได้ในไฟล์อื่นเฉยๆแต่ต้องเรียกผ่าน function ที่สร้างไว้เท่านั้น และเนื่องจากเราจะทำให้ instance นี้เป็น Adapter เราจึงจำเป็นที่ต้องมี function ต่างๆเหมือนกับ Port ของเราทั้งหมดด้วย

ผมทำการวาง print ไว้ด้วยเพื่อจะได้ test ได้โดยไม่ได้สร้าง logic มาก และเนื่องจากตอนนี้เรายังไม่ได้วาง port ไว้ เราเลยต้องไปสร้าง service ก่อนนั้นเอง

Service

Screenshot 2565-11-04 at 21.43.40.png

1. สร้างไฟล์ชื่อ user.go ขึ้นมาใน folder service ส่วนโค้ดด้านในจะคล้ายกับส่วน Repository เลยแต่จะเปลี่ยนจาก Port ของส่วน Repository ไปเป็น Port ของ Handler แทน

package service

type User struct {
	Id    int64  `json:"id" bson:"_id"`
	Email string `json:"email"`
}

type UserService interface {
	SignUp(email string, password string) (*string, *string, error)
	SignIn(email string, password string) (*UserService, error)
	ListUsers() ([]*User, error)
}

2. สร้างไฟล์ชื่อ user_service.go ขึ้นมาใน folder เดียวกันซึ่งไฟล์นี้จะเป็นหน้าที่เก็บ logic ต่างๆก่อนที่จะไปเรียกทำ operation database และก็เป็นที่วาง Port ของ repository ด้วยนั่นเองทำให้ userService สามารถเรียกใช้ function ที่มีอยู่ใน interface ได้ทั้งหมดนั่นเอง

package service

type userService struct {
	repository repository.UserRepository // interface หรือ "Port" ของ repository
}

func NewUserService(userRepository repository.UserRepository) userService { // รับ "Adapter" ของ Repository
	return userService{repository: userRepository}
}

func (s userService) SignUp(email string, password string) (*string, *string, error) {
	// implement me
  	s.repository.CreateUser(email, password, "")
	return nil, nil, nil
}

func (s userService) SignIn(email string, password string) (*UserService, error) {
  	// implement me
  	s.repository.GetUser(email)
	return nil, nil
}

func (s userService) ListUsers() ([]*User, error) {
	users, err := s.repository.GetUsers()
  	if err != nil {
		return nil, err
	}
  	var userResponse []*User
	for _, user := range users {
		userResponse = append(userResponse, &User{Id: user.Id, Email: user.Email})
	}
	return userResponse, nil
}

ในโค้ดนี้ผมได้มีตัวอย่างที่เรียกใช้ function GetUsers ด้วยซึ่งสังเกตุว่าเราเรียกได้โดยการเรียก s.repository.{your_function} ได้เลย เพราะว่าตัว repository ที่อยู่ใน struct userService มี interface ของ Repository เก็บไว้อยู่ และแน่นอน struct นี้จะเป็น private เช่นกันเพื่อจะไม่ให้สามารถสร้างได้ในไฟล์อื่นได้เฉยๆแต่ต้องเรียกผ่าน function ที่สร้างไว้เท่านั้นเหมือนเดิม

Handler

Screenshot 2565-11-04 at 21.44.46.png

ส่วนสุดท้ายนั้นเราไม่จำเป็นต้องสร้าง Port เพิ่มแล้ว แต่สร้างแค่ Adapter ที่จะมาใส่ใน "Port" ของ Service

package handler

type userHandler struct {
	service service.UserService // interface หรือ "Port" จาก Service
}

func NewUserHandler(userSerivice service.UserService) userHandler { // รับ "Adapter" ของ Service
	return userHandler{service: userSerivice}
}

func (h userHandler) SignUp(w http.ResponseWriter, r *http.Request) {
	// implement me
  	h.service.SignUp("","")
}

func (h userHandler) SignIn(w http.ResponseWriter, r *http.Request) {
	// implement me
  	h.service.SignIn("", "")
}

func (h userHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
	// implement me
  	h.service.ListUsers()
}

ทีนี้ structure ของเราก็เสร็จเป็นที่เรียบร้อยแล้วครับ ภาพเมื่อนำไปใช้จริงก็เป็นประมาณนี้

ในไฟล์ main.go

package main

func main() {
	s := &http.Server{
		Addr:           ":8080",
		ReadTimeout:    10 * time.Second,
		WriteTimeout:   10 * time.Second,
		MaxHeaderBytes: 1 << 20,
	}

  db, err := sql.Open("mysql", "{your_sql_connection_string}")
	if err != nil {
		panic(err)
	}
	userRepository := repository.NewRepositoryDB(db)
	userService := service.NewUserService(userRepository)
	userHandler := handler.NewUserHandler(userService)

	http.HandleFunc("/signup", userHandler.SignUp)
	http.HandleFunc("/signin", userHandler.SignIn)
	http.HandleFunc("/listuser", userHandler.ListUsers)

	if err := s.ListenAndServe(); err != nil {
		panic(err)
	}

	defer db.Close()
}

อย่างที่เห็นว่าเราก็จะสามารถเรียก function เพื่อที่จะสร้าง "Adapter"(reposiptory) แล้วใส่ไปใน "Port"(function ของ service) เพื่อที่จะได้ "Adapter"(service) อีกตัวและไปใส่ใน Port (function ของ handler) อีกรอบเพื่อสามารถเรียกใช้ได้โดยง่ายดายแต่ขั้นตอนก่อนหน้านั้นจะเยอะอย่างที่เห็นครับ

ก่อนจะ run โค้ดผ่านเราต้องสร้าง instance ของ database ก่อน

เมื่อเราลอง call function ก็จะได้ว่ามีผลลัพธ์ใน console ถูกต้องตามที่เราเขียนไว้เพื่อทดสอบ