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

在使用 Express 搭建后端服務(wù)時(shí),主要關(guān)注的幾個(gè)點(diǎn)是:
- 路由中間件和控制器
- SQL處理
- 響應(yīng)返回體數(shù)據(jù)結(jié)構(gòu)
- 錯(cuò)誤碼
- Web安全
- 環(huán)境變量/配置
路由基本上是按模塊或功能去劃分的。
首先是按模塊去劃分一級(jí)路由,各個(gè)模塊的子功能相當(dāng)于是用二級(jí)路由處理。
簡(jiǎn)單舉個(gè)例子,/article路由開(kāi)頭的是文章模塊,/article/add用于新增文章功能。
控制器的概念其實(shí)是從其他語(yǔ)言中借鑒而來(lái)的,Express 并沒(méi)有明確說(shuō)什么是控制器,但在我看來(lái),路由中間件的處理模塊/函數(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)卡,對(duì)所有的請(qǐng)求做一個(gè)基本的校驗(yàn)和攔截。
其實(shí)主要是對(duì)一些敏感接口(比如后臺(tái)維護(hù)類(lèi)的)做一個(gè)權(quán)限校驗(yàn)。
權(quán)限控制這塊,我設(shè)計(jì)得還是比較簡(jiǎn)單粗暴的,因?yàn)槲以跀?shù)據(jù)庫(kù)表中目前只預(yù)留了一個(gè)用戶Tusi,關(guān)聯(lián)的角色也是唯一用到的admin。畢竟目前還沒(méi)考慮開(kāi)放用戶注冊(cè)這類(lèi)的能力,有一個(gè)管理用戶基本上也夠用了。
所以我的設(shè)計(jì)是:只要在我登錄成功后的有效期內(nèi),就有權(quán)限操作敏感接口,否則就無(wú)權(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;
?????????????????//?這里會(huì)直接查庫(kù)驗(yàn)明身份
????????????????connection.query(indexSQL.GetCurrentUser,?[req.cookies.token],?function?(error,?results,?fileds)?{
??????????????????//?身份校驗(yàn)通過(guò),才繼續(xù),否則返回錯(cuò)誤碼
????????????????})
????????????})
????????}?else?{
????????????return?res.send({
????????????????...errcode.AUTH.UNAUTHORIZED
????????????});
????????}
????}?else?{
????????//?不是敏感接口,不校驗(yàn)身份
????????if?(req.method?==?'OPTIONS')?{
????????????//?OPTIONS?類(lèi)型請(qǐng)求不能去連數(shù)據(jù)庫(kù),否則會(huì)導(dǎo)致數(shù)據(jù)庫(kù)連接過(guò)多崩了
????????????next();
????????}?else?{
????????????//?從mysql連接池取得connection
????????????dbUtils.getConnection(function?(connection)?{
????????????????req.connection?=?connection;
????????????????next();
????????????},?function?(err)?{
????????????????return?res.send({
????????????????????...errcode.DB.CONNECT_EXCEPTION
????????????????});
????????????})
????????}
????}
}
如注釋所述,BaseController主要是針對(duì)敏感接口做一個(gè)身份檢查,防止系統(tǒng)數(shù)據(jù)被一些不懷好意的 HTTP 請(qǐng)求給黑了。
20220218更新
按照上面的邏輯實(shí)現(xiàn)功能并上線后,服務(wù)運(yùn)行一段時(shí)間(可能是3~5天)后,能觀察到服務(wù)請(qǐng)求會(huì)變成無(wú)法正常響應(yīng)的狀態(tài)。

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

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

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

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

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

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