從一個優秀開源項目來談前端架構

何爲系統架構師?

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

大多數人的問題

如何成爲一名前端架構師?
  • 其實,前端架構師不該該是一個頭銜,而應該是一個過程。我記得掘金上有人寫過一篇文章:《我在一個小公司,我把咱們公司前端給架構了》 , (我當時還當作《我把咱們公司架構師給上了》)
  • 我面試過不少人,從小公司出來(我也是從一個很小很小的公司出來,如今也沒在什麼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);
});
  • 幾個優秀的地方node

    • 每一個回調函數都會有聲明功能註釋
    • 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通常少不了
  • 再者是要多閱讀優秀的開源項目源碼,不用太多,可是必定要精
以上是個人感悟,後面我會在評論中補充,也歡迎你們在評論中補充探討!
寫在最後
  • 這是我今年的第一篇原創文章,也是[前端巔峯]公衆號開通留言功能後的第一篇文章
  • 若是感受我寫得不錯,幫我點個在看/贊轉發支持我一下,能夠的話,來個星標關注吧!
相關文章
相關標籤/搜索