美文网首页
云端一体midwayjs踩坑填坑指南

云端一体midwayjs踩坑填坑指南

作者: cuieney | 来源:发表于2021-11-10 17:24 被阅读0次

    前言

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

    涉及到的框架

    • 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互相调用,但是撸清逻辑还是很清晰的

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

    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的值。这里我们已经确认了是框架问题。

    [图片上传失败...(image-26fc62-1636619769150)]

    打包成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有所帮助。文章如果有不对的地方,望留言区纠正。毕竟是个半路子入门的前端。

    相关文章

      网友评论

          本文标题:云端一体midwayjs踩坑填坑指南

          本文链接:https://www.haomeiwen.com/subject/qdouzltx.html