从一个优秀开源项目来谈前端架构

Peter谭老师

何为系统架构师?

  • 系统架构师是一个最终确认和评估系统需求,给出开发规范,搭建系统实现的核心构架,并澄清技术细节、扫清主要难点的技术人员。主要着眼于系统的“技术实现”。因此他/她应该是特定的开发平台、语言、工具的大师,对常见应用场景能给出最恰当的解决方案,同时要对所属的开发团队有足够的了解,能够评估自己的团队实现特定的功能需求需要的代价。 系统架构师负责设计系统整体架构,从需求到设计的每个细节都要考虑到,把握整个项目,使设计的项目尽量效率高,开发容易,维护方便,升级简单等
这是百度百科的答案

大多数人的问题

如何成为一名前端架构师?
  • 其实,前端架构师不应该是一个头衔,而应该是一个过程。我记得掘金上有人写过一篇文章:《我在一个小公司,我把我们公司前端给架构了》 , (我当时还看成《我把我们公司架构师给上了》)
  • 我面试过很多人,从小公司出来(我也是从一个很小很小的公司出来,现在也没在什么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一般少不了
  • 再者是要多阅读优秀的开源项目源码,不用太多,但是一定要精
以上是我的感悟,后面我会在评论中补充,也欢迎大家在评论中补充探讨!
写在最后
  • 这是我今年的第一篇原创文章,也是[前端巅峰]公众号开通留言功能后的第一篇文章
  • 如果感觉我写得不错,帮我点个在看/赞转发支持我一下,可以的话,来个星标关注吧!
阅读 5.1k

前端巅峰
注重前端性能优化和前沿技术,重型跨平台开发,即时通讯技术等。 欢迎关注微信公众号:前端巅峰

前端架构师

14.1k 声望
28.3k 粉丝
0 条评论
你知道吗?

前端架构师

14.1k 声望
28.3k 粉丝
宣传栏