Vue3+TS+Node打造個人博客(后端架構(gòu))
點(diǎn)擊上方卡片“前端司南”關(guān)注我您的關(guān)注意義重大
原創(chuàng)@前端司南
本項(xiàng)目代碼已開源,具體見:
前端工程:vue3-ts-blog-frontend[1]
后端工程:express-blog-backend[2]
數(shù)據(jù)庫初始化腳本:關(guān)注公眾號前端司南,回復(fù)關(guān)鍵字“博客數(shù)據(jù)庫腳本”,即可獲取。
Express[3] 是基于 Node.js 平臺,快速、開放、極簡的 Web 開發(fā)框架。目前已經(jīng)更新到 5.x 版本。
我的博客后端其實(shí)開發(fā)得比較早,19年年底基本上已經(jīng)完成了主體功能的開發(fā),當(dāng)時用的是 Express 4.x 版本。

在使用 Express 搭建后端服務(wù)時,主要關(guān)注的幾個點(diǎn)是:
- 路由中間件和控制器
- SQL處理
- 響應(yīng)返回體數(shù)據(jù)結(jié)構(gòu)
- 錯誤碼
- Web安全
- 環(huán)境變量/配置
路由基本上是按模塊或功能去劃分的。
首先是按模塊去劃分一級路由,各個模塊的子功能相當(dāng)于是用二級路由處理。
簡單舉個例子,/article路由開頭的是文章模塊,/article/add用于新增文章功能。
控制器的概念其實(shí)是從其他語言中借鑒而來的,Express 并沒有明確說什么是控制器,但在我看來,路由中間件的處理模塊/函數(shù)就是控制器的概念。
下面是本項(xiàng)目使用到的一些控制器。

const?BaseController?=?require('../controllers/base');
const?ValidatorController?=?require('../controllers/validator');
const?UserController?=?require('../controllers/user');
const?BannerController?=?require('../controllers/banner');
const?ArticleController?=?require('../controllers/article');
const?TagController?=?require('../controllers/tag');
const?CategoryController?=?require('../controllers/category');
const?CommentController?=?require('../controllers/comment');
const?ReplyController?=?require('../controllers/reply');
module.exports?=?function(app)?{
?app.use(BaseController);
?app.use('/validator',?ValidatorController);
?app.use('/user',?UserController);
?app.use('/banner',?BannerController);
?app.use('/article',?ArticleController);
?app.use('/tag',?TagController);
?app.use('/category',?CategoryController);
?app.use('/comment',?CommentController);
?app.use('/reply',?ReplyController);
};
BaseController
其中,BaseController是用作第一道關(guān)卡,對所有的請求做一個基本的校驗(yàn)和攔截。
其實(shí)主要是對一些敏感接口(比如后臺維護(hù)類的)做一個權(quán)限校驗(yàn)。
權(quán)限控制這塊,我設(shè)計(jì)得還是比較簡單粗暴的,因?yàn)槲以跀?shù)據(jù)庫表中目前只預(yù)留了一個用戶Tusi,關(guān)聯(lián)的角色也是唯一用到的admin。畢竟目前還沒考慮開放用戶注冊這類的能力,有一個管理用戶基本上也夠用了。
所以我的設(shè)計(jì)是:只要在我登錄成功后的有效期內(nèi),就有權(quán)限操作敏感接口,否則就無權(quán)操作!
BaseController大體工作流程如下:

BaseController的主體代碼結(jié)構(gòu)大概如下:
router.use(function(req,?res,?next)?{
????//?authMap?維護(hù)了敏感接口列表
????const?authority?=?authMap.get(req.path);
????//?首先檢查是不是敏感接口
????if?(authority)?{
????????//?需要檢驗(yàn)身份的接口
????????if?(req.cookies.token)?{
????????????//?取到?token?去做校驗(yàn)
????????????dbUtils.getConnection(function?(connection)?{
????????????????req.connection?=?connection;
?????????????????//?這里會直接查庫驗(yàn)明身份
????????????????connection.query(indexSQL.GetCurrentUser,?[req.cookies.token],?function?(error,?results,?fileds)?{
??????????????????//?身份校驗(yàn)通過,才繼續(xù),否則返回錯誤碼
????????????????})
????????????})
????????}?else?{
????????????return?res.send({
????????????????...errcode.AUTH.UNAUTHORIZED
????????????});
????????}
????}?else?{
????????//?不是敏感接口,不校驗(yàn)身份
????????if?(req.method?==?'OPTIONS')?{
????????????//?OPTIONS?類型請求不能去連數(shù)據(jù)庫,否則會導(dǎo)致數(shù)據(jù)庫連接過多崩了
????????????next();
????????}?else?{
????????????//?從mysql連接池取得connection
????????????dbUtils.getConnection(function?(connection)?{
????????????????req.connection?=?connection;
????????????????next();
????????????},?function?(err)?{
????????????????return?res.send({
????????????????????...errcode.DB.CONNECT_EXCEPTION
????????????????});
????????????})
????????}
????}
}
如注釋所述,BaseController主要是針對敏感接口做一個身份檢查,防止系統(tǒng)數(shù)據(jù)被一些不懷好意的 HTTP 請求給黑了。
20220218更新
按照上面的邏輯實(shí)現(xiàn)功能并上線后,服務(wù)運(yùn)行一段時間(可能是3~5天)后,能觀察到服務(wù)請求會變成無法正常響應(yīng)的狀態(tài)。

其實(shí)我能感覺到可能是mysql連接池未合理釋放導(dǎo)致的。
但是由于我一開始采取的方案是:在BaseController給req掛載connection,并在具體的業(yè)務(wù)控制器執(zhí)行完sql查詢語句后再自行釋放connection,這個基本使用過程我在后面一節(jié)也說到了。
如果要完全改掉這種調(diào)用方式,代碼改動還是挺大的,所以我一直拖著沒改,發(fā)現(xiàn)問題了就通過 PM2 重啟服務(wù)也能接著用。最近還是咬咬牙全部重構(gòu)了,具體見refactor: 重構(gòu)sql調(diào)用部分[4]。

業(yè)務(wù)Controller
前端會分模塊,后端自然也會。業(yè)務(wù)模塊會有很多,比如文章,分類,標(biāo)簽,等等。這些都可以分成不同的Controller處理。
業(yè)務(wù)Controller的大體結(jié)構(gòu)如下,一個子路由就對應(yīng)一個功能:
/**
?*?@param?{Number}?count?查詢數(shù)量
?*?@description?根據(jù)傳入的count獲取閱讀排行top?N的文章
?*/
router.get('/top_read',?function?(req,?res,?next)?{
??//?業(yè)務(wù)代碼
}
/**
?*?@param?{Number}?pageNo?頁碼數(shù)
?*?@param?{Number}?pageSize?一頁數(shù)量
?*?@description?分頁查詢文章
?*/
router.get('/page',?function?(req,?res,?next)?{
??//?業(yè)務(wù)代碼
}
/**
?*?@param?{Number}?id?當(dāng)前文章的id
?*?@description?查詢上一篇和下一篇文章的id
?*/
router.get('/neighbors',?function?(req,?res,?next)?{
??//?業(yè)務(wù)代碼
}
SQL處理SQL 這塊,我沒有直接用 ORM 工具。因?yàn)槲矣X得自己的 SQL 基礎(chǔ)并不是很好,還需要自己多寫 SQL 語句練習(xí)一下,所以我只用了一個mysql的庫。

安裝mysql依賴:
npm?install?--save?mysql
簡單使用時,可以直接創(chuàng)建連接,然后執(zhí)行 SQL 語句:
var?mysql??????=?require('mysql');
var?connection?=?mysql.createConnection({
??host?????:?'localhost',
??user?????:?'me',
??password?:?'secret',
??database?:?'my_db'
});
?
connection.connect();
?
connection.query('SELECT?1?+?1?AS?solution',?function?(error,?results,?fields)?{
??if?(error)?throw?error;
??console.log('The?solution?is:?',?results[0].solution);
});
?
connection.end();
實(shí)際上,更推薦使用連接池,可以避免重復(fù)向 MySQL 申請連接,實(shí)現(xiàn)了連接的重用,在響應(yīng)速度上也會更快!
var?mysql?=?require('mysql');
var?pool??=?mysql.createPool(...);
?
pool.getConnection(function(err,?connection)?{
??if?(err)?throw?err;?//?not?connected!
?
??//?Use?the?connection
??connection.query('SELECT?something?FROM?sometable',?function?(error,?results,?fields)?{
????//?When?done?with?the?connection,?release?it.
????connection.release();
?
????//?Handle?error?after?the?release.
????if?(error)?throw?error;
?
????//?Don't?use?the?connection?here,?it?has?been?returned?to?the?pool.
??});
});
實(shí)際操作時,我是在BaseController中執(zhí)行了pool.getConnection,然后把connection對象掛載到req對象上,后續(xù)的路由中間件就可以直接從req對象中取得connection,可以少嵌套一層回調(diào),也避免了每處業(yè)務(wù)代碼都寫這部分重復(fù)的getConnection代碼。
BaseController的關(guān)鍵代碼:
//?從mysql連接池取得connection
dbService.getConnection(function?(connection)?{
??req.connection?=?connection;
??next();
},?function?(err)?{
??return?res.send({
????...errcode.DB.CONNECT_EXCEPTION
??});
})
業(yè)務(wù)處直接從req獲取到connection對象:
router.get('/page',?function?(req,?res,?next)?{
??const?connection?=?req.connection;
??const?pageNo?=?Number(req.query.pageNo?||?1);
??const?pageSize?=?Number(req.query.pageSize?||?10);
??connection.query(indexSQL.GetPagedArticle,?[(pageNo?-?1)?*?pageSize,?pageSize],?function?(error,?results,?fileds)?{
????connection.release();
????//?其他業(yè)務(wù)代碼
??})
SQL 語句主要是以字符串的形式編寫,通過?作為一個參數(shù)槽位,接收一些動態(tài)的值。
比如一個邏輯刪除的語句,我們會這樣寫:
//?邏輯刪除/恢復(fù)
UpdateArticleDeleted:?'UPDATE?article?SET?deleted?=???WHERE?id?=??',
第一個?是留給字段deleted的值,第二個?便是傳具體的id值。
而參數(shù)傳值是通過connection.query的第二個參數(shù)攜帶的。
注意,這個參數(shù)是一個數(shù)組,數(shù)組中的值會按照從左到右的順序依次替換掉 SQL 字符串中的?,變成一個真實(shí)的可執(zhí)行的 SQL 語句。
connection.query(indexSQL.UpdateArticleDeleted,?[params.deleted,?params.id],?function?(error,?results,?fileds)?{})
connection.query執(zhí)行回調(diào)后切記調(diào)用connection.release釋放連接。
另外要注意的一個就是 MySQL 的事務(wù)處理。對事務(wù)而言,初步要關(guān)注的是這三個 API!具體的使用場景我在后面的具體應(yīng)用會再提到,這里就不展開了!
//?開始事務(wù),對應(yīng)?MySQL?begin?語句
connection.beginTransaction();
//?事務(wù)提交,對應(yīng)?MySQL?commit?語句
connection.commit();
//?事務(wù)回滾,對應(yīng)?MySQL?rollback?語句
connection.rollback();
20220218更新
為了保留在這個項(xiàng)目中我使用mysql思路的一個轉(zhuǎn)變過程,前面的 mysql 調(diào)用過程,我還是按照最初的想法展開介紹的,關(guān)鍵的也就是這么幾點(diǎn)。
- BaseController 統(tǒng)一獲取 mysql pool 的 connection 對象,并掛載到 req 對象上,供后面的業(yè)務(wù)使用。
- 業(yè)務(wù) Controller 與 mysql 交互時,只需要從 req 對象中取得 connection,通過 connection.query 去執(zhí)行 sql 語句。
- 業(yè)務(wù) Controller 執(zhí)行完 sql 語句后,主動 release 釋放掉 connection。
- 事務(wù)場景中,事務(wù)處理完畢后,統(tǒng)一 release 釋放掉 connection,而不是每個 query 都自行釋放 connection。
這樣的設(shè)計(jì),雖然省去了在具體業(yè)務(wù) Controller 執(zhí)行getConnection(少一層回調(diào)寫法),但是在connection.release()的把控上還存在漏洞,一旦業(yè)務(wù)調(diào)用方忘記調(diào)用release(),就有可能造成服務(wù)不可用。而且有的業(yè)務(wù)不需要與 mysql 交互,也必須要記得 release(),雖然可以用一些配置字段去規(guī)避,也并不能從根本上解決問題!
所以我的修改方案是:
- 總體的原則是高內(nèi)聚,低耦合。
- 封裝 mysql 的查詢過程,把 getConnection, query, release 等幾個關(guān)鍵行為都放在封裝的代碼中控制,對外只暴露一些封裝好的方法,這樣就不用擔(dān)心調(diào)用方忘記某些關(guān)鍵操作(比如
release())。 - 關(guān)鍵 API Promise 化,這樣在一些復(fù)雜的異步過程中可以做到事半功倍,特別是涉及事務(wù)處理的時候!
核心代碼見db.js[5]
響應(yīng)返回體響應(yīng)返回體的數(shù)據(jù)結(jié)構(gòu)是需要前后端進(jìn)行約定的,只有約定好規(guī)范,雙方才能緊密有序地配合起來。通常來說,會涉及到錯誤碼,信息,數(shù)據(jù)等字段。
其中錯誤碼code,信息message兩個字段應(yīng)該是通用的。數(shù)據(jù)部分data則隨業(yè)務(wù)的需要,可能會有多種情況,比如數(shù)組結(jié)構(gòu),對象結(jié)構(gòu),或者是普通數(shù)據(jù)類型。
{
??code:?"0",
??message:?"查詢成功",
??data:?{
????id:?1,
????name:?'xxx'
??}
}
錯誤碼錯誤碼是后端規(guī)范中必不可少的部分。錯誤碼的設(shè)計(jì)是為了快速定位問題,也為一些業(yè)務(wù)監(jiān)控系統(tǒng)提供了分析和統(tǒng)計(jì)依據(jù)。
每個程序員會有自己的一些編碼風(fēng)格,在錯誤碼這塊,我是通過語義化的屬性名去定位到錯誤碼的。通常,一個錯誤碼會配對一條錯誤信息,也就是下面的msg字段。
module.exports?=?{
??DB:?{
????CONNECT_EXCEPTION:?{
??????code:?"-1",
??????msg:?"數(shù)據(jù)庫連接異常"
????}
??},
??AUTH:?{
????UNAUTHORIZED:?{
??????code:?"000001",
??????msg:?"對不起,您還未獲得授權(quán)"
????},
????AUTHORIZE_EXPIRED:?{
??????code:?"000002",
??????msg:?"授權(quán)已過期"
????},
????FORBIDDEN:?{
??????code:?"000003",
??????msg:?"抱歉,您沒有權(quán)限訪問該內(nèi)容"
????}
??},
}
錯誤碼的設(shè)計(jì)還有一個好處,就是方便做映射。
什么意思呢?后端返回錯誤碼-1,并且通過msg字段告訴前端錯誤信息是數(shù)據(jù)庫連接異常。但是,前端到底要不要反饋用戶這么直接粗暴的信息呢?我想,有時候是不需要的,而是通過一條委婉的提示來安撫一下用戶情緒。
比如,

所以,有了錯誤碼,前端就可以收放自如,在錯誤提示上有更多發(fā)揮的余地,而不是直白地把后端反饋的錯誤信息直接暴露給用戶。
簡單的一個映射可以是:
//?ERR_MSG
{
??"-1":?"系統(tǒng)開了個小差,請稍后重試!",
}
那么message的展示邏輯就可以是:
message.error(ERR_MSG[res.code])
Web安全主要是考慮幾個方面,XSS,CSRF,響應(yīng)頭。
XSS,指的是 Cross-Site-Scripting 跨站腳本攻擊。出現(xiàn) XSS 漏洞的主要場景是用戶輸入,比如評論,富文本等信息,如果不加以校驗(yàn),就可能會被植入惡意代碼,造成數(shù)據(jù)和財產(chǎn)損失!
針對 XSS 的校驗(yàn)不能光靠客戶端,服務(wù)端也必須進(jìn)行校驗(yàn)。我這里用的是[email protected]。
npm?install?--save?xss
xss默認(rèn)會處理掉常見的 XSS 風(fēng)險,使用起來也非常簡單。比如,在新增評論的接口處,我們可以對參數(shù)這樣處理:
const?xss?=?require("xss");
router.post('/add',?function?(req,?res,?next)?{
??const?params?=?Object.assign(req.body,?{
????create_time:?new?Date(),
??});
??//?XSS防護(hù)
??if?(params.content)?{
????params.content?=?xss(params.content)
??}
}
雖然我目前還沒有用富文本承載評論內(nèi)容,但是還是先預(yù)備一下,萬一哪天想用富文本了呢!
至于 CSRF(跨站請求偽造)攻擊,常見的漏洞來源就是基于 Cookie 的身份驗(yàn)證,因?yàn)?Cookie 會在發(fā) HTTP 請求的時候自動帶上,這樣一來攻擊者就有了可乘之機(jī),通過腳本注入,或者一些引誘點(diǎn)擊,讓你不知不覺就上了套,發(fā)出了意料之外的請求。
不過,瀏覽器也是在不斷完善 Cookie 安全這塊,比如 Chrome 80 版本默認(rèn)啟用的 SameSite=Lax,也防范了很多 CSRF 的攻擊場景。
為了安全起見,在 Set-Cookie 時,最好帶上這些屬性。
Set-Cookie:?token=74afes7a8;?HttpOnly;?Secure;?SameSite=Lax;
為了防止 CSRF 攻擊,還可以采用 csrf-token 方式,或者采用 JWT 認(rèn)證,共同點(diǎn)都是避開基于 Cookie 的身份/口令認(rèn)證方式。
另外,設(shè)置一些必要的響應(yīng)頭對于 Web 安全也至關(guān)重要!
Express 推薦我們直接用上helmet。
Helmet 通過設(shè)置各種 HTTP 請求頭,提升 Express 應(yīng)用的安全性。它不是 Web 安全的銀彈,但的確有所幫助!
安裝helmet:
npm?install?--save?helmet
使用起來也很簡單,因?yàn)樗褪且粋€中間件。
app.use(helmet());
環(huán)境變量/配置由于后端配置文件中一般會出現(xiàn)一些私密性的配置,比如數(shù)據(jù)庫配置,服務(wù)器配置,這些都不適合在開源項(xiàng)目中直接出現(xiàn)。所以,在本項(xiàng)目[6]中,我只給出了example示例,大家按照說明給出自己的配置文件即可。
- 通用配置:config/env.example.js
- 開發(fā)環(huán)境配置:config/dev.env.example.js
- 生產(chǎn)環(huán)境配置:config/prod.env.example.js
- PM2 deploy 配置:deploy.config.example.js
數(shù)據(jù)庫、郵箱配置,以及其他的參數(shù)配置,建議是給開發(fā)環(huán)境和生產(chǎn)環(huán)境單獨(dú)配置,避免本地開發(fā)時直接影響到生產(chǎn)環(huán)境。
所以,我們需要設(shè)置環(huán)境標(biāo)識,并且根據(jù)環(huán)境標(biāo)識來引用對應(yīng)的參數(shù)配置。
環(huán)境標(biāo)識我們都不陌生了,它就是process.env.NODE_ENV。由于項(xiàng)目中用到了pm2,所以我是通過pm2來配置NODE_ENV的。
env:?{
??NODE_ENV:?"development",
??PORT:?8002,
},
env_production:?{
??NODE_ENV:?'production',
??PORT:?8002,
},
所以,我們只要根據(jù)NODE_ENV來判斷開發(fā)環(huán)境或生產(chǎn)環(huán)境,然后加載對應(yīng)的參數(shù)配置即可。邏輯非常簡單!
//?配置入口文件,根據(jù)環(huán)境標(biāo)識導(dǎo)出配置
const?baseEnv?=?require("./env")
const?devEnv?=?require("./dev.env")
const?prodEnv?=?require("./prod.env")
module.exports?=?process.env.NODE_ENV?===?'production'???{
??...baseEnv,
??...prodEnv
}?:?{
??...baseEnv,
??...devEnv
}
小結(jié)本文是Vue3+TS+Node打造個人博客(后端架構(gòu)篇),從一個不太專業(yè)的視角來切入后端,主要介紹了我在為博客系統(tǒng)設(shè)計(jì)后端時的一些主要思路,諸多細(xì)節(jié)不便展開,可以打開源碼[7]了解。
有了這次全棧開發(fā)的經(jīng)驗(yàn),大大提高了我對前后端全鏈路的理解程度,這之后和后端開發(fā)們聊天也更有話題可聊了,有時候還能幫后端捋捋思路、一起排查下問題??傊浅D嗡梗?/p>
但是,要把后端做完善還有很多的路要走,看看 Java 那么多中間件就知道了,道阻且長,行則將至,加油吧!
系列文章Vue3+TS+Node打造個人博客系列文章入口可點(diǎn)擊下方鏈接,持續(xù)更新,歡迎閱讀!點(diǎn)贊關(guān)注不迷路!??
- Vue3+TS+Node打造個人博客(總覽篇)[8]
參考
[1]vue3-ts-blog-frontend: https://github.com/cumt-robin/vue3-ts-blog-frontend
[2]express-blog-backend: https://github.com/cumt-robin/express-blog-backend
[3]Express: https://www.expressjs.com.cn/
[4]refactor: 重構(gòu)sql調(diào)用部分: https://github.com/cumt-robin/express-blog-backend/commit/41628e98b2e1f2fee14289fdb8d13fe1bc0501e3
[5]db.js: https://github.com/cumt-robin/express-blog-backend/blob/main/utils/db.js
[6]本項(xiàng)目: https://github.com/cumt-robin/express-blog-backend
[7]源碼: https://github.com/cumt-robin/express-blog-backend
[8]Vue3+TS+Node打造個人博客(總覽篇): https://juejin.cn/post/7066966456638013477
END

如果覺得這篇文章還不錯點(diǎn)擊下面卡片關(guān)注我來個【分享、點(diǎn)贊、在看】三連支持一下吧

???“分享、點(diǎn)贊、在看” 支持一波?
?
