使用 Node.js、Express 和 TypeScript,结合整洁架构进行现代 API 开发
在这篇文章中,我们将探讨如何使用三种流行技术来开发一个REST API:Node.js、Express和TypeScript。这些工具在JavaScript开发者社区中非常流行,并提供了一系列强大的功能来创建Web服务。相反地,我们将重点放在应用干净架构和良好的编程实践上,以确保我们的代码是模块化、可扩展和长期维护容易的。
我们在另一出版物中创建了一个项目作为起点。该出版物详细地指导了如何使用Express和TypeScript在Node上创建一个项目。
使用 Express 构建 Node 项目的模版,在这篇文章中,我将逐步与您分享如何用 Express 和 TypeScript 结构化我的 Node 项目 …baguilar6174.medium.com你可以在该仓库的 set-up-project
分支中找到原始代码。
清洁架构由三个主要层组成:
- 领域层:它包含独立于外部技术的业务逻辑。
- 基础设施:处理例如数据库和外部服务等技术细节。
- 表示层:与用户交互,并将请求转交给领域层。
我们将为每个项目功能创建这三个层次:在这个开发过程中,我们将为一个待办事项清单创建一个完整的 CRUD 操作。如下是我们初步的目录结构。
node-template-server/
│
├── dist/
├── node_modules/
├── src/
│ ├── core/
│ │ ├── config/
│ │ ├── constants/
│ │ ├── errors/
│ │ └── types/
│ ├── features/
│ │ ├── shared/
│ │ │ ├── domain/
│ │ │ │ ├── dtos/
│ │ │ │ ├── entities/
│ │ │ └── presentation/
│ │ │ └── middlewares/
│ │ │
│ │ ├── todos/
│ │ │ ├── domain/
│ │ │ │ ├── datasources/
│ │ │ │ ├── dtos/
│ │ │ │ ├── entities/
│ │ │ │ ├── repositories/
│ │ │ │ └── usecases/
│ │ │ │
│ │ │ ├── infrastructure/
│ │ │ │ ├── local.datasource.impl.ts
│ │ │ │ └── repository.impl.ts
│ │ │ │
│ │ │ └── presentation/
│ │ │ ├── controller.ts
│ │ │ └── routes.ts
│ │ └── ...
│ ├── app.test.ts
│ ├── app.ts
│ ├── routes.ts
│ ├── server.ts
│ └── testServer.ts
├── .env
├── .env.template
├── .env.test
├── ...
├── package.json
└── ...
领域 — 实体(对象)
实体是指代表应用程序领域基本概念的对象实例。这些对象封装了系统中关键元素的核心状态和行为特征。接下来,我们将定义这个实体。
// src\features\todos\domain\entities\todo.entity.ts
import { AppError, ZERO } from '../../../../core';
export class TodoEntity {
constructor(
public id: number,
public text: string,
public isCompleted: boolean = false
) {}
public static fromJson(obj: Record<string, unknown>): TodoEntity {
const { id, text, isCompleted = false } = obj;
if (!id) {
throw AppError.badRequest('此实体需要一个ID', [{ constraint: 'ID必需', fields: ['id'] }]);
}
if (!text || (text as string).length === 零) {
throw AppError.badRequest('此实体需要一个文本内容', [{ constraint: '文本内容必需', fields: ['text'] }]);
}
return new TodoEntity(id as number, text as string, isCompleted as boolean);
}
}
🚧AppError
类的实现将在后续进行。
本文中,我会演示我们待办事项的 getAll()
方法的具体实现,不过,最后的代码里你可以看到所有这些方法(create、update、delete 和 getById
)的实现过程。
仓库是一种数据访问的抽象层,它作为领域层和基础设施层之间的桥梁。它们的主要目的是封装数据存储和检索相关的逻辑,提供一个抽象层,让领域层在处理数据时不必关心数据是如何存储或检索的具体技术细节。
// src\features\todos\domain\repositories\respository.ts
import { type TodoEntity } from '../entities/todo.entity';
export abstract class TodoRepository { // 任务仓库
abstract getAll(): Promise<TodoEntity[]>;
// 其余操作
// ...
}
场景应用
用例代表用户或系统在应用程序中可以执行的具体操作或功能。这些用例将业务逻辑封装成一种独立于基础设施和实现细节的形式,使其可以在不同环境中移植和重用。
// src\features\todos\domain\usecases\getAll.usecase.ts
import { type TodoEntity } from '../entities/todo.entity';
import { type TodoRepository } from '../repositories/respository';
export interface GetTodosUseCase {
execute: () => Promise<TodoEntity[]>;
}
export class GetTodos implements GetTodosUseCase {
constructor(private readonly repository: TodoRepository) {}
async execute(): Promise<TodoEntity[]> {
return await this.repository.getAll();
}
}
// 获取所有待办事项的用例文件
import { type TodoEntity } from '../entities/todo.entity';
import { type TodoRepository } from '../repositories/respository';
export interface GetTodosUseCase {
execute: () => Promise<TodoEntity[]>;
}
export class GetTodos implements GetTodosUseCase {
constructor(private readonly repository: TodoRepository) {}
async execute(): Promise<TodoEntity[]> {
return await this.repository.getAll();
}
}
// 获取所有待办事项的用例文件
// 引入待办事项实体类型
import { type TodoEntity } from '../entities/todo.entity';
// 引入待办事项仓库类型
import { type TodoRepository } from '../repositories/respository';
// 定义获取待办事项用例接口
export interface GetTodosUseCase {
// 执行获取所有待办事项的方法
execute: () => Promise<TodoEntity[]>;
}
// 定义获取待办事项用例类
export class GetTodos implements GetTodosUseCase {
// 构造函数,注入待办事项仓库
constructor(private readonly repository: TodoRepository) {}
// 异步执行方法,返回所有待办事项的承诺
async execute(): Promise<TodoEntity[]> {
// 调用仓库的getAll方法,获取所有待办事项
return await this.repository.getAll();
}
}
数据来源
数据源是表示应用程序所需数据来源的接口或抽象概念。这些数据源可以是数据库、Web服务、文件系统或其他任何形式的数据存储。使用数据源有助于将业务逻辑与具体的数据源细节解耦。这意味着领域层可以通过通用接口与数据源进行交互,而无需关心具体的实现细节,从而可以在不影响应用程序逻辑的情况下轻松地更换或更新数据源。
// src\features\todos\domain\datasources\datasource.ts
import { type TodoEntity } from '../entities/todo.entity';
export abstract class TodoDatasource {
abstract getAll(): Promise<TodoEntity[]>;
// 其他操作...
// ...
}
城域 — 数据传输对象
DTO(数据传输对象)是用于在应用程序的不同层之间传输数据的对象,特别是在表示层和领域层及基础设施层之间。DTO 封装相关的数据,并将其从一个上下文中传输到另一个上下文中,而不泄露底层业务逻辑。DTO 的主要功能是以结构化和连贯的方式表示信息,从而使其在应用程序中的传输更为方便。
在这篇文章中,我们不会在这里创建 DTO,但是在最终的代码中,你还可以找到用于处理 pagination
、创建、更新甚至获取 TODO 的 DTO(例如)。
在基础设施层,存储库实现负责提供在域层定义的存储库接口的具体方法实现。此实现负责与实际数据源如数据库、外部服务或其他任何形式的数据持久性机制进行互动。
// src\features\todos\infraestructure\repository.impl.ts
import { type TodoDatasource } from '../domain/datasources/datasource';
import { type TodoEntity } from '../domain/entities/todo.entity';
import { type TodoRepository } from '../domain/repositories/repository';
export class TodoRepositoryImpl implements TodoRepository {
constructor(private readonly datasource: TodoDatasource) {}
async getAll(): Promise<TodoEntity[]> {
return await this.datasource.getAll();
}
// 其他操作如下
// ...
}
基础设施 - 数据源实现
基础设施层中的数据源的实现负责提供定义于域层中的数据源接口的具体实现。此组件直接与实际数据源(比如数据库、网络服务或其他任何其他形式的数据存储介质)进行交互。
// src\features\todos\infraestructure\local.datasource.impl.ts
import { type TodoDatasource } from '../domain/datasources/datasource';
import { TodoEntity } from '../domain/entities/todo.entity';
const TODOS_MOCK = [
{
id: 1,
text: '第一个待办事项...',
isCompleted: false
},
{
id: 2,
text: '第二个待办事项...',
isCompleted: false
}
];
export class TodoDatasourceImpl implements TodoDatasource {
public async getAll(): Promise<TodoEntity[]> {
const todos = TODOS_MOCK.map((todo): TodoEntity => TodoEntity.fromJson(todo));
return todos;
}
// 其他操作实现
// ...
}
这里我从内存中获取列表,不过,这里更适合与外部数据库进行交互。例如,这可以是一个使用Prisma与数据库交互的数据源实现。
// src\features\todos\infraestructure\postgres.datasource.impl.ts
import { type TodoDatasource } from '../domain/datasources/datasource';
import { TodoEntity } from '../domain/entities/todo.entity';
export class TodoDatasourceImpl implements TodoDatasource {
public async getAll(): Promise<TodoEntity[]> {
const todosFromDB = await prisma.todo.findMany();
const todos = todosFromDB.map((todo): TodoEntity => TodoEntity.fromJson(todo));
return todos;
}
// 其他方法
// ...
}
演示 — 控制器
控制器是表示层中的组件,充当客户端请求进入应用的入口。这些控制器负责接收HTTP请求,处理它们并将其导向领域逻辑层中的相应业务逻辑。
// src\features\todos\presentation\controller.ts
import { type NextFunction, type Request, type Response } from 'express';
import { type TodoRepository } from '../domain/repositories/repository';
import { type TodoEntity } from '../domain/entities/todo.entity';
import { GetTodos } from '../domain/usecases/getAll.usecase';
export class TodoController {
//* 依赖注入(Dependency Injection)
constructor(private readonly repository: TodoRepository) {}
public getAll = (
_req: Request<unknown, unknown, unknown, unknown>,
res: Response<TodoEntity[]>,
next: NextFunction
): void => {
new GetTodos(this.repository)
.execute()
.then((result) => res.json(result))
.catch((error) => {
next(error);
});
};
// 其他操作...
}
展示 — 路线
路由是负责定义路由并处理应用程序中传入的 HTTP 请求的表现层组件。这些路由用于将 HTTP 请求映射到相应的控制器,并建立应用程序的 API 结构或路由机制。这也是初始化我们的数据源和仓库的地方,这些对于我们的控制器来说是必不可少的。
// src\features\todos\presentation\routes.ts
import { Router } from 'express';
import { TodoDatasourceImpl } from '../infraestructure/local.datasource.impl';
import { TodoRepositoryImpl } from '../infraestructure/repository.impl';
import { TodoController } from './controller';
export class TodoRoutes {
static get routes(): Router {
const router = Router();
// 此数据源可以被替换
const datasource = new TodoDatasourceImpl();
const repository = new TodoRepositoryImpl(datasource);
const controller = new TodoController(repository);
router.get('/', controller.getAll);
// 其他操作继续
// ...
return router;
}
}
路线
我们将要创建一个全局路由配置文件,用于我们的应用程序,在这里我们将为所有功能模块设置路由。
// src\routes.ts
import { Router } from 'express';
import { TodoRoutes } from './features/todos/presentation/routes';
export class AppRoutes {
static get routes(): Router {
const router = Router();
router.use('/todos', TodoRoutes.routes);
// 剩余的路由
// ...
return router;
}
}
错误处理机制
最后咱们来展示一下如何处理我们在服务器上的错误,咱们先从为自定义错误创建一些类开始。
// src\core\errors\custom.error.ts
import { HttpCode } from '../constants';
export interface ValidationType {
fields: string[];
constraint: string;
}
interface AppErrorArgs {
name?: string;
statusCode: HttpCode;
message: string;
isOperational?: boolean;
validationErrors?: ValidationType[];
}
export class AppError extends Error {
public readonly name: string;
public readonly statusCode: HttpCode;
public readonly isOperational: boolean = true;
public readonly validationErrors?: ValidationType[];
constructor(args: AppErrorArgs) {
const { message, name, statusCode, isOperational, validationErrors } = args;
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.name = name ?? '应用错误';
this.statusCode = statusCode;
if (isOperational !== undefined) this.isOperational = isOperational;
this.validationErrors = validationErrors;
Error.captureStackTrace(this);
}
static badRequest(message: string, validationErrors?: ValidationType[]): AppError {
return new AppError({ name: '无效请求错误', message, statusCode: HttpCode.BAD_REQUEST, validationErrors });
}
static unauthorized(message: string): AppError {
return new AppError({ name: '未授权错误', message, statusCode: HttpCode.UNAUTHORIZED });
}
static forbidden(message: string): AppError {
return new AppError({ name: '禁止错误', message, statusCode: HttpCode.FORBIDDEN });
}
static notFound(message: string): AppError {
return new AppError({ name: '未找到错误', message, statusCode: HttpCode.NOT_FOUND });
}
static internalServer(message: string): AppError {
return new AppError({ name: '内部服务器错误', message, statusCode: HttpCode.INTERNAL_SERVER_ERROR });
}
}
我们现在将创建一个中间件来处理我们的错误。在 Express 和 Node.js 项目中,中间件是处理 Web 应用程序请求和响应的函数,用于在处理 HTTP 请求时执行中间任务,例如数据验证、错误处理、用户身份验证和信息记录。它们提供了控制请求处理和响应生成方式的灵活性,从而使 Express 应用程序更加灵活。
// src\features\shared\presentation\middlewares\error.middleware.ts
import { type Response, type NextFunction, type Request } from 'express';
import { HttpCode } from '../../../../core/constants';
import { AppError } from '../../../../core/errors/custom.error';
export class ErrorMiddleware {
// 依赖注入
// constructor() {}
public static handleError = (error: unknown, _: Request, res: Response, next: NextFunction): void => {
if (error instanceof AppError) {
const { message, name, stack, validationErrors } = error;
const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR;
res.statusCode = statusCode;
res.json({ name, message, validationErrors, stack });
} else {
const name = 'InternalServerError';
const message = '服务器内部发生错误';
const statusCode = HttpCode.INTERNAL_SERVER_ERROR;
res.statusCode = statusCode;
res.json({ name, message });
}
next();
};
}
现在我们来对我们的 server.ts
做最后的几个修改,添加全局路由配置和来处理错误的中间件。
// src\server.ts
import { type Server as ServerHttp, type IncomingMessage, type ServerResponse } from 'http';
import express, { type Router, type Request, type Response, type NextFunction } from 'express';
import compression from 'compression';
import rateLimit from 'express-rate-limit';
import { HttpCode, ONE_HUNDRED, ONE_THOUSAND, SIXTY } from './core/constants';
import { ErrorMiddleware } from './features/shared/presentation/middlewares/error.middleware';
import { AppError } from './core/errors/custom.error';
interface ServerOptions {
port: number;
routes: Router;
apiPrefix: string;
}
export class Server {
public readonly app = express(); // 这里公开是为了测试目的
private serverListener?: ServerHttp<typeof IncomingMessage, typeof ServerResponse>;
private readonly port: number;
private readonly routes: Router;
private readonly apiPrefix: string;
constructor(options: ServerOptions) {
const { port, routes, apiPrefix } = options;
this.port = port;
this.routes = routes;
this.apiPrefix = apiPrefix;
}
async start(): Promise<void> {
// 中间件
this.app.use(express.json()); // 解析请求体中的 JSON 数据 (允许原始数据)
this.app.use(express.urlencoded({ extended: true })); // 允许 x-www-form-urlencoded
this.app.use(compression());
// 限制对公共 API 的重复请求
this.app.use(
rateLimit({
max: ONE_HUNDRED,
windowMs: SIXTY * SIXTY * ONE_THOUSAND,
message: '此 IP 的请求过于频繁,请在一小时后再试'
})
);
// CORS 设置
this.app.use((req, res, next) => {
// 指定允许的源
const allowedOrigins = ['http://localhost:3000'];
const origin = req.headers.origin;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (allowedOrigins.includes(origin!)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
res.setHeader('Access-Control-Allow-Origin', origin!);
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// 路由
this.app.use(this.apiPrefix, this.routes);
// 测试 RESTful API
this.app.get('/', (_req: Request, res: Response) => {
return res.status(HttpCode.OK).send({
message: `欢迎来到初始 API! \n 端点可在 http://localhost:${this.port}/ 下访问`
});
});
// 处理未找到的路由 /api/v1/* (当公共内容不可用时)
this.routes.all('*', (req: Request, _: Response, next: NextFunction): void => {
next(AppError.notFound(`服务器无法找到 ${req.originalUrl}!`));
});
// 错误处理中间件
this.routes.use(ErrorMiddleware.handleError);
this.serverListener = this.app.listen(this.port, () => {
console.log(`服务器正在端口 ${this.port} 上运行...`);
});
}
close(): void {
this.serverListener?.close();
}
}
在 app.ts
文件里:
// src\app.ts
import { envs } from './core/config/env'; // 引入环境配置
import { AppRoutes } from './routes'; // 引入路由配置
import { Server } from './server'; // 引入服务器配置
(() => {
main(); // 调用主函数
})();
function main(): void {
const server = new Server({
routes: AppRoutes.routes, // 设置路由
apiPrefix: envs.API_PREFIX, // 设置API前缀
port: envs.PORT // 设置端口
});
void server.start(); // 启动服务器
}
另外,我们将创建一个文件,以便在测试时能使用我们的服务器。这个测试服务器将帮助我们通过 jest
和 supertest
对我们的 endpoints
进行单元测试。
// src/testServer.ts
import { envs } from './core';
import { AppRoutes } from './routes';
import { Server } from './server';
// 一个测试服务器,用于测试目的
// 定义并导出一个测试服务器
export const testServer = new Server({
port: envs.PORT,
apiPrefix: envs.API_PREFIX,
routes: AppRoutes.routes
});
现在,我们可以运行应用程序了
yarn dev
执行 yarn dev
命令。
我们可以在<http://localhost:3000/todos>
测试本地服务器
使用 Node.js 和 Express 实现 REST API 并遵循良好的开发实践和 Clean Architecture 原则,这为开发现代和可扩展的 web 应用程序奠定了坚实的基础。通过采用模块化的方法并专注于关注点分离,开发人员可以构建一个干净且可维护的架构,从而促进系统的灵活性和持续演进。
采用干净架构可以让你保持应用程序不同层之间的清晰分离,例如领域层、基础设施层和展示层,从而使代码随时间推移更容易理解和维护。此外,采用良好的开发实践,如使用中间件处理中间任务、验证输入数据以及正确处理错误,有助于创建一个更健壮且更安全的API。
如果你觉得这个仓库对你有帮助,可以在它的页面上给它点个星,也可以给这篇文章点个赞。这个项目的源代码如下所示:(此处代码保持原文不变)
GitHub - baguilar6174/node-template-server: Express、TypeScript 和 Clean Architecture 的 Node 模板, baguilar6174/node-template-servergithub.com你可以在我GitHub仓库中找到这些和更多内容。别忘了看看我的网站。
布瑞安·阿吉拉尔的个人主页。系统工程师 & 前端开发者 · 资深React开发者 · 数据分析师爱好者…www.bryan-aguilar.com谢谢你看这篇!
如果你有任何问题,不要犹豫问我。我的邮箱随时欢迎你的消息。无论你有什么疑问或只是想打个招呼,我都会尽力回答你的问题。
共同学习,写下你的评论
评论加载中...
作者其他优质文章