@Egg介绍
概要
egg 是阿里出品的一款 node.js 后端 web 框架,基于 koa 封装,并做了一些约定。
为什么叫 egg ?
egg 有孕育的含义,因为 egg 的定位是企业级 web 基础框架,旨在帮助开发者孕育适合自己团队的框架。
技术名词解释
技术细节
创建项目
-
安装 Node.js
确保你的系统中安装了 Node.js。你可以从 Node.js 官网 下载并安装。 -
创建 Egg.js 后端项目
使用 egg-init 创建一个新的 egg.js 项目:
npm install -g egg-init
egg-init egg-example --type=simple
cd egg-example
npm install
另外一种方式:
$ npm init egg --type=simple --registry=china
解释一下 npm init egg 这种语法:
npm@6 版本引入了 npm-init 语法,等价于 npx create- 命令,而 npx 命令会去 $PATH 路径和 node_modules/.bin 路径下寻找名叫 create- 的可执行文件,如果找到了就执行,找不到就去安装。
也就是说,npm init egg 会去寻找或下载 create-egg 可执行文件,而 create-egg 包就是 egg-init 包的别名,相当于调用了 egg-init 。
- 开发 Egg.js 后端
在 egg-example 目录中,你可以开始开发你的后端服务。例如,创建一个简单的 API:
// app/controller/home.js
const Controller = require('egg').Controller;
class HomeController extends Controller {
async index() {
this.ctx.body = 'Hello world';
}
}
module.exports = HomeController;
配置路由:
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
};
创建完毕之后,目录结构如下(忽略 README文件 和 test 目录):
├── app
│ ├── controller
│ │ └── home.js
│ └── router.js
├── config
│ ├── config.default.js
│ └── plugin.js
├── package.json
启动 Egg.js 服务:
npm run dev
2024-02-18 16:07:04,402 INFO 8076 [master] node version v16.15.0
2024-02-18 16:07:04,404 INFO 8076 [master] egg version 3.19.0
2024-02-18 16:07:06,303 INFO 8076 [master] agent_worker#1:22064 started (1894ms)
2024-02-18 16:07:08,379 INFO 8076 [master] egg started on http://127.0.0.1:7001 (3975ms)
打开 http://127.0.0.1:7001/ 会看到网页上显示 hi, egg
工程结构概述
egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app/
| ├── router.js # 用于配置 URL 路由规则
│ ├── controller/ # 用于存放控制器(解析用户的输入、加工处理、返回结果)
│ ├── model/ (可选) # 用于存放数据库模型
│ ├── service/ (可选) # 用于编写业务逻辑层
│ ├── middleware/ (可选) # 用于编写中间件
│ ├── schedule/ (可选) # 用于设置定时任务
│ ├── public/ (可选) # 用于放置静态资源
│ ├── view/ (可选) # 用于放置模板文件
│ └── extend/ (可选) # 用于框架的扩展
│ ├── helper.js (可选)
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选)
│ ├── application.js (可选)
│ └── agent.js (可选)
├── config/
| ├── plugin.js # 用于配置需要加载的插件
| ├── config.{env}.js # 用于编写配置文件(env 可以是 default,prod,test,local,unittest)
config.default.js
config.local.js
config.prod.js
config.test.js
这是由 egg 框架或内置插件约定好的,是阿里总结出来的最佳实践,虽然框架也提供了让用户自定义目录结构的能力,但是依然建议大家采用阿里的这套方案。在接下来的篇章当中,会逐一讲解上述约定目录和文件的作用。
路由(Router)
路由定义了 请求路径(URL) 和 控制器(Controller) 之间的映射关系,即用户访问的网址应交由哪个控制器进行处理。我们打开 app/router.js 看一下:
module.exports = app => {
const { router, controller } = app
router.get('/', controller.home.index)
};
可以看到,路由文件导出了一个函数,接收 app 对象作为参数,通过下面的语法定义映射关系:
router.verb('path-match', controllerAction)
中间件(Middleware)
egg 约定一个中间件是一个放置在 app/middleware 目录下的单独文件,它需要导出一个普通的函数,该函数接受两个参数:
options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来。
app: 当前应用 Application 的实例。
我们新建一个 middleware/slow.js 慢查询中间件,当请求时间超过我们指定的阈值,就打印日志,代码为:
module.exports = (options, app) => {
return async function (ctx, next) {
const startTime = Date.now()
await next()
const consume = Date.now() - startTime
const { threshold = 0 } = options || {}
if (consume > threshold) {
console.log(`${ctx.url}请求耗时${consume}毫秒`)
}
}
}
然后在 config.default.js 中使用:
module.exports = {
// 配置需要的中间件,数组顺序即为中间件的加载顺序
middleware: [ 'slow' ],
// slow 中间件的 options 参数
slow: {
enable: true
},
}
这里配置的中间件是全局启用的,如果只是想在指定路由中使用中间件的话,例如只针对 /api 前缀开头的 url 请求使用某个中间件的话,有两种方式:
1.在 config.default.js 配置中设置 match 或 ignore 属性:
module.exports = {
middleware: [ 'slow' ],
slow: {
threshold: 1,
match: '/api'
},
};
2.在路由文件 router.js 中引入
module.exports = app => {
const { router, controller } = app
// 在 controller 处理之前添加任意中间件
router.get('/api/home', app.middleware.slow({ threshold: 1 }), controller.home.index)
}
egg 把中间件分成应用层定义的中间件(app.config.appMiddleware)和框架默认中间件(app.config.coreMiddleware),我们打印看一下:
module.exports = app => {
const { router, controller } = app
console.log(app.config.appMiddleware)
console.log(app.config.coreMiddleware)
router.get('/api/home', app.middleware.slow({ threshold: 1 }), controller.home.index)
}
结果是:
// appMiddleware
[ 'slow' ]
// coreMiddleware
[
'meta',
'siteFile',
'notfound',
'static',
'bodyParser',
'overrideMethod',
'session',
'securities',
'i18n',
'eggLoaderTrace'
]
其中那些 coreMiddleware 是 egg 帮我们内置的中间件,默认是开启的,如果不想用,可以通过配置的方式进行关闭:
module.exports = {
i18n: {
enable: false
}
}
控制器(Controller)
Controller 负责解析用户的输入,处理后返回相应的结果,一个最简单的 helloworld 示例:
const { Controller } = require('egg');
class HomeController extends Controller {
async index() {
const { ctx } = this;
ctx.body = 'hi, egg';
}
}
module.exports = HomeController;
当然,我们实际项目中的代码不会这么简单,通常情况下,在 Controller 中会做如下几件事情:
接收、校验、处理 HTTP 请求参数
向下调用服务(Service)处理业务
通过 HTTP 将结果响应给用户
一个真实的案例如下:
const { Controller } = require('egg');
class PostController extends Controller {
async create() {
const { ctx, service } = this;
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
};
// 校验和组装参数
ctx.validate(createRule);
const data = Object.assign(ctx.request.body, { author: ctx.session.userId });
// 调用 Service 进行业务处理
const res = await service.post.create(data);
// 响应客户端数据
ctx.body = { id: res.id };
ctx.status = 201;
}
}
module.exports = PostController;
由于 Controller 是类,因此可以通过自定义基类的方式封装常用方法,例如:
// app/core/base_controller.js
const { Controller } = require('egg');
class BaseController extends Controller {
get user() {
return this.ctx.session.user;
}
success(data) {
this.ctx.body = { success: true, data };
}
notFound(msg) {
this.ctx.throw(404, msg || 'not found');
}
}
module.exports = BaseController;
然后让所有 Controller 继承这个自定义的 BaseController:
// app/controller/post.js
const Controller = require('../core/base_controller');
class PostController extends Controller {
async list() {
const posts = await this.service.listByUser(this.user);
this.success(posts);
}
}
在 Controller 中通过 this.ctx 可以获取上下文对象,方便获取和设置相关参数,例如:
ctx.query:URL 中的请求参数(忽略重复 key)
ctx.quries:URL 中的请求参数(重复的 key 被放入数组中)
ctx.params:Router 上的命名参数
ctx.request.body:HTTP 请求体中的内容
ctx.request.files:前端上传的文件对象
ctx.getFileStream():获取上传的文件流
ctx.multipart():获取 multipart/form-data 数据
ctx.cookies:读取和设置 cookie
ctx.session:读取和设置 session
ctx.service.xxx:获取指定 service 对象的实例(懒加载)
ctx.status:设置状态码
ctx.body:设置响应体
ctx.set:设置响应头
ctx.redirect(url):重定向
ctx.render(template):渲染模板
this.ctx 上下文对象是 egg 框架和 koa 框架中最重要的一个对象,我们要弄清楚该对象的作用,不过需要注意的是,有些属性并非直接挂在 app.ctx 对象上,而是代理了 request 或 response 对象的属性,我们可以用 Object.keys(ctx) 看一下:
[
'request', 'response', 'app', 'req', 'res', 'onerror', 'originalUrl', 'starttime', 'matched',
'_matchedRoute', '_matchedRouteName', 'captures', 'params', 'routerName', 'routerPath'
]
关于ctx对象说明
在 Egg.js 中,ctx 是 context 的缩写,它代表了一次请求的上下文。ctx 是一个封装了 Node.js 原生 HTTP 请求和响应对象的对象,提供了一系列实用的方法和属性,使得处理 HTTP 请求变得更加方便。
ctx 对象包含了许多重要的属性和方法,例如:
ctx.req 和 ctx.res:分别代表 Node.js 的原生 request 和 response 对象。
ctx.request 和 ctx.response:是对原生 req 和 res 的封装,提供了更多的功能和更友好的 API。
ctx.state:用于通过中间件传递信息和你的前端视图。
ctx.params:包含路由参数,例如在路由 /users/:id 中,:id 就是一个路由参数。
ctx.query:包含 URL 查询字符串的参数。
ctx.body:用于获取或设置响应体。
当你在 Egg.js 中定义路由时,你可以使用路径参数来捕获 URL 中的动态值。在路由定义中,以冒号 : 开头的部分是路径参数的占位符。例如,在路由 /users/:id 中,:id 是一个路径参数,它会匹配任何在 /users/ 后面的一段 URL 路径。
在控制器方法中,你可以通过 ctx.params 对象访问这些路径参数。例如:
// app/controller/user.js
const Controller = require('egg').Controller;
class UserController extends Controller {
async show() {
const userId = this.ctx.params.id; // 获取路径参数 id 的值
// ... 根据 userId 进行业务逻辑处理
this.ctx.body = `User ID: ${userId}`;
}
}
module.exports = UserController;
在这个例子中,如果有一个请求是 GET /users/123,那么 ctx.params.id 的值将会是 ‘123’,控制器方法可以使用这个值来进行进一步的处理,比如查询数据库中对应的用户信息。
总结来说,ctx 是 Egg.js 提供的一个核心对象,它封装了一次 HTTP 请求的所有相关信息和操作方法,使得开发者可以方便地处理请求和响应。
服务(Service)
Service 是具体业务逻辑的实现,一个封装好的 Service 可供多个 Controller 调用,而一个 Controller 里面也可以调用多个 Service,虽然在 Controller 中也可以写业务逻辑,但是并不建议这么做,代码中应该保持 Controller 逻辑简洁,仅仅发挥「桥梁」作用。
Controller 可以调用任何一个 Service 上的任何方法,值得注意的是:Service 是懒加载的,即只有当访问到它的时候框架才会去实例化它。
通常情况下,在 Service 中会做如下几件事情:
处理复杂业务逻辑
调用数据库或第三方服务(例如 GitHub 信息获取等)
一个简单的 Service 示例,将数据库中的查询结果返回出去:
// app/service/user.js
const { Service } = require('egg').Service;
class UserService extends Service {
async find(uid) {
const user = await this.ctx.db.query('select * from user where uid = ?', uid);
return user;
}
}
module.exports = UserService;
在 Controller 中可以直接调用:
class UserController extends Controller {
async info() {
const { ctx } = this;
const userId = ctx.params.id;
const userInfo = await ctx.service.user.find(userId);
ctx.body = userInfo;
}
}
注意,Service 文件必须放在 app/service 目录,支持多级目录,访问的时候可以通过目录名级联访问:
app/service/biz/user.js => ctx.service.biz.user
app/service/sync_user.js => ctx.service.syncUser
app/service/HackerNews.js => ctx.service.hackerNews
Service 里面的函数,可以理解为某个具体业务逻辑的最小单元,Service 里面也可以调用其他 Service,值得注意的是:Service 不是单例,是 请求级别 的对象,框架在每次请求中首次访问 ctx.service.xx 时延迟实例化,所以 Service 中可以通过 this.ctx 获取到当前请求的上下文。
模板渲染
egg 框架内置了 egg-view 作为模板解决方案,并支持多种模板渲染,例如 ejs、handlebars、nunjunks 等模板引擎,每个模板引擎都以插件的方式引入,默认情况下,所有插件都会去找 app/view 目录下的文件,然后根据 config\config.default.js 中定义的后缀映射来选择不同的模板引擎:
config.view = {
defaultExtension: '.nj',
defaultViewEngine: 'nunjucks',
mapping: {
'.nj': 'nunjucks',
'.hbs': 'handlebars',
'.ejs': 'ejs',
},
}
上面的配置表示,当文件:
后缀是 .nj 时使用 nunjunks 模板引擎
后缀是 .hbs 时使用 handlebars 模板引擎
后缀是 .ejs 时使用 ejs 模板引擎
当未指定后缀时默认为 .html
当未指定模板引擎时默认为 nunjunks
接下来我们安装模板引擎插件:
$ npm i egg-view-nunjucks egg-view-ejs egg-view-handlebars --save
# 或者 yarn add egg-view-nunjucks egg-view-ejs egg-view-handlebars
然后在 config/plugin.js 中启用该插件:
exports.nunjucks = {
enable: true,
package: 'egg-view-nunjucks',
}
exports.handlebars = {
enable: true,
package: 'egg-view-handlebars',
}
exports.ejs = {
enable: true,
package: 'egg-view-ejs',
}
然后添加 app/view 目录,里面增加几个文件:
app/view
├── ejs.ejs
├── handlebars.hbs
└── nunjunks.nj
代码分别是:
<!-- ejs.ejs 文件代码 -->
<h1>ejs</h1>
<ul>
<% items.forEach(function(item){ %>
<li><%= item.title %></li>
<% }); %>
</ul>
<!-- handlebars.hbs 文件代码 -->
<h1>handlebars</h1>
{{#each items}}
<li>{{title}}</li>
{{~/each}}
<!-- nunjunks.nj 文件代码 -->
<h1>nunjunks</h1>
<ul>
{% for item in items %}
<li>{{ item.title }}</li>
{% endfor %}
</ul>
然后在 Router 中配置路由:
module.exports = app => {
const { router, controller } = app
router.get('/ejs', controller.home.ejs)
router.get('/handlebars', controller.home.handlebars)
router.get('/nunjunks', controller.home.nunjunks)
}
接下来实现 Controller 的逻辑:
const Controller = require('egg').Controller
class HomeController extends Controller {
async ejs() {
const { ctx } = this
const items = await ctx.service.view.getItems()
await ctx.render('ejs.ejs', {items})
}
async handlebars() {
const { ctx } = this
const items = await ctx.service.view.getItems()
await ctx.render('handlebars.hbs', {items})
}
async nunjunks() {
const { ctx } = this
const items = await ctx.service.view.getItems()
await ctx.render('nunjunks.nj', {items})
}
}
module.exports = HomeController
我们把数据放到了 Service 里面:
const { Service } = require('egg')
class ViewService extends Service {
getItems() {
return [
{ title: 'foo', id: 1 },
{ title: 'bar', id: 2 },
]
}
}
module.exports = ViewService
访问下面的地址可以查看不同模板引擎渲染出的结果:
GET http://localhost:7001/nunjunks
GET http://localhost:7001/handlebars
GET http://localhost:7001/ejs
你可能会问,ctx.render 方法是哪来的呢?没错,是由 egg-view 对
context 进行扩展而提供的,为 ctx 上下文对象增加了 render、renderView 和 renderString 三个方法,代码如下:
const ContextView = require('../../lib/context_view')
const VIEW = Symbol('Context#view')
module.exports = {
render(...args) {
return this.renderView(...args).then(body => {
this.body = body;
})
},
renderView(...args) {
return this.view.render(...args);
},
renderString(...args) {
return this.view.renderString(...args);
},
get view() {
if (this[VIEW]) return this[VIEW]
return this[VIEW] = new ContextView(this)
}
}
它内部最终会把调用转发给 ContextView 实例上的 render 方法,ContextView 是一个能够根据配置里面定义的 mapping,帮助我们找到对应渲染引擎的类。
参考链接:https://juejin.cn/post/6995063516470198279