# 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/cors
import 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 运行与测试

  1. 确认本机 MySQL 已启动,并在 .env 中配置正确;
  2. 执行上面的建表 SQL;
  3. 启动服务:npm run dev
  4. 使用前文的 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 module
    • package.json 是否含 "type":"module"?或者文件是否使用 .mjs
  • Error: Cannot find module 'xxx'
    • 依赖没装或装在了错误目录:执行 npm i xxx,确保命令行的当前目录正确。
  • ctx.request.bodyundefined
    • 是否 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、校验、分页等
更新于

请我喝[茶]~( ̄▽ ̄)~*

koen 微信支付

微信支付

koen 支付宝

支付宝