Kodokon kodokon.com

การออกแบบสถาปัตยกรรม API: routes, controllers, services

จัดโครงสร้าง API ของคุณเป็นชั้น ๆ ที่มีความรับผิดชอบเดียว แล้วเชื่อมพวกมันเข้าด้วยกันด้วยการฉีดพึ่งพา (injection) แบบเบา เพื่อให้ทดสอบได้

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

ไฟล์เดียวที่ปนกันทั้ง routing, ตรรกะทางธุรกิจ และการเข้าถึงข้อมูล ย่อมมีราคาแพงเสมอในท้ายที่สุด คุณไม่สามารถทดสอบกฎทางธุรกิจได้โดยไม่ต้องเปิดเซิร์ฟเวอร์ขึ้นมา และคุณไม่สามารถสลับโครงสร้างพื้นฐาน (infrastructure) ได้โดยไม่ต้องเขียนใหม่ทั้งหมด การแยกออกเป็นสามชั้นช่วยแก้ทั้งสองปัญหา Routes ประกาศ URL และมอบหมายงานต่อ Controllers แปลง HTTP ให้เป็นการเรียกทางธุรกิจ คือ อ่านคำขอ เลือกรหัสสถานะ (status code) และแปลงคำตอบเป็นข้อมูลอนุกรม (serialize) Services เก็บกฎทางธุรกิจไว้ และไม่รู้อะไรเกี่ยวกับ Express เลย กฎการพึ่งพา (dependency) นั้นเข้มงวด แต่ละชั้นรู้จักเพียงชั้นที่อยู่ถัดลงไปเท่านั้น ไม่มีทางกลับกัน

JAVASCRIPT
import { Router } from 'express';

export function createUserRouter(controller) {
  const router = Router();
  router.get('/', controller.list);
  router.post('/', controller.create);
  return router;
}
route ประกาศ ส่วน controller ลงมือทำ ไม่มีตรรกะอยู่ตรงนี้

controller คือ factory ที่รับ service เข้ามาเป็นอาร์กิวเมนต์ มันไม่ได้เก็บกฎทางธุรกิจใด ๆ หน้าที่ของมันจำกัดอยู่แค่การดึงข้อมูลจาก req เรียก service และแปลงผลลัพธ์ให้เป็นคำตอบ HTTP ข้อผิดพลาดใด ๆ จะถูกส่งต่อไปยัง next เพื่อให้ error middleware ส่วนกลางจัดการ ไม่ใช่การกระจาย res.status(500) ไปทั่วทุก controller

JAVASCRIPT
export function createUserController(service) {
  return {
    async list(req, res, next) {
      try {
        const users = await service.listUsers();
        res.json(users);
      } catch (err) {
        next(err);
      }
    },
    async create(req, res, next) {
      try {
        const user = await service.createUser(req.body);
        res.status(201).json(user);
      } catch (err) {
        next(err);
      }
    },
  };
}
การแปลง HTTP ล้วน ๆ คือ รหัสสถานะ การแปลงเป็นข้อมูลอนุกรม และการมอบหมายงาน

service รวมศูนย์การตัดสินใจไว้ คือ ความไม่ซ้ำของอีเมล และกฎการสร้าง มันโยนข้อผิดพลาด ทางธุรกิจ ออกมา โดยอย่างมากที่สุดก็เสริมด้วยฟิลด์ status ในขณะที่ไม่รู้อะไรเกี่ยวกับ Express เลย มันเองก็รับการพึ่งพาของมัน คือ repository ของข้อมูล เข้ามาเป็นอาร์กิวเมนต์เช่นกัน นี่คือ การฉีดพึ่งพาแบบเบา (lightweight injection) ฟังก์ชัน factory ธรรมดาที่ถูกเรียกครั้งเดียวตอนเริ่มต้น เข้ามาแทนที่ injection container ที่หนักหน่วง และทำให้ทุกชั้นสลับเปลี่ยนได้ในการทดสอบ

JAVASCRIPT
export function createUserService(repository) {
  return {
    listUsers() {
      return repository.findAll();
    },
    createUser(input) {
      const found = repository.findByEmail(input.email);
      if (found) {
        const err = new Error('Email already used');
        err.status = 409;
        throw err;
      }
      return repository.insert(input);
    },
  };
}
ตรรกะทางธุรกิจอยู่ที่นี่ โดยไม่มี req, res หรือ SQL

ที่เหลือคือ composition root จุดเดียวที่ factory ทั้งหลายประกอบเข้าด้วยกัน createApp รับการพึ่งพาของโครงสร้างพื้นฐาน (ในที่นี้คือฐานข้อมูล ซึ่งจะกล่าวถึงในบทถัดไป) และคืนแอปพลิเคชัน Express ที่สมบูรณ์ ซึ่ง ยังไม่ได้เริ่มฟัง (listening) การแยกการประกอบสร้างออกจากการฟังดูเหมือนเป็นเรื่องเล็กน้อย แต่มันคือสิ่งที่จะทำให้การทดสอบสามารถติดตั้ง API บนพอร์ตชั่วคราวได้ ในขณะที่เซิร์ฟเวอร์ production ยังคงมีเพียงสามบรรทัด คือ สร้างฐานข้อมูล สร้างแอป แล้วฟัง

JAVASCRIPT
import express from 'express';
import { createUserRepository } from './db/users.js';
import { createUserService } from './services/users.js';
import {
  createUserController,
} from './controllers/users.js';
import { createUserRouter } from './routes/users.js';

export function createApp({ db }) {
  const app = express();
  app.use(express.json());
  const repository = createUserRepository(db);
  const service = createUserService(repository);
  const controller = createUserController(service);
  app.use('/api/users', createUserRouter(controller));
  return app;
}
app.js: ส่วนเดียวของแอปพลิเคชันที่รู้จักทุกคน

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ในการแบ่งชั้นแบบนี้ กฎที่ว่า "อีเมลหนึ่งใช้ได้เพียงครั้งเดียว" ควรอยู่ที่ไหน?
    • ใน route ให้ใกล้กับ URL มากที่สุด
    • ใน controller ซึ่งเข้าถึง req.body ได้
    • ใน service ซึ่งเก็บกฎทางธุรกิจไว้
    • ใน middleware ส่วนกลางของ Express
  2. ประโยชน์หลักของการส่งการพึ่งพาเข้ามาเป็นอาร์กิวเมนต์ของ factory แทนที่จะ import มันเข้ามาตรง ๆ ในแต่ละมอดูล คืออะไร?
    • factory ช่วยให้โหลดมอดูลเร็วขึ้น
    • คุณสามารถสลับการพึ่งพา (ฐานข้อมูลในหน่วยความจำ, repository ปลอม) ได้โดยไม่ต้องแตะมอดูลที่ใช้มัน
    • Express บังคับให้ใช้รูปแบบนี้กับ middleware ของมัน
    • มันช่วยเลี่ยงการเขียนไฟล์แยก
  3. ทำไม createApp จึงไม่เริ่มฟังบนเครือข่ายด้วยตัวมันเอง?
    • เพราะ Express ห้ามเรียก listen ภายในฟังก์ชัน
    • เพื่อให้การทดสอบสามารถสร้างอินสแตนซ์ของแอปพลิเคชันได้โดยไม่ต้องเปิดพอร์ตตายตัว และทำให้จุดเข้าของเซิร์ฟเวอร์เรียบง่าย
    • เพราะ listen เป็นแบบอะซิงโครนัสและจะบล็อก factory
    • เพื่อลดการใช้หน่วยความจำตอนเริ่มต้น