7

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

 3 years ago
source link: https://segmentfault.com/a/1190000038876394
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

发布于 1天前

何为系统架构师?

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

这是百度百科的答案


大多数人的问题

如何成为一名前端架构师?
  • 其实,前端架构师不应该是一个头衔,而应该是一个过程。我记得掘金上有人写过一篇文章:《我在一个小公司,我把我们公司前端给架构了》 , (我当时还看成《我把我们公司架构师给上了》)
  • 我面试过很多人,从小公司出来(我也是从一个很小很小的公司出来,现在也没在什么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一般少不了
  • 再者是要多阅读优秀的开源项目源码,不用太多,但是一定要精

以上是我的感悟,后面我会在评论中补充,也欢迎大家在评论中补充探讨!

  • 这是我今年的第一篇原创文章,也是[前端巅峰]公众号开通留言功能后的第一篇文章
  • 如果感觉我写得不错,帮我点个在看/赞转发支持我一下,可以的话,来个星标关注吧!

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK