云端一体midwayjs踩坑填坑指南

2,538 阅读11分钟

前言

自转前端后更新频率低了很多,主要还是为了产出高质量的文章进行输出。此篇文章主要讲诉当你使用三方框架中遇到问题该如何定位及解决。

涉及到的框架

  • SSR 框架同时支持 React 以及 Vue2, Vue3
  • midwayjs 阿里的云端一体框架
  • eggjs node服务启动层

踩坑指南

俗话说前人种树后人乘凉,那么踩坑的人恐怕就是在种树的过程。公司今年准备切换传统的开发方式,由开发静态页面切换到云端一体的开放方式,从而提升用户体验(为了追求卓越呀)。

1.踩坑之框架选型

框架选型一定要着重选择经过市场千锤百炼的框架。这样就不用你去种树了。直接拿来主义即可。除非你的试错成本很大。

市场的框架很多,今年的QCon不论是阿里还是美团都推出了自己的云端一体框架,我们选的则是阿里的云端一体(SSR服务端渲染),模版则是基于SSR官方提供的midway-vue-ssr。那么填坑也是来源于此。浏览其ssr的github仓库,框架本身开始涨星也就是今年年初,应该内部开始向外推广。看样子内测是已经通过了。但是并没有经过千锤百炼的打磨,肯定是有些边边角角的问题。这时我们去选用这样的框架需要慎重考虑的。这是拿我们的线上用户来测bug啊。资损了可咋办。

2.踩坑之框架启动方式

本地测试预发生产的启动部署方式要一致。

问题是这样的,产生环境A用户打开页面发现是B用户的数据。但是ssr模式的数据是别人的,csr渲染的数据则是自己的。上线没多久就全量切回到csr模式(这也是框架的一个好处吧,无缝切换渲染模式)。对于这个问题其实是非常严重的。还好是在内部上线后发现的。那么问题又来了为什么开发和测试环境没有发现呢?框架开发、生产环境部署的启动方式上略有不同。这也是我们上线之后才发现问题的原因之一

3.踩坑之框架反馈及沟通性

使用的框架一定要有个线下沟通反馈群,提issue实效性太差。

其实我的这个问题持续了几个月,刚开始扫码入群进行反馈时,根本没有回复。可能之前他们没有遇到过这样的问题。或者说我表诉的内容有误。其实框架的问题对于使用者来说只能看到表面问题。内部完全是个黑盒。所以想要反馈问题还是得看懂源码,定位问题,然后才能让作者给你解决问题。这也是非常非常坑的原因(吐槽下作者,不要认为我们提bug的都是无病呻吟)

填坑指南

1.填坑之分析问题

  • 代码有问题,尝试Review代码
  • 使用方式不对,先多看看官方提供的文档
  • 通过ssr的方式请求接口,在网关层面我们cookie错乱导致的
  • 公司基础框架有问题(开始怀疑人生,因为框架官方文档贴出,阿里内部很多部门都在使用,不敢质疑)
  • 框架有问题

1.1 最初问题的解决思路来源于前三条,尝试通过这三个路径定位问题所在,但是日子一天一天的过去,这三条路看了下,并没有发现什么问题,代码层面的Review也都ok,后来以为是我们自己封装得axios有问题,在ssr渲染得时候多次被初始化导致得,尝试切换了官方提供的axios实例,但是发现也是不行,同时根据官方文档把该配置的配置都配置上,也不行。第三条路则没有前两条路这么好排除,一直在尝试,本地debugger,测试打日志,看到当前用户的cookie确实也是自己的。这就让人头疼。

1.2 写了几天业务代码,换了个思路。怀疑是不是我们公司基础框架层的多环境容器有问题。那怎么办呢,不可能在线上环境进行调试吧。因为测试环境和线上环境还是有些区别的,测试环境是只有一台容器运行,但是线上环境有多台容器在并发运行。是不是这个问题导致的。那么最接近生产环境的环境就是预发环境。那就把预发也申请和生产一样多的容器进行场景还原。


2.填坑之复现问题

根据出现问题的状态,可罗列出3种必备条件:

  • 多个用户并发访问
  • 同线上一致的环境
  • 同线上一致的代码
2.1 A/B test

排除不是公司基础架构多容器导致的

在分析问题的第四步中,我们已经把预发环境配置的和线上一致。多个用户同时刷新页面,没几次其实就复现了问题。A用户出现了B用户的数据,那么对于这个结果,对比测试环境,最让人怀疑的是多台容器。是不是这个原因导致的。继续向下调查,每次刷新,A/B用户切换刷新,复现问题后观察用户日志。确实很多次A/B用户都是分发到了不同的容器,但是也有几次出现在同一个容器里。就可以排除不是多容器导致的

2.2 框架问题

通过排除的方式确认应该是框架问题

那么继续细化新增日志,把用户请求-接口发起-接口响应-存store-取数据渲染串起来。当发生A/B用户数据异常时观察日志,竟然发现这时B用户请求响应的回来的stream里面存在着A用户的store数据。直到这里我们才开始怀疑框架问题。之所以一直没有怀疑框架问题是因为作者认为我们代码有问题,他们并没有收到相关issue。

由于ssr的vue实例router/store都是新创建的实例。不可能出现store复用。那会不会是node服务层导致的,分发流分发错误,导致用户看到了非自己的数据,这时我们又开始怀疑node层。换个node服务试试,把eggjs换成nestjs。但是想想这个不可能吧。这两个库可是在被很多人所认可的。我们继续怀疑是不是ssr问题。


3.填坑之源码阅读

择其善者而从之,其不善者而改之,阅读源码是填坑指南的必经之路,也是提升代码能力的渠道。

只能通过看源码来尝试定位问题,对于看源码肯定很多同学是拒绝的,不知道从何看起。同时又看到有多个module互相关联就很头疼。作者这个就是多个module互相调用,但是撸清逻辑还是很清晰的

modules

源码三剑客:简单过一遍,仔细看一遍,整体撸一遍(串流程不是让你撸代码)

3.1 简单过一遍

这里我用的是midway-vue-ssr模版,基于这个模块进行展开。其他的模块有兴趣的可以自行看看

  1. cli(主要为了是生成可执行命令ssr start/ssr build)
  2. core-vue(主要是对外提供render函数)
  3. plugin-midway(启动midwayjs)
  4. plugin-vue(这里是主要提供cli进行start/build的具体细节)
  5. server-utils(给其他module提供工具类)
  6. types(定义接口ts各种类的type类型)
  7. webpack(打包编译启动服务)

其实这就是第一步,每个module都过一下。简单的看下做什么的。接下来就是关联module之间的关系,其实你可能已经注意到在plugin-vue 和 server-utils里面被其他module调用了很多次。

3.2 仔细看一遍

Take the essence and discard the dregs 去其糟粕 取其精华

通过第一步已经了解了每个module的主次关系,那么如何取其精华呢?请看下述代码

  import { render } from 'ssr-core-vue'
  
  @Get('/')
  @Get('/detail/:id')
  async handler (): Promise<void> {
    try {
      this.ctx.apiService = this.apiService
      this.ctx.apiDeatilservice = this.apiDeatilservice
      const stream = await render<Readable>(this.ctx, {
        stream: true
      })
      this.ctx.body = stream
    } catch (error) {
      console.log(error)
      this.ctx.body = error
    }
  }

所有的页面请求都是来源于此,通过ssr-core-vue module提供的render函数进行具体页面的渲染。我们继续深入这个render函数。

async function render (ctx: ISSRContext, options?: UserConfig) {
  const config = Object.assign({}, defaultConfig, options ?? {})
  const { isDev, chunkName, stream } = config
  const isLocal = isDev || process.env.NODE_ENV !== 'production'
  const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
  if (isLocal) {
    // clear cache in development environment
    delete require.cache[serverFile]
  }
  if (!ctx.response.type && typeof ctx.response.type !== 'function') {
    // midway/koa 场景设置默认 content-type
    ctx.response.type = 'text/html;charset=utf-8'
  } else if (!(ctx as ExpressContext).response.hasHeader?.('content-type')) {
    // express 场景
    (ctx as ExpressContext).response.setHeader?.('Content-type', 'text/html;charset=utf-8')
  }

  const { serverRender } = require(serverFile)
  const serverRes = await serverRender(ctx, config)

  if (stream) {
    const stream = mergeStream2(new StringToStream('<!DOCTYPE html>'), renderToStream(serverRes))
    stream.on('error', (e: any) => {
      console.log(e)
    })
    return stream
  } else {
    return `<!DOCTYPE html>${await renderToString(serverRes)}`
  }
}

可以看到它的主要内容就是调用打包后build/server目录下的page.server.js,然后把node层的context和配置传入。最终生导出一个vue实例。通过vue官方提供的vue-server-renderer进行渲染页面。

其实细看到这里后续可能已经无法深入了。这里拿取的是build文件。里面全是压缩后的js。但是源码处调用了这个js的serverRender方法。我们又可以深入了。有印象的同学可能在进行第一步前戏的时候已经注意到了这个。ssr-vue-plugin这个module entry下面的server-entry.ts,这就是我们上方打包后的js里调用的方法。

const serverRender = async (ctx: ISSRContext, config: IConfig): Promise<Vue.Component> => {
  const { cssOrder, jsOrder, dynamic, mode, customeHeadScript, customeFooterScript, chunkName, parallelFetch, disableClientRender, prefix } = config
  const router = createRouter() // 创建新的router
  const store = createStore() // 创建新的store
  const base = prefix ?? PrefixRouterBase // 以开发者实际传入的为最高优先级
  const viteMode = process.env.BUILD_TOOL === 'vite'
  sync(store, router)
  let { path, url } = ctx.request

  if (base) {
    path = normalizePath(path, base)
    url = normalizePath(url, base)
  }

  const routeItem = findRoute<IFeRouteItem>(FeRoutes, path) // 根据request信息获取需要展示的routeitem

  if (!routeItem) {
    throw new Error(`
    查找组件失败,请确认当前 path: ${path} 对应前端组件是否存在
    若创建了新的页面文件夹,请重新执行 npm start 重启服务
    `)
  }

  let dynamicCssOrder = cssOrder
  if (dynamic && !viteMode) {
    dynamicCssOrder = cssOrder.concat([`${routeItem.webpackChunkName}.css`])
    dynamicCssOrder = await addAsyncChunk(dynamicCssOrder, routeItem.webpackChunkName)
  }

  const manifest = viteMode ? {} : await getManifest()

  const isCsr = !!(mode === 'csr' || ctx.request.query?.csr)

  let layoutFetchData = {}
  let fetchData = {}

  if (!isCsr) {
    const { fetch } = routeItem
    const currentFetch = fetch ? (await fetch()).default : null // 通过axios请求fetch.js api内容
    router.push(url)

    // csr 下不需要服务端获取数据
    if (parallelFetch) {
      [layoutFetchData, fetchData] = await Promise.all([
        layoutFetch ? layoutFetch({ store, router: router.currentRoute }, ctx) : Promise.resolve({}),
        currentFetch ? currentFetch({ store, router: router.currentRoute }, ctx) : Promise.resolve({})
      ])
    } else {
      layoutFetchData = layoutFetch ? await layoutFetch({ store, router: router.currentRoute }, ctx) : {}
      fetchData = currentFetch ? await currentFetch({ store, router: router.currentRoute }, ctx) : {}
    }
  } else {
    logGreen(`Current path ${path} use csr render mode`)
  }
  const combineAysncData = Object.assign({}, layoutFetchData ?? {}, fetchData ?? {})
  const state = Object.assign({}, store.state ?? {}, combineAysncData)

  // @ts-expect-error
  const app = new Vue({
    // 创建vue实例 部分源码这里就省略了
  })
  return app
}

上诉源码已经添加了部分注解。那么到这里其实可以进行第三步了,整体撸一遍串起整个流程

3.3 整体撸一遍

这也是最关键的一环,你以为你站在终点原来你站在原点。

这里只是个开始,那么我们是如何从ssr start启动服务➡️用户发起页面请求➡️egg调用render函数➡️调用build后的page.server.js 展示用户访问的页面。

那么起点就是ssr start时,上诉第一步简单的代码分析里面已经说到了ssr-cli这个module,他提供了ssr start 和 ssr build 这两个构建命令。通过这里就可以循序渐近的明白整体流程。


yargs
  .command('start', 'Start Server', {}, async (argv: Argv) => {
    spinner.start() // 控制台loading
    await handleEnv(argv, spinner) // 初始化环境变量

    const { parseFeRoutes, loadPlugin } = await import('ssr-server-utils') // 获取工具类
    await parseFeRoutes() // 获取pages下面的页面路又信息 对应build目录下面的ssr-temporary-routes.js文件
    debug(`require ssr-server-utils time: ${Date.now() - start} ms`)
    const plugin = loadPlugin() // 读取plugin文件
    debug(`loadPlugin time: ${Date.now() - start} ms`)
    spinner.stop()
    debug(`parseFeRoutes ending time: ${Date.now() - start} ms`)
    await plugin.clientPlugin?.start?.(argv) // 启动客户端打包及部署
    debug(`clientPlugin ending time: ${Date.now() - start} ms`)
    await cleanOutDir()
    await plugin.serverPlugin?.start?.(argv) // 启动服务端打包部署
    debug(`serverPlugin ending time: ${Date.now() - start} ms`)
  })
  

这里我做了一些简单的注释。可以知道ssr start 背后的逻辑。

其实可以跳过前两步,直接到第三步parseFeRoutes, loadPlugin这里,通过server-utils来获取这两个函数。那么parseFeRoutes到底干了些什么呢?请看源码!!!

const parseFeRoutes = async () => {
  const isVue = require(join(cwd, './package.json')).dependencies.vue
  const viteMode = process.env.BUILD_TOOL === 'vite'
  if (viteMode && !dynamic) {
    console.log('vite模式禁止关闭 dynamic ')
    return
  }
  let routes = ''
  const declaretiveRoutes = await accessFile(join(getFeDir(), './route.ts')) // 是否存在自定义路由
  if (!declaretiveRoutes) {
    // 根据目录结构生成前端路由表
    const pathRecord = [''] // 路径记录
    // @ts-expect-error
    const route: ParseFeRouteItem = {}
    let arr = await renderRoutes(pageDir, pathRecord, route)  // 递归页面目录生产路由数组
    if (routerPriority) {
      // 路由优先级排序
      ......
    }
    if (routerOptimize) {
      // 路由过滤
        ......
    }
    debug('Before the result that parse web folder to routes is: ', arr)
    if (isVue) {
      const layoutPath = '@/components/layout/index.vue'
      const accessVueApp = await accessFile(join(getFeDir(), './components/layout/App.vue'))
      const layoutFetch = await accessFile(join(getFeDir(), './components/layout/fetch.ts'))
      const store = await accessFile(join(getFeDir(), './store/index.ts'))
      const AppPath = `@/components/layout/App.${accessVueApp ? 'vue' : 'tsx'}`
      // 处理路由表信息数据结构
        ....
    } else {
      // React 场景
      ......
    }
  } else {
    // 使用了声明式路由
    routes = (await fs.readFile(join(getFeDir(), './route.ts'))).toString()
  }

  debug('After the result that parse web folder to routes is: ', routes)
  await writeRoutes(routes)
  然后写入/build/ssr-temporary-routes.js下
}

可以看出最终目的就是为了根据目录生成路由表信息结构

我们继续,看看生成路由表后还要干什么!解析来就是加载plugin,他是通过读取我们项目结构中的plugin.js来分别做打包部署工作,先对客户端进行打包启动webpack-serve,然后启动midway服务

const { midwayPlugin } = require('ssr-plugin-midway')
const { vuePlugin } = require('ssr-plugin-vue')

module.exports = {
  serverPlugin: midwayPlugin(),
  clientPlugin: vuePlugin()
}

接下来先来看midwayPlugin到底在做什么,然后再看vuePlugin。

import { exec } from 'child_process'
import { loadConfig } from 'ssr-server-utils'
import { Argv } from 'ssr-types'

const { cliFun } = require('@midwayjs/cli/bin/cli')

const start = (argv: Argv) => {
  const config = loadConfig()
  exec('npx cross-env ets', async (err, stdout) => {
    if (err) {
      console.log(err)
      return
    }
    console.log(stdout)
    // 透传参数给 midway-bin
    argv._[0] = 'dev'
    argv.ts = true
    argv.port = config.serverPort
    await cliFun(argv)
  })
}

export {
  start
}

这里可以看到还是比较简单的,读取服务端配置然后调用midwaysjs导出的cliFun进行启动。

接下来是重点,vuePlugin start是在做什么。


export function vuePlugin () {
  return {
    name: 'plugin-vue',
    start: async () => {
      // 本地开发的时候要做细致的依赖分离, Vite 场景不需要去加载 Webpack 构建客户端应用所需的模块
      const { startServerBuild } = await import('ssr-webpack/cjs/server') // 1.打包ssr server端
      const { getServerWebpack } = await import('./config/server') // 2.获取ssr server端cofing
      const serverConfigChain = new WebpackChain() // 3.生成一个默认的webpackChain
      if (process.env.BUILD_TOOL === 'vite') {
        await startServerBuild(getServerWebpack(serverConfigChain))
      } else {
        const { startClientServer } = await import('ssr-webpack') // 4.打包ssr client 同时启动 webpack-dev-server
        const { getClientWebpack } = await import('./config') // 5.获取ssr client端配置
        const clientConfigChain = new WebpackChain() // 6.生成一个默认的webpackChain
        await Promise.all([startServerBuild(getServerWebpack(serverConfigChain)),  startClientServer(getClientWebpack(clientConfigChain))]) 
      }
    },
    // build 方法
    .....
  }
}

这里我只贴出了 start的相关内容,在上方也添加了对应代码的相关注释。其实打包只需要把打包配置传入webpack即可。剩下就交给webpack了,对于如何打包,就跳过了。只要知道如何获取配置对应的webpack config就行了。

const startClientServer = async (webpackConfig: webpack.Configuration): Promise<void> => {
  const { webpackDevServerConfig, fePort, host } = config
  return await new Promise((resolve) => {
    const compiler = webpack(webpackConfig)

    const server = new WebpackDevServer(compiler, webpackDevServerConfig)
    compiler.hooks.done.tap('DonePlugin', () => {
      resolve()
    })

    server.listen(fePort, host)
  })
}

可以看到startClientServer源码中 仅仅做打包并启动webpack-server

const startServerBuild = async (webpackConfig: webpack.Configuration) => {
  const { webpackStatsOption } = loadConfig()
  const stats = await webpackPromisify(webpackConfig)
  console.log(stats.toString(webpackStatsOption))
}

startServerBuild源码也是仅仅打包ssr server 端

这里就先跳过如何打包,直接进入关键步骤步骤如何获取配置?

通过源码可以知道在plugin-vue/config module中的目录下有着serve端和 client端相关webpack配置内容,我们可以点开server.ts看下

const getServerWebpack = (chain: WebpackChain) => {
  const config = loadConfig() // 获取根目录下config.js配置
  const { isDev, cwd, getOutput, chainServerConfig, whiteList, chunkName } = config
  getBaseConfig(chain, true) 合并预置基础config配置
  chain.devtool(isDev ? 'inline-source-map' : false)
  chain.target('node')
  chain.entry(chunkName)
    .add(loadModule('../entry/server-entry')) //加载entry 
    .end()
    .output
    .path(getOutput().serverOutPut)
    .filename('[name].server.js')
    .libraryTarget('commonjs')
    .end()

  // 其他配置
  .......
  
  chainServerConfig(chain) // 合并用户自定义配置

  return chain.toConfig()
}

源码中可以看到,加载了预置webpack配置的同时配置server端的entry。然后返回webpackconfig。具体如何获取webpack的webpackconfig,大家可以细读源码,这里最重要的是entry这个地方,说到entry是不是回到了阅读源码的第二步。这也就串起了整个流程。

读到这里的同学相信已经串起了所有ssr start背后所做的一切。


4.填坑之解决问题

源码告一段落。可以通过读到的源码内容,定位到问题所在了。那么A用户是为何拿到了B用户的数据呢?大家知道了吗!

4.1 定位问题

同样通过还原线上场景通过egg-script进行启动部署服务,把日志打印在plugin-vue/entry/serverRender函数下的每个store后面。结果显然已经出来了,store在node运行中并没有每次初始化,第二个用户登陆后,拿到了第一个用户store的值。这里我们已经确认了是框架问题。

打包成docker镜像,运行起来如上图看到,刚开始console里面打印的log都是null,但是另一个用户进来之后,console里出现了值。

4.2 解决问题

通过上诉已经得到是store复用导致的,(这时把这个bug提上去作者才认是框架问题,吐槽下都到这里那我们自己也可以解决了啊)

框架群友也有反馈是多进程导致的,store是个全局的变量,并发导致store变量污染所致(之前写java的所以不太理解为啥子多进场能共享变量,不应该是进程之前数据隔离,线程之前数据共享吗?就没有纠结难道是node可以这样操作)最终作者的意思是没有对store进行一次deepclone导致的。在createStore的时候进行一次deepclone。确实解决了问题。

虽然提供了这个解决方式,但是到底为什么导致了ssr start和egg-script start 有所不同呢,看代码ssr-core-vue 中render也是可以看到区别的在dev环境进行了 delete require.cache


async function render (ctx: ISSRContext, options?: UserConfig) {
  const config = Object.assign({}, defaultConfig, options ?? {})
  const { isDev, chunkName, stream } = config
  const isLocal = isDev || process.env.NODE_ENV !== 'production'
  const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
  if (isLocal) { // 就是这里这里这里导致 开发环境和生产环境不一致导致,我们线上发现问题的
    // clear cache in development environment
    delete require.cache[serverFile]
  }
  .....省略的代码......

  const { serverRender } = require(serverFile)
  const serverRes = await serverRender(ctx, config)
  .....省略的代码......
}

在node环境中require第一次加载某个模块时, Node会缓存该模块, 后续加载就从缓存中获取。所以导致B用户会取到A用户的值。作者这种方式修改也是ok的,原因是每次用户拿到的store都是一个deepclone下来的,不会操作原始的store,所以每次clone的都是最初的store


那么归结下来大概有三种解决思路:

4.2.1 第一种方式就是放开delete require.cache 这个操作,但是这个也是最low的解决方式
async function render (ctx: ISSRContext, options?: UserConfig) {
  const config = Object.assign({}, defaultConfig, options ?? {})
  const { isDev, chunkName, stream } = config
  const isLocal = isDev || process.env.NODE_ENV !== 'production'
  const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
  if (isLocal) {
    // clear cache in development environment
    delete require.cache[serverFile]  //放开这一行
  }
  if (!ctx.response.type && typeof ctx.response.type !== 'function') {
    // midway/koa 场景设置默认 content-type
    ctx.response.type = 'text/html;charset=utf-8'
  } else if (!(ctx as ExpressContext).response.hasHeader?.('content-type')) {
    // express 场景
    (ctx as ExpressContext).response.setHeader?.('Content-type', 'text/html;charset=utf-8')
  }

  const { serverRender } = require(serverFile)
  const serverRes = await serverRender(ctx, config)

  if (stream) {
    const stream = mergeStream2(new StringToStream('<!DOCTYPE html>'), renderToStream(serverRes))
    stream.on('error', (e: any) => {
      console.log(e)
    })
    return stream
  } else {
    return `<!DOCTYPE html>${await renderToString(serverRes)}`
  }
}
4.2.2 第二种方式其实就是作者的这种操作,通过deepclone来每次创建一个新的,防止原始store被操作
4.2.3 第三种这个也是我自己想出的一个方法,结合第一种来修改的,每次require store之前进行一次delete也是可以操作的。
function createStore () {
  delete require.cache[require.resolve("@/store/index.ts")]
  const store = require("@/store/index.ts")
  return new Vuex.Store(store ?? {})
}

总结

希望对xdm有所帮助。文章如果有不对的地方,望留言区纠正。毕竟是个半路子入门的前端。