本文最后更新于 2025-01-10T22:20:33+08:00
Node-Koa-Templates 开源Node-Koa后端服务框架模板 参考:
【东方骏:】Koa2 + Ts 项目结构搭建 保姆级教程 。
【写代码的Mokel】nodejsts项目的基础工程化配置 .
【前端小付】fluxy-admin后台管理系统开发记录
【小满zs】项目架构MVC,IoC,DI
Node-Koa-Templates使用MVC架构 MVC(Model-View-Controller)是一种常用的软件架构模式,用于设计和组织应用程序的代码。它将应用程序分为三个主要组件:模型(Model)、视图(View)和控制器(Controller),各自负责不同的职责。
模型(Model):模型表示应用程序的数据和业务逻辑。它负责处理数据的存储、检索、验证和更新等操作。模型通常包含与数据库、文件系统或外部服务进行交互的代码。
视图(View):视图负责将模型的数据以可视化的形式呈现给用户。它负责用户界面的展示,包括各种图形元素、页面布局和用户交互组件等。视图通常是根据模型的状态来动态生成和更新的。
控制器(Controller):控制器充当模型和视图之间的中间人,负责协调两者之间的交互。它接收用户输入(例如按钮点击、表单提交等),并根据输入更新模型的状态或调用相应的模型方法。控制器还可以根据模型的变化来更新视图的显示。
由于是前后端分离,所以
依赖 1 2 3 4 5 6 7 8 web框架: koa + ts 热更新:ts-node -dev + ts-node 代码格式检查:eslint 代码格式化:prettier + onchange orm:prisma 日志:log4js token生成:jsonwebtoken 进程守护:pm2
目录结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 . ├── docs // 文档目录 ├── logs // 日志文件夹 ├── public // 资源文件夹 ├── script // 工具脚本 ├── src // 放代码 │ ├── config // 配置文件夹 │ │ │── assets // 存放证书文件 │ │ │── code // 存放http返回状态 │ │ │── casbin.model.conf // casbin权限模型配置 │ │ │── constant.ts // 服务程序全局配置文件 │ │ │── pm2.config.ts // pm2部署配置 │ ├── controllers // 控制器,处理路由逻辑 │ ├── middlewares // 中间件,请求预处理逻辑,例如权限验证 │ ├── prisma // 数据库访问ORM层 │ ├── routers // 路由定义 │ ├── services // 数据服务层,处理数据库业务 │ ├── tests // 测试文件夹 │ ├── utils // 工具函数或类 ├── .env // 环境变量文件参考 ├── .gitignore // git忽略文件 ├── README.md // 项目描述文件
搭建过程 1、初始化工程
2、安装依赖 安装生产环境依赖:
1 npm i koa koa-body koa-router log4js pm2 axios jsonwebtoken
安装生产环境依赖:
1 npm i @types/koa @types/koa-router @types/log4js @types/jsonwebtoken prettier eslint nodemon typescript ts-node @types/node -D
3、创建ts管理文件:tsconfig.json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 { "compilerOptions" : { "target" : "esnext" , "module" : "commonjs" , "rootDir" : "./src" , "strict" : true , "outDir" : "./build" , "allowSyntheticDefaultImports" : true , "esModuleInterop" : true , "forceConsistentCasingInFileNames" : true } , "ts-node" : { "compilerOptions" : { "module" : "CommonJS" } } }
4、配置ESLINT代码检测 ESLint 是一个用于识别和报告在 ECMAScript/JavaScript 代码中发现的模式的工具,其目标是使代码更加一致并避免错误。
补充安装开发依赖:
1 npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin -D
安装完成后在根目录下分别创建 .eslintrc 和 .eslintignore 文件,在 .eslintrc文件中填充一些配置项:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "root" : true , "parser" : "@typescript-eslint/parser" , "extends" : [ "eslint:recommended" , "plugin:@typescript-eslint/recommended" , "plugin:@typescript-eslint/eslint-recommended" ] , "plugins" : [ "@typescript-eslint" ] }
在.eslintignore中填入当前需要忽略的文件,包括编译输出目录build, 所有node_modules文件夹 和package-lock.json文件
1 build / node_modules / package - lock.json
在package.json中新增执行eslint工具的脚本,关于所使用的eslint命令行工具的可用options,可以在eslint的官方文档 中获取详细信息。
1 2 3 "scripts" : { "lint" : "eslint . --ext .js,.ts" }
测速:
工程目录下新建test.ts
1 2 3 function test ( ) { console .log ('test' ) }
运行脚本npm run lint
抛出错误: error 'test' is defined but never used
说明配置OK
5、配置Prettier代码格式化8 Prettier 是一个 Opinionated 的代码格式化工具。
安装:
在项目根目录下创建 .prettierrc 和 .prettierignore 文件.prettierrc
配置如下
1 2 3 4 5 6 7 { "semi" : false , "tabWidth" : 2 , "singleQuote" : true , "trailingComma" : "es5" , "printWidth" : 140 }
semi: 设置为 false,表示不使用分号作为语句结束符。 tabWidth: 设置为 2,表示使用两个空格作为一个制表符的宽度。 singleQuote: 设置为 true,表示使用单引号而不是双引号来定义字符串。 trailingComma: 设置为 “es5”,表示在对象或数组的最后一个元素之后> 允许添加逗号。 printWidth: 设置为 140,表示每行代码的最大宽度为 140 个字符。
.prettierignore
配置如下
1 2 3 build/ node_modules/ package-lock .json
关于prettier的详细的配置项,可以查阅官网文档 了解详细信息。
在package.json中新增执行prettier工具的脚本
1 2 3 "scripts" : { "format" : "prettier --config .prettierrc . --write" }
测试:console.log('test');
带有;
号,不符合"semi": false
配置项的规范
运行脚本npm run format
分号被格式化删除,说明配置OK
6、配置Prettier 和 Eslint 协同工作 Eslint既包含代码质量规则,也包含代码风格规则,当我们使用Prettier来对代码进行格式化时,Eslint的大部分代码风格的规则其实是不必要的,而且更糟糕的是,Eslint的代码风格的规则往往会跟Prettier发生冲突,所以我们需要应用一些Eslint的配置集来关闭与Prettier冲突或不必要的规则,并且将Prettier的规则转换为Eslint的规则,从而让Eslint能够完全按照我们的诉求向我们提供错误或警告信息。
在这里,会用上两个配置,分别为eslint-config-prettier 和 eslint-plugin-prettier,前者作用是关闭所有可能干扰 Prettier 规则的 ESLint 规则,确保将其放在最后,这样它有机会覆盖其他配置集,后者作用是将 Prettier 规则转换为 ESLint 规则。
安装:
1 npm i eslint-config-prettier eslint-plugin-prettier -D
修改.eslintrc文件配置,添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "root" : true , "parser" : "@typescript-eslint/parser" , "extends" : [ "eslint:recommended" , "plugin:@typescript-eslint/recommended" , "plugin:@typescript-eslint/eslint-recommended" , "prettier" ] , "plugins" : [ "@typescript-eslint" , "prettier" ] "rules" : { "prettier/prettier" : "error" , "arrow-body-style" : "off" , "prefer-arrow-callback" : "off" } }
分别是extends prettier 配置,加载 prettier 插件,和添加三条rules,添加后如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 { "root" : true , "parser" : "@typescript-eslint/parser" , "extends" : [ "eslint:recommended" , "plugin:@typescript-eslint/recommended" , "plugin:@typescript-eslint/eslint-recommended" , "prettier" ] , "plugins" : [ "@typescript-eslint" , "prettier" ] , "rules" : { "prettier/prettier" : "error" , "arrow-body-style" : "off" , "prefer-arrow-callback" : "off" } }
7、配置Husky工具 git hooks
是一些自定义的脚本,用于控制git工作的流程。git hooks 是本地的,不会被同步到 git 仓库里。为了保证每个人的本地仓库都能执行预设的 git hooks,于是就有了 husky。
Husky可以在项目中植入设定的 git hooks,在 git 提交代码的前后,预设的 git hooks 可以得到执行,以对代码、文件等进行预设的检查,一旦检查不通过,就可以阻止当前的代码提交,避免了不规范的代码和 git 提交出现在项目中。
安装:
初始化husky
根目录会生成.husky文件
在package.json中新增脚本
1 2 3 "scripts" : { "prepare" : "husky install" , }
这会使得,当其他人克隆该项目并安装依赖时会自动通过husky启用git hook
我们需要的第一个git hook是在提交commit之前执行我们的eslint工具对代码进行质量和格式检查,也就是在提交commit之前执行package.json中的lint脚本,我们通过husky命令来创建pre-commit这个git hook
1 npx husky add .husky/pre-commit "npm run lint"
.husky文件夹多出命令
测试:提交有格式错误的代码
成功抛出错误,提交被终止:
7、配置lint-staged工具 它的作用是仅对变更的文件执行相关操作,在这里,就是执行eslint检查这项操作,同时还能忽略我们所要忽略的文件。
安装:npm i -D lint-staged
创建 .lintstagedrc.js 配置文件
填充一下该配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const { ESLint } = require ('eslint' );const removeIgnoredFiles = async (files ) => { const eslint = new ESLint (); const ignoredFiles = await Promise .all (files.map ((file ) => eslint.isPathIgnored (file))); const filteredFiles = files.filter ((_, i ) => !ignoredFiles[i]); return filteredFiles.join (' ' ); };module .exports = { '*' : async (files) => { const filesToLint = await removeIgnoredFiles (files); return [`eslint ${filesToLint} --max-warnings=0` ]; }, };
该配置文件中的代码片段的含义是,对所有被lint-staged检测到的文件,其中过滤掉我们所需要忽略的文件,然后执行eslint脚本。
手动更改一下husky为我们创建的pre-commit这个git hook,将其变更为执行lint-staged命令(npx lint-staged),而不是直接执行package.json中的脚本。
8、配置commitlint,commitizen工具 使用commitlint工具并搭配git hook从而在提交commit前对我们的commit message进行格式检查。
安装:npm i -D @commitlint/cli @commitlint/config-conventional
安装完成后,在项目根目录下创建配置文件 .commitlintrc.json,并向其中填入内容:
1 2 3 4 5 6 { "extends" : [ "@commitlint/config-conventional" ] , "rules" : { "scope-empty" : [ 2 , "never" ] } }
“extends”: [“@commitlint/config-conventional”] 的作用是直接拓展官方的预设配置,
“rules”: { “scope-empty”: [2, “never”] }而这条规则是要求commit message的scope即范围不能为空
然后我们使用Husky添加 commit-msg的git hook,通过npx执行husky命令完成添加
1 npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
作用是在我们提交commit或者修改commit-msg时对commit-msg执行相关校验,如此一来,我们就可以确保我们的项目拥有一个统一的符合规范的commit message。
示例:
错误提交信息会抛出错误:
正确提交信息不会抛出错误:
commitlint配置好了,下一步就是要引入commitizen来帮助我们便捷地创建符合commitlint规范的commit message
安装:npm i -D commitizen cz-conventional-changelog
安装好后,创建 .czrc 配置文件,并向文件中填入内容
1 { "path" : "cz-conventional-changelog" }
cz-conventional-changelog是commitizen的 conventional-changelog 适配器,使用该适配器,commitizen将以AngularJS 的commit message规范逐步引导我们完成commit message的创建。
在package.json中新增脚本:cz
1 2 3 "scripts" : { "cz" : "cz" }
使用脚本npm run cz
即可执行commit message的创建。具体选项可参考https://juejin.cn/post/7024103006752735269
9、src下创建入口文件:app.ts 填充下面的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 import fs from 'fs' ;import http from 'http' ;import https from 'https' ;import path from 'path' ;import Koa from 'koa' ;import koaBody from 'koa-body' ;import Static from 'koa-static' ;import { PORT } from './config/constant' ;import { loggerMiddleware } from './middlewares/log' ;import { errorHandler } from './middlewares/error' ;import { responseHandler } from './middlewares/response' ;import { getIpAddress } from './utils/util' ;import router from './routers/index' ;const app = new Koa (); app.use (loggerMiddleware); app.use (koaBody ({ multipart : true })); app.use (errorHandler); app.use (Static (path.join (__dirname + '/../public' ))); app.use (router.routes ()).use (router.allowedMethods ()); app.use (responseHandler);const httpPort = PORT .http ;const httpServer = http.createServer (app.callback ()); httpServer.listen (httpPort); httpServer.on ('error' , (err: Error ) => { console .log (err); }); httpServer.on ('listening' , () => { const ip = getIpAddress (); const address = `http://${ip} :${httpPort} ` ; const localAddress = `http://localhost:${httpPort} ` ; console .log (`app started at address:${localAddress} or ${address} ` ); });const httpsPort = PORT .https ;const ACoptions = { key : fs.readFileSync (path.resolve (__dirname, './assets/example.com.key' )), cert : fs.readFileSync (path.resolve (__dirname, './assets/example.com_bundle.crt' )), };const httpsServer = https.createServer (ACoptions , app.callback ()); httpsServer.listen (httpsPort); httpsServer.on ('error' , (err ) => { console .log (err); }); httpsServer.on ('listening' , () => { const ip = getIpAddress (); const address = `https://${ip} :${httpsPort} ` ; const localAddress = `https://localhost:${httpsPort} ` ; console .log (`app started at address:${localAddress} or ${address} ` ); });
10 、src下创建工具库文件夹:utils utils文件夹下新增常用函数封装文件:util.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import { Context } from 'vm' import { JWT } from '../config/constant' import jwt from 'jsonwebtoken' export const getIpAddress = ( ) => { const interfaces = require ('os' ).networkInterfaces () for (const devName in interfaces) { const temp = interfaces[devName] for (let i = 0 ; i < temp.length ; i++) { const alias = temp[i] if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal ) { return alias.address } } } }export const getClientIpAddress = (ctx: Context ) => { const headers = ctx.headers if (headers['x-forwarded-for' ]) { const ipList = headers['x-forwarded-for' ].split (',' ) return ipList[0 ] } return '0.0.0.0' }
11、src下新增配置文件夹:config config文件夹下新增常用接口状态code:code.ts
这里随便写点作为示例,具体根据自己业务情况来写
1 2 3 4 5 6 7 8 export const CODE = { success : { code : 0 , message : 'success' , key : 'success' }, tokenFailed : { code : 1 , message : 'token校验失败' , key : 'tokenFailed' }, missingParameters : { code : 2 , message : '缺少参数' , key : 'missingParameters' }, adminUserIsExist : { code : 3 , message : '账号名已存在' , key : 'adminUserIsExist' }, };
config文件夹下新增全局通用的配置参数文件:constant.ts
内容仅作为示例,按实际业务动态处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import { anyKeyObject } from "../type/global" ;export const ENV = { development : "development" , production : "production" , };export const DATABASE = { development : { dbName : "xxx" , user : "root" , password : "xxx" , host : "xxx" , port : 3306 , }, production : { dbName : "xxx" , user : "root" , password : "xxx" , host : "xxx" , port : 3306 , }, };export const JWT = { secret : "xxx" , expires : 60 * 60 * 24 * 30 , };export const SMS = { accessKeyId : "xxx" , accessKeySecret : "xxx" , signName : "xxx" , templateCode : "xxx" , };export const PLATFORM = { wxMini : "微信小程序" , wxH5 : "微信H5" , webH5 : "webH5" , dyMini : "抖音小程序" , ksMini : "快手小程序" , qqMini : "QQ小程序" , };export const PAY = { wx : { miniAppid : "xxx" , h5Appid : "xxx" , mchid : "xxx" , v3Key : "xxx" , }, };export const PAY_TYPE = [{ label : "微信小程序支付" , value : 1 }];export const WX_MINI = { appid : "xxx" , secret : "xxx" , };export const FIXED_KEY = { port : 3232 , };
config文件夹下新增pm2配置文件:pm2.config.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 const ENV = { development : "development" , production : "production" , };module .exports = { apps : [ { name : "production" , script : "./src/app.js" , args : "one two" , instances : 2 , cron_restart : "0 03 * * *" , autorestart : true , watch : false , min_uptime : "200s" , max_restarts : 10 , ignore_watch : [ "node_modules" , ".idea" , "log" , ], max_memory_restart : "300M" , restart_delay : "3000" , env : { NODE_ENV : ENV .production , }, }, { name : "test" , script : "./src/app.js" , args : "one two" , instances : 1 , cron_restart : "0 03 * * *" , autorestart : true , watch : true , ignore_watch : [ "node_modules" , ".idea" , "log" , ], max_memory_restart : "300M" , env : { NODE_ENV : ENV .development , }, }, ], };
12、src下新增中间件文件夹:middleware 日志记录中间件:log.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import Koa from 'koa' ;import log4js from 'log4js' ;import { getClientIpAddress } from '../utils/util' ; log4js.configure ({ pm2 : true , appenders : { everything : { type : 'dateFile' , filename : 'logs\\log' , pattern : 'yyyy-MM-dd.log' , alwaysIncludePattern : true , keepFileExt : true , numBackups : 30 , compress : true , }, }, categories : { default : { appenders : ['everything' ], level : 'debug' }, }, });export const logger = log4js.getLogger ();export const loggerMiddleware = async (ctx: Koa.Context, next: Koa.Next ) => { const start = new Date (); await next (); const ms = Number (new Date ()) - Number (start); const remoteAddress = getClientIpAddress (ctx); const logText = `${ctx.method} ${ctx.status} ${ctx.url} 请求参数: ${JSON .stringify(ctx.request.body)} 响应参数: ${JSON .stringify( ctx.body )} - ${remoteAddress} - ${ms} ms` ; logger.info (logText); };
返回统一出口中间件:response.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import Koa from 'koa' ;import { CODE } from '../config/code' ;export const responseHandler = (ctx: Koa.Context ) => { if (ctx.body !== undefined ) { ctx.type = 'json' ; ctx.body = { code : CODE .success .code , data : ctx.body , message : CODE .success .message , }; } };
13、业务流程实现 业务流程实现是最复杂的部分,设计到四个文件夹,分别是routers
controllers
services
和 prisma
13.1、src下创建路由文件夹:routers routers
目录下新建index.ts文件,用于自动路由挂载:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import fs from 'fs' ;import path from 'path' ;import Router from 'koa-router' ;const router = new Router (); fs.readdirSync (__dirname).forEach ((file ) => { if (file !== 'index.js' ) { import (path.join (__dirname, file)) .then ((r ) => { router.use (r.default .routes ()); }) .catch ((error ) => { console .log ('路由自动加载错误' + error); }); } });export default router;
然后业务模块统一命名为:xxx.route.ts
下面展示一个最简单的路由范例:test.route.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import Router from 'koa-router' ;const router = new Router ({ prefix : '/test' }); router.post ('/post' , (ctx: any , next: any ) => { console .log ('测试路由:post' ); ctx.body = '这是一个测试路由,post请求正常!' ; }); router.delete ('/delete' , (ctx: any , next: any ) => { console .log ('测试路由:delete' ); ctx.body = '这是一个测试路由,delete请求正常!' ; }); router.get ('/get' , (ctx: any , next: any ) => { console .log ('测试路由:get' ); ctx.body = '这是一个测试路由,get请求正常!' ; }); router.put ('/update' , (ctx: any , next: any ) => { console .log ('测试路由:put' ); ctx.body = '这是一个测试路由,put请求正常!' ; });export default router;
此时将app.ts
缺少的依赖补上,运行主程序,能成功访问则,则确认路由部署成功。
下面我们展示一个基本的业务流程搭建,搭建一个example.route.ts
模块
首先在路由文件夹建立example.route.ts
,这里实现最简单的增删改查
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import Router from 'koa-router' ;const router = new Router ({ prefix : '/example' });import Controllers from '../controllers/example.controller' ; router.post ('/post' , Controllers .post ); router.delete ('/delete' , Controllers .delete ); router.get ('/get' , Controllers .get ); router.put ('/update' , Controllers .put );export default router;
13.2、src下创建路由逻辑处理文件夹:controllers 刚才的example.route.ts
中的
1 import Controllers from '../controllers/example.controller' ;
就是将逻辑处理部分模块化到Controllers里了。
接着我们新建example.controller.ts
,填充下面的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import ExampleService from '../services/example.service' ;function bigIntToString (value ) { const MAX_SAFE_INTEGER = 2 ** 53 - 1 ; return value <= MAX_SAFE_INTEGER ? Number (value) : value.toString (); }class ExampleController { async post (ctx: any , next: any ) { const { Name , Password , Email , Phone } = ctx.request .body ; const res = await ExampleService .createExample (Name , Password , Email , Phone ); const newRes = { ...res }; if (typeof res.AccountId === 'bigint' ) newRes.AccountId = bigIntToString (res.AccountId ); ctx.body = JSON .stringify (newRes); } async delete (ctx: any , next: any ) { const { AccountId } = ctx.request .body ; const res = await ExampleService .deleteExample (AccountId ); const newRes = { ...res }; if (typeof res.AccountId === 'bigint' ) newRes.AccountId = bigIntToString (res.AccountId ); ctx.body = JSON .stringify (newRes); } async get (ctx: any , next: any ) { const { id } = ctx.request .body ; const res = await ExampleService .getExample (id); const newRes = { ...res }; if (typeof res.AccountId === 'bigint' ) newRes.AccountId = bigIntToString (res.AccountId ); ctx.body = JSON .stringify (newRes); } async put (ctx: any , next: any ) { console .log (ctx.request .body ); const { AccountId , Name , Password , Email , Phone } = ctx.request .body ; const res = await ExampleService .updateExample (AccountId , Name , Password , Email , Phone ); const newRes = { ...res }; if (typeof res.AccountId === 'bigint' ) newRes.AccountId = bigIntToString (res.AccountId ); ctx.body = JSON .stringify (newRes); } }export default new ExampleController ();
这部分向下又涉及数据部分,所以我们先配置ORM环境
13.3 src下新增prisma数据库配置文件夹prisma 安装依赖:npm i prisma @prisma/client -D
使用npx prisma --help
可以查看prisma 帮助,常用命令有下面这几个
命令
说明
init
在应用中初始化 Prisma
generate
主要用来生成 Prisma Client
db
管理数据库的模式和生命周期
migrate
迁移数据库
studio
启动一个Web 端的工作台来管理数据
validate
检查 Prisma 的模式文件的语法是否正确
format
格式化Prisma的模式文件,默认就是 prisma/schema.prisma
首先我们初始化 Prisma,使用npx prisma init
命令
这个命令的效果是在命令所在目录,创建一个 .env
文件,一个 prisma
目录,并在此目录下创建 schema.prisma
文件。这里我们把prisma目录移动到src目录下,.env保留在根目录。
.env
文件用于存放数据库连接信息,prisma
目录,用来存放和 Prisma 相关的文件,其中的schema.prisma
是使用 Primsa 的主要配置文件,称之为 Prisma schema 文件,它包含三个基本结构:
这里可以安装Prisma
插件,增强schema.prisma文件的编辑体验。
prisma/schema.prisma配置如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env ("DATABASE_URL" ) } model Example { AccountId BigInt @id @default (autoincrement ()) @map ("account_id" ) Name String @map ("name" ) Password String @map ("password" ) Email String @default ("test@test.com" ) @map ("email" ) Phone String @default ("12312341234" ) @map ("phone" ) IsDeleted Boolean @default (false ) @map ("is_deleted" ) CreatedTime DateTime @default (now ()) @map ("created_time" ) pdatedTime DateTime @updatedAt @map ("updated_time" ) @@map ("example" ) }
generator
指定了要生成的 Prisma Client 为 JavaScript 语言。
datasource
指定了数据库连接是 mysql 数据库,以及数据库配置信息。
Example
是自定义的数据模型,对应数据库的一张表
.env 内容如下,配置数据库连接和数据库名称
1 DATABASE_URL=mysql://root:3.1415926@localhost:3306/example_db
执行npx prisma migrate dev --name init
将数据库设计更新到真正的数据库 数据库已经自动生成符合的表结构。
下面我们基于这张表实现最基本的增删改查:
13.4 src下新建services文件夹 回忆一下Controllers模块里,我们把对数据库操作都封装到services层,所以我们新建services文件夹,其中example模块对应的数据操作文件命名为example.service.ts
内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import { PrismaClient } from '@prisma/client' ;const prisma = new PrismaClient ();class ExampleService { async createExample (Name: string , Password: string , Email: string , Phone: string ) { const result = await prisma.example .create ({ data : { Name , Password , Email , Phone , }, }); return result; } async deleteExample (AccountId: number ) { const result = await prisma.example .delete ({ where : { AccountId }, }); return result; } async updateExample (AccountId: number , Name: string , Password: string , Email: string , Phone: string ) { const result = await prisma.example .update ({ where : { AccountId }, data : { Name , Password , Email , Phone , }, }); return result; } async getExample (AccountId ) { const result = await prisma.example .findUnique ({ where : { AccountId }, }); return result; } }export default new ExampleService ();
上面就是针对数据库增删改查的操作,相较于传统的sql,要简单不少。
有关Prisma
的其他内容,自行查阅官方文档即可。
自此就实现了最基本的整套基础api服务。
14、配置Swagger用于生成文档 Swagger(目前用OpenAPI Specification代替)是一个用于设计、构建、记录和使用REST API的强大工具。通过使用Swagger,开发者可以定义API的结构,确保API的稳定性,并生成协作所需的文档。
安装依赖:npm install swagger-jsdoc swagger-ui-express --save
在中间件文件夹middlewares
添加swagger.config.ts
,内容如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import path from 'path' ;import swaggerJSDoc from 'swagger-jsdoc' ;import AddressIp from 'ip' ;import { PORT } from '../config/constant' ;const swaggerDefinition = { info : { title : 'Swagger接口文档' , version : '1.0.0' , description : 'Swagger接口文档' , }, contact : { name : 'name' , url : 'url' , }, host : `${AddressIp.address()} :${PORT.http} ` , basePath : '/' , securityDefinitions : { token : { type : 'apiKey' , name : 'authorization' , in : 'header' , }, }, };const options = { swaggerDefinition, apis : [path.join (__dirname, '/../routers/*.ts' )], };const jsonSpc = swaggerJSDoc (options);export default jsonSpc;
在路由文件夹新建swagger.route.ts
文件,内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 import Router from 'koa-router' ;import { Context } from 'koa' ;import swaggerJSDoc from '../middlewares/swagger.config' ;const router = new Router (); router.get ('/docs' , (ctx: Context ) => { ctx.body = swaggerJSDoc; });export default router;
在入口文件挂载路由即可
1 2 3 4 5 6 7 8 import { koaSwagger } from 'koa2-swagger-ui' ; app.use (router.routes ()).use (router.allowedMethods ()); app.use (koaSwagger ({ routePrefix : '/swagger' , swaggerOptions : { url : '/docs' } }));
之后访问对应路径就可看到对应在线文档了:
下面的API参数,通过编写注释实现,具体语法可参看Swagger官方文档,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 router.post ('/register' , Controllers .register );
有token鉴权的的路由需要在注释里加入security
选型,如下;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
最后:需要将跨域中间件置于jwt认证中间键之前,不然swagger请求会出现跨域错误
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 const app = new Koa (); app.use (errorHandler); app.use (loggerMiddleware); app.use (Cors (corsHandler)); app.use (Jwtauth ); app.use (koaBody ({ multipart : true })); app.use (parameter (app)); app.use (Static (path.join (__dirname + '/../public' ))); app.use (router.routes ()).use (router.allowedMethods ()); app.use (koaSwagger ({ routePrefix : '/swagger' , swaggerOptions : { url : '/docs' } })); app.use (responseHandler);
15、安装koa-parameter 进行路由参数校验 安装依赖npm install koa-parameter --save
用法
1 2 3 4 5 6 7 8 9 10 11 12 13 const Koa = require ('koa' );const parameter = require ('koa-parameter' ); const app = new Koa (); parameter (app); app.use (async function (ctx ) { ctx.verifyParams ({ name : 'string' }); });
在路由处理函数中使用添加 ctx.verifyParams
数据校验即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class AccountController { async register (ctx: any , next: any ) { const { Name , Password , Email , Phone } = ctx.request .body ; ctx.verifyParams ({ Name : { type : 'string' , required : true , }, Password : { type : 'string' , required : true , }, Email : { type : 'string' , required : false , }, Phone : { type : 'string' , required : false , }, });
16、进行JWT鉴权 使用jsonwebtoken包实现,安装依赖npm i jsonwebtoken
登陆时,登陆成功返回token,
1 2 3 4 5 6 7 8 9 const token = 'Bearer ' + sign ({ AccountId : newRes.AccountId }, JWT .secret , { expiresIn : JWT .expires }); ctx.body = { code : 0 , msg : '用户登录成功' , data : { token : token }, };
自定义JWT拦截器(这里没使用koa-jwt是因为在使用它时老出现莫名其妙的错误)、
在中间件文件夹middlewares
添加jwt.ts
,内容如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import { verify } from 'jsonwebtoken' ;import { JWT } from '../config/constant' ;import Koa from 'koa' ;import { TOKEN_EXPIRED , TOKEN_INVALID } from '../config/code/responseCode' ;import { PublicRouter } from '../config/constant' ;export const Jwtauth = async (ctx: Koa.Context, next: Koa.Next ) => { if (checkIgnore (ctx.path )) { await next (); return ; } const { authorization = '' } = ctx.request .header ; const token = authorization.replace ('Bearer ' , '' ); try { const user = verify (token, JWT .secret ); ctx.state .user = user; } catch (err) { switch (err.name ) { case 'TokenExpiredError' : return await TOKEN_EXPIRED (ctx); case 'JsonWebTokenError' : return await TOKEN_INVALID (ctx); } } await next (); };function checkIgnore (path: string ): boolean { const ignoreRoutes = PublicRouter ; return ignoreRoutes.some ((route ) => route.test (path)); }
这里的PublicRouter
在配置文件里如下
1 2 3 export const PublicRouter = [/\/swagger/ , /\/docs/ , /^\/public/ , /\/account\/login/ , /\/account\/register/ , /\/favicon\.png/ ];
17、添加错误处理中间件: 在config下新建code目录,定义返回状态,添加下列文件
errCode.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import Koa from 'koa' ;export class ErrorModel { code : number ; msg : string ; statusCode : number ; constructor (code = 500 , msg = '未知服务器错误' , statusCode = 500 ) { this .code = code; this .msg = msg; this .statusCode = statusCode; } throwErr (ctx: Koa.Context ) { ctx.throw (this .statusCode , this .msg , { code : this .code , flag : 'ErrorModel' , }); } }export class ParameterError extends ErrorModel { constructor (code, msg = '请求错误' ) { super (code, msg, 400 ); } }export class AuthError extends ErrorModel { constructor (code, msg = 'token认证失败' ) { super (code, msg, 401 ); } }export class NotFoundError extends ErrorModel { constructor (code, msg = '未找到该api' ) { super (code, msg, 404 ); } }export class InternalServerError extends ErrorModel { constructor (code, msg = '服务器内部错误' ) { super (code, msg, 500 ); } }
successCode.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import Koa from 'koa' ;class SuccessModel { code : number ; msg : any ; data?: any ; constructor (code, msg, data? ) { this .code = code || 200 ; this .msg = msg || '操作成功' ; if (data) { this .data = data; } } success (ctx: Koa.Context ) { ctx.body = this ; } }export default SuccessModel ;
responseCode.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import SuccessModel from './successCode' ;import { ParameterError , AuthError , NotFoundError , InternalServerError } from './errCode' ;import Koa from 'koa' ;const SUCCESS = async (ctx: Koa.Context, data, msg ) => new SuccessModel (200 , msg, data).success (ctx);const USER_NO_PERMISSION = async (ctx: Koa.Context, msg = '没有权限' ) => new SuccessModel (2100 , msg).success (ctx);const USER_NOT_LOGIN = async (ctx: Koa.Context ) => new SuccessModel (2001 , '用户未登录' ).success (ctx);const USER_ACCOUNT_EXPIRED = async (ctx: Koa.Context ) => new SuccessModel (2002 , '账号已过期' ).success (ctx);const USER_ACCOUNT_DISABLE = async (ctx: Koa.Context ) => new SuccessModel (2003 , '账号不可用' ).success (ctx);const USER_ACCOUNT_NOT_EXIST = async (ctx: Koa.Context ) => new SuccessModel (2004 , '账号不存在' ).success (ctx);const USER_ACCOUNT_ALREADY_EXIST = async (ctx: Koa.Context, msg = '账号已存在' ) => new SuccessModel (2005 , msg).success (ctx);const USER_ACCOUNT_USE_BY_OTHERS = async (ctx: Koa.Context ) => new SuccessModel (2006 , '账号下线' ).success (ctx);const USER_PWD_ERROR = async (ctx: Koa.Context ) => new SuccessModel (2007 , '密码错误' ).success (ctx);const PARAM_NOT_VALID = async (ctx: Koa.Context, msg = '请求参数无效' ) => new ParameterError (1001 , msg).throwErr (ctx);const PARAM_IS_BLANK = async (ctx: Koa.Context, msg = '请求参数为空' ) => new ParameterError (1002 , msg).throwErr (ctx);const PARAM_TYPE_ERROR = async (ctx: Koa.Context, msg = '请求参数类型错误' ) => new ParameterError (1003 , msg).throwErr (ctx);const PARAM_NOT_COMPLETE = async (ctx: Koa.Context, msg = '请求参数缺失' ) => new ParameterError (1004 , msg).throwErr (ctx);export const TOKEN_IS_BLANK = async (ctx: Koa.Context ) => new AuthError (4004 , 'token为空' ).throwErr (ctx);export const TOKEN_EXPIRED = async (ctx: Koa.Context ) => new AuthError (4001 , 'token过期' ).throwErr (ctx);export const TOKEN_INVALID = async (ctx: Koa.Context ) => new AuthError (4002 , 'token无效' ).throwErr (ctx);export const AUTHENTICATION_FAIL = async (ctx: Koa.Context, msg = '认证失败' ) => new AuthError (4003 , msg).throwErr (ctx);export const NotFound = async (ctx: Koa.Context ) => new NotFoundError (404 , '未找到api,请检查请求路径以及请求方法是否出错' ).throwErr (ctx);const FAIL = async (ctx: Koa.Context, msg ) => new InternalServerError (500 , msg).throwErr (ctx);const FILE_UPLOAD_FAIL = async (ctx: Koa.Context ) => new InternalServerError (5001 , '文件上传失败' ).throwErr (ctx);
responseCode.ts
里就是我们返回消息或抛出错误直接使用的接口,使用下面的方式就可以在任意位置抛出错误:
1 2 3 4 5 app.use (async (ctx, next) => { await NotFound (ctx) })
最后,在中间件文件夹添加error.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { ErrorModel } from '../config/code/errCode' ;export const errorHandler = async (ctx, next ) => { try { await next (); } catch (err) { if (err.flag === 'ErrorModel' ) { format (err, ctx); } else { format (new ErrorModel (), ctx); } } };const format = (err: any , ctx: any ) => { ctx.status = err.statusCode ; ctx.body = { code : err.code , msg : err.message || err.msg , request : ctx.method + ' >> ' + ctx.url , }; };
18.添加权限认证系统
可参考:https://github.com/DuRuofu/Prisma-adapter-Casbin-demo
添加依赖:
npm install casbin casbin-prisma-adapter --save
详细内存请参考Node-Koa-Templates鉴权设计文档
19、src下新增测试文件夹:tests 用于单元测试
20、跟目录下新增脚本文件夹:script 用于定义一些开发脚本
其他参考
https://www.bilibili.com/video/BV1UM4y1T7QF/?spm_id_from=333.1007.top_right_bar_window_custom_collection.content.click&vd_source=ef5a0ab0106372751602034cdd9ab98e
https://juejin.cn/post/7231152303583100988#heading-5
https://www.baasapi.com/blog/prisma
https://juejin.cn/post/6847902223138029581
https://www.bilibili.com/video/BV1d64y1Y7Cg?p=11&vd_source=ef5a0ab0106372751602034cdd9ab98e