# 0. 准备工作
# 0.1 安装 Node.js(建议 Node 18+)
node -v
# v18.x 或更高
# 0.2 新建项目目录并初始化
mkdir koa-step-by-step
cd koa-step-by-step
npm init -y
# 0.3 ESM 设置(强烈建议)
在 package.json 顶层加入:
{
"type": "module"
}
作用:让 Node 默认使用 ES 模块语法(
import/export),并支持.mjs文件。
# 0.4 推荐本地开发脚本(可热重载)
npm i -D nodemon
在 package.json 里加入:
{
"scripts": {
"dev": "nodemon --ext mjs,js,json --watch . app.mjs",
"start": "node app.mjs"
}
}
以后
npm run dev就会自动重启,开发体验更好。
# 1. 阶段 A:最小可运行的 Koa(无路由、无中间件栈)
# 1.1 安装 Koa
npm i koa
# 1.2 新建 app.mjs
// app.mjs(阶段A)
// 目标:
// 1)启动一个最简单的 Koa 服务器
// 2)所有请求都返回 "Hello Koa" 文本
import Koa from 'koa'; // 引入 Koa 主类
const app = new Koa(); // 创建 Koa 实例
// app.use() 注册一个中间件函数(每个请求都会走这里)
// 这是最简单的中间件:设置响应体即可
app.use(async (ctx) => {
// ctx 是“上下文对象”,封装了 Request 和 Response 等常用属性
ctx.body = 'Hello Koa'; // 返回纯文本
});
// 启动 HTTP 服务,监听端口 3000
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:A(最小可运行服务)');
});
# 1.3 运行
npm run start
# 或
npm run dev
浏览器打开 http://localhost:3000 ,能看到 Hello Koa。
# 2. 阶段 B:引入路由(koa-router),让不同路径走不同逻辑
# 2.1 安装路由中间件
npm i koa-router
# 2.2 新建 routes/index.mjs
// routes/index.mjs(阶段B)
// 目标:为不同 URL 配置不同处理函数
import Router from 'koa-router';
// 创建路由实例,并统一加上前缀(可选)
const router = new Router({ prefix: '/api' });
// GET /api/hello -> 返回简单文本
router.get('/hello', async (ctx) => {
ctx.body = 'Hello from /api/hello';
});
// GET /api/user/:id -> 返回路径参数
router.get('/user/:id', async (ctx) => {
// 通过 ctx.params 获取 :id 的值
const { id } = ctx.params;
ctx.body = { message: `你请求的用户ID是 ${id}` };
});
export default router;
# 2.3 修改 app.mjs 使用路由
// app.mjs(阶段B)
import Koa from 'koa';
import router from './routes/index.mjs'; // 引入路由
const app = new Koa();
// 将路由注册到应用(注意两步:routes + allowedMethods)
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:B(已加入路由)');
});
此时访问:
- GET
http://localhost:3000/api/hello - GET
http://localhost:3000/api/user/123
# 3. 阶段 C:解析请求体(koa-bodyparser)并实现第一个 POST API
# 3.1 安装 body 解析中间件
npm i koa-bodyparser
# 3.2 在 app.mjs 启用 bodyParser
// app.mjs(阶段C)
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import router from './routes/index.mjs';
const app = new Koa();
// 解析 JSON / x-www-form-urlencoded 请求体
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:C(支持解析请求体 & POST 请求)');
});
# 3.3 在路由里加一个 POST 示例
// routes/index.mjs(阶段C,新增 POST 路由)
import Router from 'koa-router';
const router = new Router({ prefix: '/api' });
router.get('/hello', async (ctx) => {
ctx.body = 'Hello from /api/hello';
});
router.get('/user/:id', async (ctx) => {
const { id } = ctx.params;
ctx.body = { message: `你请求的用户ID是 ${id}` };
});
// POST /api/echo -> 回显请求体
router.post('/echo', async (ctx) => {
// 通过 ctx.request.body 获取 JSON 或表单提交的数据
const body = ctx.request.body;
ctx.body = {
received: body, // 回显你发过来的数据
tip: '你已成功通过 POST 发送数据到 Koa 服务端'
};
});
export default router;
测试 POST:
# Mac/Linux
curl -X POST http://localhost:3000/api/echo \
-H "Content-Type: application/json" \
-d '{"name":"Alice","age":20}'
# Windows PowerShell(注意引号)
curl -X POST http://localhost:3000/api/echo `
-H "Content-Type: application/json" `
-d "{""name"":""Alice"",""age"":20}"
# 4. 阶段 D:全局错误处理 + 日志打印(理解“洋葱模型”)
# 4.1 在 app.mjs 添加错误捕获中间件
// app.mjs(阶段D)
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import router from './routes/index.mjs';
const app = new Koa();
// ① 全局错误处理中间件(最外层):try/catch 所有下游中间件的异常
app.use(async (ctx, next) => {
const start = Date.now(); // 统计耗时
try {
await next(); // 交给下一个中间件
} catch (err) {
// 统一错误响应格式
ctx.status = err.status || 500;
ctx.body = { code: ctx.status, message: err.message || 'Server Error' };
// 打印到控制台方便定位
console.error('Error:', err);
} finally {
const ms = Date.now() - start;
// 记录访问日志
console.log(`${ctx.method} ${ctx.url} - ${ctx.status || 200} ${ms}ms`);
}
});
// ② 解析请求体
app.use(bodyParser());
// ③ 注册业务路由
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:D(全局错误处理 & 访问日志)');
});
理解洋葱模型:最外层中间件先进入,await next() 后进入下一层;当下一层返回时,回到上一层继续执行 finally 等收尾逻辑。
# 4.2 在路由里人为抛错试试看
// routes/index.mjs(阶段D,增加一个错误示例)
import Router from 'koa-router';
const router = new Router({ prefix: '/api' });
router.get('/boom', async () => {
// 主动抛出一个错误,观察上文错误处理中间件如何工作
const err = new Error('模拟服务端异常:/api/boom');
err.status = 418; // 仅演示,418是Teapot彩蛋状态码
throw err;
});
export default router;
访问 GET /api/boom,你会看到结构化的错误响应 + 控制台日志。
# 5. 阶段 E:静态资源与 favicon(避免多次计数/404)
# 5.1 安装 koa-static
npm i koa-static
# 5.2 在项目根目录建 public/
koa-step-by-step/
├─ app.mjs
├─ routes/
│ └─ index.mjs
└─ public/
├─ index.html
└─ favicon.ico (可选)
简单写一个 public/index.html:
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Koa Static</title></head>
<body>
<h1>Hello Static</h1>
<p>这是 public/index.html 静态页面。</p>
</body>
</html>
# 5.3 修改 app.mjs 启用静态资源
// app.mjs(阶段E)
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import serve from 'koa-static';
import path from 'path';
import { fileURLToPath } from 'url';
import router from './routes/index.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = new Koa();
app.use(async (ctx, next) => {
const start = Date.now();
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = { code: ctx.status, message: err.message || 'Server Error' };
console.error('Error:', err);
} finally {
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ctx.status || 200} ${ms}ms`);
}
});
app.use(bodyParser());
// 开放 public 目录为静态资源根目录:访问 / 即可打开 public/index.html
app.use(serve(path.join(__dirname, 'public')));
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:E(支持静态资源与 favicon)');
});
现在访问 http://localhost:3000/ 就能看到静态页面。
提示:许多浏览器会自动请求
/favicon.ico。如果没有图标,偶尔会触发两次访问等“小惊喜”。把favicon.ico放到public/里,可以避免这类困扰。
# 6. 阶段 F(可选):模板渲染(服务器端渲染一个简单页面)
如果你的服务只做 API,可跳过本节。这里展示如何用 EJS 渲染模板。
# 6.1 安装依赖
npm i koa-views ejs
# 6.2 目录结构新增 views/
koa-step-by-step/
├─ app.mjs
├─ routes/
│ └─ index.mjs
├─ public/
│ └─ index.html
└─ views/
└─ home.ejs
views/home.ejs:
<!doctype html>
<html>
<head><meta charset="utf-8"><title>EJS Demo</title></head>
<body>
<h1><%= title %></h1>
<p>服务器时间:<%= now %></p>
</body>
</html>
# 6.3 在 app.mjs 配置视图中间件
// app.mjs(阶段F)
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import serve from 'koa-static';
import views from 'koa-views';
import path from 'path';
import { fileURLToPath } from 'url';
import router from './routes/index.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = new Koa();
app.use(async (ctx, next) => {
try { await next(); } catch (err) {
ctx.status = err.status || 500;
ctx.body = { code: ctx.status, message: err.message || 'Server Error' };
}
});
app.use(bodyParser());
app.use(serve(path.join(__dirname, 'public')));
// 配置视图目录与模板引擎
app.use(views(path.join(__dirname, 'views'), { extension: 'ejs' }));
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:F(支持 EJS 模板渲染)');
});
# 6.4 在路由中渲染一个页面
// routes/index.mjs(阶段F,新增 SSR 路由)
import Router from 'koa-router';
const router = new Router({ prefix: '/api' });
// ...前面的接口保留
// GET /page/home -> 渲染 EJS 模板(注意:此路由无 /api 前缀更合适,这里仅演示)
router.get('/page/home', async (ctx) => {
await ctx.render('home', {
title: 'EJS 模板渲染示例',
now: new Date().toLocaleString()
});
});
export default router;
# 7. 阶段 G:逐步“REST 化” —— 用内存数组先做 CRUD
先不接数据库,用内存数组模拟数据源,把一套用户资源的 CRUD 接口打通,理解 REST 的路径与方法约定。
# 7.1 新建 routes/users.mjs
// routes/users.mjs(阶段G)
// 目标:实现 RESTful 风格的用户资源接口(内存模拟数据库)
import Router from 'koa-router';
const router = new Router({ prefix: '/api/users' });
// 用一个内存数组模拟“数据库表”
const users = [
{ id: 1, name: 'Alice', age: 20 },
{ id: 2, name: 'Bob', age: 22 }
];
// 工具:生成新ID(简单起见)
const nextId = () => (users.length ? users[users.length - 1].id + 1 : 1);
// GET /api/users -> 列表
router.get('/', async (ctx) => {
ctx.status = 200;
ctx.body = { code: 200, data: users };
});
// GET /api/users/:id -> 详情
router.get('/:id', async (ctx) => {
const id = Number(ctx.params.id);
const user = users.find(u => u.id === id);
if (!user) {
ctx.throw(404, `用户 ${id} 不存在`);
}
ctx.body = { code: 200, data: user };
});
// POST /api/users -> 新增
router.post('/', async (ctx) => {
const { name, age } = ctx.request.body || {};
if (!name || typeof age !== 'number') {
ctx.throw(400, '参数不合法:需要 name(string) 与 age(number)');
}
const newUser = { id: nextId(), name, age };
users.push(newUser);
ctx.status = 201; // 201:已创建
ctx.body = { code: 201, data: newUser };
});
// PUT /api/users/:id -> 全量更新
router.put('/:id', async (ctx) => {
const id = Number(ctx.params.id);
const { name, age } = ctx.request.body || {};
const idx = users.findIndex(u => u.id === id);
if (idx === -1) ctx.throw(404, `用户 ${id} 不存在`);
if (!name || typeof age !== 'number') ctx.throw(400, '参数不合法:需要 name 与 age');
users[idx] = { id, name, age };
ctx.body = { code: 200, data: users[idx] };
});
// PATCH /api/users/:id -> 部分更新(可选)
router.patch('/:id', async (ctx) => {
const id = Number(ctx.params.id);
const patch = ctx.request.body || {};
const user = users.find(u => u.id === id);
if (!user) ctx.throw(404, `用户 ${id} 不存在`);
Object.assign(user, patch);
ctx.body = { code: 200, data: user };
});
// DELETE /api/users/:id -> 删除
router.delete('/:id', async (ctx) => {
const id = Number(ctx.params.id);
const idx = users.findIndex(u => u.id === id);
if (idx === -1) ctx.throw(404, `用户 ${id} 不存在`);
const deleted = users.splice(idx, 1)[0];
ctx.body = { code: 200, data: deleted };
});
export default router;
# 7.2 在 app.mjs 中注册新路由文件
// app.mjs(阶段G)
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import serve from 'koa-static';
import path from 'path';
import { fileURLToPath } from 'url';
import baseRouter from './routes/index.mjs';
import usersRouter from './routes/users.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = new Koa();
app.use(async (ctx, next) => {
try { await next(); } catch (err) {
ctx.status = err.status || 500;
ctx.body = { code: ctx.status, message: err.message || 'Server Error' };
}
});
app.use(bodyParser());
app.use(serve(path.join(__dirname, 'public')));
// 注册多个路由模块
app.use(baseRouter.routes()).use(baseRouter.allowedMethods());
app.use(usersRouter.routes()).use(usersRouter.allowedMethods());
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
console.log(' 当前阶段:G(RESTful 内存版 users 资源)');
});
测试:
# 列表
curl http://localhost:3000/api/users
# 详情
curl http://localhost:3000/api/users/1
# 新增
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Carol","age":28}'
# 更新
curl -X PUT http://localhost:3000/api/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Alice Updated","age":21}'
# 删除
curl -X DELETE http://localhost:3000/api/users/2
补充:CORS
若前端与后端不在同一域名/端口,需开启 CORS:npm i @koa/corsimport cors from '@koa/cors'; app.use(cors());
# 7.3 如何实现数据库的增删改查(CURA)
数据库的四个基本功能
| 方法 | 描述 | 对应 SQL |
|---|---|---|
| Create | 新增一条记录 | INSERT |
| Read | 查询记录 | SELECT |
| Update | 更新记录 | UPDATE |
| Delete | 删除记录 | DELETE |
在 RESTful API 中,常用 HTTP 方法对应 CRUD:
| HTTP 方法 | 路径 | CRUD |
|---|---|---|
| GET | /api/users | 查询所有用户 |
| GET | /api/users/:id | 查询单个用户 |
| POST | /api/users | 新增用户 |
| PUT | /api/users/:id | 全量更新用户 |
| PATCH | /api/users/:id | 部分更新用户 |
| DELETE | /api/users/:id | 删除用户 |
首先在根目录创建.env文件
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=123456
DB_NAME=testdb
创建数据库模块database/mysqldb.mjs:
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
// 创建连接池
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// 通用 SQL 查询函数
export async function query(sql, params) {
const [results] = await pool.execute(sql, params);
return results;
}
// ----------------- 用户增删改查 -----------------
// 获取所有用户
export async function getAllUsers() {
return await query('SELECT * FROM users');
}
// 根据 ID 获取用户
export async function getUserById(id) {
const results = await query('SELECT * FROM users WHERE id = ?', [id]);
return results[0];
}
// 新增用户
export async function createUser(name, age) {
const result = await query('INSERT INTO users (name, age) VALUES (?, ?)', [name, age]);
return { id: result.insertId, name, age };
}
// 更新用户
export async function updateUser(id, name, age) {
await query('UPDATE users SET name = ?, age = ? WHERE id = ?', [name, age, id]);
return { id, name, age };
}
// 删除用户
export async function deleteUser(id) {
const result = await query('DELETE FROM users WHERE id = ?', [id]);
return result.affectedRows > 0;
}
# 8. 阶段 H:重构为 MVC(Model-View-Controller)分层
现在把“路由里写业务”的代码拆分:路由仅负责匹配路径、Controller 负责接入/出参与校验、Service 负责业务、Model 负责数据存储(此处先用内存/假数据,下一步再换 MySQL)。
# 8.1 目录调整
koa-step-by-step/
├─ app.mjs
├─ routes/
│ ├─ index.mjs
│ └─ users.mjs # 现在只保留路径定义和与 controller 的绑定
├─ controllers/
│ └─ userController.mjs
├─ services/
│ └─ userService.mjs
├─ models/
│ └─ userModel.mjs # 此阶段仍用内存数组模拟 DB
└─ public/
# 8.2 models/userModel.mjs(数据访问层)
// models/userModel.mjs(阶段H - Model 层)
/**
* 此处仍然用内存数组模拟数据库。
* 下一阶段会把这里的实现换成 MySQL 版本,但对外暴露的方法名尽量保持一致,
* 这样 Controller/Service 基本不用改动,实现“可替换的数据层”。
*/
const users = [
{ id: 1, name: 'Alice', age: 20 },
{ id: 2, name: 'Bob', age: 22 }
];
const nextId = () => (users.length ? users[users.length - 1].id + 1 : 1);
export async function findAll() {
return users;
}
export async function findById(id) {
return users.find(u => u.id === id) || null;
}
export async function create({ name, age }) {
const user = { id: nextId(), name, age };
users.push(user);
return user;
}
export async function update(id, { name, age }) {
const idx = users.findIndex(u => u.id === id);
if (idx === -1) return null;
users[idx] = { id, name, age };
return users[idx];
}
export async function patch(id, patchObj) {
const user = users.find(u => u.id === id);
if (!user) return null;
Object.assign(user, patchObj);
return user;
}
export async function remove(id) {
const idx = users.findIndex(u => u.id === id);
if (idx === -1) return null;
return users.splice(idx, 1)[0];
}
直接调用数据库增删改查函数,但保持原来的 RESTful API 路由结构:
// routes/users.mjs(阶段G -> 数据库版)
// 目标:实现 RESTful 风格的用户资源接口,直接操作 MySQL 数据库
import Router from 'koa-router';
// 从 mysqldb.mjs 导入封装好的增删改查函数
import { getAllUsers, getUserById, createUser, updateUser, deleteUser } from '../database/mysqldb.mjs';
const router = new Router({ prefix: '/api/users' });
// ----------------- RESTful 路由 -----------------
// GET /api/users -> 获取用户列表
router.get('/', async (ctx) => {
try {
const users = await getAllUsers(); // 调用数据库查询所有用户
ctx.status = 200;
ctx.body = { code: 200, data: users };
} catch (err) {
ctx.throw(500, err.message);
}
});
// GET /api/users/:id -> 根据 ID 获取用户详情
router.get('/:id', async (ctx) => {
const id = Number(ctx.params.id);
try {
const user = await getUserById(id); // 调用数据库查询单个用户
if (!user) {
ctx.throw(404, `用户 ${id} 不存在`);
}
ctx.status = 200;
ctx.body = { code: 200, data: user };
} catch (err) {
ctx.throw(500, err.message);
}
});
// POST /api/users -> 新增用户
router.post('/', async (ctx) => {
const { name, age } = ctx.request.body || {};
if (!name || typeof age !== 'number') {
ctx.throw(400, '参数不合法:需要 name(string) 与 age(number)');
}
try {
const newUser = await createUser(name, age); // 调用数据库新增用户
ctx.status = 201; // 201:已创建
ctx.body = { code: 201, data: newUser };
} catch (err) {
ctx.throw(500, err.message);
}
});
// PUT /api/users/:id -> 全量更新用户
router.put('/:id', async (ctx) => {
const id = Number(ctx.params.id);
const { name, age } = ctx.request.body || {};
if (!name || typeof age !== 'number') {
ctx.throw(400, '参数不合法:需要 name(string) 与 age(number)');
}
try {
const updatedUser = await updateUser(id, name, age); // 调用数据库更新
ctx.status = 200;
ctx.body = { code: 200, data: updatedUser };
} catch (err) {
ctx.throw(500, err.message);
}
});
// PATCH /api/users/:id -> 部分更新用户
router.patch('/:id', async (ctx) => {
const id = Number(ctx.params.id);
const patch = ctx.request.body || {};
try {
const existingUser = await getUserById(id);
if (!existingUser) ctx.throw(404, `用户 ${id} 不存在`);
// 合并已有数据与更新数据
const updatedUser = await updateUser(id, patch.name ?? existingUser.name, patch.age ?? existingUser.age);
ctx.status = 200;
ctx.body = { code: 200, data: updatedUser };
} catch (err) {
ctx.throw(500, err.message);
}
});
// DELETE /api/users/:id -> 删除用户
router.delete('/:id', async (ctx) => {
const id = Number(ctx.params.id);
try {
const success = await deleteUser(id);
if (!success) ctx.throw(404, `用户 ${id} 不存在`);
ctx.status = 200;
ctx.body = { code: 200, message: `用户 ${id} 已删除` };
} catch (err) {
ctx.throw(500, err.message);
}
});
export default router;
# 8.3 services/userService.mjs(业务层)
// services/userService.mjs(阶段H - Service 层)
/**
* 业务层:进行业务规则校验(示例简单化),
* 将 Controller 的输入转为 Model 层所需格式;
* 对 Model 输出进行二次处理(如脱敏、聚合等)。
*/
import * as User from '../models/userModel.mjs';
export async function listUsers() {
return await User.findAll();
}
export async function getUser(id) {
const user = await User.findById(id);
return user; // 可添加“脱敏”等处理
}
export async function createUser({ name, age }) {
if (!name || typeof age !== 'number') {
const err = new Error('参数不合法:需要 name(string) 与 age(number)');
err.status = 400;
throw err;
}
return await User.create({ name, age });
}
export async function updateUser(id, { name, age }) {
if (!name || typeof age !== 'number') {
const err = new Error('参数不合法:需要 name 与 age');
err.status = 400;
throw err;
}
const updated = await User.update(id, { name, age });
if (!updated) {
const err = new Error(`用户 ${id} 不存在`);
err.status = 404;
throw err;
}
return updated;
}
export async function patchUser(id, patchObj) {
const updated = await User.patch(id, patchObj);
if (!updated) {
const err = new Error(`用户 ${id} 不存在`);
err.status = 404;
throw err;
}
return updated;
}
export async function removeUser(id) {
const deleted = await User.remove(id);
if (!deleted) {
const err = new Error(`用户 ${id} 不存在`);
err.status = 404;
throw err;
}
return deleted;
}
# 8.4 controllers/userController.mjs(控制器层)
// controllers/userController.mjs(阶段H - Controller 层)
/**
* 控制器:仅负责与 Koa 的 ctx 交互(取参、设响应、设状态码)。
* 真正的业务逻辑不写在这里,全部委托给 Service。
*/
import * as UserService from '../services/userService.mjs';
export async function list(ctx) {
const data = await UserService.listUsers();
ctx.status = 200;
ctx.body = { code: 200, data };
}
export async function detail(ctx) {
const id = Number(ctx.params.id);
const data = await UserService.getUser(id);
if (!data) {
ctx.throw(404, `用户 ${id} 不存在`);
}
ctx.body = { code: 200, data };
}
export async function create(ctx) {
const { name, age } = ctx.request.body || {};
const data = await UserService.createUser({ name, age });
ctx.status = 201;
ctx.body = { code: 201, data };
}
export async function update(ctx) {
const id = Number(ctx.params.id);
const { name, age } = ctx.request.body || {};
const data = await UserService.updateUser(id, { name, age });
ctx.body = { code: 200, data };
}
export async function patch(ctx) {
const id = Number(ctx.params.id);
const patchObj = ctx.request.body || {};
const data = await UserService.patchUser(id, patchObj);
ctx.body = { code: 200, data };
}
export async function remove(ctx) {
const id = Number(ctx.params.id);
const data = await UserService.removeUser(id);
ctx.body = { code: 200, data };
}
# 8.5 routes/users.mjs(只负责绑定路径到 Controller)
// routes/users.mjs(阶段H)
import Router from 'koa-router';
import * as UserController from '../controllers/userController.mjs';
const router = new Router({ prefix: '/api/users' });
router.get('/', UserController.list);
router.get('/:id', UserController.detail);
router.post('/', UserController.create);
router.put('/:id', UserController.update);
router.patch('/:id',UserController.patch);
router.delete('/:id', UserController.remove);
export default router;
至此,已经是一个结构清晰的 REST + MVC(无数据库版)。
# 9. 阶段 I:把 Model 换成 MySQL(mysql2/promise + 连接池)
# 9.1 安装依赖
npm i mysql2 dotenv
# 9.2 新增 database/mysql.mjs(连接池与 query 封装)
// database/mysql.mjs(阶段I - 数据库基础封装)
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config(); // 读取 .env
// 创建连接池(推荐生产环境使用连接池,而非单连接)
export const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '123456',
database: process.env.DB_NAME || 'testdb',
port: Number(process.env.DB_PORT || 3306),
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// 统一的 SQL 执行函数:自动格式化参数,返回 rows
export async function query(sql, params = []) {
const [rows] = await pool.execute(sql, params);
return rows;
}
# 9.3 在项目根目录创建 .env
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=123456
DB_NAME=testdb
# 9.4 初始化数据表(一次性)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
age INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
# 9.5 重写 models/userModel.mjs 为 MySQL 实现
// models/userModel.mjs(阶段I - MySQL 版本 Model 层)
import { query } from '../database/mysql.mjs';
export async function findAll() {
// 返回全部用户
return await query('SELECT id, name, age, created_at FROM users ORDER BY id ASC');
}
export async function findById(id) {
const rows = await query('SELECT id, name, age, created_at FROM users WHERE id = ?', [id]);
return rows[0] || null;
}
export async function create({ name, age }) {
// 使用 ? 占位符防止 SQL 注入
const result = await query('INSERT INTO users (name, age) VALUES (?, ?)', [name, age]);
// 插入成功后返回新记录
const inserted = await findById(result.insertId);
return inserted;
}
export async function update(id, { name, age }) {
const result = await query('UPDATE users SET name = ?, age = ? WHERE id = ?', [name, age, id]);
if (result.affectedRows === 0) return null;
return await findById(id);
}
export async function patch(id, patchObj) {
// 动态拼接字段(示例简化,仅允许 name/age)
const fields = [];
const params = [];
if (patchObj.name !== undefined) { fields.push('name = ?'); params.push(patchObj.name); }
if (patchObj.age !== undefined) { fields.push('age = ?'); params.push(patchObj.age); }
if (fields.length === 0) return await findById(id); // 无变化
params.push(id);
const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = ?`;
const result = await query(sql, params);
if (result.affectedRows === 0) return null;
return await findById(id);
}
export async function remove(id) {
const toDelete = await findById(id);
if (!toDelete) return null;
const result = await query('DELETE FROM users WHERE id = ?', [id]);
if (result.affectedRows === 0) return null;
return toDelete;
}
无感替换数据层:我们没有改 Controller/Service 的代码,因为它们的调用接口未变。这就是解耦的好处。
# 9.6 运行与测试
- 确认本机 MySQL 已启动,并在
.env中配置正确; - 执行上面的建表 SQL;
- 启动服务:
npm run dev; - 使用前文的
curl测试 CRUD 接口;现在数据真实写入 MySQL!
# 10. 阶段 J:额外增强(可选但常用)
# 10.1 CORS(跨域)
npm i @koa/cors
import cors from '@koa/cors';
app.use(cors({ origin: '*' })); // 生产建议更精细控制
# 10.2 请求参数校验(轻量示例)
在 Service 层已经有简单校验;进一步可以使用
zod,joi,yup等库统一校验。
# 10.3 分页 & 模糊查询(以 MySQL 为例,改造 Model)
// 示例:分页查询(page 从 1 开始)
export async function findPaged({ page = 1, pageSize = 10, keyword = '' }) {
const offset = (page - 1) * pageSize;
const where = keyword ? 'WHERE name LIKE ?' : '';
const params = keyword ? [`%${keyword}%`, pageSize, offset] : [pageSize, offset];
const rows = await query(
`SELECT id, name, age, created_at FROM users ${where} ORDER BY id ASC LIMIT ? OFFSET ?`,
params
);
const totalRows = await query(
`SELECT COUNT(*) AS c FROM users ${keyword ? 'WHERE name LIKE ?' : ''}`,
keyword ? [`%${keyword}%`] : []
);
return { list: rows, total: totalRows[0].c, page, pageSize };
}
# 11. 常见坑位排查(FAQ)
SyntaxError: Cannot use import statement outside a modulepackage.json是否含"type":"module"?或者文件是否使用.mjs?
Error: Cannot find module 'xxx'- 依赖没装或装在了错误目录:执行
npm i xxx,确保命令行的当前目录正确。
- 依赖没装或装在了错误目录:执行
ctx.request.body是undefined- 是否
app.use(bodyParser())放在了路由前面?是否设置了正确的Content-Type?
- 是否
- 中文乱码
- 前端/客户端请求与响应头编码问题;确保响应头
Content-Type与实际内容一致(Koa 会自动根据ctx.body设定)。
- 前端/客户端请求与响应头编码问题;确保响应头
- MySQL 连接失败
- 检查
.env配置、数据库是否启动、端口是否被占用、防火墙、权限是否允许远程连接。
- 检查
# 12. 最终项目结构参考(到阶段 I 为止)
koa-step-by-step/
├─ app.mjs
├─ public/
│ └─ index.html
├─ views/ # 若使用模板渲染
│ └─ home.ejs
├─ routes/
│ ├─ index.mjs
│ ├─ users.mjs
├─ controllers/
│ └─ userController.mjs
├─ services/
│ └─ userService.mjs
├─ models/
│ └─ userModel.mjs # 阶段H:内存; 阶段I:MySQL
├─ database/
│ └─ mysql.mjs
├─ .env
└─ package.json
# 13. 一句话回顾每个阶段
- A:只有一个文件的“Hello Koa”
- B:加上
koa-router,不同路径返回不同内容 - C:解析请求体,拥有第一个 POST 接口
- D:全局错误处理 + 访问日志,理解“洋葱模型”
- E:静态资源与 favicon
- F:(可选)EJS 模板渲染
- G:RESTful 内存版 users 资源(CRUD)
- H:重构为 MVC(Controller/Service/Model 分层)
- I:替换 Model → MySQL(真实落库)
- J:加强:CORS、校验、分页等
