ข้าม​ไป​ยัง​เนื้อหา

Hexagonal Architecture (Ports and Adapters): แยก​แกน application ออก​จาก​โลก​ภายนอก

เกี่ยว​กับ​บท​แปล​นี้

บทความ​นี้​เป็น บท​แปล​ภาษา​ไทย​อย่าง​ไม่​เป็น​ทางการ ของ “Hexagonal architecture (Ports and Adapters)” โดย Alistair Cockburn หนึ่ง​ใน​ผู้​ร่วม​ร่าง Agile Manifesto เผยแพร่​ครั้ง​แรก​เมื่อ 4 กันยายน 2005 (ใน​ชื่อ HaT Technical Report 2005.02) และ​ปรับปรุง​เรื่อย​มา​บน alistair.cockburn.us

แปล​และ​เรียบเรียง​เพื่อ​การ​ศึกษา — ไดอะแกรม​เชิง​แนวคิด​วาด​ใหม่​เป็น​ภาษา​ไทย (ใน​ต้นฉบับ Cockburn วาด​แกน application เป็น​รูป​หก​เหลี่ยม (hexagon) ที่​มี port อยู่​รอบ​ด้าน แต่ Mermaid วาด​หก​เหลี่ยม​ไม่​สะดวก จึง​คลี่​ออก​มา​เป็น​ผัง​ไหล​ซ้าย-ขวา​แทน โดย​ยัง​รักษา​ความหมาย “ข้าง​ใน​หนึ่ง​เดียว ล้อม​รอบ​ด้วย adapter หลาย​ตัว” ไว้)

ศัพท์​เทคนิค​คง​ไว้​เป็น​ภาษา​อังกฤษ​ตาม​ต้นฉบับ ลิขสิทธิ์​ของ​เนื้อหา​ต้นฉบับ​เป็น​ของ​ผู้​เขียน

ทำให้ application สามารถ​ถูก​ขับ​เคลื่อน​ได้​เท่า ๆ กัน​ไม่​ว่า​จะ​โดย​ผู้​ใช้​ที่​เป็น​มนุษย์ โดย​โปรแกรม​อื่น โดย test อัตโนมัติ หรือ​โดย script แบบ batch และ​ให้​พัฒนา-ทดสอบ​มัน​ได้​แบบ​แยก​เดี่ยว (in isolation) โดย​ไม่​ต้อง​พึ่ง​อุปกรณ์​และ​ฐาน​ข้อมูล​จริง​ใน​เวลา​รัน (run-time)

เมื่อ​มี​คำร้อง (request) วิ่ง​เข้า​มา​จาก​ภายนอก มัน​จะ​ถูก​แปลง​จาก protocol เฉพาะ​ของ​อุปกรณ์​นั้น ๆ ให้​กลาย​เป็นการ​เรียก​ใช้งาน (procedure call) หรือ​ข้อความ (message) ที่ application เข้าใจ แล้ว​ส่ง​ต่อ​เข้าไป​ยัง application กล่าว​คือ application “ไม่รู้เรื่อง” เกี่ยว​กับ​ธรรมชาติ​ของ​อุปกรณ์ input เลย

และ​เมื่อ application มี output จะ​ส่ง​กลับ​ออก​ไป มัน​ก็​ส่ง​ออก​ผ่าน​กลไก​เดียวกัน​นี้​ใน​ทิศทาง​กลับ​กัน จาก​ภายใน​ออก​ไป​ยัง​ภายนอก

การ​มี​จุด​แยก​แบบ​นี้​ทำให้​เรา​สามารถ​สร้าง adapter ตัว​ใหม่​มา​ต่อ​ได้​ใน​ภายหลัง โดยที่​ตัว application ไม่​รับรู้​ด้วย​ซ้ำ​ว่า​มัน​คุย​อยู่​กับ​อะไร

ปัญหา​ที่ Ports and Adapters ตั้งใจ​แก้ คือ​ปัญหา​สุด​คลาสสิก​สอง​ข้อ​ที่​กัด​กิน​ซอฟต์แวร์​มา​นาน และ​ทั้ง​สอง​ข้อ​จริง ๆ แล้ว​เป็น “ความ​ผิดพลาด​เดียวกัน” ที่​ปรากฏ​คนละ​ฝั่ง

ฝั่ง​ผู้​ใช้ — business logic รั่ว​ไป​ปน​อยู่​ใน code UI. ความ​ผิดพลาด​ที่​พบ​บ่อย​ที่สุด​คือ​ปล่อย​ให้​ตรรกะ​ทาง​ธุรกิจ​ซึม​เข้าไป​อยู่​ใน code ของ​หน้า​จอ ผล​ที่​ตาม​มา​มี​สาม​เรื่อง​ที่​ทบ​ต้น​กัน:

  • ทดสอบ​อัตโนมัติ​ไม่​ได้ เพราะ​ตรรกะ​ที่​อยาก​ทดสอบ​ผูก​ติด​กับ​รายละเอียด​ของ​หน้า​จอ​ที่​เปลี่ยน​บ่อย​และ test ยาก
  • ยก​จาก​ระบบ​ที่​มนุษย์​ขับ​มา​เป็น​ระบบ​ที่​รัน​แบบ batch ไม่​ได้ เพราะ​ตัว logic เรียก​ใช้งาน​ได้​ก็​ต่อ​เมื่อ​มี​คน​กด​ปุ่ม​บน​หน้า​จอ​เท่านั้น
  • ให้​โปรแกรม​อื่น​มา​ขับ application ของ​เรา​ไม่​ได้ ใน​เมื่อ​ทาง​เข้า​เดียว​คือ GUI

ฝั่ง server — application พัน​กัน​แน่น​กับ​ฐาน​ข้อมูล. ใน​อีก​ด้าน​หนึ่ง ตรรกะ​ของ application ไป​ผูก​กับ​การ​มี​อยู่​ของ​ฐาน​ข้อมูล​ภายนอก​จน​แยก​ไม่​ออก นัก​พัฒนา​ต้อง​คอย​และ​งาน​สะดุด​หยุด​ชะงัก​ทุก​ครั้ง​ที่​ฐาน​ข้อมูล​ยัง​ไม่​พร้อม กำลัง​ถูก​เปลี่ยน หรือ​ไม่มี​ให้​ใช้​ใน​เวลา​ที่​ต้องการ​เขียน​หรือ​ทดสอบ code

องค์กร​มัก​พยายาม​แก้​ด้วย​การ​เพิ่ม “layer” (layer) ใหม่​ใน​สถาปัตยกรรม พร้อม​สัญญา​เสียง​แข็ง​ว่า​คราว​นี้​จะ​ไม่​เอา business logic ไป​ปน​อีก​แล้ว แต่​เนื่องจาก​ไม่มี​กลไก​ตรวจ​จับ​ว่า​เมื่อไหร่​คำ​สัญญา​นั้น​ถูก​ละเมิด อีก​ไม่​กี่​ปี​ต่อ​มา​ก็​จะ​พบ​ว่า code ปน​กัน​กลับ​มา​เหมือน​เดิม

ทางออก​ที่​เรา​ต้องการ​คือ​ให้ ทุก logic ที่​น่า​สนใจ​ของ application เปิด​ให้​เรียก​ผ่าน API ได้​เต็ม​รูปแบบ โดย​ไม่​ต้อง​ผ่าน UI หรือ​ฐาน​ข้อมูล​จริง เมื่อ​ทำได้​เช่น​นั้น​แล้ว:

  • ทำ automated regression test ได้​เต็ม​ระบบ
  • ผู้เชี่ยวชาญ​ด้าน​ธุรกิจ​เขียน​เคส​ทดสอบ​อัตโนมัติ​ได้​ตั้งแต่​ก่อน GUI จะ​เสร็จ
  • รัน application แบบ “headless” (ไม่มีหน้า​จอ) เพื่อ​ให้​โปรแกรม​อื่น​มา​เรียก​ใช้ function ได้
  • และ​เมื่อ business logic เผลอ​รั่ว​ออก​ไป​นอก​ที่ทาง​ของ​มัน ก็​ตรวจ​จับ​และ​ดึง​กลับ​ได้​ง่าย​ขึ้น

กุญแจ​สำคัญ​คือ​มอง​ให้​ออกว่า​ปัญหา​ทั้ง​ฝั่ง​ผู้​ใช้​และ​ฝั่ง server นั้น แท้จริง​เป็น ความ​ผิดพลาด​เดียวกัน: การ​เอา business logic ไป​พัน​กับ code ที่​ทำ​หน้าที่​คุย​กับ​สิ่ง​ภายนอก

ความ​ไม่​สมมาตร (asymmetry) ที่​เรา​ควร​ใช้​ประโยชน์ ไม่ใช่ “ซ้าย-ขวา” หรือ “บน-ล่าง” แต่​เป็น ข้าง​ใน-ข้าง​นอก (inside vs outside) กฎ​มี​ข้อ​เดียว​และ​เรียบ​ง่าย: code ที่​อยู่​ส่วน “ข้าง​ใน” ต้อง​ไม่​รั่ว​ออก​ไป​ปน​กับ​ส่วน “ข้าง​นอก” เมื่อ​ยึด​กฎ​นี้ ปัญหา​ทั้ง​สอง​ข้อ​ข้าง​ต้น​ก็​หาย​ไป​พร้อม​กัน

Port (ช่อง​ทางการ​สนทนา). คำ​ว่า port ชวน​ให้​นึกถึง port ของ​ระบบ​ปฏิบัติการ หรือ port ของ​อุปกรณ์​อิเล็กทรอนิกส์ protocol ของ​แต่ละ port ไม่​ได้​ถูก​กำหนด​ด้วย​เทคโนโลยี แต่​ถูก​กำหนด​ด้วย จุด​ประสงค์​ของ​การ​สนทนา ที่​เกิด​ขึ้น​ตรง​นั้น และ​ใน​ทาง code มัน​ปรากฏ​ออก​มา​ใน​รูป​ของ API หรือ interface กล่าว​คือ port คือ “หน้าตา​ของ​การ​คุย​กัน” หนึ่ง​ชุด ที่​ประกาศ​ไว้​ที่​ขอบ​ของ application

Adapter (ตัว​แปลง). adapter คือ​ชิ้น​ส่วน​ที่​แปลง​ระหว่าง​นิยาม​ของ API ที่ port ประกาศ​ไว้ กับ​สัญญาณ/รูปแบบ​ที่​อุปกรณ์​หรือ​เทคโนโลยี​จริง​ต้องการ ตัวอย่าง​เช่น GUI ก็​คือ adapter ตัว​หนึ่ง​ที่​แปลง​การ​เคลื่อนไหว​ของ​มนุษย์ (คลิก พิมพ์) ให้​กลาย​เป็นการ​เรียก API ของ port ฝั่ง​ขับ และ​จุด​สำคัญ​คือ 1 port รองรับ adapter ได้​หลาย​ตัว: นอกจาก GUI แล้ว ยัง​ต่อ test harness (ชุด​เครื่องมือ​ทดสอบ), ตัว​ขับ​แบบ batch, หรือ​ช่อง​ทาง​ให้ application อื่น​เข้า​มา​คุย​ได้​ทั้งหมด โดยที่​เนื้อ​ใน​ของ application ไม่​ต้อง​เปลี่ยน

ฝั่ง​ฐาน​ข้อมูล​ก็​เช่น​กัน adapter อาจ​เป็น SQL adapter, flat-file adapter, หรือ mock (in-memory) database adapter การ​สนทนา​ที่ application ทำ​ผ่าน API นั้น​เหมือน​เดิม​ทุก​ประการ ไม่​ว่า​เทคโนโลยี​จัด​เก็บ​ข้อมูล​จริง​จะ​เปลี่ยน​ไป​เป็น​อะไร

ทำไม​ต้อง hexagon (หก​เหลี่ยม)?

Cockburn ย้ำ​ว่า​เลข “หก” ไม่มี​ความหมาย​พิเศษ ไม่​ได้​แปล​ว่า​ต้อง​มี6 port เขา​เลือก​วาด​เป็น hexagon ด้วย​เหตุผล​ทาง​ภาพ​ล้วน ๆ คือ

  • (1) เพื่อ​เน้น​ความ​ไม่​สมมาตร ข้าง​ใน-ข้าง​นอก และ​แสดง​ว่า​มี port อยู่​หลาย​ด้าน
  • (2) เพื่อ​กัน​ไม่​ให้​คน​คิด​แบบ “layer ซ้อน​กัน​มิติ​เดียว” (บน-กลาง-ล่าง) ที่​ชวน​ให้​ลาก business logic ไหล​ลง​มา​ปน​กับ persistence
  • (3) เพื่อ​ให้​มี​ที่​ว่าง​พอ​วาด port ราว 2 ถึง 4 ด้าน ซึ่ง​เป็น​จำนวน​ที่​พบ​บ่อย​ใน​ทาง​ปฏิบัติ

ด้วย​เหตุ​ที่​ชื่อ “hexagonal” ทำให้​คน​ติด​กับ​เลข​หก​อยู่​บ่อย ๆ ภายหลัง Cockburn จึง​ชอบ​เรียก​รูปแบบ​นี้​ด้วย​ชื่อ​ที่​ตรง​ความหมายกว่า​ว่า Ports and Adapters

ใน​รูปแบบ​ที่​ง่าย​ที่สุด application หนึ่ง​ตัว (แกน​ที่​มี business logic) จะ​สื่อสาร​กับ​โลก​ภายนอก​ผ่าน port เสมอ และ​ทุก​ครั้ง​ที่​ผ่าน port จะมี adapter คั่น​อยู่​เพื่อ​แปลง​เทคโนโลยี ด้าน​หนึ่ง​เป็น​ฝั่ง​ที่ “ขับ” application อีก​ด้าน​เป็น​ฝั่ง​ที่​ถูก application “ขับ”

flowchart LR
  Human[Human user] --> UIAdapter[UI adapter]
  UIAdapter --> LeftPort[Driving port API]
  LeftPort --> App[Application core - business logic]
  App --> RightPort[Driven port API]
  RightPort --> DBAdapter[Database adapter]
  DBAdapter --> DB[Database]

ภาพ 1 — รูปแบบ​พื้นฐาน​ของ Ports and Adapters แกน application อยู่​ตรง​กลาง ไม่รู้จัก​ทั้ง​มนุษย์​และ​ฐาน​ข้อมูล​โดยตรง มัน​รู้จัก​แค่ port (คือ API) เท่านั้น ส่วน adapter คือ​ชั้น​แปลง​ระหว่าง port กับ​เทคโนโลยี​จริง · ดู​ภาพ​ต้นฉบับ ↗

พอ​เรา​เห็น​แล้ว​ว่า “1 port ต่อ​ได้​หลาย adapter” ภาพ​ก็​ขยาย​ออก​เป็น​แบบ​ต่อ​ไป​นี้

ฝั่ง​ซ้าย (ฝั่ง​ขับ) มี adapter ได้​หลาย​แบบ เช่น test อัตโนมัติ, มนุษย์​ผ่าน GUI, application ที่​คุย​ผ่าน HTTP, หรือ application ใน​เครื่อง​เดียวกัน

ส่วน​ฝั่ง​ขวา (ฝั่ง​ถูก​ขับ) ก็​สลับ​ได้​ระหว่าง​ฐาน​ข้อมูล​จำลอง​ใน​หน่วย​ความ​จำ (in-memory mock), ฐาน​ข้อมูล​สำหรับ test, และ​ฐาน​ข้อมูล​จริง​ตอน​รัน

flowchart LR
  Test[Automated test] --> DriveAd[Driving adapters]
  User[Human user] --> DriveAd
  Http[HTTP application] --> DriveAd
  Local[Local application] --> DriveAd
  DriveAd --> DrivePort[Driving port API]
  DrivePort --> App[Application]
  App --> DrivenPort[Driven port API]
  DrivenPort --> DrivenAd[Driven adapters]
  DrivenAd --> Mock[In-memory mock database]
  DrivenAd --> TestDB[Test database]
  DrivenAd --> RunDB[Runtime database]

ภาพ 2 — application ที่​มี2 port ทำงาน​อยู่ และ​แต่ละ port มี adapter หลาย​ตัว​สลับ​กัน​ได้ ข้อ​กำหนด​เชิง​หน้าที่ (functional specification) ของ application ถูก​ตรึง​ไว้​ที่​ระดับ interface ของ hexagon ชั้น​ใน ไม่ใช่​ที่​ตัว adapter · ดู​ภาพ​ต้นฉบับ ↗

หาก​นำ hexagon ไป​เทียบ​กับ​ภาพ “สถาปัตยกรรม​แบบ layer” ที่​คุ้น​เคย ก็​จะ​ได้​ว่า adapter ฝั่ง​ผู้​ใช้​อยู่​ด้าน​บน แกน application อยู่​ตรง​กลาง และ adapter ฝั่ง​ข้อมูล​อยู่​ด้าน​ล่าง

flowchart TB
  A1[User-side adapters - FIT, GUI, batch] --> P1[Driving port]
  P1 --> APP[Application - business logic]
  APP --> P2[Driven port]
  P2 --> A2[Data-side adapters - mock, SQL, flat file]

ภาพ 3 — Ports and Adapters เทียบ​กับ​ผัง3 layer ลำดับ​ที่ adapter ถูก​ทยอย​ต่อ​เข้า​มาระหว่าง​พัฒนา​มัก​เป็น​ดังนี้: (1) ต่อ FIT test harness คู่​กับ​ฐาน​ข้อมูล​จำลอง (2) เพิ่ม GUI แต่​ยัง​ใช้​ฐาน​ข้อมูล​จำลอง​อยู่ (3) ทำ integration test ด้วย script อัตโนมัติ คู่​กับ​ฐาน​ข้อมูล​จริง (4) ใช้งาน​จริง มนุษย์​ขับ คู่​กับ​ฐาน​ข้อมูล​จริง · ดู​ภาพ​ต้นฉบับ ↗

ตัวอย่าง​ใน​ต้นฉบับ​ใช้​ตรรกะ​ที่​เล็ก​จน​เห็น​ภาพ​ได้​ทั้งหมด นั่น​คือ​ส่วนลด:

discount(amount) = amount * rate(amount)

โดย amount มา​จาก​ผู้​ใช้ (เข้า​มา​ทาง port ฝั่ง​ขับ) ส่วน rate มา​จาก​ฐาน​ข้อมูล (ออก​ไป​ทาง port ฝั่ง​ถูก​ขับ) เรา​จะ​ประกอบ​ระบบ​ที​ละ​ขั้น โดยที่ ตัว application แทบ​ไม่​เปลี่ยน​เลย เปลี่ยน​แค่ adapter รอบ ๆ

ขั้น​ที่ 1 — FIT + application + ฐาน​ข้อมูล​จำลอง​แบบ​ค่า​คงที่. เริ่ม​จาก​ทดสอบ​ก่อน​มี UI ด้วย​กรอบ test FIT โดย​เขียน​พฤติกรรม​ที่​คาด​หวัง​ลง​ใน​ตาราง HTML แล้ว​ให้ ColumnFixture ทำ​หน้าที่​เป็น adapter ฝั่ง​ขับ แปลง​แต่ละ​แถว​ของ​ตาราง​ให้​กลาย​เป็นการ​เรียก application:

eg.Discounter
amount discount()
100 5
200 10
package eg;
import fit.ColumnFixture;
// ColumnFixture คือ adapter ฝั่งขับ: มันอ่านค่าจาก column ในตาราง FIT
// แล้วเรียก application จากนั้นเอาผลลัพธ์ไปเทียบกับ column ค่าคาดหวัง
public class Discounter extends ColumnFixture {
private Application app = new Application();
public double amount; // input จากตาราง
public double discount() { // output ที่ FIT จะตรวจสอบ
return app.discount(amount);
}
}

รัน​ได้​จาก​บรรทัด​คำ​สั่ง​ผ่าน FIT FileRunner จึง​เสียบ​เข้า​กับ​ระบบ build อัตโนมัติ (เช่น CruiseControl) ได้​ทันที

ใน​ขั้น​นี้ rate ยัง​ถูก​ฝัง​เป็น​ค่า​คงที่​แบบ​แบน​ราบ​ไว้​ใน​ตัว application คือ 5% คงที่ จึง​ได้​ผลลัพธ์ 100 → 5 และ 200 → 10 เสมือน​ฐาน​ข้อมูล​จำลอง​ที่​ง่าย​ที่สุด

ทั้งนี้​ค่า 5% คงที่​ของ​ขั้น​ที่ 1 เป็น​คนละ​เรื่อง​กับ​อัตรา​แบบ​ขั้น​บันได 1%/2%/5% ที่​จะ​เพิ่ม​เข้า​มา​ใน​ขั้น​ที่ 3

ขั้น​ที่ 2 — UI + application + ฐาน​ข้อมูล​จำลอง​แบบ​ค่า​คงที่. ต่อ GUI adapter เข้า​มา​แยก​ต่างหาก โดย​ไม่​แตะ code application ผล​คือ​ตอน​นี้​ทดสอบ​ได้​ทั้ง​แบบ​อัตโนมัติ (ผ่าน FIT) และ​แบบ​มือ (ผ่าน​หน้า​จอ) พร้อม​กัน

ขั้น​ที่ 3 — (FIT หรือ UI) + application + ฐาน​ข้อมูล​จำลอง. ถึง​คราว​แทนที่​ค่า​คงที่​ด้วย adapter ฐาน​ข้อมูล​ที่​สลับ​ได้​จริง เรา​แยก interface RateRepository ออก​มา​เป็น port ฝั่ง​ถูก​ขับ แล้ว​ให้ RepositoryFactory เป็น​ตัว​เลือก​ว่า​จะ​คืน mock หรือ​ของ​จริง ส่วน application รับ repository เข้า​มา​ทาง constructor ซึ่ง​ก็​คือ dependency injection:

// port ฝั่งถูกขับ: application ประกาศว่าต้องการอะไร โดยไม่พูดถึงเทคโนโลยี
public interface RateRepository {
double getRate(double amount);
}
// adapter จำลองในหน่วยความจำ ใช้ตอน test ไม่ต้องมีฐานข้อมูลจริง
public class MockRateRepository implements RateRepository {
public double getRate(double amount) {
if (amount <= 100) return 0.01;
if (amount <= 1000) return 0.02;
return 0.05;
}
}
// factory ตัดสินใจว่าจะให้ adapter ตัวไหน โดย application ไม่รับรู้
public class RepositoryFactory {
public static RateRepository getMockRateRepository() {
return new MockRateRepository();
}
// getDatabaseRateRepository() ... คืน adapter ที่คุย SQL จริง
}
public class Application {
private final RateRepository rateRepository;
// ฉีด adapter ผ่าน constructor: สลับ mock กับของจริงได้โดยไม่แก้ logic
public Application(RateRepository repo) {
this.rateRepository = repo;
}
public double discount(double amount) {
return amount * rateRepository.getRate(amount);
}
}

พอ​โครง​เป็น​แบบ​นี้ การ​เปลี่ยน mock ให้​เป็น​ฐาน​ข้อมูล​จริง​จึง​เป็น​เพียง​การ​เปลี่ยน adapter ที่ factory ส่ง​ให้ ตัว Application ไม่​เปลี่ยน​แม้แต่​บรรทัด​เดียว นี่​คือ​หัวใจ​ของ​ทั้ง​รูปแบบ

แม้ port ทุก​ด้าน​จะ​เท่าเทียม​กัน​ใน​เชิง​แนวคิด แต่​ใน​ทาง​ปฏิบัติ​มี​ความ​ต่าง​ที่​เป็น​ประโยชน์​ระหว่าง ฝั่ง​ซ้าย/บน (primary) กับ ฝั่ง​ขวา/ล่าง (secondary):

  • Primary actor (ฝั่ง​ขับ). คือ actor ที่​เป็น​ฝ่าย​เริ่ม​บทสนทนา กระตุ้น​ให้ application เริ่ม​ทำงาน​จาก​สภาวะ​นิ่ง เรา​จึง​วาด​ไว้​ด้าน​ซ้าย​หรือ​ด้าน​บน​ของ hexagon ฝั่ง​นี้​เข้า​กัน​ได้​ดี​ที่สุด​กับ adapter ทดสอบ​อย่าง FIT เพราะ FIT ถูก​ออกแบบ​มา​เพื่อ​อ่าน script แล้ว​ขับ application
  • Secondary actor (ฝั่ง​ถูก​ขับ). คือ actor ที่​ถูก application เรียก​ใช้ เพื่อ​ตอบ​คำถาม (เช่น ฐาน​ข้อมูล) หรือ​เพื่อ​รับ​การ​แจ้ง​เตือน (เช่น ระบบ​ส่ง​อีเมล) เรา​วาด​ไว้​ด้าน​ขวา​หรือ​ด้าน​ล่าง ฝั่ง​นี้​เข้า​กัน​ได้​ดี​ที่สุด​กับ adapter ทดสอบ​อย่าง mock object เพราะ​มัน​ถูก​ออกแบบ​มา​เพื่อ​ตอบ​คำถาม​และ​บันทึก​เหตุการณ์​ที่​ถูก​เรียก

การ​แยก​นี้​สอดคล้อง​กับ​แนวคิด primary/secondary actor ใน​การ​เขียน use case และ​กับ context diagram ของ​ระบบ​พอดี ประโยชน์​ที่แท้​จริง​เกิด​จาก​การ​ที่​แต่ละ​ฝั่ง​รัน​แบบ​แยก​เดี่ยว​ได้ ไม่ใช่​จาก​การ​เอา​ไป​ใช้​เป็น​ทาง​ลัด​ทาง​สถาปัตยกรรม

ความ​ผิดพลาด​ที่​พบ​บ่อย​คือ​เขียน use case โดย​ยัด​ความ​รู้เรื่อง​เทคโนโลยี​ของ port ภายนอก​เข้าไป​ด้วย (เช่น อ้าง​ถึง​หน้า​จอ ปุ่ม หรือ​รูปแบบ​ตาราง​ใน​ฐาน​ข้อมูล) ผล​คือ specification ยาว อ่าน​ยาก เปราะ และ​แพง​ใน​การ​ดูแล

แนวทาง​ที่​ถูกต้อง​คือ เขียน use case ที่​ขอบ​ของ application (คือ​ที่ hexagon ชั้น​ใน) โดย​ระบุ​แต่ function และ​เหตุการณ์​ใน​เชิง​จุด​ประสงค์ ไม่​พูด​ถึง​เทคโนโลยี​ภายนอก​เลย specification ที่​ได้​จะ​สั้น​ลง อ่าน​ง่าย​ขึ้น ดูแล​ถูก​ลง และ​ทน​ต่อ​การ​เปลี่ยนแปลง​ตาม​กาล​เวลา​ได้​ดี​กว่า​มาก

คำถาม​นี้​ไม่มี​สูตร​ตายตัว ลอง​นึกถึง​สอง​สุด​ขั้ว: ปลาย​ด้าน​หนึ่ง​คือ​ให้​ทุก use case มี port ของ​ตัวเอง (ได้​เป็น​ร้อย ๆ port) อีก​ปลาย​คือ​ยุบ port ฝั่ง​ขับ​ทั้งหมด​เป็น​หนึ่ง และ port ฝั่ง​ถูก​ขับ​ทั้งหมด​เป็น​หนึ่ง (เหลือ​แค่​สอง) ทั้ง​สอง​สุด​ขั้ว​ดู​จะ​ไม่​เหมาะ ตัวอย่าง​ที่​พบ​จริง เช่น:

  • ระบบ​แจ้ง​เตือน​สภาพ​อากาศ — 4 port: feed ข้อมูล​อากาศ, ผู้​ดูแล​ระบบ, สมาชิก​ที่​รับ​การ​แจ้ง​เตือน, และ​ฐาน​ข้อมูล​สมาชิก
  • เครื่อง​ชง​กาแฟ — 4 port: ผู้​ใช้, ฐาน​ข้อมูล​สูตร/ราคา, ชุด​จ่าย (dispenser), และ​กล่อง​รับ​เหรียญ
  • ระบบ​จ่าย​ยา​ใน​โรงพยาบาล — 3 port: พยาบาล, ฐาน​ข้อมูล​ใบสั่ง​ยา, และ​ชุด​จ่าย​ยา

การ​เลือก​จำนวน port ที่ “ผิด” ไป​บ้าง​ไม่​ได้​สร้าง​ความ​เสียหาย​อะไร​มาก มัน​เป็น​เรื่อง​ของ​สัญชาตญาณ Cockburn เอง​มัก​เอนเอียง​ไป​ทาง​จำนวน​น้อย คือ​ราว 2 ถึง 4 port

flowchart LR
  WeatherFeed[National weather service feed] --> FeedAdapter[Feed adapter]
  FeedAdapter --> P1[Weather feed port]
  Admin[Administrator] --> AdminAdapter[Admin GUI adapter]
  AdminAdapter --> P2[Administration port]
  P1 --> APP[Weather alert application]
  P2 --> APP
  APP --> P3[Notification port]
  P3 --> NotifyAdapter[Telephone or email adapter]
  NotifyAdapter --> Subscriber[Subscriber]
  APP --> P4[Subscriber database port]
  P4 --> DBAdapter[Database adapter]
  DBAdapter --> SubDB[Subscriber database]

ภาพ 4 — application แจ้ง​เตือน​สภาพ​อากาศ ที่​เดิม​ใช้​แจ้ง​เตือน​แผ่นดิน​ไหว พายุ​ทอร์นาโด ไฟ​ไหม้ และ​น้ำ​ท่วม​จาก​กรม​อุตุนิยมวิทยา​ไป​ยัง​โทรศัพท์/เครื่อง​ตอบ​รับ​ของ​สมาชิก มี4 port ได้แก่ feed จาก​กรม​อุตุนิยมวิทยา, การ​แจ้ง​เตือน​สมาชิก (โทรศัพท์/เครื่อง​ตอบ​รับ/อีเมล), หน้า​จอ​ผู้​ดูแล​ระบบ, และ​ฐาน​ข้อมูล​สมาชิก · ดู​ภาพ​ต้นฉบับ ↗

ตอน​แรก​ทีม​พัฒนา​ระบบ​แจ้ง​เตือน​สภาพ​อากาศ​พยายาม​นิยาม interface ตาม เทคโนโลยี ที่​ใช้​อยู่ (เช่น สาย​ที่​รับ feed มา, สาย​โทรศัพท์​ที่​ใช้​แจ้ง​เตือน)

พอ​มี​คำขอ​ให้​เพิ่ม feed แบบ HTTP, เพิ่ม​การ​แจ้ง​เตือน​ทาง​อีเมล, และ​ให้​จับ​กลุ่ม​บริการ​ต่าง​กัน​ตาม​ความ​ต้องการ​ของ​ลูกค้า​แต่ละ​ราย สถาปัตยกรรม​เดิม​ก็​เริ่ม​พัง

เมื่อ​ทีม​เปลี่ยน​มุมมอง​มา​จัด​สถาปัตยกรรม​ตาม จุด​ประสงค์ แทน​เทคโนโลยี โดย​ให้ adapter เป็น​สิ่ง​ที่​สลับ​ได้ ผล​ก็​พลิก:

  • เพิ่ม HTTP adapter และ email adapter ได้​ง่าย
  • รัน​แบบ headless ผ่าน API ได้ ทำให้​ต่อ app-to-app adapter ให้​ระบบ​อื่น​มา​ใช้ได้
  • ต่อ test/mock adapter เพื่อ​ทำ regression test แบบ​แยก​เดี่ยว​ได้

รูปแบบ​นี้​ปรากฏ​ใน​สิ่ง​ที่​เรา​ใช้​กัน​ทุก​วัน ระบบ​ปฏิบัติการ Macintosh (ต้น​ยุค 1990) เรียกร้อง​ให้​ทุก interface ต้อง​ขับ​ผ่าน API ได้ Windows ก็​วิวัฒน์​ไป​ใน​ทาง​เดียวกัน

และ​ปรากฏการณ์ Web 2.0 (ราว​ปี 2005) ก็​คือ​การ​ที่​บริการ​ต่าง ๆ เปิด API ของ port ฝั่ง​ขับ ออก​สู่​สาธารณะ เพื่อ​ให้ application ของ​บุคคล​ที่​สาม​เข้า​มา​เรียก​ใช้ได้​ตรง ๆ เช่น การ​เอา​ข้อมูล​อาชญากรรม​ใน​ท้องถิ่น​ไป​วาง​ซ้อน​บน Google Maps หรือ​การ​ฝัง​ความ​สามารถ​ด้าน​ภาพถ่าย​ของ Flickr เข้าไป​ใน application อื่น

ข้อสังเกต​คือ​กระแส​นี้​พูด​ถึง​แต่ port ฝั่ง​ขับ​เป็น​หลัก ส่วน port ฝั่ง​ถูก​ขับ​แทบ​ไม่​ถูก​กล่าว​ถึง

มี​ตัวอย่าง​หนึ่ง​ที่ Willem Bogaerts เขียน​ไว้​บน C2 wiki ว่าด้วย application ที่​ต้อง “พัก” output ไว้​เพื่อ​เลือก​เก็บ​บาง​ส่วน

สถาปัตยกรรม​แบบ layer ดั้งเดิม​จะ​เน้น​แยก UI ออก​จาก​ที่​จัด​เก็บ (storage) แต่​ใน​มุม​ของ Ports and Adapters “output” กลาย​เป็น​เรื่อง​ที่​เป็นกลาง โดย​ให้​ตัว​ควบคุม​การนำ​เสนอ (presentation control) จัดการ​ทั้ง​การ​พัก​ข้อมูล​ไว้​ชั่วคราว (buffer) และ​การ​เก็บ ผล​คือ​สถาปัตยกรรม​ด้าน output เรียบ​ง่าย​ลง

อีก​อุปมา​ที่ Cockburn ชอบ​ใช้​คือ เครื่อง​เสียง​แบบ​แยก​ชิ้น (component stereo) แต่ละ​ชิ้น​มี interface ที่​นิยาม​ไว้​ชัด​ตาม​จุด​ประสงค์ และ​ต่อ​เข้า​ด้วย​กัน​ได้​ไม่​จำกัด​รูปแบบ​ผ่าน​สาย​และ adapter นี่​คือ​ภาพ​ของ​การ​ประกอบ​ระบบ​ที่​ยืดหยุ่น​ที่​เรา​ต้องการ

พัฒนา​แบบ​ทีม​กระจาย​และ​ทีม​ใหญ่ (สถานะ: ยัง​ทดลอง)

หัวข้อ​ที่​มีชื่อ​ว่า “พัฒนา​แบบ​ทีม​กระจาย​และ​ทีม​ใหญ่ (สถานะ: ยัง​ทดลอง)”

ทีม​ที่​อยู่​คนละ​ที่​ต่าง​สร้าง​ชิ้น​ส่วน​ของ​ตน​ให้​เข้า​กับ Ports and Adapters โดย​ใช้ FIT และ mock เพื่อ​ทดสอบ application/คอมโพเนนต์ของ​ตัวเอง​แบบ​แยก​เดี่ยว ระบบ build (CruiseControl) รัน​ทุก​ครึ่ง​ชั่วโมง​ด้วย​ชุด FIT บวก mock พอ subsystem ไหน​เสร็จ ก็​ค่อย​เปลี่ยน mock เป็น​ฐาน​ข้อมูล test จริง

แยก​การ​พัฒนา UI ออก​จาก application logic (สถานะ: ยัง​ทดลอง​ระยะ​แรก)

หัวข้อ​ที่​มีชื่อ​ว่า “แยก​การ​พัฒนา UI ออก​จาก application logic (สถานะ: ยัง​ทดลอง​ระยะ​แรก)”

บริบท​คือ​เทคโนโลยี​และ metaphor ของ UI ยัง​ไม่​นิ่ง สถาปัตยกรรม​ของ​บริการ​เบื้องหลัง​ก็​ยัง​ไม่​ตัดสิน​ใจ​และ​คาด​ว่า​จะ​เปลี่ยน​หลาย​รอบ​ใน​หก​เดือน แต่ project เริ่ม​แล้ว​และ​มี​แรง​กดดัน​เรื่อง​กำหนด​เวลา

ทีม application จึง​สร้าง FIT test และ mock เพื่อ​แยก​ตัว​ออก​มา ทำให้​มี function ที่​ทดสอบ​ได้​และ​สาธิต​ให้​ผู้​ใช้​ดู​ได้​ก่อน โดย​ยัง​เลื่อน​การ​ตัดสิน​ใจ​เรื่อง UI และ​บริการ​เบื้องหลัง​ออก​ไป ตั้งใจ​ให้การ​เติม UI และ​บริการ​เข้า​มา​ภายหลัง “ทำได้​อย่าง​ตรง​ไป​ตรง​มา” เมื่อ​ตัดสิน​ใจ​เสร็จ

  • Adapter. หนังสือ Design Patterns นิยาม Adapter ว่า “แปลง interface ของ class หนึ่ง​ให้​เป็น​อีก interface ที่ client คาด​หวัง” — Ports and Adapters ก็​คือ​การนำ Adapter pattern มา​ใช้​อย่าง​เป็น​ระบบ​ทั้ง​สอง​ฝั่ง​ของ application
  • Model-View-Controller (MVC). ปรากฏ​ตั้งแต่ Smalltalk ปี 1974 และ​มี​สาย​พันธุ์​อย่าง Model-Interactor และ Model-View-Presenter แต่ละ​แบบ​คือ Ports and Adapters ที่​ทำ​เฉพาะ port ฝั่ง​ขับ เท่านั้น ไม่​ครอบคลุม​ฝั่ง​ถูก​ขับ
  • Mock Objects และ Loopback. mock object คือ “สายลับ​สอง​หน้า” ที่​ใช้​ทดสอบ​พฤติกรรม​ของ object อื่น มัน​เลียน​แบบ interface ของ actor ภายนอก คอย​สังเกต​วิธี​ที่​ถูก​เรียก แล้ว​รายงาน​เมื่อ​ผิด​จาก​ที่​คาด Ports and Adapters ยืม​คำ​ว่า “mock” มา​ใช้​เรียก actor ภายนอก​จำลอง​ใน​หน่วย​ความ​จำ ส่วน Loopback คือ​การ​วางตัวแทน​ภายใน​ไว้​แทน​อุปกรณ์​ภายนอก​อย่าง​ชัด​แจ้ง
  • Pedestal. รูปแบบ​ของ Barry Rubel (จาก Patterns for Generating a Layered Architecture) สร้าง​แกน​สมมาตร​ใน​ซอฟต์แวร์​ควบคุม โดย​สร้าง object แทน​อุปกรณ์​ฮาร์ดแวร์​แล้ว​เชื่อม​ผ่าน​ชั้น​ควบคุม มัน​อธิบาย​ฝั่ง​ใด​ฝั่ง​หนึ่ง​ของ hexagon ได้ แต่​ไม่​ได้​เน้น​ว่า adapter ทั้ง​สอง​ฝั่ง​มี​ความ​คล้าย​กัน และ​บริบท​ของ​มัน​คือ​การ​ควบคุมเชิงกลไก ซึ่ง​ไม่​ตรง​กับ​งาน IT เสียที​เดียว
  • Checks. ภาษา​แห่ง​รูปแบบ​ของ Ward Cunningham ว่าด้วย​การ​ตรวจ​จับ​และ​จัดการ​ข้อ​ผิดพลาด​ของ input จาก​ผู้​ใช้ ใช้ได้​ตรง​บริเวณ​ขอบ​ของ hexagon ชั้น​ใน
  • Dependency Inversion และ Dependency Injection. หลักการ​ของ Bob Martin ที่​ว่า “module ระดับ​สูง​ไม่​ควร​พึ่ง module ระดับ​ต่ำ ทั้ง​คู่ควร​พึ่ง​สิ่ง​ที่​เป็น​นามธรรม (abstraction) และ​รายละเอียด​ควร​พึ่ง​นามธรรม ไม่ใช่​กลับ​กัน” คือ​แกน​ทฤษฎี​ที่​ทำให้ Ports and Adapters เป็น​ไป​ได้ ส่วน Dependency Injection (Martin Fowler) คือ​รูปแบบ​ที่​แสดง​วิธี​สลับ adapter ของ secondary actor ได้​จริง จะ​เสียบ​ผ่าน​การ​เขียน code โดยตรง หรือ​ผ่าน file ตั้ง​ค่า (เช่น framework Spring) ก็ได้
Ports & Adapters กับ DDD: เปลือก​มาตรฐาน​ที่​ห่อ domain model

สำหรับ​งาน Domain-Driven Design Ports and Adapters คือ เปลือก (shell) มาตรฐาน ที่​ห่อ domain model เอา​ไว้​ตรง​กลาง

hexagon ชั้น​ใน​คือ domain model บริสุทธิ์ (Entities, Value Objects, Aggregates) ที่​ล้อม​รอบ​ด้วย Application Service ซึ่ง​ประสาน use case

ส่วน Repository ก็​คือ port ฝั่ง​ถูก​ขับ​ที่​ทำให้ domain ยัง​คง persistence-ignorant คือ​ไม่รู้​ว่า​ข้อมูล​ถูก​เก็บ​ด้วย​เทคโนโลยี​อะไร

ฝั่ง UI/REST/CLI/test เป็น adapter ฝั่ง​ขับ ทำให้ ทดสอบ domain แบบ​แยก​เดี่ยว ได้​โดย​ไม่​ต้อง​มี​ฐาน​ข้อมูล​จริง

flowchart LR
  Client[UI, REST, CLI, tests] --> InAdapter[Driving adapters]
  InAdapter --> AppSvc[Application Service - use cases]
  AppSvc --> Domain[Domain model - Entities, Value Objects, Aggregates]
  AppSvc --> RepoPort[Repository port]
  RepoPort --> RepoAdapter[Repository adapter - SQL, in-memory]
  RepoAdapter --> Store[Data store]

อ่าน​เพิ่ม​เรื่อง​การ​จัด​สถาปัตยกรรม​รอบ domain ได้ที่​บท Architecture & Advanced และ Application Architecture

Cockburn ขอบคุณ

  • Gyan Sharma (Intermountain Health Care) สำหรับ code ตัวอย่าง
  • Rebecca Wirfs-Brock สำหรับ​หนังสือ Object Design
  • ผู้​เขียน​หนังสือ Design Patterns สำหรับ​รากฐาน​ของ Adapter pattern
  • ชุมชน​บน wiki ของ Ward Cunningham สำหรับ​บทสนทนา​เกี่ยว​กับ​รูปแบบ​นี้​ตลอด​หลาย​ปี โดย​เฉพาะ​บทความ “hexagonal soup” ของ Kevin Rutherford (silkandspinach.net) ที่​ให้​มุมมอง​เพิ่มเติม
  • FIT — Framework for Integrated Test (Ward Cunningham)
  • Adapter pattern — Design Patterns (Gang of Four)
  • Pedestal pattern (Barry Rubel)
  • Checks — pattern language (Ward Cunningham)
  • Dependency Inversion Principle (Robert C. Martin)
  • Dependency Injection (Martin Fowler)
  • Mock Objects (Freeman และ​คณะ)
  • Loopback pattern และ​เอกสาร​เรื่อง use case (Alistair Cockburn)

📄 อ่าน​ต้นฉบับ​ภาษา​อังกฤษ: Hexagonal architecture (Ports and Adapters) — Alistair Cockburn, alistair.cockburn.us (เผยแพร่​ครั้ง​แรก 4 กันยายน 2005 ใน​ชื่อ HaT Technical Report 2005.02)