11.1 这一篇做什么
前面几篇分别讲了每一层的写法,这一篇把它们串起来,从零写完一个项目管理模块的完整 CRUD。
读完这篇,你应该能照着写出其他任何模块。
11.2 要实现的接口
| 方法 | URL | 功能 |
|---|---|---|
| GET | /api/v1/projects | 分页查询项目列表,支持按名称搜索 |
| GET | /api/v1/projects/:id | 查询单个项目详情 |
| POST | /api/v1/projects | 新建项目 |
| PUT | /api/v1/projects/:id | 更新项目 |
| DELETE | /api/v1/projects/:id | 删除项目 |
统一响应格式:{ status: 0, data: ... }
11.3 第一步:定义数据模型
src/models/project/project.ts:
exportinterfaceProject{id:number;name:string;description:string;status:'active'|'inactive';created_at:number;updated_at:number;}11.4 第二步:写 Model 层
src/models/project/project-model.ts:
importtype{Project}from'./project.js';exportclassProjectModel{#projects:Project[];constructor(initialData:readonlyProject[]=[]){this.#projects=initialData.map((p)=>structuredClone(p));}list():Project[]{returnstructuredClone(this.#projects);}findById(id:number):Project|undefined{constproject=this.#projects.find((p)=>p.id===id);returnproject?structuredClone(project):undefined;}findByName(name:string):Project|undefined{constproject=this.#projects.find((p)=>p.name===name);returnproject?structuredClone(project):undefined;}create(input:Pick<Project,'name'|'description'|'status'>):Project{constnow=Math.floor(Date.now()/1000);constproject:Project={...input,id:this.#nextId(),created_at:now,updated_at:now,};this.#projects.push(structuredClone(project));returnstructuredClone(project);}update(id:number,input:Partial<Pick<Project,'name'|'description'|'status'>>,):Project|undefined{constproject=this.#projects.find((p)=>p.id===id);if(!project)returnundefined;if(input.name!==undefined)project.name=input.name;if(input.description!==undefined)project.description=input.description;if(input.status!==undefined)project.status=input.status;project.updated_at=Math.floor(Date.now()/1000);returnstructuredClone(project);}delete(id:number):boolean{constindex=this.#projects.findIndex((p)=>p.id===id);if(index<0)returnfalse;this.#projects.splice(index,1);returntrue;}#nextId():number{if(this.#projects.length===0)return1;returnMath.max(...this.#projects.map((p)=>p.id))+1;}}11.5 第三步:准备初始数据
src/data/project/index.ts:
importtype{Project}from'@/models/project/project.js';constCREATED_AT=1_700_000_000;exportconstprojectData:readonlyProject[]=[{id:1,name:'示例项目A',description:'这是第一个示例项目',status:'active',created_at:CREATED_AT,updated_at:CREATED_AT,},{id:2,name:'示例项目B',description:'这是第二个示例项目',status:'inactive',created_at:CREATED_AT,updated_at:CREATED_AT,},];11.6 第四步:写 Service 层
src/services/project/project-service.ts:
import{AppError}from'@/errors/app-error.js';importtype{ProjectModel}from'@/models/project/project-model.js';importtype{Project}from'@/models/project/project.js';exportclassProjectService{constructor(privatereadonlyprojects:ProjectModel){}// 查询列表(支持按名称模糊搜索)list(name=''):Project[]{constall=this.projects.list();if(!name)returnall;constlower=name.toLowerCase();returnall.filter((p)=>p.name.toLowerCase().includes(lower));}// 查询详情getById(id:number):Project{constproject=this.projects.findById(id);if(!project){thrownewAppError('项目不存在',{status:2});}returnproject;}// 新建create(body:Record<string,unknown>):Project{constname=this.#requireString(body.name,'项目名称不能为空');constdescription=typeofbody.description==='string'?body.description:'';conststatus=body.status==='inactive'?'inactive':'active';if(this.projects.findByName(name)){thrownewAppError('项目名称已存在',{status:2});}returnthis.projects.create({name,description,status});}// 更新update(id:number,body:Record<string,unknown>):Project{constcurrent=this.projects.findById(id);if(!current){thrownewAppError('项目不存在',{status:2});}constname=body.name!==undefined?this.#requireString(body.name,'项目名称不能为空'):undefined;if(name&&name!==current.name){if(this.projects.findByName(name)){thrownewAppError('项目名称已存在',{status:2});}}constupdated=this.projects.update(id,{name,description:typeofbody.description==='string'?body.description:undefined,status:body.status==='inactive'||body.status==='active'?body.status:undefined,});if(!updated){thrownewAppError('更新失败',{status:1});}returnupdated;}// 删除delete(id:number):void{constcurrent=this.projects.findById(id);if(!current){thrownewAppError('项目不存在',{status:2});}constsuccess=this.projects.delete(id);if(!success){thrownewAppError('删除失败',{status:1});}}#requireString(value:unknown,errorMsg:string):string{conststr=typeofvalue==='string'?value.trim():'';if(!str){thrownewAppError(errorMsg,{status:2});}returnstr;}}11.7 第五步:写 Controller 层
src/controllers/project/project-controller.ts:
importtype{Request,Response}from'express';importtype{ProjectService}from'@/services/project/project-service.js';import{sendOk,sendList}from'@/utils/response.js';import{parseOffset,parseSize,parseText}from'@/utils/pagination.js';exportclassProjectController{constructor(privatereadonlyservice:ProjectService){}list=(req:Request,res:Response)=>{constoffset=parseOffset(req.query.offset);constsize=parseSize(req.query.size);constname=parseText(req.query.name);constall=this.service.list(name);returnsendList(res,all.slice(offset,offset+size),{count:all.length,offset,size,});};get=(req:Request,res:Response)=>{constproject=this.service.getById(Number(req.params.id));returnsendOk(res,project);};create=(req:Request,res:Response)=>{constproject=this.service.create(req.body);returnsendOk(res,{id:project.id});};update=(req:Request,res:Response)=>{this.service.update(Number(req.params.id),req.body);returnsendOk(res,null);};remove=(req:Request,res:Response)=>{this.service.delete(Number(req.params.id));returnsendOk(res,null);};}11.8 第六步:写 Router 层
src/routers/project/project-router.ts:
import{Router}from'express';importtype{ProjectController}from'@/controllers/project/project-controller.js';exportfunctioncreateProjectRouter(controller:ProjectController){constrouter=Router();router.get('/',controller.list);// GET /projectsrouter.get('/:id',controller.get);// GET /projects/:idrouter.post('/',controller.create);// POST /projectsrouter.put('/:id',controller.update);// PUT /projects/:idrouter.delete('/:id',controller.remove);// DELETE /projects/:idreturnrouter;}11.9 第七步:注册到总路由
src/bootstrap.ts里加上:
import{projectData}from'./data/project/index.js';import{ProjectModel}from'./models/project/project-model.js';import{ProjectService}from'./services/project/project-service.js';import{ProjectController}from'./controllers/project/project-controller.js';exportfunctionbootstrap(){constprojects=newProjectModel(projectData);constproject=newProjectController(newProjectService(projects));return{projects,controllers:{project},};}src/routers/index.ts里加上:
import{createProjectRouter}from'./project/project-router.js';exportfunctioncreateRouter(controllers:any){constrouter=Router();router.use('/projects',createProjectRouter(controllers.project));returnrouter;}11.10 测试一下
启动服务:
npmrun dev用 curl 测试:
# 列表curlhttp://localhost:3000/api/v1/projects# 搜索curl"http://localhost:3000/api/v1/projects?name=A"# 详情curlhttp://localhost:3000/api/v1/projects/1# 新建curl-XPOST http://localhost:3000/api/v1/projects\-H"Content-Type: application/json"\-d'{"name":"新项目","description":"测试","status":"active"}'# 更新curl-XPUT http://localhost:3000/api/v1/projects/3\-H"Content-Type: application/json"\-d'{"name":"改名后的项目"}'# 删除curl-XDELETE http://localhost:3000/api/v1/projects/311.11 小结
一个完整的 CRUD 模块需要 6 个文件:
| 文件 | 作用 |
|---|---|
models/project/project.ts | 类型定义 |
models/project/project-model.ts | 数据操作 |
data/project/index.ts | 初始数据 |
services/project/project-service.ts | 业务逻辑 |
controllers/project/project-controller.ts | 请求处理 |
routers/project/project-router.ts | 路由映射 |
每一层职责单一,改其中一层不影响其他层。照着这个模板写其他模块就行。