從一個優秀開源專案來談前端架構

Peter譚老師發表於2021-01-12

何為系統架構師?

  • 系統架構師是一個最終確認和評估系統需求,給出開發規範,搭建系統實現的核心構架,並澄清技術細節、掃清主要難點的技術人員。主要著眼於系統的“技術實現”。因此他/她應該是特定的開發平臺、語言、工具的大師,對常見應用場景能給出最恰當的解決方案,同時要對所屬的開發團隊有足夠的瞭解,能夠評估自己的團隊實現特定的功能需求需要的代價。 系統架構師負責設計系統整體架構,從需求到設計的每個細節都要考慮到,把握整個專案,使設計的專案儘量效率高,開發容易,維護方便,升級簡單等
這是百度百科的答案

大多數人的問題

如何成為一名前端架構師?
  • 其實,前端架構師不應該是一個頭銜,而應該是一個過程。我記得掘金上有人寫過一篇文章:《我在一個小公司,我把我們公司前端給架構了》 , (我當時還看成《我把我們公司架構師給上了》)
  • 我面試過很多人,從小公司出來(我也是從一個很小很小的公司出來,現在也沒在什麼BATJ ),最大的問題在於,覺得自己不是leader,就沒有想過如何去提升、優化專案,而是去研究一些花裡胡哨的東西,卻沒有真正使用在專案中。(自然很少會有深度)
  • 在一個兩至三人的前端團隊小公司,你去不斷優化、提升專案體驗,更新迭代替換技術棧,那麼你就是前端架構師

正式開始

我們從一個比較不錯的專案入手,談談一個前端架構師要做什麼
  • SpaceX-API
  • SpaceX-API 是什麼?
  • SpaceX-API 是一個用於火箭、核心艙、太空艙、發射臺和發射資料的開源 REST API(並且是使用Node.js編寫,我們用這個專案借鑑無可厚非)
為了閱讀的舒適度,我把下面的正文儘量口語化一點
先把程式碼搞下來
git clone https://github.com/r-spacex/SpaceX-API.git
  • 一個優秀的開源專案搞下來以後,怎麼分析它?大部分時候,你應該先看它的目錄結構以及依賴的第三方庫(package.json檔案)
找到package.json檔案的幾個關鍵點:
  • main欄位(專案入口)
  • scripts欄位(執行命令指令碼)
  • dependenciesdevDependencies欄位(專案的依賴,區分線上依賴和開發依賴,我本人是非常看中這個點,SpaceX-API也符合我的觀念,嚴格的區分依賴按照)
 "main": "server.js",
   "scripts": {
    "test": "npm run lint && npm run check-dependencies && jest --silent --verbose",
    "start": "node server.js",
    "worker": "node jobs/worker.js",
    "lint": "eslint .",
    "check-dependencies": "npx depcheck --ignores=\"pino-pretty\""
  },
  • 通過上面可以看到,專案入口為server.js
  • 專案啟動命令為npm run start
  • 幾個主要的依賴:
    "koa": "^2.13.0",
    "koa-bodyparser": "^4.3.0",
    "koa-conditional-get": "^3.0.0",
    "koa-etag": "^4.0.0",
    "koa-helmet": "^6.0.0",
    "koa-pino-logger": "^3.0.0",
    "koa-router": "^10.0.0",
    "koa2-cors": "^2.0.6",
    "lodash": "^4.17.20",
    "moment-range": "^4.0.2",
    "moment-timezone": "^0.5.32",
    "mongoose": "^5.11.8",
    "mongoose-id": "^0.1.3",
    "mongoose-paginate-v2": "^1.3.12",
    "eslint": "^7.16.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-jest": "^24.1.3",
    "eslint-plugin-mongodb": "^1.0.0",
    "eslint-plugin-no-secrets": "^0.6.8",
    "eslint-plugin-security": "^1.4.0",
    "jest": "^26.6.3",
    "pino-pretty": "^4.3.0"
  • 都是一些通用主流庫: 主要是koa框架,以及一些koa的一些中介軟體,monggose(連線使用mongoDB),eslint(程式碼質量檢查)
這裡強調一點,如果你的程式碼需要兩人及以上維護,我就強烈建議你不要使用任何黑魔法,以及不使用非主流的庫,除非你編寫核心底層邏輯時候非用不可(這個時候應該只有你維護)
專案目錄

  • 這是一套標準的REST API,嚴格分層
  • 幾個重點目錄 :

    • server.js 專案入口
    • app.js 入口檔案
    • services 資料夾=>專案提供服務層
    • scripts 資料夾=>專案指令碼
    • middleware 資料夾=>中介軟體
    • docs 資料夾=>文件存放
    • tests 資料夾=>單元測試程式碼存放
    • .dockerignore docker的忽略檔案
    • Dockerfile 執行docker build命令讀取配置的檔案
    • .eslintrc eslint配置檔案
    • jobs 資料夾=>我想應該是對應檢查他們api服務的程式碼,裡面都是準備的一些引數然後直接調服務

逐個分析

從專案依賴安裝說起
  • 安裝環境嚴格區分開發依賴和線上依賴,讓閱讀程式碼者一目瞭然哪些依賴是線上需要的
  "dependencies": {
    "blake3": "^2.1.4",
    "cheerio": "^1.0.0-rc.3",
    "cron": "^1.8.2",
    "fuzzball": "^1.3.0",
    "got": "^11.8.1",
    "ioredis": "^4.19.4",
    "koa": "^2.13.0",
    "koa-bodyparser": "^4.3.0",
    "koa-conditional-get": "^3.0.0",
    "koa-etag": "^4.0.0",
    "koa-helmet": "^6.0.0",
    "koa-pino-logger": "^3.0.0",
    "koa-router": "^10.0.0",
    "koa2-cors": "^2.0.6",
    "lodash": "^4.17.20",
    "moment-range": "^4.0.2",
    "moment-timezone": "^0.5.32",
    "mongoose": "^5.11.8",
    "mongoose-id": "^0.1.3",
    "mongoose-paginate-v2": "^1.3.12",
    "pino": "^6.8.0",
    "tle.js": "^4.2.8",
    "tough-cookie": "^4.0.0"
  },
  "devDependencies": {
    "eslint": "^7.16.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-jest": "^24.1.3",
    "eslint-plugin-mongodb": "^1.0.0",
    "eslint-plugin-no-secrets": "^0.6.8",
    "eslint-plugin-security": "^1.4.0",
    "jest": "^26.6.3",
    "pino-pretty": "^4.3.0"
  },
專案目錄劃分
  • 目錄劃分,嚴格分層
  • 通用,清晰簡介明瞭,讓人一看就懂
正式開始看程式碼
  • 入口檔案,server.js開始
const http = require('http');
const mongoose = require('mongoose');
const { logger } = require('./middleware/logger');
const app = require('./app');

const PORT = process.env.PORT || 6673;
const SERVER = http.createServer(app.callback());

// Gracefully close Mongo connection
const gracefulShutdown = () => {
  mongoose.connection.close(false, () => {
    logger.info('Mongo closed');
    SERVER.close(() => {
      logger.info('Shutting down...');
      process.exit();
    });
  });
};

// Server start
SERVER.listen(PORT, '0.0.0.0', () => {
  logger.info(`Running on port: ${PORT}`);

  // Handle kill commands
  process.on('SIGTERM', gracefulShutdown);

  // Prevent dirty exit on code-fault crashes:
  process.on('uncaughtException', gracefulShutdown);

  // Prevent promise rejection exits
  process.on('unhandledRejection', gracefulShutdown);
});
  • 幾個優秀的地方

    • 每個回撥函式都會有宣告功能註釋
    • SERVER.listen的host引數也會傳入,這裡是為了避免產生不必要的麻煩。至於這個麻煩,我這就不解釋了(一定要有能看到的預設值,而不是去靠猜)
    • 對於監聽埠啟動服務以後一些異常統一捕獲,並且統一日誌記錄,process程式退出,防止出現僵死執行緒、埠占用等(因為node部署時候可能會用pm2等方式,在 Worker 執行緒中,process.exit()將停止當前執行緒而不是當前程式)
app.js入口檔案
  • 這裡是由koa提供基礎服務
  • monggose負責連線mongoDB資料庫
  • 若干中介軟體負責 跨域、日誌、錯誤、資料處理等
const conditional = require('koa-conditional-get');
const etag = require('koa-etag');
const cors = require('koa2-cors');
const helmet = require('koa-helmet');
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const mongoose = require('mongoose');
const { requestLogger, logger } = require('./middleware/logger');
const { responseTime, errors } = require('./middleware');
const { v4 } = require('./services');

const app = new Koa();

mongoose.connect(process.env.SPACEX_MONGO, {
  useFindAndModify: false,
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useCreateIndex: true,
});

const db = mongoose.connection;

db.on('error', (err) => {
  logger.error(err);
});
db.once('connected', () => {
  logger.info('Mongo connected');
  app.emit('ready');
});
db.on('reconnected', () => {
  logger.info('Mongo re-connected');
});
db.on('disconnected', () => {
  logger.info('Mongo disconnected');
});

// disable console.errors for pino
app.silent = true;

// Error handler
app.use(errors);

app.use(conditional());

app.use(etag());

app.use(bodyParser());

// HTTP header security
app.use(helmet());

// Enable CORS for all routes
app.use(cors({
  origin: '*',
  allowMethods: ['GET', 'POST', 'PATCH', 'DELETE'],
  allowHeaders: ['Content-Type', 'Accept'],
  exposeHeaders: ['spacex-api-cache', 'spacex-api-response-time'],
}));

// Set header with API response time
app.use(responseTime);

// Request logging
app.use(requestLogger);

// V4 routes
app.use(v4.routes());

module.exports = app;
  • 邏輯清晰,自上而下,首先連線db資料庫,掛載各種事件後,經由koa各種中介軟體,而後真正使用koa路由提供api服務(程式碼編寫順序,即程式碼執行後的業務邏輯,我們寫前端的react等的時候,也提倡由生命週期執行順序去編寫元件程式碼,而不是先編寫unmount生命週期,再編寫mount),例如應該這樣:
//元件掛載
componentDidmount(){

}
//元件需要更新時
shouldComponentUpdate(){

}
//元件將要解除安裝
componentWillUnmount(){

}
...
render(){}
router的程式碼,簡介明瞭
const Router = require('koa-router');
const admin = require('./admin/routes');
const capsules = require('./capsules/routes');
const cores = require('./cores/routes');
const crew = require('./crew/routes');
const dragons = require('./dragons/routes');
const landpads = require('./landpads/routes');
const launches = require('./launches/routes');
const launchpads = require('./launchpads/routes');
const payloads = require('./payloads/routes');
const rockets = require('./rockets/routes');
const ships = require('./ships/routes');
const users = require('./users/routes');
const company = require('./company/routes');
const roadster = require('./roadster/routes');
const starlink = require('./starlink/routes');
const history = require('./history/routes');
const fairings = require('./fairings/routes');

const v4 = new Router({
  prefix: '/v4',
});

v4.use(admin.routes());
v4.use(capsules.routes());
v4.use(cores.routes());
v4.use(crew.routes());
v4.use(dragons.routes());
v4.use(landpads.routes());
v4.use(launches.routes());
v4.use(launchpads.routes());
v4.use(payloads.routes());
v4.use(rockets.routes());
v4.use(ships.routes());
v4.use(users.routes());
v4.use(company.routes());
v4.use(roadster.routes());
v4.use(starlink.routes());
v4.use(history.routes());
v4.use(fairings.routes());

module.exports = v4;
模組眾多,找幾個代表性的模組
  • admin模組
const Router = require('koa-router');
const { auth, authz, cache } = require('../../../middleware');

const router = new Router({
  prefix: '/admin',
});

// Clear redis cache
router.delete('/cache', auth, authz('cache:clear'), async (ctx) => {
  try {
    await cache.redis.flushall();
    ctx.status = 200;
  } catch (error) {
    ctx.throw(400, error.message);
  }
});

// Healthcheck
router.get('/health', async (ctx) => {
  ctx.status = 200;
});

module.exports = router;
  • 分析程式碼
  • 這是一套標準的restful API , 提供的/admin/cache介面,請求方式為delete,請求這個介面,首先要經過authauthz兩個中介軟體處理
這裡補充一個小細節
  • 一個使用者訪問一套系統,有兩種狀態,未登陸和已登陸,如果你未登陸去執行一些操作,後端應該返回401。但是登入後,你只能做你許可權內的事情,例如你只是一個打工人,你說你要關閉這個公司,那麼對不起,你的狀態碼此時應該是403
回到admin
  • 此刻的你,想要清空這個快取,呼叫/admin/cache介面,那麼首先要經過auth中介軟體判斷你是否有登入
/**
 * Authentication middleware
 */
module.exports = async (ctx, next) => {
  const key = ctx.request.headers['spacex-key'];
  if (key) {
    const user = await db.collection('users').findOne({ key });
    if (user?.key === key) {
      ctx.state.roles = user.roles;
      await next();
      return;
    }
  }
  ctx.status = 401;
  ctx.body = 'https://youtu.be/RfiQYRn7fBg';
};
  • 如果沒有登入過,那麼意味著你沒有許可權,此時為401狀態碼,你應該去登入.如果登入過,那麼應該前往下一個中介軟體authz. (所以redux的中介軟體原始碼是多麼重要.它可以說貫穿了我們整個前端生涯,我以前些過它的分析,有興趣的可以翻一翻公眾號)
/**
 * Authorization middleware
 *
 * @param   {String}   role   Role for protected route
 * @returns {void}
 */
module.exports = (role) => async (ctx, next) => {
  const { roles } = ctx.state;
  const allowed = roles.includes(role);
  if (allowed) {
    await next();
    return;
  }
  ctx.status = 403;
};
  • authz這裡會根據你傳入的操作型別(這裡是'cache:clear'),看你的對應所有許可權roles裡面是否包含傳入的操作型別role.如果沒有,就返回403,如果有,就繼續下一個中介軟體 - 即真正的/admin/cache介面
// Clear redis cache
router.delete('/cache', auth, authz('cache:clear'), async (ctx) => {
  try {
    await cache.redis.flushall();
    ctx.status = 200;
  } catch (error) {
    ctx.throw(400, error.message);
  }
});
  • 此時此刻,使用try catch包裹邏輯程式碼,當redis清除所有快取成功即會返回狀態碼400,如果報錯,就會丟擲錯誤碼和原因.接由洋蔥圈外層的error中介軟體處理
/**
 * Error handler middleware
 *
 * @param   {Object}    ctx       Koa context
 * @param   {function}  next      Koa next function
 * @returns {void}
 */
module.exports = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err?.kind === 'ObjectId') {
      err.status = 404;
    } else {
      ctx.status = err.status || 500;
      ctx.body = err.message;
    }
  }
};
  • 這樣只要任意的server層內部出現異常,只要丟擲,就會被error中介軟體處理,直接返回狀態碼和錯誤資訊. 如果沒有傳入狀態碼,那麼預設是500(所以我之前說過,程式碼要穩定,一定要有顯示的指定預設值,要關注程式碼異常的邏輯,例如前端setLoading,請求失敗也要取消loading,不然使用者就沒法重試了,有可能這一瞬間只是使用者網路出錯呢)
補一張koa洋蔥圈的圖

再接下來看其他的services
  • 現在,都非常輕鬆就能理解了
// Get one history event
router.get('/:id', cache(300), async (ctx) => {
  const result = await History.findById(ctx.params.id);
  if (!result) {
    ctx.throw(404);
  }
  ctx.status = 200;
  ctx.body = result;
});

// Query history events
router.post('/query', cache(300), async (ctx) => {
  const { query = {}, options = {} } = ctx.request.body;
  try {
    const result = await History.paginate(query, options);
    ctx.status = 200;
    ctx.body = result;
  } catch (error) {
    ctx.throw(400, error.message);
  }
});
通過這個專案,我們能學到什麼
  • 一個能上天的專案,必然是非常穩定、高可用的,我們首先要學習它的優秀點:用最簡單的技術加上最簡單的實現方式,讓人一眼就能看懂它的程式碼和分層
  • 再者:簡潔的註釋是必要的
  • 從業務角度去抽象公共層,例如鑑權、錯誤處理、日誌等為公共模組(中介軟體,前端可能是一個工具函式或元件)
  • 多考慮錯誤異常的處理,前端也是如此,js大多錯誤發生來源於a.b.c這種程式碼(如果a.bundefined那麼就會報錯了)
  • 顯示的指定預設值,不讓程式碼閱讀者去猜測
  • 目錄分割槽必定要簡潔明瞭,分層清晰,易於維護和擴充
成為一個優秀前端架構師的幾個點
  • 原生JavaScript、CSS、HTML基礎紮實(系統學習過)
  • 原生Node.js基礎紮實(系統學習過),Node.js不僅提供服務,更多的是用於製作工具,以及現在serverless場景也會用到,還有ssr
  • 熟悉框架和類庫原理,能手寫簡易的常用類庫,例如promise redux 等
  • 資料結構基礎紮實,瞭解常用、常見演算法
  • linux基礎紮實(做工具,搭環境,編寫構建指令碼等有會用到)
  • 熟悉TCP和http等通訊協議
  • 熟悉作業系統linux Mac windows iOS 安卓等(在跨平臺產品時候會遇到)
  • 會使用docker(部署相關)
  • 會一些c++最佳(在addon場景等,再者Node.js和JavaScript本質上是基於C++
  • 懂基本資料庫、redis、nginxs操作,像跨平臺產品,基本前端都會有個sqlite之類的,像如果是node自身提供服務,資料庫和redis一般少不了
  • 再者是要多閱讀優秀的開源專案原始碼,不用太多,但是一定要精
以上是我的感悟,後面我會在評論中補充,也歡迎大家在評論中補充探討!
寫在最後
  • 這是我今年的第一篇原創文章,也是[前端巔峰]公眾號開通留言功能後的第一篇文章
  • 如果感覺我寫得不錯,幫我點個在看/贊轉發支援我一下,可以的話,來個星標關注吧!

相關文章