ThinkJS
首发于ThinkJS
使用 ThinkJS + Vue.js开发博客系统

使用 ThinkJS + Vue.js开发博客系统

前言

前段时间利用闲暇时间把博客重写了一遍,除了实现博客基本的文章系统、评论系统外还完成了一个简单的插件系统。博客采用 ThinkJS 完成了服务端功能,Vue.js 完成了前后端分离的后台管理功能,而博客前台部分考虑到搜索引擎的问题,还是放在了服务端做渲染。在这里记录一下主要实现的功能与遇到的问题。

功能分析

一个完整的博客系统大概需要用户登录、文章管理、标签、分类、评论、自定义配置等,根据这些功能,初步预计需要这些表:

1.文章表  
2.评论表  
3.文章分类表  
4.标签表  
5.文章与分类映射表(一对多)  
6.文章与标签映射表(多对多)  
7.配置表  
8.用户表

共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 之前大概了解过,之前自己也实现过类似的功能,搜索了一下,找到了 node-jsonwebtoken 这个包,使用起来很简单,主要就是加密和解密两个功能一番折腾之后成功运行。

偶然去 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(action) {

  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 会抛出一个异常,所以这里用了 catch 捕获处理一下。

前端身份失效检测

为了安全起见,我们的 token 一般设置的都有效期,所以有三种情况需要我们进行处理.

  1. 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.jscomment方法。

然后我们扩展一下 controller ,增加一个 hook 方法,用来根据不同的标识调用对应的插件。我们可以遍历一下 src/service/ 找到对应的文件,然后调用其方法即可。但是考虑到文件遍历可能出现的异常和性能的损耗,我把这部分功能转移到了服务启动时即检测插件并保存到配置中。看一下 ThinkJS 的运行流程,可以放到 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 中对插件列表遍历并依次执行即可。

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 语句。激活完成后重启进程让代码生效即可。重启功能可以参考子进程如何通知主进程重启服务?

其他

项目的开发过程中或多或少也存在一些问题,这里我也分享一下我碰到的一些问题,希望能帮助到大家。

编辑器及文件上传

markdown 编辑器用了 mavonEditor 配置很方便,不多说,主要说一下文件上传遇到的一个问题。

前端代码


<mavon-editor ref=md @imgAdd="imgAdd" class="editor" v-model="formItem.content"></mavon-editor>

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);

最初使用了 ThinkJS 官网的上传示例代码,使用 rename 进行文件转移,而在 windows 下临时目录可能和项目目录不在同一盘符下,进行移动的话就会抛出一个异常:Error: EXDEV, cross-device link not permitted,没有权限移动,这时候就只能先读文件,再写文件。所以这里也用了一个 try catch 来捕获异常,主要是因为 ThinkJS 会将上传的文件先放到临时目录中。关于跨盘 rename 的问题,在 github.com/nodejs/node- 找到了原因,大意是操作系统限制 rename 仅仅是重命名路径引用地址,并没有将数据移动过去,重命名不能跨文件系统操作,所以如果跨文件系统操作需要先复制、然后删除旧数据。

后来在群里聊天,@阿特 大佬提到,上传是 payload 这个中间件处理的, 可以对 payload 这个中间件设置指定临时目录为项目下的某个目录,这样就保证临时目录和项目目录在同一盘符下。

{
    handle: 'payload',
    options: {
        uploadDir: path.join(think.ROOT_PATH, 'runtime/data')
    }
}

这样就可以直接使用 rename 来操作了。

iView 按需加载

因为 iView 默认是作为插件全部加载进来,所以打包出来的文件很大。需要调整为按需加载。按照https://www.iViewui.com/docs/guide/start#按需引用搞定之后出现了一个问题,就是执行 npm run build 时会报一个错。ERROR in js/index.c26f6242.js? from UglifyJs 大概是这个样子,看了一下错误原因,大概是因为按需加载之后,是直接加载的 iView 模块下 src 的 js文件,里面采用的都是 ES6 语法,造成压缩失败。去 Issue 搜了一下,找到了解决方案 github.com/iView/iView/

部署

如果前后端不分离的话,用 webpack 将前端的入口页面 index.html 编译到 ThinkJS 后端项目的首页模版位置,然后把资源编译到后端项目资源文件夹下,对应路径设置好。这样就把前端项目整合进了后端项目,然后再按照 ThinkJS 部署方式来部署,也是可以的。

如果是前后端分离,作为两个项目部署的话,前端路由使用普通模式的话也很好处理,如果使用 history 模式,就要要将请求转发至 index.html 入口页面处理,跟有些 mvc 框架单入口是一个概念。这时候其实就是前端项目接管了路由。

location / {
    try_files $uri $uri/ /index.html;
}

然后还要处理一下后端请求部分,如果不是同一域名,就要解决跨域问题。这里前后端使用同一个域名,针对 api 请求做一下反向代理即可。注意这部分要写在请求转发的上面。

set $node_port 8360;
    location ~ ^/api/ {
    proxy_pass http://127.0.0.1:$node_port$request_uri;

}

后端使用 pm2 守护进程即可。

后记

以上就是我整个项目的开发过程以及遇到的一些问题的总结,如果有什么疑问欢迎大家留言讨论。最后欢迎大家 Star 基于 ThinkJS + Vue 开发的博客系统

编辑于 2018-08-16

文章被以下专栏收录

    ThinkJS 是一款基于 Koa 2.0 面向未来的企业级 Node.js 框架,致力于整合了大量的项目最佳实践,让企业级开发变得如此简单、高效。本专栏会分享一些在 ThinkJS 项目开发过程中总结的一些经验以及问题,同时也非常欢迎大家投稿。