Egg.js 快速入门
一、引言
本章节将帮助你快速上手 Egg.js 开发。将通过两种方式带你入门:
- 脚手架快速初始化:适合快速启动项目,开箱即用
- 逐步手动搭建:适合深入理解框架原理,循序渐进
完成本章节后,你将掌握:
- 如何创建和运行 Egg.js 项目
- 理解 MVC 架构模式在 Egg.js 中的实现
- 掌握 Controller、Service、Middleware 等核心概念
- 学会编写单元测试
二、快速初始化
推荐直接使用脚手架,只需几条简单指令,即可快速生成项目(npm >=6.1.0):
2.1 创建项目
# 创建项目目录
$ mkdir egg-example && cd egg-example
# 初始化项目(支持多种模板类型)
$ npm init egg --type=simple
# 安装依赖
$ npm i2.2 可用的项目模板
| 模板类型 | 说明 | 适用场景 |
|---|---|---|
simple | 简单模板,最小化配置 | 快速原型、学习示例 |
normal | 标准模板,包含常用功能 | 一般 Web 应用 |
empty | 空模板,无任何预设 | 高度定制化项目 |
ts | TypeScript 模板 | TypeScript 项目 |
2.3 启动项目
# 开发模式启动(支持热重载)
$ npm run dev
# 访问应用
$ open http://localhost:70012.4 项目目录结构
脚手架生成的项目结构如下:
egg-example
├── app # 核心业务代码目录
│ ├── controller/ # 控制器 - 处理请求,返回响应
│ │ └── home.js
│ ├── public/ # 静态资源目录
│ ├── router.js # 路由配置
│ └── service/ # 服务层 - 业务逻辑封装
├── config # 配置文件目录
│ ├── config.default.js # 默认配置
│ ├── config.prod.js # 生产环境配置
│ └── plugin.js # 插件配置
├── test # 测试文件目录
│ └── app/
│ └── controller/
│ └── home.test.js
├── package.json
└── README.md三、逐步搭建
通常可以通过 npm init egg 快速选择适合对应业务模型的脚手架,快速启动 Egg.js 项目的开发。
但为了更好的了解 Egg.js,可以跳过脚手架,手动一步步的搭建出一个 Hacker News 项目。
通过这个实战案例,你将深入学习:
- 项目初始化与配置
- Controller、Service、Middleware 的编写
- 模板渲染与静态资源管理
- 扩展机制的使用
- 单元测试的编写
3.1 初始化项目
先来初始化下目录结构:
# 创建项目目录
$ mkdir egg-example
$ cd egg-example
# 初始化 package.json
$ npm init -y
# 安装 Egg.js 核心依赖
$ npm i egg --save
# 安装开发工具
$ npm i egg-bin --save-dev添加 npm scripts 到 package.json:
{
"name": "egg-example",
"scripts": {
"dev": "egg-bin dev",
"test": "egg-bin test",
"cov": "egg-bin cov"
}
}依赖说明:
| 包名 | 类型 | 作用 |
|---|---|---|
egg | 生产依赖 | Egg.js 框架核心 |
egg-bin | 开发依赖 | 开发调试工具,支持热重载 |
3.2 编写 Controller
如果你熟悉 Web 开发或 MVC,肯定猜到第一步需要编写的是 Controller 和 Router。
Controller 负责解析用户的输入,处理后返回相应的结果。
// app/controller/home.js
const Controller = require('egg').Controller
class HomeController extends Controller {
async index() {
const { ctx } = this
ctx.body = 'Hello world'
}
}
module.exports = HomeController配置路由映射:
// app/router.js
module.exports = (app) => {
const { router, controller } = app
// 将 GET / 请求映射到 HomeController 的 index 方法
router.get('/', controller.home.index)
}添加配置文件:
// config/config.default.js
// keys 用于加密 Cookie,请务必修改为自己的密钥
exports.keys = 'your-cookie-secret-keys-here'此时目录结构如下:
egg-example
├── app
│ ├── controller
│ │ └── home.js
│ └── router.js
├── config
│ └── config.default.js
└── package.json完整的目录结构规范参见目录结构。
启动应用:
$ npm run dev
$ open http://localhost:7001注意:
- Controller 有
class和exports两种编写方式,本文示范的是前者,你可能需要参考 Controller 文档。- Config 也有
module.exports和exports的写法,具体参考 Node.js modules 文档。
3.3 静态资源
Egg 内置了 static 插件,线上环境建议部署到 CDN,无需该插件。
static 插件默认映射 /public/* -> app/public/* 目录。
目录结构:
app/public
├── css
│ └── news.css
└── js
├── lib.js
└── news.js访问方式:
<!-- 在模板中引用 -->
<link rel="stylesheet" href="/public/css/news.css" />
<script src="/public/js/lib.js"></script>3.4 模板渲染
绝大多数情况,都需要读取数据后渲染模板,然后呈现给用户。故需要引入对应的模板引擎。
框架并不强制你使用某种模板引擎,只是约定了 View 插件开发规范,开发者可以引入不同的插件来实现差异化定制。
更多用法参见 View。
安装模板引擎插件
在本例中,使用 Nunjucks 来渲染:
$ npm i egg-view-nunjucks --save配置插件
// config/plugin.js
exports.nunjucks = {
enable: true,
package: 'egg-view-nunjucks'
}// config/config.default.js
exports.keys = 'your-cookie-secret-keys-here'
// 添加 view 配置
exports.view = {
defaultViewEngine: 'nunjucks',
mapping: {
'.tpl': 'nunjucks' // .tpl 后缀的文件使用 nunjucks 引擎渲染
}
}注意:配置文件在
config目录下,不是app/config!
编写模板
为列表页编写模板文件,一般放置在 app/view 目录下:
<!-- app/view/news/list.tpl -->
<!DOCTYPE html>
<html>
<head>
<title>Hacker News</title>
<link rel="stylesheet" href="/public/css/news.css" />
</head>
<body>
<ul class="news-view view">
{% for item in list %}
<li class="item">
<a href="{{ item.url }}">{{ item.title }}</a>
</li>
{% endfor %}
</ul>
</body>
</html>添加 Controller 和 Router
// app/controller/news.js
const Controller = require('egg').Controller
class NewsController extends Controller {
async list() {
const { ctx } = this
const dataList = {
list: [
{ id: 1, title: 'this is news 1', url: '/news/1' },
{ id: 2, title: 'this is news 2', url: '/news/2' }
]
}
await ctx.render('news/list.tpl', dataList)
}
}
module.exports = NewsController// app/router.js
module.exports = (app) => {
const { router, controller } = app
router.get('/', controller.home.index)
router.get('/news', controller.news.list)
}启动浏览器,访问 http://localhost:7001/news 即可看到渲染后的页面。
提示:开发期默认开启了 development 插件,修改后端代码后,会自动重启 Worker 进程。
3.5 编写 Service
在实际应用中,Controller 一般不会自己产出数据,也不会包含复杂的逻辑,复杂的过程应抽象为业务逻辑层 Service。
Service 的职责:
- 封装可复用的业务逻辑
- 与数据库、外部 API 进行交互
- 处理复杂的数据计算和转换
来添加一个 Service 抓取 Hacker News 的数据:
// app/service/news.js
const Service = require('egg').Service
class NewsService extends Service {
async list(page = 1) {
// 读取配置
const { serverUrl, pageSize } = this.config.news
// 使用内置 HttpClient 获取 Hacker News API
const { data: idList } = await this.ctx.curl(
`${serverUrl}/topstories.json`,
{
data: {
orderBy: '"$key"',
startAt: `"${pageSize * (page - 1)}"`,
endAt: `"${pageSize * page - 1}"`
},
dataType: 'json'
}
)
// 并行获取详细信息
const newsList = await Promise.all(
Object.keys(idList).map((key) => {
const url = `${serverUrl}/item/${idList[key]}.json`
return this.ctx.curl(url, { dataType: 'json' })
})
)
return newsList.map((res) => res.data)
}
}
module.exports = NewsService配置 News API:
// config/config.default.js
exports.news = {
pageSize: 5,
serverUrl: 'https://hacker-news.firebaseio.com/v0'
}提示:框架提供了内置的 HttpClient 来方便开发者使用 HTTP 请求。
然后在 Controller 中调用 Service:
// app/controller/news.js
class NewsController extends Controller {
async list() {
const { ctx } = this
const dataList = await ctx.service.news.list()
await ctx.render('news/list.tpl', { list: dataList })
}
}3.6 编写扩展
遇到一个小问题,我们的资讯时间的数据是 UnixTime 格式的,希望显示为便于阅读的格式。
框架提供了一种快速扩展的方式,只需在 app/extend 目录下提供扩展脚本即可,具体参见扩展。
在这里,可以使用 View 插件支持的 Helper 来实现:
安装依赖:
$ npm i moment --save编写扩展:
// app/extend/helper.js
const moment = require('moment')
exports.relativeTime = (time) => {
return moment(new Date(time * 1000)).fromNow()
}
exports.formatTime = (time, format = 'YYYY-MM-DD HH:mm:ss') => {
return moment(new Date(time * 1000)).format(format)
}在模板中使用:
<!-- app/view/news/list.tpl -->
<span>{{ helper.relativeTime(item.time) }}</span>
<!-- 输出: "2 hours ago" -->
<span>{{ helper.formatTime(item.time) }}</span>
<!-- 输出: "2024-01-15 14:30:00" -->3.7 编写 Middleware
假设有个需求:我们的新闻站点,禁止百度爬虫访问。
聪明的同学们一定很快能想到可以通过 Middleware 判断 User-Agent:
// app/middleware/robot.js
// options === app.config.robot
module.exports = (options, app) => {
return async function robotMiddleware(ctx, next) {
const source = ctx.get('user-agent') || ''
const match = options.ua.some((ua) => ua.test(source))
if (match) {
ctx.status = 403
ctx.message = 'Go away, robot.'
} else {
await next()
}
}
}配置中间件:
// config/config.default.js
// 注册中间件
exports.middleware = ['robot']
// 中间件配置
exports.robot = {
ua: [/Baiduspider/i, /Googlebot/i] // 禁止的爬虫列表
}测试效果:
# 正常访问
$ curl http://localhost:7001/news
# 模拟爬虫访问(返回 403)
$ curl http://localhost:7001/news -A "Baiduspider"更多参见中间件文档。
3.8 配置文件详解
写业务的时候,不可避免的需要有配置文件,框架提供了强大的配置合并管理功能:
配置加载顺序
框架会按照以下顺序加载配置,后加载的会覆盖前面的:
框架默认配置 -> 插件配置 -> 应用默认配置 -> 环境配置多环境配置
| 配置文件 | 加载时机 | 用途 |
|---|---|---|
config.default.js | 所有环境 | 默认配置,作为基础 |
config.prod.js | 生产环境 | 生产环境特有配置 |
config.local.js | 本地开发 | 本地开发环境配置 |
config.test.js | 测试环境 | 测试环境配置 |
配置示例
// config/config.default.js
exports.robot = {
ua: [/curl/i, /Baiduspider/i] // 默认配置
}
// config/config.prod.js
exports.robot = {
ua: [/Baiduspider/i] // 生产环境配置,覆盖默认配置
}
// config/config.local.js
exports.robot = {
ua: [] // 本地开发环境不禁止任何爬虫
}在 Service 中使用配置:
// app/service/some.js
const Service = require('egg').Service
class SomeService extends Service {
async list() {
const rule = this.config.robot.ua
// 使用配置...
}
}
module.exports = SomeService3.9 单元测试
单元测试非常重要,框架也提供了 egg-bin 来帮开发者无痛的编写测试。
测试目录结构
测试文件应该放在项目根目录下的 test 目录下,并以 .test.js 为后缀名:
test
├── app
│ ├── controller
│ │ └── home.test.js
│ ├── service
│ │ └── news.test.js
│ └── middleware
│ └── robot.test.js
└── fixtures
└── test-data.js安装测试依赖
$ npm i egg-mock --save-dev编写测试用例
// test/app/middleware/robot.test.js
const { app, mock, assert } = require('egg-mock/bootstrap')
describe('test/app/middleware/robot.test.js', () => {
// 测试:应该阻止爬虫访问
it('should block robot', () => {
return app
.httpRequest()
.get('/')
.set('User-Agent', 'Baiduspider')
.expect(403)
})
// 测试:应该允许正常用户访问
it('should allow normal user', () => {
return app
.httpRequest()
.get('/')
.set('User-Agent', 'Mozilla/5.0')
.expect(200)
})
})运行测试
# 运行所有测试
$ npm test
# 运行测试覆盖率
$ npm run cov就这么简单,更多请参见 单元测试。
四、调试技巧
4.1 日志查看
Egg.js 内置了强大的日志系统,日志文件位于 logs 目录:
| 日志文件 | 内容 |
|---|---|
egg-web.log | Web 请求相关日志 |
egg-app.log | 应用级别日志 |
common-error.log | 错误日志 |
egg-agent.log | Agent 进程日志 |
4.2 使用 VS Code 调试
创建 .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Egg Debug",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"console": "integratedTerminal",
"protocol": "auto"
}
]
}4.3 常见问题排查
问题 1:端口被占用
# 查找占用端口的进程
$ lsof -i :7001
# 终止进程
$ kill -9 <PID>问题 2:配置未生效
- 检查配置文件命名是否正确
- 确认环境变量
EGG_SERVER_ENV是否设置正确 - 检查配置文件语法是否正确
五、常见问题解答 (FAQ)
5.1 如何修改默认端口?
// config/config.default.js
exports.cluster = {
listen: {
port: 7002,
hostname: '127.0.0.1'
}
}5.2 如何处理跨域请求?
$ npm i egg-cors --save// config/plugin.js
exports.cors = {
enable: true,
package: 'egg-cors'
}
// config/config.default.js
exports.cors = {
origin: '*',
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH'
}5.3 如何连接数据库?
$ npm i egg-mysql --save// config/plugin.js
exports.mysql = {
enable: true,
package: 'egg-mysql'
}
// config/config.default.js
exports.mysql = {
client: {
host: '127.0.0.1',
port: 3306,
user: 'root',
password: 'password',
database: 'test'
}
}5.4 如何关闭 CSRF 验证?
// config/config.default.js
exports.security = {
csrf: {
enable: false // 不推荐在生产环境关闭
}
}六、后记
短短几章内容,只能讲 Egg 的冰山一角,建议开发者继续阅读其他章节:
| 学习路径 | 推荐章节 | 说明 |
|---|---|---|
| 深入核心 | 核心概念 | 理解框架的内置对象和运行机制 |
| 项目模板 | 骨架说明 | 了解不同类型的项目模板 |
| 扩展机制 | 插件开发 | 学习如何开发和发布插件 |
| 团队协作 | 框架开发 | 封装适合团队的上层框架 |
| 渐进式开发 | 渐进式开发 | 代码的共建、复用和下沉 |
| 测试驱动 | 单元测试 | 测试驱动开发的最佳实践 |
七、下一步学习
完成本章学习后,建议继续以下内容:
-
深入理解核心概念
- 学习 Application、Context、Request、Response 对象
- 掌握中间件的洋葱模型
- 理解多进程模型
-
实战项目开发
- 集成数据库(MySQL、MongoDB)
- 使用缓存(Redis)
- 实现用户认证和授权
-
进阶技能
- 性能优化技巧
- 安全防护措施
- 生产环境部署
参考资源: