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

  in   tech with  29  comment

前段时间利用闲暇时间把博客重写了一遍,除了实现博客基本的文章系统、评论系统外还完成了一个简单的插件系统。博客采用 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 的问题,在 https://github.com/nodejs/node-v0.x-archive/issues/2703 找到了原因,大意是操作系统限制 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 搜了一下,找到了解决方案 https://github.com/iView/iView/issues/1279#issuecomment-314390166

部署

如果前后端不分离的话,用 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 开发的博客系统

注:本文同时发布在知乎使用 ThinkJS + Vue.js开发博客系统

Responses
  1. 4564564

    Reply
  2. wwwwww

    学习了~~~~~~~~~~~~~~~~`

    Reply
  3. @nigel

    test啥啊,我都天天删无意义的评论~~~我都想加个垃圾评论过滤功能了

    Reply
  4. nigel

    test

    Reply
  5. @老白

    修复了

    Reply
  6. @老白

    哪些关键词搜不到呢?

    Reply
  7. 老白

    搜索有点问题,有一些搜不到

    Reply
  8. frank
    @lscho

    有没有微信联系方式或者QQ呢?有个项目求你做一下。

    Reply
  9. @yyx

    里面的就是源文件,只是去除了一下空格和换行符,你用编辑器格式化一下就可以

    Reply
  10. yyx

    请问下 www/static/home/style.css 有源文件吗?

    Reply
  11. @bug007

    前台部分没必要强行用vue,本来也没啥数据交互的东西。适合vue的是中后台。

    Reply
  12. 我在选择vue和搜索引擎之间,确实好纠结,本来博客是记录自己的一些东西的,但是又想分享的东西被搜索引擎收录。然后我的方式是,title和description服务端输出,body部分vue来处理了……

    Reply
  13. @zengyun

    尴尬。。我还没发现这个问题。。这两天修复一下

    Reply
  14. 输入中文搜索会报500,如果写文章能够增加seo关键词,描述 就好了。

    Reply
  15. @曲振青

    我的网站也是吗?我这边没发现,你把链接发一下我看看吧

    Reply
  16. 曲振青

    您好 请问搜索要怎么用呀 我自己部署的和你的网站都是 要么搜不到 要么404

    Reply
  17. nuolu

    你好!我在github上看到了您的项目,有一些安装部署的问题遇到了一些问题,先您请教一下,我现在admin目录下npm install 然后在adapter.js 和adapter.production.js修改了一下修改了mysql的相对应的信息,然后又在mysql中创建了新的数据库blog然后将database.sql导入,这写我做完以后,在admin目录和外面的目录先本别npm start 但是项目无法正常启动,麻烦您讲一下部署目录的正确方法!

    Reply
  18. zengyun

    你好,一直线上部署为500错误 能给一个具体的教程吗?

    Reply
  19. @jiumi

    这个提示是因为跨域了,可以看一下 https://lscho.com/tech/cross-domain.html 这个,解决方法一般都是cors,就是后端响应头加上规则。。具体代码在https://github.com/lscho/ThinkJS-Vue.js-blog/blob/master/src/controller/rest.js#L13。你可以结合你的代码,写一下

    Reply
  20. jiumi

    想问一下博主,您这个前后端分离是怎么配置的? Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8080' is therefore not allowed access. The response had HTTP status code 500.

    Reply
  21. @潜

    不好意思,之前遗漏了,mysql 文件现在已经上传了

    Reply
  22. @edison

    不好意思,之前遗漏了,mysql 文件现在已经上传了

    Reply
  23. @Zero

    404修好了,顺便加了个邮件评论提醒

    Reply
  24. helloworld

    第一步,下载SQL,where is it?

    Reply
  25. 404页好像还没改好 http://lscho.com/search/%3Cscript%3Ealert('xxs')%3C/script%3E/

    Reply
  26. 围观萌叔大佬

    Reply
  27. @潜

    最近可能修改一下表结构,所以sql文件稍后上传

    Reply
  28. 哥,sql文件呢?github源码上也没有...

    Reply
  29. 第一步,下载SQL,where is it?

    Reply