編者注:ThinkJS 作為一款 Node.js 高效能企業級 Web 框架,收到了越來越多的使用者的喜愛。今天我們請來了 ThinkJS 使用者 @lscho 同學為我們分享他基於 ThinkJS 開發一款類 CMS 的部落格系統的心得。下面就趕緊讓我們來看看 ThinkJS 和 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 之前大概瞭解過,之前自己也實現過類似的功能,搜尋了一下,找到了 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 一般設定的都有效期,所以有三種情況需要我們進行處理.
- 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 按需載入
因為 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 開發的部落格系統。