{T}

Egg.js 快速入门

一、引言

本章节将帮助你快速上手 Egg.js 开发。将通过两种方式带你入门:

  1. 脚手架快速初始化:适合快速启动项目,开箱即用
  2. 逐步手动搭建:适合深入理解框架原理,循序渐进

完成本章节后,你将掌握:

  • 如何创建和运行 Egg.js 项目
  • 理解 MVC 架构模式在 Egg.js 中的实现
  • 掌握 Controller、Service、Middleware 等核心概念
  • 学会编写单元测试

二、快速初始化

推荐直接使用脚手架,只需几条简单指令,即可快速生成项目(npm >=6.1.0):

2.1 创建项目

bash
# 创建项目目录
$ mkdir egg-example && cd egg-example

# 初始化项目(支持多种模板类型)
$ npm init egg --type=simple

# 安装依赖
$ npm i

2.2 可用的项目模板

模板类型说明适用场景
simple简单模板,最小化配置快速原型、学习示例
normal标准模板,包含常用功能一般 Web 应用
empty空模板,无任何预设高度定制化项目
tsTypeScript 模板TypeScript 项目

2.3 启动项目

bash
# 开发模式启动(支持热重载)
$ npm run dev

# 访问应用
$ open http://localhost:7001

2.4 项目目录结构

脚手架生成的项目结构如下:

code
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 初始化项目

先来初始化下目录结构:

bash
# 创建项目目录
$ mkdir egg-example
$ cd egg-example

# 初始化 package.json
$ npm init -y

# 安装 Egg.js 核心依赖
$ npm i egg --save

# 安装开发工具
$ npm i egg-bin --save-dev

添加 npm scriptspackage.json

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,肯定猜到第一步需要编写的是 ControllerRouter

Controller 负责解析用户的输入,处理后返回相应的结果。

javascript
// 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

配置路由映射:

javascript
// app/router.js
module.exports = (app) => {
  const { router, controller } = app
  // 将 GET / 请求映射到 HomeController 的 index 方法
  router.get('/', controller.home.index)
}

添加配置文件:

javascript
// config/config.default.js
// keys 用于加密 Cookie,请务必修改为自己的密钥
exports.keys = 'your-cookie-secret-keys-here'

此时目录结构如下:

code
egg-example
├── app
│   ├── controller
│   │   └── home.js
│   └── router.js
├── config
│   └── config.default.js
└── package.json

完整的目录结构规范参见目录结构

启动应用:

bash
$ npm run dev
$ open http://localhost:7001

注意:

  • Controller 有 classexports 两种编写方式,本文示范的是前者,你可能需要参考 Controller 文档。
  • Config 也有 module.exportsexports 的写法,具体参考 Node.js modules 文档

3.3 静态资源

Egg 内置了 static 插件,线上环境建议部署到 CDN,无需该插件。

static 插件默认映射 /public/* -> app/public/* 目录。

目录结构:

bash
app/public
├── css
│   └── news.css
└── js
    ├── lib.js
    └── news.js

访问方式:

html
<!-- 在模板中引用 -->
<link rel="stylesheet" href="/public/css/news.css" />
<script src="/public/js/lib.js"></script>

3.4 模板渲染

绝大多数情况,都需要读取数据后渲染模板,然后呈现给用户。故需要引入对应的模板引擎。

框架并不强制你使用某种模板引擎,只是约定了 View 插件开发规范,开发者可以引入不同的插件来实现差异化定制。

更多用法参见 View

安装模板引擎插件

在本例中,使用 Nunjucks 来渲染:

bash
$ npm i egg-view-nunjucks --save

配置插件

javascript
// config/plugin.js
exports.nunjucks = {
  enable: true,
  package: 'egg-view-nunjucks'
}
javascript
// 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 目录下:

html
<!-- 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

javascript
// 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
javascript
// 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 的数据:

javascript
// 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:

javascript
// config/config.default.js
exports.news = {
  pageSize: 5,
  serverUrl: 'https://hacker-news.firebaseio.com/v0'
}

提示:框架提供了内置的 HttpClient 来方便开发者使用 HTTP 请求。

然后在 Controller 中调用 Service:

javascript
// 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 来实现:

安装依赖:

bash
$ npm i moment --save

编写扩展:

javascript
// 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)
}

在模板中使用:

html
<!-- 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:

javascript
// 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()
    }
  }
}

配置中间件:

javascript
// config/config.default.js
// 注册中间件
exports.middleware = ['robot']

// 中间件配置
exports.robot = {
  ua: [/Baiduspider/i, /Googlebot/i]  // 禁止的爬虫列表
}

测试效果:

bash
# 正常访问
$ curl http://localhost:7001/news

# 模拟爬虫访问(返回 403)
$ curl http://localhost:7001/news -A "Baiduspider"

更多参见中间件文档。

3.8 配置文件详解

写业务的时候,不可避免的需要有配置文件,框架提供了强大的配置合并管理功能:

配置加载顺序

框架会按照以下顺序加载配置,后加载的会覆盖前面的:

code
框架默认配置 -> 插件配置 -> 应用默认配置 -> 环境配置

多环境配置

配置文件加载时机用途
config.default.js所有环境默认配置,作为基础
config.prod.js生产环境生产环境特有配置
config.local.js本地开发本地开发环境配置
config.test.js测试环境测试环境配置

配置示例

javascript
// 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 中使用配置:

javascript
// app/service/some.js
const Service = require('egg').Service

class SomeService extends Service {
  async list() {
    const rule = this.config.robot.ua
    // 使用配置...
  }
}

module.exports = SomeService

3.9 单元测试

单元测试非常重要,框架也提供了 egg-bin 来帮开发者无痛的编写测试。

测试目录结构

测试文件应该放在项目根目录下的 test 目录下,并以 .test.js 为后缀名:

code
test
├── app
│   ├── controller
│   │   └── home.test.js
│   ├── service
│   │   └── news.test.js
│   └── middleware
│       └── robot.test.js
└── fixtures
    └── test-data.js

安装测试依赖

bash
$ npm i egg-mock --save-dev

编写测试用例

javascript
// 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)
  })
})

运行测试

bash
# 运行所有测试
$ npm test

# 运行测试覆盖率
$ npm run cov

就这么简单,更多请参见 单元测试

四、调试技巧

4.1 日志查看

Egg.js 内置了强大的日志系统,日志文件位于 logs 目录:

日志文件内容
egg-web.logWeb 请求相关日志
egg-app.log应用级别日志
common-error.log错误日志
egg-agent.logAgent 进程日志

4.2 使用 VS Code 调试

创建 .vscode/launch.json

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:端口被占用

bash
# 查找占用端口的进程
$ lsof -i :7001

# 终止进程
$ kill -9 <PID>

问题 2:配置未生效

  • 检查配置文件命名是否正确
  • 确认环境变量 EGG_SERVER_ENV 是否设置正确
  • 检查配置文件语法是否正确

五、常见问题解答 (FAQ)

5.1 如何修改默认端口?

javascript
// config/config.default.js
exports.cluster = {
  listen: {
    port: 7002,
    hostname: '127.0.0.1'
  }
}

5.2 如何处理跨域请求?

bash
$ npm i egg-cors --save
javascript
// 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 如何连接数据库?

bash
$ npm i egg-mysql --save
javascript
// 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 验证?

javascript
// config/config.default.js
exports.security = {
  csrf: {
    enable: false  // 不推荐在生产环境关闭
  }
}

六、后记

短短几章内容,只能讲 Egg 的冰山一角,建议开发者继续阅读其他章节:

学习路径推荐章节说明
深入核心核心概念理解框架的内置对象和运行机制
项目模板骨架说明了解不同类型的项目模板
扩展机制插件开发学习如何开发和发布插件
团队协作框架开发封装适合团队的上层框架
渐进式开发渐进式开发代码的共建、复用和下沉
测试驱动单元测试测试驱动开发的最佳实践

七、下一步学习

完成本章学习后,建议继续以下内容:

  1. 深入理解核心概念

    • 学习 Application、Context、Request、Response 对象
    • 掌握中间件的洋葱模型
    • 理解多进程模型
  2. 实战项目开发

    • 集成数据库(MySQL、MongoDB)
    • 使用缓存(Redis)
    • 实现用户认证和授权
  3. 进阶技能

    • 性能优化技巧
    • 安全防护措施
    • 生产环境部署

参考资源: