What’s egg?
egg is a node.js backend web framework from Ali, based on koa wrappers with some conventions.
Why is it called egg?
egg has the meaning of nurturing, because egg is positioned as an enterprise web foundation framework, aiming to help developers nurture the framework for their own teams.
Which products are developed in egg?
Language Bird is developed in egg, with the following architecture:
Which companies use egg?
Boxmart, Turnaround Used Cars, PingWest, Xiaomi, 58 Tongcheng, etc. (Tech Stack Selection Reference Link)
Does egg support Typescript?
Although egg itself is written in JavaScript, egg applications can be written in Typescript by creating a project with the following command (see link):
$ npx egg-init --type=ts showcase
Will there be smart tips for writing egg in JavaScript?
Yes, by adding the following declaration to package.json, a typings directory will be dynamically generated in the project root containing the type declarations for the various models (refer to the link):
"egg": {
"declarations": true
}
What is the relationship between egg and koa?
koa is the base framework for egg, and egg is an enhancement to koa.
Do I need to know koa to learn egg?
It’s possible to get started with egg without knowing koa, but knowing koa will help you understand egg at a deeper level.
Create a project
We create an egg project using the base template and selecting a domestic mirror:
$ npm init egg --type=simple --registry=china
# yarn create egg --type=simple --registry=china
Explain the syntax of something like npm init egg
:
The npm@6 version introduces thenpm-init <initializer>
syntax, which is equivalent to thenpx create-<initializer>
command, while thenpx
command looks for an executable namedcreate-<initializer>
under the$PATH
path andnode_modules/.bin
path, and executes it if it is found, or installs it if it is not.
That is,npm init egg
looks for or downloads thecreate-egg
executable, and the create-egg package is an alias for the egg-init package, which is equivalent to a call toegg-init
.
Once created, the directory structure is as follows (ignoring the README file and the test directory):
├── app
│ ├── controller
│ │ └── home.js
│ └── router.js
├── config
│ ├── config.default.js
│ └── plugin.js
├── package.json
This is the minimalized egg project, after installing the dependencies with npm i
or yarn
, run the startup command:
$ npm run dev
[master] node version v14.15.1
[master] egg version 2.29.1
[master] agent_worker#1:23135 started (842ms)
[master] egg started on http://127.0.0.1:7001 (1690ms)
Open http://127.0.0.1:7001/
and you will see hi, egg
displayed on the page.
Catalog Conventions
The project created above is just a minimal structure. A typical egg project has the following directory structure:
This is agreed upon by the egg framework or built-in plug-ins, and is a best practice summarized by Ali. Although the framework also provides the ability for users to customize the directory structure, it is still recommended that you adopt Ali’s solution. In the next chapters, we will explain the role of the above agreed directories and files one by one.
Routing (Router)
A route defines the mapping between a URL and a controller, i.e., which controller should handle the URL that the user accesses. Let’s open app/router.js
and take a look:
module.exports = app => {
const { router, controller } = app
router.get('/', controller.home.index)
};
As you can see, the routing file exports a function that receives the app object as an argument and defines the mapping relationship via the following syntax:
router.verb('path-match', controllerAction)
where verb
is generally lowercase for HTTP verbs, for example:
- HEAD –
router.head
- OPTIONS –
router.options
- GET –
router.get
- PUT –
router.put
- POST –
router.post
- PATCH –
router.patch
DELETE –router.delete
orrouter.del
In addition to this, there is a special verb router.redirect
for redirection.
Instead, controllerAction
specifies a specific function within a file in the controller
directory via dot (-) syntax, for example:
controller.home.index
controller.v1.user.create
Here are some examples and their explanations:
module.exports = app => {
const { router, controller } = app router.get('/news', controller.news.index)
router.get('/user/:id/:name', controller.user.info)
router.get(/^\/package\/([\w-.]+\/[\w-.]+)$/, controller.package.detail)
}
In addition to creating routes using the verb approach, egg provides the following syntax for quickly generating CRUD routes:
router.resources('posts', '/posts', controller.posts)
The following routes are automatically generated:
HTTP method | Request Path | route name | Controller Functions |
---|---|---|---|
GET | /posts | posts | app.controller.posts.index |
GET | /posts/new | new_post | app.controller.posts.new |
GET | /posts/:id | post | app.controller.posts.show |
GET | /posts/:id/edit | edit_post | app.controller.posts.edit |
POST | /posts | posts | app.controller.posts.create |
PATCH | /posts/:id | post | app.controller.posts.update |
DELETE | /posts/:id | post | app.controller.posts.destroy |
You just need to implement the corresponding method in the controller. |
As the project gets bigger, there will be more and more route maps, and we may want to be able to split the route maps by file, and there are two ways to do this:
Manual introduction, i.e., write the routing files to theapp/router
directory, and then introduce these files inapp/router.js
. Sample code:// app/router.js module.exports = app => { require('./router/news')(app) require('./router/admin')(app) }; // app/router/news.js module.exports = app => { app.router.get('/news/list', app.controller.news.list) app.router.get('/news/detail', app.controller.news.detail) }; // app/router/admin.js module.exports = app => { app.router.get('/admin/user', app.controller.admin.user) app.router.get('/admin/log', app.controller.admin.log) };
Use the egg-router-plus plugin to automatically introduceapp/router/**/*.js
and provide namespace functionality:// app/router.js module.exports = app => { const subRouter = app.router.namespace('/sub') subRouter.get('/test', app.controller.sub.test) }
In addition to the HTTP verb, the Router provides a redirect method for internal redirection, for example:
module.exports = app => {
app.router.get('index', '/home/index', app.controller.home.index)
app.router.redirect('/', '/home/index', 302)
}
Middleware
The egg convention for a middleware is a separate file placed in the app/middleware
directory, which needs to export a common function that takes two arguments:
options: Configuration items for the middleware, the framework will pass inapp.config[${middlewareName}]
.
app: instance of the current Application.
We create a new middleware/slow.js
slow query middleware that prints logs when the request time exceeds the threshold we specify, with the code:
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}`)
}
}
}
It is then used in config.default.js
:
module.exports = {
middleware: [ 'slow' ],
slow: {
enable: true
},
}
The middleware configured here is globally enabled. There are two ways to use the middleware if you just want to use it for a specific route, e.g., if you want to use the middleware only for url requests starting with the /api
prefix:
Set the match or ignore attribute in theconfig.default.js
configuration:module.exports = { middleware: [ 'slow' ], slow: { threshold: 1, match: '/api' }, };
Introduced in the routing file
router.js
module.exports = app => { const { router, controller } = app router.get('/api/home', app.middleware.slow({ threshold: 1 }), controller.home.index) }
egg splits middleware into application-defined middleware ( app.config.appMiddleware
) and framework-default middleware ( app.config.coreMiddleware
), which we print and look at:
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)
}
The result:
// appMiddleware
[ 'slow' ]
// coreMiddleware
[
'meta',
'siteFile',
'notfound',
'static',
'bodyParser',
'overrideMethod',
'session',
'securities',
'i18n',
'eggLoaderTrace'
]
The coreMiddleware is the built-in middleware that egg helps us with, and it’s enabled by default, so if you don’t want to use it, you can turn it off through the configuration:
module.exports = {
i18n: {
enable: false
}
}
Controller
The Controller is responsible for parsing the user’s input, processing it and returning the appropriate results, a simple helloworld example:
const { Controller } = require('egg');
class HomeController extends Controller {
async index() {
const { ctx } = this;
ctx.body = 'hi, egg';
}
}
module.exports = HomeController;
Of course, the code in our real projects is not that simple. Typically, we do several things in the Controller:
- Receive, validate, and process HTTP request parameters
- Calling down to a Service to handle the business
- Responds to the user with results via HTTP
A real life case is as follows:
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 });
const res = await service.post.create(data);
ctx.body = { id: res.id };
ctx.status = 201;
}
}
module.exports = PostController;
Since Controllers are classes, you can encapsulate common methods by customizing the base class, for example:
// 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;
Then all Controllers inherit from this custom 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);
}
}
You can get the context object through this.ctx
in the Controller, which is convenient for getting and setting related parameters, for example:
ctx.query
: request parameters in URL (ignore duplicate keys)
ctx.quries
: request parameters in the URL (duplicate keys are put into an array)-
ctx.params
: Named parameters on the Router -
ctx.request.body
: the contents of the HTTP request body -
ctx.request.files
: Front-end uploaded file object -
ctx.getFileStream()
: Get the uploaded file stream
ctx.multipart()
: Access tomultipart/form-data
data-
ctx.cookies
: read and set cookies -
ctx.session
: read and set session
ctx.service.xxx
: Get an instance of the specified service object (lazy loading)-
ctx.status
: Setting Status Codes -
ctx.body
: Setting the response body -
ctx.set
: Setting the response header -
ctx.redirect(url)
: Redirection -
ctx.render(template)
: Rendering Templates
this.ctx
The context object is one of the most important objects in the egg and koa frameworks, and we need to be clear about what it does, but note that some properties are not directly hooked to the app.ctx
object, but instead proxy properties of the request or response object, which we can see with Object.keys(ctx)
:
[
'request', 'response', 'app', 'req', 'res', 'onerror', 'originalUrl', 'starttime', 'matched',
'_matchedRoute', '_matchedRouteName', 'captures', 'params', 'routerName', 'routerPath'
]
Service
Service is the implementation of specific business logic, a packaged Service can be used for multiple Controllers to call, and a Controller can also call multiple Services, although you can also write business logic in the Controller, but it is not recommended to do so, the code should be to keep the Controller logical Although you can write business logic in the Controller, it is not recommended. You should keep the Controller logic in the code simple and just play the role of a bridge.
The Controller can call any method on any Service. It’s worth noting that a Service is lazy-loaded, i.e., the framework instantiates it only when it’s accessed.
Typically, several things are done in a Service:
- Handling complex business logic
Calling databases or third-party services (e.g., GitHub information fetching, etc.)
A simple example of a Service that returns the results of a query from a database:
// 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;
It can be called directly in the 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;
}
}
Note that the Service file must be placed in the app/service
directory, which supports multiple levels of directories, and can be accessed by cascading through directory names when accessing it:
app/service/biz/user.js => ctx.service.biz.user
app/service/sync_user.js => ctx.service.syncUser
app/service/HackerNews.js => ctx.service.hackerNews
Functions inside the Service can be understood as the smallest unit of a specific business logic, and inside the Service you can also call other Services. It is worth noting that: Service is not a singleton, it is a request-level object, and the framework delays the instantiation of it the first time it accesses ctx.service.xx
in each request, so you can get the context of the current request in the Service through this.ctx. so the Service can get the context of the current request through this.ctx.
template rendering
The egg framework has built-in egg-view as a template solution, and supports a variety of template rendering, such as ejs, handlebars, nunjunks and other template engines, each template engine is introduced as a plugin, by default, all plugins will go to the app/view
directory to find the file, and then according to the suffix mapping defined in config\config.default.js
to choose a different template engine:
config.view = {
defaultExtension: '.nj',
defaultViewEngine: 'nunjucks',
mapping: {
'.nj': 'nunjucks',
'.hbs': 'handlebars',
'.ejs': 'ejs',
},
}
The configuration above indicates that when the file:
The nunjunks template engine is used when the suffix is.nj
.
Use the handlebars template engine when the suffix is.hbs
Use the ejs template engine when the suffix is.ejs
- Defaults to
.html
- Defaults to nunjunks when no template engine is specified.
Next we install the Template Engine plugin:
$ npm i egg-view-nunjucks egg-view-ejs egg-view-handlebars --save
# yarn add egg-view-nunjucks egg-view-ejs egg-view-handlebars
Then enable the plugin in 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',
}
Then add the app/view
directory and add a couple of files in there:
app/view
├── ejs.ejs
├── handlebars.hbs
└── nunjunks.nj
The codes are respectively:
<h1>ejs</h1>
<ul>
<% items.forEach(function(item){ %>
<li><%= item.title %></li>
<% }); %>
</ul>
<h1>handlebars</h1>
{{#each items}}
<li>{{title}}</li>
{{~/each}}
<h1>nunjunks</h1>
<ul>
{% for item in items %}
<li>{{ item.title }}</li>
{% endfor %}
</ul>
Then configure the route in the 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)
}
Next, you implement the logic of the 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
We put the data inside the Service:
const { Service } = require('egg')
class ViewService extends Service {
getItems() {
return [
{ title: 'foo', id: 1 },
{ title: 'bar', id: 2 },
]
}
}
module.exports = ViewService
Visit the address below to see the results rendered by the different template engines:
GET http://localhost:7001/nunjunks
GET http://localhost:7001/handlebars
GET http://localhost:7001/ejs
Where did the ctx.render method come from, you may ask? That’s right, it’s provided by egg-view’s extension to context, which adds the methods render
, renderView
, and renderString
to the ctx context object, with the following code:
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)
}
}
Internally, it will eventually forward the call to the render method on the ContextView instance, which is a class that can help us find the corresponding rendering engine based on the mapping defined in the configuration.
When we explained template rendering in the previous lesson, we already knew how to use plugins, i.e., we just need to declare them in the config/plugin.js
of the application or framework:
exports.myPlugin = {
enable: true,
package: 'egg-myPlugin',
path: path.join(__dirname, '../lib/plugin/egg-mysql'),
env: ['local', 'unittest', 'prod'],
}
After opening the plugin, you can use the functions provided by the plugin:
app.myPlugin.xxx()
If the plugin contains configuration that requires user customization, this can be specified at config.default.js
, for example:
exports.myPlugin = {
hello: 'world'
}
A plugin is actually a ‘mini application’ that contains Service, Middleware, Configuration, Framework Extensions, etc., but does not have an independent Router and Controller, nor can it define its own plugin.js
.
In the development of essential to connect to the database, the most practical plug-in is the database integration plug-ins.
Integrating MongoDB
First, make sure that the MongoDB database is installed and started on your computer. If you have a Mac computer, you can use the following commands to quickly install and start it:
$ brew install mongodb-community
$ brew services start mongodb/brew/mongodb-community
# mongod --config /usr/local/etc/mongod.conf
Then install the egg-mongoose plugin:
$ npm i egg-mongoose
# yarn add egg-mongoose
Enable the plugin in config/plugin.js
:
exports.mongoose = {
enable: true,
package: 'egg-mongoose',
}
Define connection parameters in config/config.default.js
:
config.mongoose = {
client: {
url: 'mongodb://127.0.0.1/example',
options: {}
}
}
The model is then defined in model/user.js
:
module.exports = app => {
const mongoose = app.mongoose
const UserSchema = new mongoose.Schema(
{
username: {type: String, required: true, unique: true},
password: {type: String, required: true},
},
{ timestamps: true }
)
return mongoose.model('user', UserSchema)
}
Call mongoose’s methods in the controller:
const {Controller} = require('egg')
class UserController extends Controller {
async index() {
const {ctx} = this
ctx.body = await ctx.model.User.find({})
}
async show() {
const {ctx} = this
ctx.body = await ctx.model.User.findById(ctx.params.id)
}
async create() {
const {ctx} = this
ctx.body = await ctx.model.User.create(ctx.request.body)
}
async update() {
const {ctx} = this
ctx.body = await ctx.model.User.findByIdAndUpdate(ctx.params.id, ctx.request.body)
}
async destroy() {
const {ctx} = this
ctx.body = await ctx.model.User.findByIdAndRemove(ctx.params.id)
}
}
module.exports = UserController
Finally, configure the RESTful route mapping:
module.exports = app => {
const {router, controller} = app
router.resources('users', '/users', controller.user)
}
Integration with MySQL
First make sure that the MySQL database is installed on your computer, or if you have a Mac, you can quickly install and start it with the following commands:
$ brew install mysql
$ brew services start mysql
# mysql.server start
$ mysql_secure_installation
There is an official egg-mysql plugin for connecting to MySQL databases, which is very simple to use:
$ npm i egg-mysql
# yarn add egg-mysql
Enable the plugin in config/plugin.js
:
exports.mysql = {
enable: true,
package: 'egg-mysql',
}
Define connection parameters in config/config.default.js
:
config.mysql = {
client: {
host: 'localhost',
port: '3306',
user: 'root',
password: 'root',
database: 'cms',
}
}
Then you can get the mysql object in the Controller or Service’s app.mysql
, for example:
class UserService extends Service {
async find(uid) {
const user = await this.app.mysql.get('users', { id: 11 });
return { user }
}
}
If an error is reported on startup:
ERROR 5954 nodejs.ER_NOT_SUPPORTED_AUTH_MODEError: ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by server; consider upgrading MySQL client
It is because you are using MySQL 8.x version, and egg-mysql depends on ali-rds package, which is Ali’s own package, and it depends on mysql package, which is deprecated and does not support caching_sha2_password encryption method. You can run the following command in MySQL workbench to solve the problem:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'
flush privileges
But a better way to integrate MySQL is to use an ORM framework to help us manage the code in the data layer. sequelize is the most popular ORM framework today, and it supports multiple data sources such as MySQL, PostgreSQL, SQLite, and MSSQL. Next, we’re going to use sequelize to connect to a MySQL database. dependency:
npm install egg-sequelize mysql2 --save
yarn add egg-sequelize mysql2
Then turn on the egg-sequelize plugin at config/plugin.js
:
exports.sequelize = {
enable: true,
package: 'egg-sequelize',
}
Also write the sequelize configuration in config/config.default.js
config.sequelize = {
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
database: 'example',
}
Then create the books table in the egg_example library:
CREATE TABLE `books` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`name` varchar(30) DEFAULT NULL COMMENT 'book name',
`created_at` datetime DEFAULT NULL COMMENT 'created time',
`updated_at` datetime DEFAULT NULL COMMENT 'updated time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='book';
Create the model/book.js
file with the code:
module.exports = app => {
const { STRING, INTEGER } = app.Sequelize
const Book = app.model.define('book', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
name: STRING(30),
})
return Book
}
Add controller/book.js
controller:
const Controller = require('egg').Controller
class BookController extends Controller {
async index() {
const ctx = this.ctx
ctx.body = await ctx.model.Book.findAll({})
}
async show() {
const ctx = this.ctx
ctx.body = await ctx.model.Book.findByPk(+ctx.params.id)
}
async create() {
const ctx = this.ctx
ctx.body = await ctx.model.Book.create(ctx.request.body)
}
async update() {
const ctx = this.ctx
const book = await ctx.model.Book.findByPk(+ctx.params.id)
if (!book) return (ctx.status = 404)
await book.update(ctx.request.body)
ctx.body = book
}
async destroy() {
const ctx = this.ctx
const book = await ctx.model.Book.findByPk(+ctx.params.id)
if (!book) return (ctx.status = 404)
await book.destroy()
ctx.body = book
}
}
module.exports = BookController
Finally, configure the RESTful route mapping:
module.exports = app => {
const {router, controller} = app
router.resources('books', '/books', controller.book)
}
Custom Plug-ins
Mastered the use of plug-ins, the next step is to talk about how to write their own plug-ins, first of all, according to the plug-in scaffolding template to create a plug-in project:
npm init egg --type=plugin
# yarn create egg --type=plugin
The default directory structure is:
├── config
│ └── config.default.js
├── package.json
Plugins do not have separate routers and controllers, and need to specify plugin-specific information in the eggPlugin
node in package.json
, for example:
{
"eggPlugin": {
"name": "myPlugin",
"dependencies": [ "registry" ],
"optionalDependencies": [ "vip" ],
"env": [ "local", "test", "unittest", "prod" ]
}
}
The meaning of the above fields is:
name
– Plugin name, the name of the dependent plugin will be specified when configuring the dependency.
dependencies
– List of plugins on which the current plugin is strongly dependent (if the dependent plugin is not found, the application launch fails).
optionalDependencies
– The list of optional dependent plugins for the current plugin (if the dependent plugin is not enabled, it will only warn and will not affect the application startup).
env
– Specify that the current plug-in is enabled only in certain operating environments
What can be done inside that plugin?
Extending built-in objects: Define files such asrequest.js
andresponse.js
in theapp/extend/
directory, just like the application.
For example, the egg-bcrypt library simply extendsextend.js
:
Just callctx.genHash(plainText)
andctx.compare(plainText, hash)
directly in your project.
Insert custom middleware: write middleware inapp/middleware
and use it inapp.js
For example, the egg-cors library defines acors.js
middleware that uses koa-cors as is.Configure directly in
config/config.default.js
, for example:exports.cors = { origin: '*', allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH' }
Do some initialization at startup: add synchronous or asynchronous initialization code toapp.js
For example, the egg-elasticsearch code:
It just establishes an ES connection before starting, and thebeforeStart
method can also define asynchronous startup logic. Although the code above is synchronous, i.e., it doesn’t matter whether it’s wrapped inbeforeStart
or not, it can be wrapped in an async function if it has asynchronous logic.
Setting up a timed task: Add a timed task to theapp/schedule/
directory. Timed tasks are covered in detail in the next section.
timed task
A complex business scenario will inevitably have the need for timed tasks, for example:
- Check if a user has a birthday every day and send birthday wishes automatically
- Backup your database every day to prevent data loss due to improper operation.
- Delete temporary files once a week to free up disk space
- Regularly fetch data from remote interface and update local cache
The egg framework provides a timed task feature, in the app/schedule
directory, each file is an independent timed task, you can configure the properties of the timed task and the method to be executed, for example, to create a update_cache.js
update cache task, executed every minute:
const Subscription = require('egg').Subscription
class UpdateCache extends Subscription {
static get schedule() {
return {
interval: '1m',
type: 'all',
}
}
async subscribe() {
const res = await this.ctx.curl('http://www.api.com/cache', {
dataType: 'json',
})
this.ctx.app.cache = res.data
}
}
module.exports = UpdateCache
That is, egg gets the configuration of the timed task from the static accessor property schedule and executes the subscribe method accordingly. The timing of the execution of the task can be specified as either interval or cron:
The interval can be a number or a string, if it is a number it means milliseconds, for example5000
is 5 seconds, if it is a character type, it will be converted to milliseconds by the ms package, for example 5 seconds can be directly written as5s
.
cron expressions are parsed by cron-parser with the syntax:* * * * * * ┬ ┬ ┬ ┬ ┬ ┬ │ │ │ │ │ | │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) │ │ │ │ └───── month (1 - 12) │ │ │ └────────── day of month (1 - 31) │ │ └─────────────── hour (0 - 23) │ └──────────────────── minute (0 - 59) └───────────────────────── second (0 - 59, optional)
There are two types of execution tasks:
worker
Type: only one worker will execute this timed task (randomly selected)
all
Type: Each worker executes this timed task
Which type to use depends on the specific business, for example, the task of updating the cache is definitely choose all, while the task of backing up the database choose worker is enough, otherwise it will repeat the backup.
There are some scenarios where we may need to manually execute a timed task, such as the initialization task at application startup, which can be run via app.runSchedule(schedulePath)
. app.runSchedule
Accept a timed task file path (relative path in app/schedule
directory or full absolute path), code in app.js
as:
module.exports = app => {
app.beforeStart(async () => {
await app.runSchedule('update_cache')
})
}
error handling
In the development environment will provide a very friendly visual interface to help developers locate the problem, for example, when we replace model.User
with lowercase call this method:
It is easy for developers to debug quickly by going directly to the line where the error is located. But don’t worry, in a production environment, egg does not expose the error stack to the user, but instead returns the following error message:
Internal Server Error, real status: 500
If our project is front-end and back-end separated and all returns are in JSON format, we can configure the following in config/plugin.js
:
module.exports = {
onerror: {
accepts: () => 'json',
},
};
Then the error call stack is returned directly in JSON format:
{
"message": "Cannot read property 'find' of undefined",
"stack": "TypeError: Cannot read property 'find' of undefined\n at UserController.index (/Users/keliq/code/egg-project/app/controller/user.js:7:37)",
"name": "TypeError",
"status": 500
}
The accepts function is an implementation of the idea of content negotiation, that is, letting the user decide what format to return in, which is a great example of egg’s flexibility. For example, if you want to return JSON when content-type
is “ and HTML in other cases, you can write it this way:
module.exports = {
onerror: {
accepts: (ctx) => {
if (ctx.get('content-type') === 'application/json') return 'json';
return 'html';
}
},
};
But we can also customize the error in config/config.default.js
:
module.exports = {
onerror: {
errorPageUrl: '/public/error.html',
},
};
At this point, errors from the production environment are redirected to that path, followed by the parameter ?real_status=500
. In fact, egg’s errors are handled by the built-in plugin egg-onerror, which catches any exceptions thrown by all of a request’s handlers (Middleware, Controller, Service) and automatically returns different types of errors depending on the type of request it is trying to get:
module.exports = {
onerror: {
all(err, ctx) {
ctx.body = 'error'
ctx.status = 500
},
html(err, ctx) {
ctx.body = '<h3>error</h3>'
ctx.status = 500
},
json(err, ctx) { // json hander
ctx.body = {message: 'error'}
ctx.status = 500
},
},
}
One thing to note, though: the framework does not treat the 404 status returned by the server as an exception. egg will respond as follows if it finds a status code of 404 and no body:
When the request is JSON, JSON is returned:{ "message": "Not Found" }
When the request is HTML, HTML is returned:<h1>404 Not Found</h1>
Many factories write their own 404 pages, so if you also have this need, you can also write an HTML yourself and specify it in config/config.default.js
:
module.exports = {
notfound: {
pageUrl: '/404.html',
}
}
However, the above only redirects the 404 response of the default HTML request to the specified page. If you want to fully customize the server’s response on 404, including customizing the JSON return, as you would customize the exception handling, you just need to add a middleware/notfound_handler.js
middleware:
module.exports = () => {
return async function (ctx, next) {
await next()
if (ctx.status === 404 && !ctx.body) {
ctx.body = ctx.acceptJSON ? { error: 'Not Found' } : '<h1>Page Not Found</h1>'
}
}
}
Of course, don’t forget to introduce that middleware in config/config.default.js
:
config.middleware = ['notfoundHandler']
life cycle
The following lifecycle hooks are provided for easy invocation during egg startup:
The configuration file is about to be loaded, which is the last time to dynamically modify the configuration (configWillLoad
)- Configuration file loading complete (
configDidLoad
) - File loading complete (
didLoad
) - Plug-in startup complete (
willReady
) - Worker ready (
didReady
) - Application startup complete (
serverDidReady
) - The application is about to close (
beforeClose
)
Just create app.js
in the project root directory, add and export a class:
class AppBootHook {
constructor(app) {
this.app = app
}
configWillLoad() {
}
configDidLoad() {
}
async didLoad() {
}
async willReady() {
}
async didReady() {
}
async serverDidReady() {
}
async beforeClose() {
/
}
}
module.exports = AppBootHook
Framework extensions
The egg framework provides the following extension points:
Application: Koa’s global application object (application level), there is only one globally, it is created when the application is launched
Context: Koa’s request context object (request level), each request generates a Context instance
Request: Koa’s Request object (request level), provides request-related properties and methods.
Response: Koa’s Response object (at the request level), providing response-related properties and methods.
Helper: Used to provide some useful utility functions.
That is, developers can extend the above framework built-in objects in any way they want. The extension is written as:
const BAR = Symbol('bar')
module.exports = {
foo(param) {},
get bar() {
if (!this[BAR]) {
this[BAR] = this.get('x-bar')
}
return this[BAR]
},
}
The this
inside the extension point method refers to the extension point object itself. The essence of extension is to merge a user-defined object into the prototype of the Koa extension point object, i.e.:
Extending Application is to merge the object defined inapp/extend/application.js
with the prototype object of Koa Application, and when the application starts, it will generateapp
object based on the extended prototype, which can be accessed throughctx.app.xxx
:
Extending a Context means merging the objects defined inapp/extend/context.js
with the prototype objects of the Koa Context, and generating ctx objects based on the extended prototype when processing requests.
Extending Request/Response means merging the objects defined inapp/extend/<request|response>.js
with the built-in prototype objects ofrequest
orresponse
, and generatingrequest
orresponse
objects based on the extended prototype when processing requests.
Extending Helper is to merge the object defined inapp/extend/helper.js
with the built-in prototype object ofhelper
, and generatehelper
object based on the extended prototype when processing requests.
Customized frames
The most powerful feature of egg is that it allows teams to customize the framework, which means that you can encapsulate the upper layer of the framework based on egg and only need to extend two classes:
Application: Application is instantiated when the App Worker starts up, single instance.
Agent: Agent Worker instantiates Agent when it starts, single instance
Customize the frame steps:
npm init egg --type=framework --registry=china
# yarn create egg --type=framework --registry=china
The following directory structure is generated:
├── app
│ ├── extend
│ │ ├── application.js
│ │ └── context.js
│ └── service
│ └── test.js
├── config
│ ├── config.default.js
│ └── plugin.js
├── index.js
├── lib
│ └── framework.js
├── package.json
As you can see, other than the extra lib directory, the structure is not any different from a normal egg project, so let’s take a look at the code in lib/framework.js
:
const path = require('path')
const egg = require('egg')
const EGG_PATH = Symbol.for('egg#eggPath')
class Application extends egg.Application {
get [EGG_PATH]() {
return path.dirname(__dirname)
}
}
class Agent extends egg.Agent {
get [EGG_PATH]() {
return path.dirname(__dirname)
}
}
module.exports = Object.assign(egg, {
Application,
Agent,
})
As you can see, you just customize two classes, Application and Agent, and mount them on the egg object. These two customized classes assign the accessor property Symbol.for('egg#eggPath')
to path.dirname(__dirname)
, which is the root directory of the framework. In order to test the custom framework locally, let’s go to the framework project (let’s say it’s called my-framework) and run it:
npm link # yarn link
Then go to the egg project and run it:
npm link my-framework
Finally, add the following code to the package.json of your egg project:
"egg": {
"framework": "my-framework"
},
Custom framework implementation principle is based on class inheritance, each layer of the framework must inherit the previous layer of the framework and specify the eggPath, and then traverse the prototype chain to get the framework of each layer of the path, the prototype chain below the framework of the higher priority, for example: department framework (department) > enterprise framework (enterprise) > Egg
const Application = require('egg').Application
class Enterprise extends Application {
get [EGG_PATH]() {
return '/path/to/enterprise'
}
}
const Application = require('enterprise').Application
class Department extends Application {
get [EGG_PATH]() {
return '/path/to/department'
}
}
The benefits of the timed framework is a layer of business logic reuse, different departmental frameworks directly with the company’s framework inside the written business logic, and then supplement their own business logic. Although plug-ins can also achieve the effect of code reuse, but the business logic is not good enough to encapsulate into a plug-in, encapsulated into a framework will be better, the following is the difference between the application, the framework and the plug-in:
package.json | ✅ | ✅ | ✅ |
config/plugin.{env}.js | ✅ | ✅ | ❌ |
config/config.{env}.js | ✅ | ✅ | ✅ |
app/extend/application.js | ✅ | ✅ | ✅ |
app/extend/request.js | ✅ | ✅ | ✅ |
app/extend/response.js | ✅ | ✅ | ✅ |
app/extend/context.js | ✅ | ✅ | ✅ |
app/extend/helper.js | ✅ | ✅ | ✅ |
agent.js | ✅ | ✅ | ✅ |
app.js | ✅ | ✅ | ✅ |
app/service | ✅ | ✅ | ✅ |
app/middleware | ✅ | ✅ | ✅ |
app/controller | ✅ | ❌ | ❌ |
app/router.js | ✅ | ❌ | ❌ |
In addition to using Symbol.for('egg#eggPath')
to specify the path to the current framework for inheritance, you can also customize the loader by simply providing the Symbol.for('egg#loader')
accessor property and customizing the AppWorkerLoader:
const path = require('path')
const egg = require('egg')
const EGG_PATH = Symbol.for('egg#eggPath')
const EGG_LOADER = Symbol.for('egg#loader')
class MyAppWorkerLoader extends egg.AppWorkerLoader {
}
class Application extends egg.Application {
get [EGG_PATH]() {
return path.dirname(__dirname)
}
get [EGG_LOADER]() {
return MyAppWorkerLoader
}
}
AppWorkerLoader inherits from egg-core’s EggLoader, which is a base class that provides some built-in methods based on the rules of file loading; it doesn’t call these methods itself, but rather is called by the inherited classes.
- loadPlugin()
- loadConfig()
- loadAgentExtend()
- loadApplicationExtend()
- loadRequestExtend()
- loadResponseExtend()
- loadContextExtend()
- loadHelperExtend()
- loadCustomAgent()
- loadCustomApp()
- loadService()
- loadMiddleware()
- loadController()
- loadRouter()
That means we can override these methods in our custom AppWorkerLoader:
const {AppWorkerLoader} = require('egg')
const {EggLoader} = require('egg-core')
The final output is:
From the output you can see the loading order by default. In this way, the loading logic of the framework can be completely left to the developer, how to load Controller, Service, Router and so on.