解释 JavaScript 中的 Clean Architecture (整洁架构) 或 Hexagonal Architecture (六边形架构) 如何实现业务逻辑与基础设施的解耦。

各位观众,各位靓仔靓女,欢迎来到今天的“架构不秃头”系列讲座!我是你们的老朋友,人称“Bug终结者”,今天我们要聊聊JavaScript里的Clean Architecture,也叫Hexagonal Architecture,这玩意儿听着玄乎,其实就是教你如何优雅地把你的代码“脱耦”,让业务逻辑和基础设施各玩各的,互不干扰,这样以后你换数据库、换框架,甚至换语言,都不用大动干戈,轻松愉快。

开场白:代码的烦恼

想象一下,你写了一个超棒的待办事项应用。你吭哧吭哧写了几个月,终于完成了。但是,你的业务逻辑(添加任务、删除任务、标记完成等等)和你的数据库代码(连接数据库、读写数据)以及用户界面代码(显示任务列表、处理用户输入)全搅和在一起。

现在,老板突然说:“我们要换成GraphQL API了!”,或者“我们要支持PostgreSQL数据库了!”。

这时,你的内心是不是崩溃的?你不得不把整个代码库翻个底朝天,修改一堆东西,小心翼翼地测试,生怕改坏了什么。

这就是耦合性太高的痛苦。Clean Architecture 就像一个“离婚协议”,让你的业务逻辑和基础设施“和平分手”,各自安好。

什么是Clean Architecture?(或者 Hexagonal Architecture?)

简单来说,Clean Architecture是一种软件架构模式,它的核心思想是:

  • 依赖倒置原则(Dependency Inversion Principle): 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
  • 关注点分离(Separation of Concerns): 将不同的职责划分到不同的模块中,每个模块只负责一件事情。
  • 最内层是业务逻辑: 业务逻辑是整个应用的核心,不应该依赖于任何外部的东西。
  • 外层是基础设施: 数据库、用户界面、第三方服务等等,都属于基础设施,它们应该依赖于业务逻辑。

Hexagonal Architecture 是 Clean Architecture 的一种具体实现方式,它把应用看作是一个六边形(或者其他多边形),六边形的中心是业务逻辑,六边形的各个边代表不同的端口(ports),端口定义了业务逻辑和外部世界交互的接口。适配器(adapters)连接端口和外部世界,负责将外部世界的请求转换成业务逻辑可以理解的格式,并将业务逻辑的响应转换成外部世界可以理解的格式。

核心概念详解:端口(Ports)和适配器(Adapters)

这两个概念是理解Hexagonal Architecture的关键。

  • 端口(Ports): 端口是业务逻辑定义的一组接口,用于与外部世界交互。 端口定义了 什么 可以发生,而不是 如何 发生。 端口分为两类:

    • 驱动端口(Driving Ports,也叫Primary Ports): 由外部世界驱动的端口,例如用户界面、API客户端等。 它们调用业务逻辑。
    • 被驱动端口(Driven Ports,也叫 Secondary Ports): 业务逻辑驱动的端口,例如数据库、消息队列、第三方服务等。 业务逻辑调用它们。
  • 适配器(Adapters): 适配器是连接端口和外部世界的桥梁。 适配器负责将外部世界的请求转换成业务逻辑可以理解的格式,并将业务逻辑的响应转换成外部世界可以理解的格式。 适配器也分为两类:

    • 驱动适配器(Driving Adapters,也叫Primary Adapters): 连接驱动端口的适配器。 例如:REST API 控制器,GraphQL resolvers。
    • 被驱动适配器(Driven Adapters,也叫Secondary Adapters): 连接被驱动端口的适配器。 例如:数据库连接器,消息队列客户端。

用JavaScript代码演示:一个简单的待办事项应用

为了让大家更好地理解,我们用一个简单的待办事项应用来演示 Clean Architecture。

1. 业务逻辑(Application Core):

这是最核心的部分,包含业务逻辑,不依赖任何外部东西。

// src/core/todo/todo.js
class Todo {
  constructor(id, title, completed = false) {
    this.id = id;
    this.title = title;
    this.completed = completed;
  }

  complete() {
    this.completed = true;
  }

  updateTitle(newTitle) {
    this.title = newTitle;
  }
}

export default Todo;

// src/core/todo/todo-service.js
class TodoService {
  constructor(todoRepository) {
    this.todoRepository = todoRepository; // 依赖一个抽象的 repository
  }

  async createTodo(title) {
    const newTodo = new Todo(Date.now(), title); // 简单地用时间戳作为ID
    return this.todoRepository.save(newTodo);
  }

  async getTodo(id) {
    return this.todoRepository.getById(id);
  }

  async getAllTodos() {
    return this.todoRepository.getAll();
  }

  async completeTodo(id) {
    const todo = await this.getTodo(id);
    if (!todo) {
      throw new Error('Todo not found');
    }
    todo.complete();
    return this.todoRepository.save(todo);
  }

  async updateTodoTitle(id, newTitle) {
    const todo = await this.getTodo(id);
     if (!todo) {
      throw new Error('Todo not found');
    }
    todo.updateTitle(newTitle);
    return this.todoRepository.save(todo);
  }

  async deleteTodo(id) {
    return this.todoRepository.delete(id);
  }
}

export default TodoService;

// src/core/todo/todo-repository.js  -- 端口(Port)定义
class TodoRepository {
  async save(todo) {
    throw new Error('Method "save" must be implemented.');
  }

  async getById(id) {
    throw new Error('Method "getById" must be implemented.');
  }

  async getAll() {
    throw new Error('Method "getAll" must be implemented.');
  }

   async delete(id) {
    throw new Error('Method "delete" must be implemented.');
  }
}

export default TodoRepository;

2. 驱动端口和适配器(Driving Ports & Adapters):

这里我们用一个简单的REST API作为驱动适配器。

// src/interfaces/controllers/todo-controller.js
class TodoController {
  constructor(todoService) {
    this.todoService = todoService;
  }

  async createTodo(req, res) {
    try {
      const todo = await this.todoService.createTodo(req.body.title);
      res.status(201).json(todo);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }

  async getTodo(req, res) {
    try {
      const todo = await this.todoService.getTodo(req.params.id);
      if (todo) {
        res.json(todo);
      } else {
        res.status(404).json({ message: 'Todo not found' });
      }
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }

  async getAllTodos(req, res) {
    try {
      const todos = await this.todoService.getAllTodos();
      res.json(todos);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }

  async completeTodo(req, res) {
    try {
      const todo = await this.todoService.completeTodo(req.params.id);
      res.json(todo);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }

    async updateTodoTitle(req, res) {
    try {
      const todo = await this.todoService.updateTodoTitle(req.params.id, req.body.title);
      res.json(todo);
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }

  async deleteTodo(req, res) {
    try {
      await this.todoService.deleteTodo(req.params.id);
      res.status(204).send();
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }
}

export default TodoController;

// src/interfaces/routes/todo-routes.js  (Express.js 示例)
import express from 'express';
import TodoController from '../controllers/todo-controller.js';

const router = express.Router();

const setupTodoRoutes = (todoController) => {

  router.post('/', (req, res) => todoController.createTodo(req, res));
  router.get('/:id', (req, res) => todoController.getTodo(req, res));
  router.get('/', (req, res) => todoController.getAllTodos(req, res));
  router.patch('/:id/complete', (req, res) => todoController.completeTodo(req, res));
  router.put('/:id', (req, res) => todoController.updateTodoTitle(req, res));
  router.delete('/:id', (req, res) => todoController.deleteTodo(req, res));

  return router;
}

export default setupTodoRoutes;

3. 被驱动端口和适配器(Driven Ports & Adapters):

这里我们用一个内存数据库作为被驱动适配器。

// src/infrastructure/repositories/in-memory-todo-repository.js
import TodoRepository from '../../core/todo/todo-repository.js';

class InMemoryTodoRepository extends TodoRepository {
  constructor() {
    super();
    this.todos = new Map();
  }

  async save(todo) {
    this.todos.set(todo.id, todo);
    return todo;
  }

  async getById(id) {
    return this.todos.get(id) || null;
  }

  async getAll() {
    return Array.from(this.todos.values());
  }

    async delete(id) {
    this.todos.delete(id);
  }
}

export default InMemoryTodoRepository;

// src/infrastructure/repositories/postgres-todo-repository.js  (PostgreSQL 示例)
import TodoRepository from '../../core/todo/todo-repository.js';
import { Pool } from 'pg';

class PostgresTodoRepository extends TodoRepository {
  constructor(connectionString) {
    super();
    this.pool = new Pool({ connectionString });
  }

  async save(todo) {
    const client = await this.pool.connect();
    try {
      const query = `
        INSERT INTO todos (id, title, completed)
        VALUES ($1, $2, $3)
        ON CONFLICT (id) DO UPDATE SET
          title = $2,
          completed = $3
        RETURNING *;
      `;
      const values = [todo.id, todo.title, todo.completed];
      const result = await client.query(query, values);
      return result.rows[0]; // Assuming the query returns the saved todo
    } finally {
      client.release();
    }
  }

  async getById(id) {
    const client = await this.pool.connect();
    try {
      const query = `SELECT * FROM todos WHERE id = $1;`;
      const values = [id];
      const result = await client.query(query, values);
      return result.rows[0] || null;
    } finally {
      client.release();
    }
  }

  async getAll() {
    const client = await this.pool.connect();
    try {
      const query = `SELECT * FROM todos;`;
      const result = await client.query(query);
      return result.rows;
    } finally {
      client.release();
    }
  }

  async delete(id) {
    const client = await this.pool.connect();
    try {
      const query = `DELETE FROM todos WHERE id = $1;`;
      const values = [id];
      await client.query(query, values);
    } finally {
      client.release();
    }
  }
}

export default PostgresTodoRepository;

4. 应用启动(Application Startup):

// src/index.js  (Express.js 示例)
import express from 'express';
import bodyParser from 'body-parser';
import setupTodoRoutes from './interfaces/routes/todo-routes.js';
import TodoController from './interfaces/controllers/todo-controller.js';
// import InMemoryTodoRepository from './infrastructure/repositories/in-memory-todo-repository.js';
import PostgresTodoRepository from './infrastructure/repositories/postgres-todo-repository.js';
import TodoService from './core/todo/todo-service.js';

const app = express();
const port = 3000;

app.use(bodyParser.json());

// 选择使用哪个 Repository (InMemory or Postgres)
// const todoRepository = new InMemoryTodoRepository();
const todoRepository = new PostgresTodoRepository('postgresql://user:password@host:port/database'); // 替换为你的PostgreSQL连接字符串

const todoService = new TodoService(todoRepository);
const todoController = new TodoController(todoService);
const todoRoutes = setupTodoRoutes(todoController);

app.use('/todos', todoRoutes);

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

重点:依赖注入(Dependency Injection)

在上面的代码中,我们使用了依赖注入。 TodoController 依赖于 TodoServiceTodoService 依赖于 TodoRepository。 但是,它们都依赖于抽象(接口),而不是具体的实现。 这使得我们可以轻松地替换不同的实现,例如,将 InMemoryTodoRepository 替换为 PostgresTodoRepository,而无需修改 TodoServiceTodoController 的代码。

Clean Architecture 的好处

  • 可测试性(Testability): 由于业务逻辑不依赖于任何外部东西,我们可以轻松地编写单元测试来测试业务逻辑。我们可以使用模拟(mock)或桩(stub)来模拟外部依赖,而无需连接到真实的数据库或调用真实的API。
  • 可维护性(Maintainability): 由于不同的职责被划分到不同的模块中,每个模块只负责一件事情,因此代码更容易理解和修改。 当我们需要修改某个模块时,我们只需要关注该模块的代码,而无需担心会影响到其他模块。
  • 灵活性(Flexibility): 由于业务逻辑和基础设施解耦,我们可以轻松地替换不同的基础设施实现,而无需修改业务逻辑的代码。 例如,我们可以轻松地将数据库从 MySQL 切换到 PostgreSQL,或者将用户界面从 React 切换到 Angular。
  • 可扩展性(Scalability): Clean Architecture 可以帮助我们构建可扩展的应用程序。 我们可以根据需要添加新的模块,而无需修改现有的代码。

一些常见问题和误解

  • Clean Architecture 太复杂了? 对于简单的应用来说,Clean Architecture 可能会显得有些 overkill。 但是,对于复杂的应用来说,Clean Architecture 可以帮助我们更好地组织代码,提高代码的可维护性和可测试性。
  • Clean Architecture 必须使用某种特定的框架或库? 不是的。 Clean Architecture 是一种架构模式,而不是一种特定的框架或库。 你可以使用任何你喜欢的框架或库来实现 Clean Architecture。
  • Clean Architecture 会增加代码量? 是的,Clean Architecture 可能会增加一些代码量,因为我们需要定义更多的接口和类。 但是,这些额外的代码可以提高代码的可读性、可维护性和可测试性,从长远来看,可以节省更多的时间和精力。
  • 端口和适配器是不是很多余? 端口定义了业务逻辑的边界,适配器负责将外部世界的请求转换成业务逻辑可以理解的格式。有了端口和适配器,业务逻辑就可以独立于外部世界而存在,这使得我们可以轻松地替换不同的基础设施实现。

总结

Clean Architecture 是一种强大的软件架构模式,它可以帮助我们构建可维护、可测试、灵活和可扩展的应用程序。 虽然 Clean Architecture 可能会增加一些代码量,但是从长远来看,它可以节省更多的时间和精力。 希望今天的讲座能够帮助大家更好地理解 Clean Architecture,并在实际项目中应用它。

最后的温馨提示:

架构不是银弹,不要过度设计。 在选择架构模式时,要根据项目的实际情况进行权衡。 对于简单的应用,可以采用更简单的架构模式。 对于复杂的应用,可以考虑使用 Clean Architecture。

感谢大家的收看,我们下期再见! 如果大家还有什么问题,欢迎在评论区留言,我会尽力解答。 祝大家写代码不加班,头发茂盛!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注