各位观众,各位靓仔靓女,欢迎来到今天的“架构不秃头”系列讲座!我是你们的老朋友,人称“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
依赖于 TodoService
,TodoService
依赖于 TodoRepository
。 但是,它们都依赖于抽象(接口),而不是具体的实现。 这使得我们可以轻松地替换不同的实现,例如,将 InMemoryTodoRepository
替换为 PostgresTodoRepository
,而无需修改 TodoService
或 TodoController
的代码。
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。
感谢大家的收看,我们下期再见! 如果大家还有什么问题,欢迎在评论区留言,我会尽力解答。 祝大家写代码不加班,头发茂盛!