一杯茶的时间,上手 Koa2 + MySQL 开发
一杯茶的时间,上手 Koa2 + MySQL 开发

本文由图雀社区成员 mRc 写作而成,欢迎加入图雀社区,一起创作精彩的免费技术教程,予力编程行业发展。
如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连,鼓励我们写出更好的教程
凭借精巧的“洋葱模型”和对 Promise 以及 async/await 异步编程的完全支持,Koa 框架自从诞生以来就吸引了无数 Node 爱好者。然而 Koa 本身只是一个简单的中间件框架,要想实现一个足够复杂的 Web 应用还需要很多周边生态支持。这篇教程不仅会带你梳理 Koa 的基础知识,还会充分地运用和讲解构建 Web 应用必须的组件(路由、数据库、鉴权等),最终实现一个较为完善的用户系统。
起步
Koa 作为 Express 原班人马打造的新生代 Node.js Web 框架,自从发布以来就备受瞩目。正如 Koa 作者们在文档中所指出的:
Philosophically, Koa aims to "fix and replace node", whereas Express "augments node".(Express 是 Node 的补强,而 Koa 则是为了解决 Node 的问题并取代之。)
在这一篇文章中,我们将手把手带你开发一个简单的用户系统 REST API,支持用户的增删改查以及 JWT 鉴权,从实战中感受 Koa2 的精髓,它相比于 Express 做出的突破性的改变。我们将选择 TypeScript 作为开发语言,数据库选用 MySQL,并使用 TypeORM 作为数据库桥接层。
注意
这篇文章不会涉及 Koa 源码级别的原理分析,重心会放在让你完全掌握如何去使用 Koa 及周边生态去开发 Web 应用,并欣赏 Koa 的设计之美。此外,这篇教程比较长,如果一杯茶不够的话可以续杯~
预备知识
本教程假定你已经具备了以下知识:
- JavaScript 语言基础知识(包括一些常用的 ES6+ 语法)
- Node.js 基础知识,还有 npm 的基本使用,可以参考这篇教程进行学习
- TypeScript 基础知识,只需了解简单的类型注解就可以了,可以参考我们的 TypeScript 系列教程
- (非必须)Express 框架基础知识,对于体验 Koa 之美大有帮助,而且在本文中我们会大量穿插和 Express 的对比,可参考这篇教程进行学习
所用技术
- Node.js:10.x 及以上
- npm:6.x 及以上
- Koa:2.x
- MySQL:推荐稳定的 5.7 版本及以上
- TypeORM:0.2.x
学习目标
学完这篇教程,你将学会:
- 如果编写 Koa 中间件
- 通过
@koa/router
实现路由配置 - 通过 TypeORM 连接和读写 MySQL 数据库(其他数据库都类似)
- 了解 JWT 鉴权的原理,并动手实现
- 掌握 Koa 的错误处理机制
准备初始代码
我们已经为你准备好了项目的脚手架,运行以下命令克隆我们的初始代码:
git clone -b start-point https://github.com/tuture-dev/koa-quickstart.git
如果你访问 GitHub 不流畅,可以克隆我们的 Gitee 仓库:
git clone -b start-point https://gitee.com/tuture/koa-quickstart.git
然后进入项目,安装依赖:
cd koa-quickstart && npm install
注意
这里我使用了package-lock.json
确保所有依赖版本一致,如果你用yarn
安装依赖出现问题,建议删除node_modules
,重新用npm install
安装。
最简单的 Koa 服务器
创建 src/server.ts
,编写第一个 Koa 服务器,代码如下:
// src/server.ts
import Koa from 'koa';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
// 初始化 Koa 应用实例
const app = new Koa();
// 注册中间件
app.use(cors());
app.use(bodyParser());
// 响应用户请求
app.use((ctx) => {
ctx.body = 'Hello Koa';
});
// 运行服务器
app.listen(3000);
整个流程与一个基本的 Express 服务器几乎完全一致:
- 初始化应用实例
app
- 注册相关的中间件(跨域
cors
和请求体解析中间件bodyParser
) - 添加请求处理函数,响应用户请求
- 运行服务器
定睛一看,第 3 步中的请求处理函数(Request Handler)好像不太一样。在 Express 框架中,一个请求处理函数一般是这样的:
function handler(req, res) {
res.send('Hello Express');
}
两个参数分别对应请求对象(Request)和响应对象(Response),但是在 Koa 中,请求处理函数却只有一个参数 ctx
(Context,上下文),然后只需向上下文对象写入相关的属性即可(例如这里就是写入到返回数据 body
中):
function handler(ctx) {
ctx.body = 'Hello Koa';
}
我的天,Koa 这是故意偷工减料的吗?先不用急,我们马上在下一节讲解中间件时就会了解到 Koa 这样设计的独到之处。
运行服务器
我们通过 npm start
就能开启服务器了。可以通过 Curl (或者 Postman 等)来测试我们的 API:
$ curl localhost:3000
Hello Koa
提示
我们的脚手架中配置好了 Nodemon,因此接下来无需关闭服务器,修改代码保存后会自动加载最新的代码并运行。
第一个 Koa 中间件
严格意义上来说,Koa 只是一个中间件框架,正如它的介绍所说:
Expressive middleware for node.js using ES2017 async functions.(通过 ES2017 async 函数编写富有表达力的 Node.js 中间件)
下面这个表格更能说明 Koa 和 Express 的鲜明对比:

可以看到,Koa 实际上对标的是 Connect(Express 底层的中间件层),而不包含 Express 所拥有的其他功能,例如路由、模板引擎、发送文件等。接下来,我们就来学习 Koa 最重要的知识点:中间件。
大名鼎鼎的“洋葱模型”
你也许从来没有用过 Koa 框架,但很有可能听说过“洋葱模型”,而 Koa 正是洋葱模型的代表框架之一。下面这个图你也许很熟悉了:

不过以个人观点,这个图实在是太像“洋葱”了,反而不太好理解。接下来我们将以更清晰直观的方式来感受 Koa 中间件的设计之美。首先我们来看一下 Express 的中间件是什么样的:

请求(Request)直接依次贯穿各个中间件,最后通过请求处理函数返回响应(Response),非常简单。然后我们来看看 Koa 的中间件是什么样的:

可以看到,Koa 中间件不像 Express 中间件那样在请求通过了之后就完成了自己的使命;相反,中间件的执行清晰地分为两个阶段。我们马上来看下 Koa 中间件具体是什么样的。
Koa 中间件的定义
Koa 的中间件是这样一个函数:
async function middleware(ctx, next) {
// 第一阶段
await next();
// 第二阶段
}
第一个参数就是 Koa Context,也就是上图中贯穿所有中间件和请求处理函数的绿色箭头所传递的内容,里面封装了请求体和响应体(实际上还有其他属性,但这里暂时不讲),分别可以通过 ctx.request
和 ctx.response
来获取,以下是一些常用的属性:
ctx.url // 相当于 ctx.request.url
ctx.body // 相当于 ctx.response.body
ctx.status // 相当于 ctx.response.status
提示
关于所有请求和响应上面的属性及其别称,请参考 Context API 文档。
中间件的第二个参数便是 next
函数,这个熟悉 Express 的同学一定知道它是干什么的:用来把控制权转交给下一个中间件。但是它跟 Express 的 next
函数本质的区别在于,Koa 的 *next
* 函数返回的是一个 Promise,在这个 Promise 进入完成状态(Fulfilled)后,就会去执行中间件中第二阶段的代码。
那么我们不禁要问:这样把中间件的执行拆分为两个阶段,到底有什么好处吗?我们来通过一个非常经典的例子来感受一下:日志记录中间件(包括响应时间的计算)。
实战:日志记录中间件
让我们来实现一个简单的日志记录中间件 logger
,用于记录每次请求的方法、URL、状态码和响应时间。创建 src/logger.ts
,代码如下:
// src/logger.ts
import { Context } from 'koa';
export function logger() {
return async (ctx: Context, next: () => Promise<void>) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} ${ctx.status} - ${ms}ms`);
};
}
严格意义上讲,这里的 logger
是一个中间件工厂函数(Factory),调用这个工厂函数后返回的结果才是真正的 Koa 中间件。之所以写成一个工厂函数,是因为我们可以通过给工厂函数传参的方式来更好地控制中间件的行为(当然这里的 logger
比较简单,就没有任何参数)。
在这个中间件的第一阶段,我们通过 Date.now()
先获取请求进入的时间,然后通过 await next()
让出执行权,等待下游中间件运行结束后,再在第二阶段通过计算 Date.now()
的差值来得出处理请求所用的时间。
思考一下,如果用 Express 来实现这个功能,中间件应该怎么写,会有 Koa 这么简单优雅吗?
提示
这里通过两个Date.now()
之间的差值来计算运行时间其实是不精确的,为了获取更准确的时间,建议使用process.hrtime()
。
然后我们在 src/server.ts
中把刚才的 logger
中间件通过 app.use
注册进去,代码如下:
// src/server.ts
// ...
import { logger } from './logger';
// 初始化 Koa 应用实例
const app = new Koa();
// 注册中间件
app.use(logger());
app.use(cors());
app.use(bodyParser());
// ...
这时候再访问我们的服务器(通过 Curl 或者其他请求工具),应该可以看到输出日志:

关于 Koa 框架本身的内容基本讲完了,但是对于一个比较完整的 Web 服务器来说,我们还需要更多的“武器装备”才能应对日常的业务逻辑。在接下来的部分,我们将通过社区的优秀组件来解决两个关键问题:路由和数据库,并演示如何结合 Koa 框架进行使用。
实现路由配置
由于 Koa 只是一个中间件框架,所以路由的实现需要独立的 npm 包。首先安装 @koa/router
及其 TypeScript 类型定义:
$ npm install @koa/router
$ npm install @types/koa__router -D
注意
有些教程使用koa-router
,但由于koa-router
目前处于几乎无人维护的状态,所以我们这里使用维护更积极的 Fork 版本@koa/router
。
路由规划
在这篇教程中,我们将实现以下路由:
GET /users
:查询所有的用户GET /users/:id
:查询单个用户PUT /users/:id
:更新单个用户DELETE /users/:id
:删除单个用户POST /users/login
:登录(获取 JWT Token)POST /users/register
:注册用户
实现 Controller
在 src
中创建 controllers
目录,用于存放控制器有关的代码。首先是 AuthController
,创建 src/controllers/auth.ts
,代码如下:
// src/controllers/auth.ts
import { Context } from 'koa';
export default class AuthController {
public static async login(ctx: Context) {
ctx.body = 'Login controller';
}
public static async register(ctx: Context) {
ctx.body = 'Register controller';
}
}
然后创建 src/controllers/user.ts
,代码如下:
// src/controllers/user.ts
import { Context } from 'koa';
export default class UserController {
public static async listUsers(ctx: Context) {
ctx.body = 'ListUsers controller';
}
public static async showUserDetail(ctx: Context) {
ctx.body = `ShowUserDetail controller with ID = ${ctx.params.id}`;
}
public static async updateUser(ctx: Context) {
ctx.body = `UpdateUser controller with ID = ${ctx.params.id}`;
}
public static async deleteUser(ctx: Context) {
ctx.body = `DeleteUser controller with ID = ${ctx.params.id}`;
}
}
注意到在后面三个 Controller 中,我们通过 ctx.params
获取到路由参数 id
。
实现路由
然后我们创建 src/routes.ts
,用于把控制器挂载到对应的路由上面:
// src/routes.ts
import Router from '@koa/router';
import AuthController from './controllers/auth';
import UserController from './controllers/user';
const router = new Router();
// auth 相关的路由
router.post('/auth/login', AuthController.login);
router.post('/auth/register', AuthController.register);
// users 相关的路由
router.get('/users', UserController.listUsers);
router.get('/users/:id', UserController.showUserDetail);
router.put('/users/:id', UserController.updateUser);
router.delete('/users/:id', UserController.deleteUser);
export default router;
可以看到 @koa/router
的使用方式基本上与 Express Router 保持一致。
注册路由
最后,我们需要将 router
注册为中间件。打开 src/server.ts
,修改代码如下:
// src/server.ts
// ...
import router from './routes';
import { logger } from './logger';
// 初始化 Koa 应用实例
const app = new Koa();
// 注册中间件
app.use(logger());
app.use(cors());
app.use(bodyParser());
// 响应用户请求
app.use(router.routes()).use(router.allowedMethods());
// 运行服务器
app.listen(3000);
可以看到,这里我们调用 router
对象的 routes
方法获取到对应的 Koa 中间件,还调用了 allowedMethods
方法注册了 HTTP 方法检测的中间件,这样当用户通过不正确的 HTTP 方法访问 API 时,就会自动返回 405 Method Not Allowed 状态码。
我们通过 Curl 来测试路由(也可以自行使用 Postman):
$ curl localhost:3000/hello
Not Found
$ curl localhost:3000/auth/register
Method Not Allowed
$ curl -X POST localhost:3000/auth/register
Register controller
$ curl -X POST localhost:3000/auth/login
Login controller
$ curl localhost:3000/users
ListUsers controller
$ curl localhost:3000/users/123
ShowUserDetail controller with ID = 123
$ curl -X PUT localhost:3000/users/123
UpdateUser controller with ID = 123
$ curl -X DELETE localhost:3000/users/123
DeleteUser controller with ID = 123
同时可以看到服务器的输出日志如下:

路由已经接通,接下来就让我们来接入真实的数据吧!
接入 MySQL 数据库
从这一步开始,我们将正式接入数据库。Koa 本身是一个中间件框架,理论上可以接入任何类型的数据库,这里我们选择流行的关系型数据库 MySQL。并且,由于我们使用了 TypeScript 开发,因此这里使用为 TS 量身打造的 ORM 库 TypeORM。
数据库的准备工作
首先,请安装和配置好 MySQL 数据库,可以通过两种方式:
- 官网下载安装包,这里是下载地址
- 使用 MySQL Docker 镜像
在确保 MySQL 实例运行之后,我们打开终端,通过命令行连接数据库:
$ mysql -u root -p
输入预先设置好的根帐户密码之后,就进入了 MySQL 的交互式执行客户端,然后运行以下命令:
--- 创建数据库
CREATE DATABASE koa;
--- 创建用户并授予权限
CREATE USER 'user'@'localhost' IDENTIFIED BY 'pass';
GRANT ALL PRIVILEGES ON koa.* TO 'user'@'localhost';
--- 处理 MySQL 8.0 版本的认证协议问题
ALTER USER 'user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'pass';
flush privileges;
TypeORM 的配置和连接
首先安装相关的 npm 包,分别是 MySQL 驱动、TypeORM 及 reflect-metadata
(反射 API 库,用于 TypeORM 推断模型的元数据):
$ npm install mysql typeorm reflect-metadata
然后在项目根目录创建 ormconfig.json
,TypeORM 会读取这个数据库配置进行连接,代码如下:
// ormconfig.json
{
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "user",
"password": "pass",
"database": "koa",
"synchronize": true,
"entities": ["src/entity/*.ts"],
"cli": {
"entitiesDir": "src/entity"
}
}
这里有一些需要解释的字段:
database
就是我们刚刚创建的koa
数据库synchronize
设为true
能够让我们每次修改模型定义后都能自动同步到数据库(如果你接触过其他的 ORM 库,其实就是自动数据迁移)entities
字段定义了模型文件的路径,我们马上就来创建
接着修改 src/server.ts
,在其中连接数据库,代码如下:
// src/server.ts
import Koa from 'koa';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
import { createConnection } from 'typeorm';
import 'reflect-metadata';
import router from './routes';
import { logger } from './logger';
createConnection()
.then(() => {
// 初始化 Koa 应用实例
const app = new Koa();
// 注册中间件
app.use(logger());
app.use(cors());
app.use(bodyParser());
// 响应用户请求
app.use(router.routes()).use(router.allowedMethods());
// 运行服务器
app.listen(3000);
})
.catch((err: string) => console.log('TypeORM connection error:', err));
创建数据模型定义
在 src
目录下创建 entity
目录,用于存放数据模型定义文件。在其中创建 user.ts
,代表用户模型,代码如下:
// src/entity/user.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ select: false })
password: string;
@Column()
email: string;
}
可以看到,用户模型有四个字段,其含义很容易理解。而 TypeORM 则是通过装饰器这种优雅的方式来将我们的 User
类映射到数据库中的表。这里我们使用了三个装饰器:
Entity
用于装饰整个类,使其变成一个数据库模型Column
用于装饰类的某个属性,使其对应于数据库表中的一列,可提供一系列选项参数,例如我们给password
设置了select: false
,使得这个字段在查询时默认不被选中PrimaryGeneratedColumn
则是装饰主列,它的值将自动生成
提示
关于 TypeORM 所有的装饰器定义及其详细使用,请参考其装饰器文档。
在 Controller 中操作数据库
然后就可以在 Controller 中进行数据的增删改查操作了。首先我们打开 src/controllers/user.ts
,实现所有 Controller 的逻辑,代码如下:
// src/controllers/user.ts
import { Context } from 'koa';
import { getManager } from 'typeorm';
import { User } from '../entity/user';
export default class UserController {
public static async listUsers(ctx: Context) {
const userRepository = getManager().getRepository(User);
const users = await userRepository.find();
ctx.status = 200;
ctx.body = users;
}
public static async showUserDetail(ctx: Context) {
const userRepository = getManager().getRepository(User);
const user = await userRepository.findOne(+ctx.params.id);
if (user) {
ctx.status = 200;
ctx.body = user;
} else {
ctx.status = 404;
}
}
public static async updateUser(ctx: Context) {
const userRepository = getManager().getRepository(User);
await userRepository.update(+ctx.params.id, ctx.request.body);
const updatedUser = await userRepository.findOne(+ctx.params.id);
if (updatedUser) {
ctx.status = 200;
ctx.body = updatedUser;
} else {
ctx.status = 404;
}
}
public static async deleteUser(ctx: Context) {
const userRepository = getManager().getRepository(User);
await userRepository.delete(+ctx.params.id);
ctx.