对上篇文章回顾下,上篇讲到了
服务(Service)
插件
定时任务
框架扩展
启动自定义
应用部署
日志
HttpClient
通过 ctx.cookies,我们可以在 controller 中便捷、安全的设置和读取 Cookie。
classHomeControllerextendsController{
asyncadd() {
constctx =this.ctx;
letcount = ctx.cookies.get('count');
count = count ?Number(count) :0;
ctx.cookies.set('count', ++count);
ctx.body = count;
}
asyncremove() {
constctx =this.ctx;
ctx.cookies.set('count',null);
ctx.status =204;
}
}
ctx.cookies.set(key, value, options)
设置 Cookie 其实是通过在 HTTP 响应中设置 set-cookie 头完成的,每一个 set-cookie 都会让浏览器在 Cookie 中存一个键值对。在设置 Cookie 值的同时,协议还支持许多参数来配置这个 Cookie 的传输、存储和权限。
{Number} maxAge: 设置这个键值对在浏览器的最长保存时间。是一个从服务器当前时刻开始的毫秒数。
{Date} expires: 设置这个键值对的失效时间,如果设置了 maxAge,expires 将会被覆盖。如果 maxAge 和 expires 都没设置,Cookie 将会在浏览器的会话失效(一般是关闭浏览器时)的时候失效。
{String} path: 设置键值对生效的 URL 路径,默认设置在根路径上(/),也就是当前域名下的所有 URL 都可以访问这个 Cookie。
{String} domain: 设置键值对生效的域名,默认没有配置,可以配置成只在指定域名才能访问。
{Boolean} httpOnly: 设置键值对是否可以被 js 访问,默认为 true,不允许被 js 访问。
{Boolean} secure: 设置键值对只在 HTTPS 连接上传输,框架会帮我们判断当前是否在 HTTPS 连接上自动设置 secure 的值。
除了这些属性之外,框架另外扩展了 3 个参数的支持:
{Boolean} overwrite:设置 key 相同的键值对如何处理,如果设置为 true,则后设置的值会覆盖前面设置的,否则将会发送两个 set-cookie 响应头。
{Boolean} signed:设置是否对 Cookie 进行签名,如果设置为 true,则设置键值对的时候会同时对这个键值对的值进行签名,后面取的时候做校验,可以防止前端对这个值进行篡改。默认为 true。
{Boolean} encrypt:设置是否对 Cookie 进行加密,如果设置为 true,则在发送 Cookie 前会对这个键值对的值进行加密,客户端无法读取到 Cookie 的明文值。默认为 false。
Cookie 是加签不加密的,浏览器可以看到明文,js 不能访问,不能被客户端(手工)篡改。
如果想要 Cookie 在浏览器端可以被 js 访问并修改:
ctx.cookies.set(key, value, {
httpOnly:false,
signed:false,
});
由于浏览器和其他客户端实现的不确定性,为了保证 Cookie 可以写入成功,建议 value 通过 base64 编码或者其他形式 encode 之后再写入。
由于浏览器对 Cookie 有长度限制限制,所以尽量不要设置太长的 Cookie。一般来说不要超过 4093 bytes。当设置的 Cookie value 大于这个值时,框架会打印一条警告日志。
ctx.cookies.get(key, options)
上面在设置 Cookie 的时候,我们可以设置 options.signed 和 options.encrypt 来对 Cookie 进行签名或加密,因此对应的在获取 Cookie 的时候也要传相匹配的选项。
如果设置的时候指定为 signed,获取时未指定,则不会在获取时对取到的值做验签,导致可能被客户端篡改。
如果设置的时候指定为 encrypt,获取时未指定,则无法获取到真实的值,而是加密过后的密文。
Cookie 秘钥
由于我们在 Cookie 中需要用到加解密和验签,所以需要配置一个秘钥供加密使用。在 config/config.default.js 中
module.exports = {
keys:'key1,key2',
};
keys 配置成一个字符串,可以按照逗号分隔配置多个 key。Cookie 在使用这个配置进行加解密时:
加密和加签时只会使用第一个秘钥。
解密和验签时会遍历 keys 进行解密。
如果我们想要更新 Cookie 的秘钥,但是又不希望之前设置到用户浏览器上的 Cookie 失效,可以将新的秘钥配置到 keys 最前面,等过一段时间之后再删去不需要的秘钥即可。
Cookie 在 Web 应用中经常承担标识请求方身份的功能,所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别。
框架内置了 Session 插件,给我们提供了 ctx.session 来访问或者修改当前用户 Session 。
classHomeControllerextendsController{
asyncfetchPosts() {
constctx =this.ctx;
// 获取 Session 上的内容
constuserId = ctx.session.userId;
constposts =awaitctx.service.post.fetch(userId);
// 修改 Session 的值
ctx.session.visited = ctx.session.visited ? (ctx.session.visited +1) :1;
ctx.body = {
success:true,
posts,
};
}
}
Session 的使用方法非常直观,直接读取它或者修改它就可以了,如果要删除它,直接将它赋值为 null:
ctx.session =null;
需要 特别注意 的是:设置 session 属性时需要避免以下几种情况(会造成字段丢失,详见 koa-session 源码)
不要以 _ 开头
不能为 isNew
// ❌ 错误的用法
ctx.session._visited =1;// --> 该字段会在下一次请求时丢失
ctx.session.isNew ='HeHe';// --> 为内部关键字, 不应该去更改
// ✔️ 正确的用法
ctx.session.visited =1;// --> 此处没有问题
Session 的实现是基于 Cookie 的,默认配置下,用户 Session 的内容加密后直接存储在 Cookie 中的一个字段中,用户每次请求我们网站的时候都会带上这个 Cookie,我们在服务端解密后使用。Session 的默认配置如下:
exports.session = {
key:'EGG_SESS',
maxAge:24*3600*1000,// 1 天
httpOnly:true,
encrypt:true,
};
可以看到这些参数除了 key 都是 Cookie 的参数,key 代表了存储 Session 的 Cookie 键值对的 key 是什么。在默认的配置下,存放 Session 的 Cookie 将会加密存储、不可被前端 js 访问,这样可以保证用户的 Session 是安全的。
扩展存储
Session 默认存放在 Cookie 中,但是如果我们的 Session 对象过于庞大,就会带来一些额外的问题:
前面提到,浏览器通常都有限制最大的 Cookie 长度,当设置的 Session 过大时,浏览器可能拒绝保存。
Cookie 在每次请求时都会带上,当 Session 过大时,每次请求都要额外带上庞大的 Cookie 信息。
我们只需要设置 app.sessionStore 即可将 Session 存储到指定的存储中。
// app.js
module.exports = app => {
app.sessionStore = {
// support promise / async
asyncget (key) {
// return value;
},
asyncset (key, value, maxAge) {
// set key to store
},
asyncdestroy (key) {
// destroy key
},
};
};
sessionStore 的实现我们也可以封装到插件中,例如 egg-session-redis 就提供了将 Session 存储到 redis 中的能力,在应用层,我们只需要引入 egg-redis 和 egg-session-redis 插件即可。
// plugin.js
exports.redis = {
enable:true,
package:'egg-redis',
};
exports.sessionRedis = {
enable:true,
package:'egg-session-redis',
};
一旦选择了将 Session 存入到外部存储中,就意味着系统将强依赖于这个外部存储,当它挂了的时候,我们就完全无法使用 Session 相关的功能了。因此我们更推荐大家只将必要的信息存储在 Session 中,保持 Session 的精简并使用默认的 Cookie 存储,用户级别的缓存不要存储在 Session 中。
Session 实践
修改用户 Session 失效时间
虽然在 Session 的配置中有一项是 maxAge,但是它只能全局设置 Session 的有效期,我们经常可以在一些网站的登陆页上看到有 记住我 的选项框,勾选之后可以让登陆用户的 Session 有效期更长。这种针对特定用户的 Session 有效时间设置我们可以通过 ctx.session.maxAge= 来实现。
constms =require('ms');
classUserControllerextendsController{
asynclogin() {
constctx =this.ctx;
const{ username, password, rememberMe } = ctx.request.body;
constuser =awaitctx.loginAndGetUser(username, password);
// 设置 Session
ctx.session.user = user;
// 如果用户勾选了 `记住我`,设置 30 天的过期时间
if(rememberMe) ctx.session.maxAge = ms('30d');
}
}
延长用户 Session 有效期
默认情况下,当用户请求没有导致 Session 被修改时,框架都不会延长 Session 的有效期,但是在有些场景下,我们希望用户如果长时间都在访问我们的站点,则延长他们的 Session 有效期,不让用户退出登录态。
// config/config.default.js
module.exports = {
session: {
renew:true,
},
};
onerror 插件的配置中支持 errorPageUrl 属性,当配置了 errorPageUrl 时,一旦用户请求线上应用的 HTML 页面异常,就会重定向到这个地址。
在 config/config.default.js 中 先配置静态文件地址
// config/config.default.js
module.exports = {
static: {
prefix:'/',
dir: path.join(appInfo.baseDir,'app/public'),
},
};
// config/config.default.js
module.exports = {
onerror: {
// 线上页面发生异常时,重定向到这个页面上
errorPageUrl:'/50x.html',
},
};
// config/config.default.js
module.exports = {
onerror: {
all(err, ctx) {
// 在此处定义针对所有响应类型的错误处理方法
// 注意,定义了 config.all 之后,其他错误处理方法不会再生效
ctx.body ='error';
ctx.status =500;
},
html(err, ctx) {
// html hander
ctx.body ='<h3>error</h3>';
ctx.status =500;
},
json(err, ctx) {
// json hander
ctx.body = {message:'error'};
ctx.status =500;
},
jsonp(err, ctx) {
// 一般来说,不需要特殊针对 jsonp 进行错误定义,jsonp 的错误处理会自动调用 json 错误处理,并包装成 jsonp 的响应格式
},
},
};
框架并不会将服务端返回的 404 状态当做异常来处理,但是框架提供了当响应为 404 且没有返回 body 时的默认响应。
当请求被框架判定为需要 JSON 格式的响应时,会返回一段 JSON:
{"message":"Not Found"}
当请求被框架判定为需要 HTML 格式的响应时,会返回一段 HTML:
<h1>404Not Found</h1>
框架支持通过配置,将默认的 HTML 请求的 404 响应重定向到指定的页面。
// config/config.default.js
module.exports = {
notfound: {
pageUrl:'/404.html',
},
};
自定义 404 响应
在一些场景下,我们需要自定义服务器 404 时的响应,和自定义异常处理一样,我们也只需要加入一个中间件即可对 404 做统一处理:
// app/middleware/notfound_handler.js
module.exports = () => {
returnasyncfunctionnotFoundHandler(ctx, next){
awaitnext();
if(ctx.status ===404&& !ctx.body) {
if(ctx.acceptJSON) {
ctx.body = {error:'Not Found'};
}else{
ctx.body ='<h1>Page Not Found</h1>';
}
}
};
};
在配置中引入中间件:
// config/config.default.js
module.exports = {
middleware: ['notfoundHandler'],
};
框架提供了 egg-mysql 插件来访问 MySQL 数据库。这个插件既可以访问普通的 MySQL 数据库,也可以访问基于 MySQL 协议的在线数据库服务
安装与配置
npm i --save egg-mysql
开启插件:
// config/plugin.js
exports.mysql = {
enable:true,
package:'egg-mysql',
};
在 config/config.${env}.js 配置各个环境的数据库连接信息。
单数据源
如果我们的应用只需要访问一个 MySQL 数据库实例,可以如下配置:
// config/config.${env}.js
exports.mysql = {
// 单数据库信息配置
client: {
// host
host:'mysql.com',
// 端口号
port:'3306',
// 用户名
user:'test_user',
// 密码
password:'test_password',
// 数据库名
database:'test',
},
// 是否加载到 app 上,默认开启
app:true,
// 是否加载到 agent 上,默认关闭
agent:false,
};
使用方式:
awaitapp.mysql.query(sql, values);// 单实例可以直接通过 app.mysql 访问
多数据源
exports.mysql = {
clients: {
// clientId, 获取client实例,需要通过 app.mysql.get('clientId') 获取
db1: {
// host
host:'mysql.com',
// 端口号
port:'3306',
// 用户名
user:'test_user',
// 密码
password:'test_password',
// 数据库名
database:'test',
},
db2: {
// host
host:'mysql2.com',
// 端口号
port:'3307',
// 用户名
user:'test_user',
// 密码
password:'test_password',
// 数据库名
database:'test',
},
// ...
},
// 所有数据库配置的默认值
default: {
},
// 是否加载到 app 上,默认开启
app:true,
// 是否加载到 agent 上,默认关闭
agent:false,
};
使用方式:
constclient1 = app.mysql.get('db1');
awaitclient1.query(sql, values);
constclient2 = app.mysql.get('db2');
awaitclient2.query(sql, values);
由于对 MySQL 数据库的访问操作属于 Web 层中的数据处理层,因此我们强烈建议将这部分代码放在 Service 层中维护。
// app/service/user.js
classUserServiceextendsService{
asyncfind(uid) {
// 假如 我们拿到用户 id 从数据库获取用户详细信息
constuser =awaitthis.app.mysql.get('users', {id:11});
return{ user };
}
}
// app/controller/user.js
classUserControllerextendsController{
asyncinfo() {
constctx =this.ctx;
constuserId = ctx.params.id;
constuser =awaitctx.service.user.find(userId);
ctx.body = user;
}
}
Create
// 插入
constresult =awaitthis.app.mysql.insert('posts', {title:'Hello World'});// 在 post 表中,插入 title 为 Hello World 的记录
=> INSERT INTO`posts`(`title`) VALUES('Hello World');
console.log(result);
=>
{
fieldCount:0,
affectedRows:1,
insertId:3710,
serverStatus:2,
warningCount:2,
message:'',
protocol41:true,
changedRows:0
}
// 判断插入成功
constinsertSuccess = result.affectedRows ===1;
Read
可以直接使用 get 方法或 select 方法获取一条或多条记录。select 方法支持条件查询与结果的定制。
constpost =awaitthis.app.mysql.get('posts', {id:12});
=> SELECT * FROM`posts`WHERE`id`=12LIMIT0,1;
查询全表
constresults =awaitthis.app.mysql.select('posts');
=> SELECT * FROM`posts`;
条件查询和结果定制
where 查询条件 { status: 'draft', author: ['author1', 'author2'] }
columns 查询的列名 ['author', 'title']
orders 排序方式 [['created_at','desc'], ['id','desc']]
limit 10 查询条数
offset 0 偏移量
constresults =awaitthis.app.mysql.select('posts', {// 搜索 post 表
where: {status:'draft',author: ['author1','author2'] },// WHERE 条件
columns: ['author','title'],// 要查询的表字段
orders: [['created_at','desc'], ['id','desc']],// 排序方式
limit:10,// 返回数据量
offset:0,// 数据偏移量
});
=> SELECT`author`,`title`FROM`posts`
WHERE`status`='draft'AND`author`IN('author1','author2')
ORDER BY`created_at`DESC,`id`DESC LIMIT0,10;
Update
// 修改数据,将会根据主键 ID 查找,并更新
constrow = {
id:123,
name:'fengmk2',
otherField:'other field value',// any other fields u want to update
modifiedAt:this.app.mysql.literals.now,// `now()` on db server
};
constresult =awaitthis.app.mysql.update('posts', row);// 更新 posts 表中的记录
=> UPDATE`posts`SET`name`='fengmk2',`modifiedAt`= NOW() WHERE id =123;
// 判断更新成功
constupdateSuccess = result.affectedRows ===1;
// 如果主键是自定义的 ID 名称,如 custom_id,则需要在 `where` 里面配置
constrow = {
name:'fengmk2',
otherField:'other field value',// any other fields u want to update
modifiedAt:this.app.mysql.literals.now,// `now()` on db server
};
constoptions = {
where: {
custom_id:456
}
};
constresult =awaitthis.app.mysql.update('posts', row, options);// 更新 posts 表中的记录
=> UPDATE`posts`SET`name`='fengmk2',`modifiedAt`= NOW() WHERE custom_id =456;
// 判断更新成功
constupdateSuccess = result.affectedRows ===1;
Delete
constresult =awaitthis.app.mysql.delete('posts', {
author:'fengmk2',
});
=> DELETE FROM`posts`WHERE`author`='fengmk2';
使用 query 可以执行合法的 sql 语句。
我们极其不建议开发者拼接 sql 语句,这样很容易引起 sql 注入!!
如果必须要自己拼接 sql 语句,请使用 mysql.escape 方法。
constpostId =1;
constresults =awaitthis.app.mysql.query('update posts set hits = (hits + ?) where id = ?', [1, postId]);
=> update posts set hits = (hits +1) where id =1;
一般来说,事务是必须满足4个条件(ACID):Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(可靠性)
原子性:确保事务内的所有操作都成功完成,否则事务将被中止在故障点,以前的操作将回滚到以前的状态。
一致性:对于数据库的修改是一致的。
隔离性:事务是彼此独立的,不互相影响
持久性:确保提交事务后,事务产生的结果可以永久存在。
因此,对于一个事务来讲,一定伴随着 beginTransaction、commit 或 rollback,分别代表事务的开始,成功和失败回滚。
手动控制
优点:beginTransaction, commit 或 rollback 都由开发者来完全控制,可以做到非常细粒度的控制。
缺点:手写代码比较多,不是每个人都能写好。忘记了捕获异常和 cleanup 都会导致严重 bug。
constconn =awaitapp.mysql.beginTransaction();// 初始化事务
try{
awaitconn.insert(table, row1);// 第一步操作
awaitconn.update(table, row2);// 第二步操作
awaitconn.commit();// 提交事务
}catch(err) {
// error, rollback
awaitconn.rollback();// 一定记得捕获异常后回滚事务!!
throwerr;
}
自动控制:Transaction with scope
API:beginTransactionScope(scope, ctx)
scope: 一个 generatorFunction,在这个函数里面执行这次事务的所有 sql 语句。
ctx: 当前请求的上下文对象,传入 ctx 可以保证即便在出现事务嵌套的情况下,一次请求中同时只有一个激活状态的事务。
constresult =awaitapp.mysql.beginTransactionScope(asyncconn => {
// don't commit or rollback by yourself
awaitconn.insert(table, row1);
awaitconn.update(table, row2);
return{success:true};
}, ctx);// ctx 是当前请求的上下文,如果是在 service 文件中,可以从 `this.ctx` 获取到
// if error throw on scope, will auto rollback
如果需要调用 MySQL 内置的函数(或表达式),可以使用 Literal。
NOW():数据库当前系统时间,通过 app.mysql.literals.now 获取。
awaitthis.app.mysql.insert(table, {
create_time:this.app.mysql.literals.now,
});
=> INSERT INTO`$table`(`create_time`) VALUES(NOW())
自定义表达式
下例展示了如何调用 MySQL 内置的 CONCAT(s1, ...sn) 函数,做字符串拼接。
constLiteral =this.app.mysql.literals.Literal;
constfirst ='James';
constlast ='Bond';
awaitthis.app.mysql.insert(table, {
id:123,
fullname:newLiteral(`CONCAT("${first}", "${last}"`),
});
=> INSERT INTO`$table`(`id`,`fullname`) VALUES(123, CONCAT("James","Bond"))
启动的时候
windows
set DEBUG=ali-rds* && npm run dev
linux、 mac
DEBUG=ali-rds* npm run dev
在 Node.js 社区中,sequelize 是一个广泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源。
安装依赖
npm install --save egg-sequelize mysql2
在 config/plugin.js 中引入 egg-sequelize 插件
exports.sequelize = {
enable:true,
package:'egg-sequelize',
};
在 config/config.default.js 中编写 sequelize 配置
config.sequelize = {
dialect:'mysql',
host:'127.0.0.1',
port:3306,
database:'egg-sequelize-doc-default',
};
我们可以在不同的环境配置中配置不同的数据源地址,用于区分不同环境使用的数据库,例如我们可以新建一个 config/config.unittest.js 配置文件,写入如下配置,将单测时连接的数据库指向 egg-sequelize-doc-unittest。
exports.sequelize = {
dialect:'mysql',
host:'127.0.0.1',
port:3306,
database:'egg-sequelize-doc-unittest',
};
在项目的演进过程中,每一个迭代都有可能对数据库数据结构做变更,怎样跟踪每一个迭代的数据变更,并在不同的环境(开发、测试、CI)和迭代切换中,快速变更数据结构呢?这时候我们就需要 Migrations 来帮我们管理数据结构的变更了。
sequelize 提供了 sequelize-cli 工具来实现 Migrations,我们也可以在 egg 项目中引入 sequelize-cli。
安装 sequelize-cli
npm install --save-dev sequelize-cli
在 egg 项目中,我们希望将所有数据库 Migrations 相关的内容都放在 database 目录下,所以我们在项目根目录下新建一个 .sequelizer 配置文件:
'use strict';
constpath =require('path');
module.exports = {
config: path.join(__dirname,'database/config.json'),
'migrations-path': path.join(__dirname,'database/migrations'),
'seeders-path': path.join(__dirname,'database/seeders'),
'models-path': path.join(__dirname,'app/model'),
};
初始化 Migrations 配置文件和目录
npx sequelize init:config
npx sequelize init:migrations
执行完后会生成 database/config.json 文件和 database/migrations 目录,我们修改一下 database/config.json 中的内容,将其改成我们项目中使用的数据库配置:
{
"development": {
"username":"root",
"password":null,
"database":"egg-sequelize-doc-default",
"host":"127.0.0.1",
"dialect":"mysql"
},
"test": {
"username":"root",
"password":null,
"database":"egg-sequelize-doc-unittest",
"host":"127.0.0.1",
"dialect":"mysql"
}
}
时 sequelize-cli 和相关的配置也都初始化好了,我们可以开始编写项目的第一个 Migration 文件来创建我们的一个 users 表了。
npx sequelize migration:generate --name=init-users
执行完后会在 database/migrations 目录下生成一个 migration 文件(${timestamp}-init-users.js),我们修改它来处理初始化 users 表:
'use strict';
module.exports = {
// 在执行数据库升级时调用的函数,创建 users 表
up:async(queryInterface, Sequelize) => {
const{ INTEGER, DATE, STRING } = Sequelize;
awaitqueryInterface.createTable('users', {
id: {type: INTEGER,primaryKey:true,autoIncrement:true},
name: STRING(30),
age: INTEGER,
created_at: DATE,
updated_at: DATE,
});
},
// 在执行数据库降级时调用的函数,删除 users 表
down:asyncqueryInterface => {
awaitqueryInterface.dropTable('users');
},
};
# 升级数据库
npx sequelize db:migrate
# 如果有问题需要回滚,可以通过`db:migrate:undo`回退一个变更
# npx sequelize db:migrate:undo
# 可以通过`db:migrate:undo:all`回退到初始状态
# npx sequelize db:migrate:undo:all
首先我们来在 app/model/ 目录下编写 user 这个 Model:
'use strict';
module.exports = app => {
const{ STRING, INTEGER, DATE } = app.Sequelize;
constUser = app.model.define('user', {
id: {type: INTEGER,primaryKey:true,autoIncrement:true},
name: STRING(30),
age: INTEGER,
created_at: DATE,
updated_at: DATE,
});
returnUser;
};
这个 Model 就可以在 Controller 和 Service 中通过 app.model.User 或者 ctx.model.User 访问到了,例如我们编写 app/controller/users.js:
// app/controller/users.js
constController =require('egg').Controller;
functiontoInt(str){
if(typeofstr ==='number')returnstr;
if(!str)returnstr;
returnparseInt(str,10) ||0;
}
classUserControllerextendsController{
asyncindex() {
constctx =this.ctx;
constquery = {limit: toInt(ctx.query.limit),offset: toInt(ctx.query.offset) };
ctx.body =awaitctx.model.User.findAll(query);
}
asyncshow() {
constctx =this.ctx;
ctx.body =awaitctx.model.User.findByPk(toInt(ctx.params.id));
}
asynccreate() {
constctx =this.ctx;
const{ name, age } = ctx.request.body;
constuser =awaitctx.model.User.create({ name, age });
ctx.status =201;
ctx.body = user;
}
asyncupdate() {
constctx =this.ctx;
constid = toInt(ctx.params.id);
constuser =awaitctx.model.User.findByPk(id);
if(!user) {
ctx.status =404;
return;
}
const{ name, age } = ctx.request.body;
awaituser.update({ name, age });
ctx.body = user;
}
asyncdestroy() {
constctx =this.ctx;
constid = toInt(ctx.params.id);
constuser =awaitctx.model.User.findByPk(id);
if(!user) {
ctx.status =404;
return;
}
awaituser.destroy();
ctx.status =200;
}
}
module.exports = UserController;
工具总结
sequelize-cli
sequelize-auto 自动从数据库导出 bare sequelize models