编者注: ThinkJS https://thinkjs.org 作为一款 Node.js 高性能企业级 web 框架, 收到了越来越多的用户的喜爱. 今天我们请来了 ThinkJS https://thinkjs.org 用户 @lscho 同学为我们分享他基于 ThinkJS https://thinkjs.org 开发一款类 CMS 的博客系统的心得. 下面就赶紧让我们来看看 ThinkJS https://thinkjs.org 和 vue.js 能擦除怎样的火花吧!
前言
前段时间利用闲暇时间把博客重写了一遍, 除了实现博客基本的文章系统, 评论系统外还完成了一个简单的插件系统. 博客采用 ThinkJS 完成了服务端功能, vue.js 完成了前后端分离的后台管理功能, 而博客前台部分考虑到搜索引擎的问题, 还是放在了服务端做渲染. 在这里记录一下主要实现的功能与遇到的问题.
功能分析
一个完整的博客系统大概需要用户登录, 文章管理, 标签, 分类, 评论, 自定义配置等, 根据这些功能, 初步预计需要这些表:
文章表
评论表
文章分类表
标签表
文章与分类映射表 (一对多)
文章与标签映射表 (多对多)
配置表
用户表
共 8 张表, 然后参考 Typecho 的设计, 再结合 ThinkJS 的模型关联功能, 做了一下精简, 分类表与标签表合并, 两个映射表合并, 最终得到以下 6 张表设计方案.
内容表 - content
关系表 - relationship
项目表 - meta
评论表 - comment
配置表 - config
用户表 - user
复制代码
ThinkJS 的模型关联功能可以很方便的处理这种表结构的分类和标签关系, 比如我们在内容模型即
src/model/content.js
写如下关联关系, 即可在使用模型查询文章时将分类和标签数据查到, 而不用手工执行多次查询.
- get relation() {
- return {
- category: {
- type: think.Model.BELONG_TO,
- model: 'meta',
- key: 'category_id',
- fKey: 'id',
- field: 'id,name,slug,description,count'
- },
- tag: {
- type: think.Model.MANY_TO_MANY,
- model: 'meta',
- rModel: 'relationship',
- rfKey: 'meta_id',
- key: 'id',
- fKey: 'content_id',
- field: 'id,name,slug,description,count'
- }
- };
- }
复制代码
接口鉴权
表结构设计好了之后剩下就要开始开发接口了. 接口方面因为使用了 RESTful 接口规范, 所以基本上就是 CURD 功能, 具体的就不多表了, 这里我们主要说一下如何对所有接口进行权限验证.
因为后台部分是前后端分离的, 所以鉴权部分使用了 JWT 鉴权. JWT 之前大概了解过, 之前自己也实现过类似的功能, 搜索了一下, 找到了 https://github.com/auth0/node-jsonwebtoken 这个包, 使用起来很简单, 主要就是加密和解密两个功能一番折腾之后成功运行.
偶然去 ThinkJS 仓库看了一下, 竟然有发现了 https://github.com/ThinkJS/think-session-jwt 这个插件, 也是基于 node-jsonwebtoken 的. 这个就更好用了, 配置完之后直接用 ThinkJS 的 ctx.session 方法就可以生成和验证. 配置的时候需要注意一下 tokenType 这个参数, 他决定了如何获取 token , 我这里用的是 header , 也就是说后面会从每个请求的 header 中找 token,key 值为配置的 tokenName.
后端权限认证
因为 API 接口遵循 RESTful 风格, 而且也没有复杂的角色权限概念, 所以简单的对非 GET 类型的请求, 都验证 token 是否有效, ThinkJS 的控制器提供了前置操作 __before. 在
src/controller/rest.js
中做一下逻辑判断, 通过的才会继续执行.
- async __before() {
- this.userInfo = await this.session('userInfo').catch(_ => ({}));
- const isAllowedMethod = this.isMethod('GET');
- const isAllowedResource = this.resource === 'token';
- const isLogin = !think.isEmpty(this.userInfo);
- if(!isAllowedMethod && !isAllowedResource && !isLogin) {
- return this.ctx.throw(401, '请登录后操作');
- }
- }
复制代码
这里遇到一个问题, 就是当 token 错误时, node-jsonwebtoken 会抛出一个异常, 所以这里用了 try catch 捕获处理一下.
前端身份失效检测
为了安全起见, 我们的 token 一般设置的都有效期, 所以有三种情况需要我们进行处理.
token 不存在, 这种很好处理, 直接在路由的前置操作中判断是否存在, 存在则放行, 不存在则转向登录界面
- beforeEnter:(to, from, next)=>{
- if(!localStorage.getItem('token')){
- next({ path: '/login' });
- }else{
- next();
- }
- }
复制代码
2.token 错误. 这种需要后端检测之后才能知道该 token 是否有效. 这里服务端检测失效之后会返回 401 状态码以便前端识别. 我们在 axios 的请求响应拦截器中进行判断即可, 因为 4XX 的状态码会抛出异常, 所以代码如下
- axios.interceptors.response.use(data => {
- // 这里可以对成功的请求进行各种处理
- return data;
- },error=>{
- if (error.response) {
- switch (error.response.status) {
- case 401:
- store.commit("clearToken");
- router.replace("/login");
- break;
- }
- }
- return Promise.reject(error.response.data)
- })
复制代码
3.token 过期. 这种情况也可以不用处理, 因为我们在 axios 的响应拦截器中已经判断过, 如果返回状态码为 401 的话也会跳转到登录页面. 但是在实际使用中却发现体验不好的地方, 因为客户端中 token 是保存在 localStorage 中, 不会自动清理, 所以我们在 token 过期之后直接打开后台的话, 界面会先显示后台, 然后请求返回 401, 页面才跳转到登录界面. 包括阿里云控制台, 七牛云控制台等用了类似鉴权方式其实都存在这种现象, 对于强迫症来说可能有点不爽. 这种情况也是可以解决掉的.
我们先来看一下 JWT 的相关知识, JWT 包含了使用. 分隔的三部分: Header 头部, Payload 负载, Signature 签名, 其结构看起来是这样的 Header.Payload.Signature. 抛开 Header,Signature 不去介绍, Payload 其实是一段明文数据经过 base64 转码之后得到的. 而其中就包含了我们设置的信息, 一般都会有过期时间. 在路由前置操作中进行判断即可得知 token 是否过期, 这样就可以避免页面两次跳转的问题. 我们对 Payload 解码之后会得到:
{"userInfo":{"id":1},"iat":1534065923,"exp":1534109123}
复制代码
可以看到 exp 就是过期时间, 对这个时间进行判断, 即可得知是否过期.
- let tokenArray = token.split('.')
- if (tokenArray.length !== 3) {
- next('/login')
- }
- let payload = Base64.decode(tokenArray[1])
- if (Date.now()> payload.exp * 1000) {
- next('/login')
- }
复制代码
另外这里顺便提一下, 因为 Payload 是明文数据, 所以千万不要在 jwt 中保存敏感数据.
插件机制
除了正常的增删改查功能之外, 在我的博客系统中我还实现了一个简单的插件机制,方便我对代码进行解耦, 提高代码灵活性. 举个例子, 有时候我们会针对某个点扩展出很多功能, 比如在用户评论之后, 我们可能需要更新缓存, 邮件通知, 文章评论数量更新等等, 我们可能会写下如下代码.
- let insertId = await model.add(data);
- if(insertId){
- await this.updateCache();
- await this.push();
- ...
- }
复制代码
后面一旦这些方法发生改变, 修改起来就太麻烦了. 用过 php 博客系统的同学应该都知道, 插件机制强大又方便, 所以我决定实现一个插件功能.
期望功能是在程序某个点留下标识 (一般都称为钩子), 即可对这个点进行扩展, 如下.
- let insertId = await model.add(data);
- if(insertId){
- await this.hook('commentCreate',data);
- }
复制代码
因为程序是自用的, 只是方便自己以后扩展功能, 只需要实现核心功能即可. 所以并没有增加某个目录作为插件目录, 而是放在 src/service/ 下面, 符合 ThinkJS 的文件结构, 然后做了一个约定. 只要在 src/service/ 下面的 js 文件, 并且有 registerHook 方法, 那么就可以作为插件被调用. 如
src/service/email.js
这个文件用来处理邮件通知, 那么给他增加一个方法:
- static registerHook() {
- return {
- 'comment': ['commentCreate']
- };
- }
复制代码
就表示在 commentCreate 这个功能点下, 会调用
src/service/email.js
的 comment 方法.
然后我们扩展 https://ThinkJS.org/zh-cn/doc/3.0/extend.html#toc-50c 一下 controller , 增加一个 hook 方法, 用来根据不同的标识调用对应的插件. 我们可以遍历一下 src/service/ 找到对应的文件, 然后调用其方法即可. 但是考虑到文件遍历可能出现的异常和性能的损耗, 我把这部分功能转移到了服务启动时即检测插件并保存到配置中. 看一下 ThinkJS 的运行流程 https://ThinkJS.org/zh-cn/doc/3.0/flow.html#toc-ca6 , 可以放到
src/bootstrap/worker.js
这个文件中. 大致代码如下.
- const hooks = [];
- for (const Service of Object.values(think.app.services)) {
- const isHookService = think.isFunction(Service.registerHook);
- if (!isHookService) {
- continue;
- }
- const service = new Service();
- const serviceHooks = Service.registerHook();
- for (const hookFuncName in serviceHooks) {
- if (!think.isFunction(service[hookFuncName])) {
- continue;
- }
- let funcForHooks = serviceHooks[hookFuncName];
- if (think.isString(funcForHooks)) {
- funcForHooks = [funcForHooks];
- }
- if (!think.isArray(funcForHooks)) {
- continue;
- }
- for (const hookName of funcForHooks) {
- if (!hooks[hookName]) {
- hooks[hookName] = [];
- }
- hooks[hookName].push({ service, method: hookFuncName });
- }
- }
- }
- think.config('hooks', hooks);
复制代码
然后在
src/extend/controller.js
中的 hook 中对插件列表遍历并依次执行即可.
- //src/extend/controller.js
- module.exports = {
- async hook(...args) {
- const { hooks } = think.config();
- const hookFuncs = hooks[name];
- if (!think.isArray(hookFuncs)) {
- return;
- }
- for(const {service, method} of hookFuncs) {
- await service[method](...args);
- };
- }
- }
复制代码
至此, 简单的插件功能完成.
当然如果想实现像 Wordpress ,Typecho 那种完整的插件功能也很简单. 后台增加一个插件管理, 可以进行上传, 然后给插件增加一个激活函数和一个禁用函数. 点击插件管理中的激活与禁用就分别调用这两个方法, 可以保存默认配置等等. 如果插件需要创建数据表, 可以在激活函数中执行相关 sql 语句. 激活完成后重启进程让代码生效即可. 重启功能可以参考子进程如何通知主进程重启服务? https://ThinkJS.org/zh-cn/doc/3.0/multi_process.html#toc-640
其他
- imgAdd(pos, $file){
- var formdata = new FormData();
- formdata.append('image', $file);
- image.upload(formdata).then(res=>{
- if(res.errno==0&&res.data.url){
- this.$refs.md.$img2Url(pos, res.data.url);
- }
- });
- }
- const file = this.file('image');
- const extname=path.extname(file.name);
- const filename = path.basename(file.path);
- const basename=think.md5(filename)+extname;
- const savepath = '/upload/'+basename;
- const filepath = path.join(think.ROOT_PATH, "www"+savepath);
- think.mkdir(path.dirname(filepath));
- await rename(file.path, filepath);
- {
- handle: 'payload',
- options: {
- uploadDir: path.join(think.ROOT_PATH, 'runtime/data')
- }
- }
- location / {
- try_files $uri $uri//index.html;
- }
- set $node_port 8360;
- location ~ ^/api/ {
- proxy_pass http://127.0.0.1:$node_port$request_uri;
- }
来源: https://juejin.im/post/5b70de1a51882561495c8234