掘金技术整理(一)掘金的后端架构

20,369 阅读9分钟

掘金从上线到现在,网站的前端重构了 3 次,后端也陆陆续续修改了整个网站的结构 2 次,但是随着业务不断推演复杂,团队人手增加,有需要一波进一步的优化!

这周,我们会根据当下掘金的情况和接下里的主要业务,整理代码。

掘金技术整理系列文章:

后端架构梳理

在架构开发之前,我们首先要梳理现有的网站状态和需求,然后再做优化

  1. 后端语言、框架、功能架构状态
  2. 主要业务
  3. 代码结构
  4. 一些最佳实践
  5. 下一步的展望

后端语言、框架、功能架构状态

后端语言 Node.js

由于网站的早期开发是我来负责的,虽然我曾经写过 PHPRuby on RailsPython 的后台,但因为自己同时写很多前端代码因此对 JavaScript 最熟。与此同时,我们选择了 LeanCloud 作为我们的云存储、推送、托管平台,因而就继续使用了 Node.js 为开发框架。

  • 当前版本:v4.4.5

框架 Express.js

因为选择了 LeanCloud 的缘故,其后端你托管要用 Express 加上本身对这个框架的使用还算熟练,便沿用了下来。

  • 当前版本:4.13.3

功能架构状态

上面已经讲了我们选择了 LeanCloud,具体来讲我们使用了如下功能:

  • LeanStorage:数据存储
  • LeanMessage:移动应用推送
  • Leanengine:云引擎
    • Web 服务器
      • 网页渲染
      • 简单的 API 接口
    • 云函数
      • 数据绑定脚本
      • 定时脚本
  • LeanAnalytics:数据统计工具
  • 当前版本:1.0.0-beta

由于掘金网站的主页面是一个纯前端应用,其部分业务会与后端数据接口直接关联。下图主要展示了我们后端的整体状态:

其中黑色的箭头表示业务需求,而黄色箭头代表数据更新需求。


主要业务模块

Web Server

网站服务器是整个应用最基础的部分,它处理网页端的页面、API 请求,外联用户信息和 LeanStorage 数据库。

config.js               // 后端配置文件
server.js               // 服务器启动脚本
app.js                  // 后端业务,被 server.js 引用
cloud.js                // 云函数定义,被 app.js 引用
webpack.config.js       // webpack 打包配置文件

根据不同的环境(生产环境、前端开发、后端开发),config.js 定义了各个开发环境需要的配置文件信息。例如在 package.jsonnpm scripts 里会定义:

"dev": "cross-env NODE_ENV=devFrontend supervisor -i vue,node_modules server"

这样,npm run dev 就会设置当下的 NODE_ENVdevFrontend,也就是前端开发环境。

接下来 server.js 就会根据当下环境读取的配置文件开启服务端业务,如 app.js 定义的后端业务代码或 cloud.js 里的云函数。当然,在不同环境下 webpack 也会做不同的操作。

app.js

app.js 援引了配置文件后,执行 Express.js 框架,开启业务代码。除了基本的 middleware(页面 template engine jade, cookie, session 等等),其最主要的业务包含:

  • 页面:routes
  • 用户:auth
  • 错误处理

由于网站大量使用前端 router,因而在 routes 定义中也要格外小心,掘金的开发方式是这样的。例如 Styleguide 页面,可能是这样的定义的:

// app.js 文件
// routes/page.js 里定义了网页相关的路由及后端渲染
var page = require('./routes/page');
// app 绑定了 styleguide 页面的路由,及几个前端路由统一
app.get('/styleguide',              page.styleguide);
app.get('/styleguide/base',         page.styleguide);
app.get('/styleguide/components',   page.styleguide);

cloud.js 云函数

LeanCloud 很好地支持了云函数,可以帮助你完成后端的数据触发性 Hook 脚本及定时脚本。

例如每一条评论存储在 Comment Table 里,那么对于这条评论,我们可以捕捉到

beforeSave                  // 存储之前
afterSave                   // 存储之后
beforeUpdate                // 更新之前
afterUpdate                 // 更新之后
beforeDelete                // 删除之前
afterDelete                 // 删除之后

加入,我们想要实现一个功能,就是增加一个 comment 后,更新相应的文章的评论数加一,而删除后则减一。

// cloud.js 文件
// cloud/comment.js 定义了关于 Comment 数据的 Hook 函数
var comment = require('./cloud/comment');
AV.Cloud.afterSave('Comment', comment.afterSave);
AV.Cloud.afterDelete('Comment', comment.afterDelete);
// cloud/comment.js
exports.afterSave = function(request, response) {
    ... // update 相关文章的数据
    if (ok) {
        response.success();
    } else {
        response.error('...');
    }
})

webpack.config.js

webpack 是一个模块打包工具,随着它的插件、业务越来越强大,它也像是之前的 gruntgulp 一样分摊了一部分脚本自动化的功能。

  • webpack.config.base.js:基本的打包配置文件,主要用于开发环境热更新
  • webpack.config.prod.js:生产环境的配置文件,引用了 base,定义打包需求,生成 build 好的文件

这里我就不展开关于 webpack 本身的配置优化的部分。


代码结构

除了上面说的最基本的服务器开启文件,整个项目的代码结构如下:

config.js
server.js

app.js
/routes                 // 各个路由的后端业务逻辑
/views                  // 网页渲染的 jade 文件
/vue                    // 各个页面的 vue 业务逻辑
/redis                  // 缓存定义
/public                 // 外部访问的静态文件
/assets                 // 后端静态文件
  /data                 // 后端静态数据
  /scss                 // SCSS 样式文件

cloud.js
/cloud                  // 云函数相关定义文件

webpack.config.js       // webpack 打包配置文件
webpack.config.base.js
webpack.config.prod.js

当我们要增加一个页面的时候

我们再以 Styleguide 为例,如果我们要添加这样的一个网页代码:

  1. 我们确认它应该在 app.js 的路由的哪个模块下,/styleguide 是一个独立页面,因而它应该被定义在 /routes/page.js 里,并定义到:
     // page.js 文件
     exports.styleguide = function(req, res) {
         res.render('styleguide', {
             title: '掘金前端 Style Guide'
         })
     }
  2. 路由绑定:
     // app.js
     app.get('/styleguide', page.styleguide);
  3. 由于它是是一个网页,因而我们还要在 /views 里面定义 /views/styleguide.jade
  4. 这个时候我们会看这个页面是否会是一个前端网页:
    • 是:在 /vue 里定义,并要在 webpack.config.base.js 里定义打包逻辑
    • 否:则在 page.js 里后端渲染页面是传入数据
  5. 基于网页的复杂度来测试是否需要独立的样式,则需要定义 /assets/scss/styleguide.scss(更多 CSS 结构我们会在另外一篇文章中详细描述)

这样,整个 Styleguide 页面会影响到的后端代码是:

app.js                  // 路由绑定到 /styleguide
/routes
  page.js               // 定义了 styleguide 后端业务
/views
  styleguide.jade
/vue
  /styleguide           // styleguide 相关 vue 前端业务
    main.js
    app.vue
/assets
  /scss
    /pages/styleguide   // [optional] styleguide 内的复杂组件样式
      __style.scss
      layout.scss
      ...
    styleguide.scss     // styleguide 相关独立样式

webpack.config.base.js  // 定义新的 styleguide 相对应的 entry

当我们要增加 Hook 函数的时候

举例,我们要开发一个数据的 Hook 函数到 LeanCloud,比如说每当一个新的 Comment 生成的时候,我们要更新对应文章的评论数及最新的评论:

cloud.js                // AV.Cloud.afterSave('Comment', comment.afterSave)
/cloud
  comment.js            // exports.afterSave = function(request, response) {}

当我们要增加一个定时脚本的时候

  1. 定义脚本在 /cloud/____.js 文件里
  2. 更新 cloud.js 文件注册脚本,如:AV.Cloud.define('cloudFunctionName', functionName)
  3. 部署后,在 LeanCloud 的定时脚本控制台定义运行的周期及时间

一些最佳实践

Node.js

  1. 能用环境变量却别开的数据,都放到 config.js 里,不要用 ifelse 语句区分
  2. 善用 npm scripts 绑定运行函数,如:
     npm run dev             // 开发,测试数据
     npm run dev-backend     // 后端开发,测试数据
     npm run dev-build       // 测试数据,打包
     npm run prod            // 开发,生产数据
     npm run prod-backend    // 后端开发,生产数据
     npm run prod-build      // 生产数据,部署前的打包
     npm run test            // 测试
     npm start               // 开启服务器
  3. 函数名竟可能简单易懂,类似于 getPopEntries 可以明确到 getPopularEntries
  4. 每当安装、删除库的时候,记得用 npm install/uninstall PACKAGE --save/--save-dev,随时更新库
  5. 命名规范
    • 常数:I_LOVE_YOU 用下划线加大写字母
    • 变量:iLoveYou 驼峰,无论是普通变量还是函数名
    • Class:ILoveYou 首字母大写的驼峰,包括 LeanCloud 自己的数据 Table 名
    • 当内嵌的回调函数用到了类似变量名则,使用 _iLoveYou 加前置下划线

LeanCloud

  1. 不要重复 LeanCloud 定义好的数据类名,如 AV.User,不要使用 '_User'
  2. 善用 Promise,将复杂的业务改写为清晰的异步处理流:
     start()
         .then(step1)
         .then(step2)
         .then(step3)
         .then(step4)
         .catch(errorHandler)
         .finally(callback)
    • 但小心,LeanCloud 修改了几个 keyword 函数名,如:
      • always 替换了原有的 finally
      • AV.Promise.error 替换了 Promise.reject
    • AV.Promise.when([promise1, promise2, promise3]) 可以在三个 promise 都完成的情况下做异步操作
  3. LeanStorage 数据库查询的技巧:
    • 多用自带的一些语句,如 exists, startsWith, matches
    • 利用 select 拿去部分数据
    • 查询一个数据时,善用 query.first()query.get(id),但是注意:
      • first()then(function(obj) {}) 中的 obj 可能是 null
      • get() 后如果得不到数据会直接引发 error
    • 使用 AV.Object.createWithoutData(TABLE_NAME, ID) 来实现指针查询,无需取一遍数据
    • 关联查询:query.include('reply.user'),一个文章查询可以用这类语句直接查出来一个评论的回复的用户数据,也就是说拿出来的一个 comment 可以访问到 comment.get('reply')comment.get('reply').get('user')
    • 内嵌查询:matchesQuery

Git 管理

origin/
  master                    // 线上版本
  |- hotfix-login           // 热修复,如登录异常
  release                   // 最新的要部署的版本
  develop                   // 开发分支
  |- feature-homepage-v2    // 正在开发的业务,如第二版的首页
  |- feature-timeline-api   // 正在开发的业务,如 Timeline 的 API

developer-ming
  master
  release
  develop
  |- feature-timeline-api   // 我正在开发这个 feature,不断和 origin 同步

新的业务

  1. 任何的一个新的业务开发都要在本地从 develop fork 出来一个新的 branch feature-name
  2. 业务开发完成后,提交 Pull Request,feature-name -> develop,记得打 label 到 feature
  3. Code Review,如果有错误,在 feature-name 里修复
  4. 相关负责人 Merge Pull Request,假删除这个分支

部署新的业务

  1. develop 上不断 merge 新的 review 过的业务功能
  2. 部署前,发 Pull Request 到 develop -> release
  3. 相关负责人 Code Review,合并代码
  4. npm run build 打包业务代码,准备部署
  5. 部署前的 commit,打 label 到 publish
  6. 发 PR 到 release -> master,标注版本号
  7. 部署,如果出错,回滚或者新建 hotfix 分支

小技巧

  1. developrelease 的同步,用 git rebase
  2. developfeature 分支不做 build 操作
  3. 多人负责一个 feature 的时候,可以就一个功能再分拆到各个 branches

下一步的展望

根据产品接下来的发展路径,有几个重要的功能需要优化。

  1. 后端渲染页面,SEO 优化
  2. 文章页面的收敛,利用 Browser Agent
    • 分享文章:Web Desktop 的详情页
    • 分享文章:Web Mobile 的阅读页
    • 原创文章:Web Desktop/Mobile 的阅读页
    • 沸点活动:Web Desktop/Mobile 的详情页
    • 所有文章:App 内的阅读页 / JSBridge
  3. 不同页面的前后端打包,根据不同页面的需求,加载相应的程序组件
    • 脱离手动打包、配合部署
    • 组件如:
      • 用户
      • Vue 通用样式组件
      • 页面本身的业务代码
  4. 网页间的跳转逻辑
  5. API 服务器独立,并配合移动端也无需求,通用 API 实现
  6. 推送逻辑重构