Skip to main content

BFF基础架构实现

· 18 min read
石晓波

项目基础构建

本项目使用的是 MVC 基础机构模式

项目根目录下执行如下命令创建基础的 package.json:

npm init -y

根目录下创建 src 文件,src 下分别创建 client 和 server 文件夹,用来保存客户端代码和服务器代码。使用基础的 MVC 架构模式创建文件夹,完整主文件目录结构如下:

├── src
├──── web
├─────── assets
├─────── components
├─────── views
├──── server
├─────── assets
├─────── controllers
├─────── middlewares
├─────── models
├── node_modules
├── scripts
├── logs
├── dist
├── config
├── tests
├── README.md #帮助项目开发文件
├── app.js #项目启动文件
├── package-lock.json
└── package.json

基础代码实现

服务端代码

创建 app.js 为入口文件,引入 koa web 开发框架,初始化 app。编写配置文件,配置文件需要根据进程中的参数判断开发环境开始生产环境,配置文件代码如下:

/src/server/config/index.js
import { extend } from "lodash";
import { join } from "path";

let config = {
//配置后续资源路径和其他公共配置
viewDir: join(__dirname, "..", "views"),
staticDir: join(__dirname, "..", "assets"),
};

if (process.env.NODE_ENV === "development") {
let localConfig = {
port: 8081,
memoryFlag: false,
};

config = extend(config, localConfig);
}

if (process.env.NODE_ENV === "production") {
let localConfig = {
port: 8082,
memoryFlag: true,
};

config = extend(config, localConfig);
}

export default config;

初始化 koa,并引入 swig 模板引擎。

/src/server/app.js
import Koa from "koa";
import render from "koa-swig";
import { wrap } from "co";
import { port, viewDir, memoryFlag } from "./config";

const app = new Koa();

app.context.render = wrap(
render({
root: viewDir,
autoescape: true,
cache: memoryFlag,
ext: "html",
varControls: ["[[", "]]"],
writeBody: false,
})
);

app.listen(port, () => {
console.log("🍺服务器启动成功", port);
});

Controller

根据 MVC 架构模式定义 Controller,首先定义 Controller 基类,包含基础的 log 信息打印等业务信息,然后定义子类 IndexController、BooksController。

/src/server/controllers/Controller
class Controller {
constructor() {}

log() {
console.log("父类Controller");
}
}

BooksController 中整合了 Model 和 View

/src/server/controllers/BooksController.js
import Controller from "./Controller";
import Books from "../models/Books";
class BooksController extends Controller {
constructor() {
super();
}

//服务端渲染 直出
async actionIndex(ctx, next) {
const book = new Book();
const data = await book.getData();
ctx.body = await ctx.render("books/pages/list", data);
}

async actionCreate(ctx, next) {
ctx.body = await ctx.render("books/pages/create");
}
}
/src/server/controllers/IndexController.js
import Controller from "./Controller";
class IndexController extends Controller {
constructor() {
super();
}
async actionIndex(ctx, next) {
ctx.body = "石晓波🏮";
}
}
export default IndexController;

Router

定义了 Controller 和 Action 后需要将这些方法和路由进行整合,所以加入 koa-simple-router 的使用,代码如下:

/src/server/controllers/index.js
import router from "koa-simple-router";
import BooksController from "./BooksController";
import IndexController from "./IndexController";
const bookController = new BooksController();
const indexController = new IndexController();

export default (app) => {
app.use(
router((_) => {
_.get("/", indexController.actionIndex);
_.get("/books/list", bookController.actionIndex);
_.get("/books/create", bookController.actionCreate);
})
);
};

同时在 app.js 中添加对如上函数的引用并传入 app

/src/server/app.js
import controllers from './controllers';
...
controllers(app)
...

Model

model 定义数据,作为中间代理层,Model 层主要是做数据交互,数据获取、数据整合。如上 Controller 中使用了 Books,如下是 Books 的代码:

/src/server/Model/Books.js
import { get } from "axios";
class Books {
getData() {
return get("http://localhost/basic/web/index.php?r=books");
}
}

错误处理

程序运行难免会遇到错误,我们需要将错误信息收集并做持久化处理,所以自定义处理处理中间件并使用 log4j 对错误信息进行持久化处理。

首先在 app 文件中增加对 log4j 的配置以及引入错误处理中间件,需要注意的是错误处理中间件需要在业务代码前才能捕获到业务代码的错误。

/src/server/app.js
import { configure, getLogger } from "log4js";
import errorHandler from "./middlewares/errorHandler.js";
configure({
appenders: { cheese: { type: "file", filename: "logs/yd.log" } },
categories: { default: { appenders: ["cheese"], level: "error" } },
});
const logger = getLogger("cheese");

errorHandler.error(app, logger);

如下是自定义错误处理中间件代码:

/src/server/middleWares/errorHandler.js
class errorHandler{
static error(app,logger){
app.use(async (ctx, next)=>{
try{
await next();
} catch(e){
logger.error(e);
ctx.body = "500请求啦~恢复中.'"
}
})

app.use(async (ctx, next) => {
await next();
if(404 !== ctx.status){
return;
}
ctx.body = '<script type="text/javascript" src="//qzonestyle.gtimg.cn/qzone/hybrid/app/404/search_children.js" charset="utf-8"></script>';
})
}
}

客户端代码

swig模板引擎

首先需要创建模板公共布局文件,后续所有页面都可以基于公共文件进行块内容填充,如下是构建公共布局模板:

/src/web/views/layouts/layout.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{% block title %}{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
<div>
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>

swig使用了块占位符,以此模板为基础的页面可以只写填充标记相关的代码,模板会自行编译出对应的html文件。

创建模块页面

创建books模块继承自基础模块,并引入定义的组件

/src/web/views/books/pages/list.html
{% extends '@layouts/layout.html' %}

{% block title %} 图书列表页📚 {%endblock %}

{% block head %}
<!--injectcss-->
{% endblock %}

{% block content %}
{% include "@components/banner/banner.html" %}
<h1>展示图书</h1>
{% endblock %}

{% block scripts %}
<!--injectjs-->
{% endblock %}
/src/web/views/books/pages/create.html
{% extends '@layouts/layout.html' %}

{% block title %} 图书列表页📚 {%endblock %}

{% block head %}
<!--injectcss-->
{% endblock %}

{% block content %}
{% include "@components/banner/banner.html" %}
<h1>展示图书</h1>
{% endblock %}

{% block scripts %}
<!--injectjs-->
{% endblock %}

定义公共组件

定义公共组件banner

/src/web/components/banner/banner.html
<div class="baner">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/books/list">展示图书</a></li>
<li><a href="/books/create">添加图书</a></li>
</ul>
</div>
/src/web/components/banner/banner.js
const banner = {
init() {
console.log('banner');
},
};
export default banner;

webpack入口文件

webpack是以js为入口文件的,所以给每个页面都定义一个入口文件,这里设置一种规则模块下的入口文件定义为[模块名]-[页面名].entry.js,webpack打包时可以根据制定的规则解析入口js文件名,将打包后的文件插入到对应的html页面中。

/src/web/views/books/boosk-create.entry.js
import banner from '@/components/banner/banner.js';
banner.init();
/src/web/views/books/boosk-list.entry.js
import banner from '@/components/banner/banner.js';
banner.init();

编译环境构建

编译环境区分生产环境和开发环境webpack通过使用webpack-merge对不同环境的配置文件和基础文件进行合并,gulp通过使用process.env.NODE_ENV来区分开发环境和生产环境。

客户端代码编译

客户端代码使用webpack进行编译,因为是MPA应用,所以需要定义多入口,使用glob使用正则匹配的方式获取多入口的js,获取的js为上面根据命名规则定义的入口文件,循环遍历这些入口文件解析出模块和文件名称,将获取的html文件为webpack打包插入的目标文件也就是template属性,并将以template为模板打包后的文件输出到dist下同等的目录下,这里需要注意两点,第一如果不设置html-webpack-plugin的chunk属性那么会将所有的js文件都插入到模板中,所以需要设置chunk属性['runtime', entryKey],entryKey为[模块名]-[文件名]的组合,为什么要使用这个名称的文件呢,因为多入口打包是以该名称为key,目标js文件为目标定义的。完成入口和循环生成html-webpack-plugin的实例配置后将插件的实例写入webpack的plugins中。如上插件中使用了chunks的配置那么我们就需要将webpack打包中的runtime单独提出来,配置optimization.runtimeChunk即可。

如下是webpack公共部分的配置:

const argv = require('yargs-parser')(process.argv.slice(2));
const _mode = argv.mode || 'development';
const _mergeConfig = require(`./config/webpack.${_mode}.js`);
const { merge } = require('webpack-merge');
const { sync } = require('glob');
const { resolve } = require('path');
const files = sync('./src/web/views/**/*.entry.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlAfterPlugin = require('./config/HtmlAfterPlugin');

let _entry = {};
let _plugins = [];
for (let item of files) {
// console.log(item)
if (/.+\/([a-zA-Z]+-[a-zA-Z]+)(\.entry\.js)/g.test(item) == true) {
const entryKey = RegExp.$1;
_entry[entryKey] = item;
const [dist, template] = entryKey.split('-');
_plugins.push(
new HtmlWebpackPlugin({
filename: `../views/${dist}/pages/${template}.html`,
template: `src/web/views/${dist}/pages/${template}.html`,
chunks: ['runtime', entryKey],
inject: false,
})
);
} else {
console.log('🐖', '项目配置匹配失败');
process.exit(-1);
}
}
const webpackConfig = {
entry: _entry,
optimization: {
runtimeChunk: {
name: 'runtime',
},
},
plugins: [..._plugins, new HtmlAfterPlugin()],
resolve: {
alias: {
'@': resolve('src/web'),
},
},
};
module.exports = merge(webpackConfig, _mergeConfig);

对于入口文件来说,开发环境和生产环境略微有些区别,生产环境建议给js文件名加hash的方式防止文件缓存问题,配置如下

//开发环境
output: {
path: join(__dirname, '../dist/assets'),
publicPath: '/',
filename: 'scripts/[name].bundle.js',
},
//生产环境
output: {
path: join(__dirname, '../dist/assets'),
publicPath: '/',
filename: 'scripts/[name].[contenthash:5].bundle.js',
},

配置运行命令后执行编译,发现虽然打包成功但是我们的components和layout在dist下没有,所以需要将文件拷贝到dist下,使用copy-wepbkac-plugin配置如下:

  new CopyPlugin({
patterns: [
{
from: join(__dirname, '../', 'src/web/views/layouts/layout.html'),
to: '../views/layouts/layout.html',
},
],
}),
new CopyPlugin({
patterns: [
{
from: 'src/web/components/**/*.html',
to: '../components',
transformPath(targetPath, absolutePath) {
//win环境
return targetPath.replace('src\\web\\components\\', '');
//mac环境
// return targetPath.replace('src/web/components/', '');
},
},
],
}),

自定义 html-after-plugin 插件

如上配置都完成后发现还是不能运行,查看html文件后发现打包虽然完成,但是js文件插入的位置有问题,未能按照我们在模板中定义的位置将js文件插入进去,而是将js文件都追加在了最后面,导致运行的时候生成html文件中没有引入js,所以需要我们自定义webpack插件,因为自定义的webpack插件是基于html-webpack-plugin的所以需要关闭该插件的默认插入操作,设置inject为false。具体的API可以查看npm.js中html-webpack-plugin中的介绍,自定义的webpack插件中主要使用到了两个生命周期beforeAssetTagGenerationbeforeEmit,第一个生命周期是资源生成前可以保存所有的资源路径,第二个生命周期是插入到html之前所以可以在该生命周期对html内容进行替换,具体实现如下:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const pluginName = 'HtmlAfterPlugin';

const assetsHelp = (data) => {
let js = [];
const getAssetsName = {
js: (item) => `<script src="${item}"></script>`,
};
for (let jsitem of data.js) {
js.push(getAssetsName.js(jsitem));
}
return {
js,
};
};

class HtmlAfterPlugin {
constructor() {
this.jsarr = [];
}
apply(compiler) {
compiler.hooks.compilation.tap(pluginName, (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
pluginName,
(htmlPligunData, cb) => {
const { js } = assetsHelp(htmlPligunData.assets);
this.jsarr = js;
cb(null, htmlPligunData);
}
);
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
pluginName,
(data, cb) => {
let _html = data.html;
_html = _html.replace('<!--injectjs-->', this.jsarr.join(''));
_html = _html.replace(/@components/g, '../../../components');
_html = _html.replace(/@layouts/g, '../../layouts');
data.html = _html;
cb(null, data);
}
);
});
}
}

module.exports = HtmlAfterPlugin;

在wepbpack中引入我们自定义的插件,发现js插入到了理想中的位置。

服务端代码编译

gulp小巧且容易使用,因为我们这里只对ES Module进行编译,编译目标是Commonjs的规范,使用gulp不会产生其他冗余的代码,代码也比较简洁,需要注意的是我们使用的编译插件是@babel/plugin-transform-modules-commonjs所以这里不能使用wepback中使用的babel配置,所以需要设置babelrc属性为false,开发环境需要使用gulp-watch对文件进行监听,当文件发生变化时自动编译。为了优化运行环境引入gulp-rollup的tree-shaking,对无用代码进行清洗,编译环境自动删除无用代码。完整配置如下:

gulpfile.js
const gulp = require('gulp');
const watch = require('gulp-watch');
const entry = './src/server/**/*.js';
const plumber = require('gulp-plumber');
const cleanEntry = './src/server/config/index.js';
const rollup = require('gulp-rollup');
const babel = require('gulp-babel');
const replace = require('@rollup/plugin-replace');
const prepack = require('gulp-prepack');
function builddev() {
return watch(entry, { ignoreInitial: false }, () => {
gulp
.src(entry)
.pipe(plumber())
.pipe(
babel({
babelrc: false,
plugins: ['@babel/plugin-transform-modules-commonjs'],
})
)
.pipe(gulp.dest('dist'));
});
}

function buildprod() {
return gulp
.src(entry)
.pipe(
babel({
babelrc: false,
ignore: [cleanEntry],
plugins: ['@babel/plugin-transform-modules-commonjs'],
})
)
.pipe(gulp.dest('dist'));
}

//清理环境变量
function buildconfig() {
return (
gulp
.src(entry)
.pipe(
rollup({
input: cleanEntry,
output: {
format: 'cjs',
},
plugins: [
replace({
'process.env.NODE_ENV': JSON.stringify('production'),
}),
],
})
)
// .pipe(prepack({}))
.pipe(gulp.dest('./dist'))
);
}

let build = gulp.series(builddev);
if (process.env.NODE_ENV == 'production') {
build = gulp.series(buildprod, buildconfig);
}

gulp.task('default', build);

落地页和切页优化

完成所有的配置后页面可以正常显示也可以正常的切换页面以及刷新。接下来我们给layout中增加一些公共库的使用比如jquery,发现每次刷新切换都会重新加载jquery

pjax

修改公共的layout.html,增加如下代码:

  <div id="app">
{% block content %}{% endblock %}
</div>
<script>
<script src="https://cdn.staticfile.org/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.staticfile.org/jquery.pjax/2.0.1/jquery.pjax.min.js"></script>
$(document).pjax('a', '#app');
</script>

为了优化如上这种情况,避免静态公共资源被重复加载,造成资源的浪费,这里选择加入pjax,pjax=ajax+pushState实现不刷新页面而修改页面内容。首先需要做的就是拦截所有<a>标签的默认事件,然后传入一个容器,容器就是需要替换的内容展示的位置。

修改代码重新切换页面发现http://localhost:8081/books/list?_pjax=%23app多了这么一条请求,因为服务器没有对这条请求进行响应所以还是走原来的路线,发起默认的list请求,所以需要增加服务器对pjax请求的响应处理。需要增加对header头中的x-pjax为判断条件,返回内容,注意要主动设置ctx.status=200ctx.type = 'html',那么到底要返回什么内容呢,这里就需要注意页面内容的加载分为落地页站内切,落地页需要返回整体所有的内容,站内切则只需要返回部分内容,到底返回那些内容呢,我们可以给需要动态返回的内容都增加一个classpjaxcontent,通常会给所有的组件都会添加。这里为了服务器端对html进行解析和DOM操作引入一个库cheerio类似服务器端的jquery,动态获取到所有的pjaxcontent元素通过流的方式分片发送给客户端。代码如下:

async actionIndex(ctx, next) {
// const book = new Book();
// const { data } = await book.getData();
const data = '123';
// console.log('🐻', data);
// ctx.body = {
// data,
// };
const html = await ctx.render('books/pages/list', {
data,
});
if (ctx.request.header['x-pjax']) {
console.log('站内切');
const $ = cheerio.load(html);
ctx.status = 200;
ctx.type = 'html';
//每个组件都需要加 否则切页动态替换的时候不能加载所有的组件
$('.pjaxcontent').each(function () {
ctx.res.write($(this).html());
});
//因为有缓存的原因原地切虽然没有重新加载js但是已经在栈中,当两个页面来回切就会发现js没有运行所以要加如下代码
$('.lazyload-js').each(function () {
ctx.res.write(
`<script class="lazyload-js" src="${$(this).attr('src')}"></script>`
);
});
ctx.res.end();
} else {
function createSSRStreamPromise() {
console.log('落地页');
return new Promise((resolve, reject) => {
const htmlStream = new Readable()
htmlStream.push(html);
htmlStream.push(null);
ctx.type = 'html';
ctx.status = 200;
htmlStream.on('error', (err) => {
reject(err)
})
.pipe(ctx.res)
})
}

await createSSRStreamPromise();
}
ctx.body = await ctx.render('books/pages/list');
}

总结

BFF基础架构已经全部实现也实现了简单的性能优化,站内切只加载部分资源,刷页全部加载,即保证了落地页的直出又保证了切页速度,结合了SSR的优点和SPA的优点。如上文章中使用了分片传输到客户端使用了bigpie,如需了解bigpie的详细信息可以查看bigpie初探