使用 ThinkJS + Vue.js 開發部落格系統

ThinkJS發表於2018-08-13

編者注:ThinkJS 作為一款 Node.js 高效能企業級 Web 框架,收到了越來越多的使用者的喜愛。今天我們請來了 ThinkJS 使用者 @lscho 同學為我們分享他基於 ThinkJS 開發一款類 CMS 的部落格系統的心得。下面就趕緊讓我們來看看 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() {
    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 一般設定的都有效期,所以有三種情況需要我們進行處理.

  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 中對外掛列表遍歷並依次執行即可。

//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 按需載入

因為 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 開發的部落格系統

相關文章