編者注:ThinkJS 做爲一款 Node.js 高性能企業級 Web 框架,收到了愈來愈多的用戶的喜好。今天咱們請來了 ThinkJS 用戶 @lscho 同窗爲咱們分享他基於 ThinkJS 開發一款類 CMS 的博客系統的心得。下面就趕忙讓咱們來看看 ThinkJS 和 Vue.js 能擦除怎樣的火花吧!javascript
前段時間利用閒暇時間把博客重寫了一遍,除了實現博客基本的文章系統、評論系統外還完成了一個簡單的插件系統。博客採用 ThinkJS 完成了服務端功能,Vue.js 完成了先後端分離的後臺管理功能,而博客前臺部分考慮到搜索引擎的問題,仍是放在了服務端作渲染。在這裏記錄一下主要實現的功能與遇到的問題。php
一個完整的博客系統大概須要用戶登陸、文章管理、標籤、分類、評論、自定義配置等,根據這些功能,初步預計須要這些表:html
共8張表,而後參考 Typecho 的設計,再結合 ThinkJS 的模型關聯功能,作了一下精簡,分類表與標籤表合併,兩個映射表合併,最終獲得如下6張表設計方案。前端
內容表 - content
關係表 - relationship
項目表 - meta
評論表 - comment
配置表 - config
用戶表 - user
複製代碼
ThinkJS 的模型關聯功能能夠很方便的處理這種表結構的分類和標籤關係,好比咱們在內容模型即 src/model/content.js
寫以下關聯關係,便可在使用模型查詢文章時將分類和標籤數據查到,而不用手工執行屢次查詢。java
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 功能,具體的就很少表了,這裏咱們主要說一下如何對全部接口進行權限驗證。node
由於後臺部分是先後端分離的,因此鑑權部分使用了 JWT 鑑權。JWT 以前大概瞭解過,以前本身也實現過相似的功能,搜索了一下,找到了 node-jsonwebtoken 這個包,使用起來很簡單,主要就是加密和解密兩個功能一番折騰以後成功運行。webpack
偶然去 ThinkJS 倉庫看了一下,居然有發現了 think-session-jwt 這個插件,也是基於 node-jsonwebtoken 的。這個就更好用了,配置完以後直接用 ThinkJS 的 ctx.session 方法就能夠生成和驗證。配置的時候須要注意一下 tokenType 這個參數,他決定了如何獲取 token ,我這裏用的是 header ,也就是說後面會從每一個請求的 header 中找 token,key 值爲配置的 tokenName。ios
由於 API 接口遵循 RESTful 風格,並且也沒有複雜的角色權限概念,因此簡單的對非 GET 類型的請求,都驗證 token 是否有效,ThinkJS 的控制器提供了前置操做 __before
。在src/controller/rest.js
中作一下邏輯判斷,經過的纔會繼續執行。git
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
捕獲處理一下。github
爲了安全起見,咱們的 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
方法。
而後咱們擴展一下 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
中對插件列表遍歷並依次執行便可。
//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 語句。激活完成後重啓進程讓代碼生效便可。重啓功能能夠參考子進程如何通知主進程重啓服務?
項目的開發過程當中或多或少也存在一些問題,這裏我也分享一下我碰到的一些問題,但願能幫助到你們。
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 默認是做爲插件所有加載進來,因此打包出來的文件很大。須要調整爲按需加載。按照www.iViewui.com/docs/guide/…搞定以後出現了一個問題,就是執行 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 開發的博客系統。