花椒前端基於 Docker 的 SSR 持續開發整合環境實踐

花椒技術發表於2020-04-07

專案收益

  1. 整體開發效率提升20%。
  2. 加快首屏渲染速度,減少白屏時間,弱網環境下頁面開啟速度提升40%。

權衡

在選擇使用SSR之前,需要考慮以下事項!

  1. SSR需要可以執行Node.js的伺服器,學習成本相對較高。
  2. 對於伺服器而言,比僅提供靜態檔案,必須處理更高負載,考慮頁面快取等問題。
  3. 一套程式碼兩個執行環境。beforeCreate 和created 生命週期在伺服器端渲染和客戶端都會執行,如果在兩套環境中加入具有副作用的程式碼或特定平臺的API,會引起問題。

推薦在實踐之前先了解官方文件,可以對vue ssr有一定的認知。

ssr 流程

首先搭建一個簡單的 SSR 服務

安裝依賴

	yarn add vue vue-server-renderer koa
複製程式碼

vue-server-renderer 是vue srr 伺服器端渲染的核心模組,我們會使用koa搭建伺服器。

const Koa = require('koa');
const server = new Koa();

const Vue = require('vue');
const renderer = require('vue-server-renderer').createRenderer();

const router = require('koa-router')();

const app = new Vue({
    data: {
        msg: 'vue ssr'
    },
    template: '<div>{{msg}}</div>'
});

router.get('*', (ctx) => {
    // 將 Vue 渲染為 HTML後返回
    renderer.renderToString(app, (err, html) => {
        if (err) {
            throw err;
        }
        ctx.body = html;
    })
});
server.use(router.routes()).use(router.allowedMethods());
module.exports = server;
複製程式碼

這樣一個簡單的伺服器端渲染就實現了。

ssr 具體實現

在上面ssr服務的基礎上,將逐步完善為實際應用的程式。

  1. 目錄結構
app
├── src
│	├── components
│	├── router
│	├── store
│	├── index.js
│	├── App.vue
│	├── index.html
│	├── entry-server.js // 執行在伺服器端
│   └── entry-client.js // 執行在瀏覽器
└── server
	├── app.js
	└── ssr.js
複製程式碼

2、由於伺服器端和客戶端的差異,需要由不同的入口函式來實現。 這兩個入口函式分別是entry-server.js和entry-client.js。
伺服器端入口檔案:

import cookieUtils from 'cookie-parse';
import createApp from './index.js';
import createRouter from './router/router';
import createStore from'./store/store';

export default context => {
    return new Promise((resolve, reject) => {
        const router = createRouter();
        const app = createApp({ router });
        const store = createStore({ context });
        const cookies = cookieUtils.parse(context.cookie || '');
		// 設定伺服器端 router 的位置
        router.push(context.url);
        // 等到 router 將可能的非同步元件和鉤子函式解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();

            if (!matchedComponents.length) {
                return reject(new Error('404'));
            }
            // 對所有匹配的路由元件呼叫 asyncData,進行資料預取。
            Promise.all(
                matchedComponents.map(({ asyncData }) => {
                    asyncData && asyncData({
                        store,
                        route: router.currentRoute,
                        cookies,
                        context: {
                            ...context,
                        }
                    })
                })
            )
            .then(() => {
                context.meta = app.$meta;
                context.state = store.state;

                resolve(app);
            })
            .catch(reject);
        }, () => {
            reject(new Error('500 Server Error'));
        });
    });
}
複製程式碼

客戶端入口檔案:

import createApp from './index.js';
import createRouter from './router/router';

export const initClient = () => {
    const router = createRouter();
    const app = createApp({ router });
    const cookies = cookieUtils.parse(document.cookie);

    router.onReady(() => {
    	if (window.__INITIAL_STATE__) {
            store.replaceState(window.__INITIAL_STATE__);
        }
        
        // 新增路由鉤子函式,用於處理 asyncData.
        // 在初始路由 resolve 後執行,
        // 以便我們不會二次預取(double-fetch)已有的資料。
        // 使用 `router.beforeResolve()`,以便確保所有非同步元件都 resolve。
        router.beforeResolve((to, from, next) => {
            const matched = router.getMatchedComponents(to);
            const prevMatched = router.getMatchedComponents(from);
            
            // 我們只關心非預渲染的元件
    		// 所以我們對比它們,找出兩個匹配列表的差異元件
            let diffed = false;

            const activated = matched.filter((c, i) => {
                return diffed || (diffed = (prevMatched[i] !== c))
            });
          
            if (!activated.length) {
                return next()
            }

            Promise.all(activated.map(c => {
                if (c.asyncData) {
                	// 將cookie透傳給資料預取的函式,在伺服器進行資料預取時需要手動將cookie傳給後端伺服器。
                    return c.asyncData({
                        store,
                        route: to,
                        cookies, 
                        context: {
                        }
                    })
                }
            })).then(() => {
                next()
            }).catch(next)
        });
        app.$mount('#app')
    });
}
複製程式碼

3、改造app.js適應ssr
由於nodejs伺服器是一個長期執行的程式,當程式碼進入該程式時,會進行一次取值並保留在記憶體中,這將導致請求會共享一個單利物件。為了避免這個問題,程式採用暴露一個重複執行的工廠函式,為每個請求建立不同的例項。

import Vue from 'vue';
import App from './App.vue';

export default function createApp({ router }) {
    const app = new Vue({
        router,
        render: h => h(App),
    });
    return app;
};
複製程式碼

4、自動載入router 和 store 模組。
一個spa專案,由於router和store都是在統一的入口檔案裡管理,我們根據專案需要把各個功能模組的相關store和router拆分開來,當專案變大之後,每次手動修改import會產生很多副作用,為了減少修改store和router入口引起的副作用,需要自動載入專案的router和store。下面是store的實現,router實現和store類似。

// store 實現
// ...  

// 使用require.context匹配出module模組下的所有store,一次性載入到router裡面。  
const storeContext = require.context('../module/', true, /\.(\/.+)\/js\/store(\/.+){1,}\.js/);

// ...

const getStore = (context) => {

    storeContext.keys().filter((key) => {
        const filePath = key.replace(/^(\.\/)|(js\/store\/)|(\.js)$/g, '');
        let moduleData = storeContext(key).default || storeContext(key);
        const namespaces = filePath.split('/');

        moduleData = normalizeModule(moduleData, filePath);
        store.modules = store.modules || {};
        const storeModule = getStoreModule(store, namespaces); // 遞迴建立模組
        VUEX_PROPERTIES.forEach((property) => {
            mergeProperty(storeModule, moduleData[property], property); // 將每個模組的store統一掛載管理
        });
        return true;
    });
};
export default ({ context }) => {
    getStore(context);

    return new Vuex.Store({
        modules: {
            ...store.modules,
        },
    });
};
複製程式碼

5、 webpack 構建配置

├── webpack.base.conf.js // 通用配置
├── webpack.client.conf.js // 客戶端打包配置
├── webpack.server.conf.js  // 伺服器端打包配置
複製程式碼

webpack.base.conf.js 是構建專案的通用配置,可以根據需要修改相應的配置,這裡說一下 webpack.client.conf.js和webpack.server.conf.js的配置。

webpack.server.conf.js 配置
通過VueSSRServerPlugin外掛會生成伺服器bundle物件,預設是vue-ssr-server-bundle.json,裡面盛放著伺服器的整個輸出。

const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const path = require('path');
const baseConfig = require('./webpack.base.conf.js');
const resolve = (src = '') => path.resolve(__dirname, './', src);

const config =  merge(baseConfig, {
    entry: {
        app: ['./src/entry-server.js'],
    },
    target: 'node',
    devtool: 'source-map',
    output: {
        filename: '[name].js',
        publicPath: '',
        path: resolve('./dist'),
        libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({
        // 告訴Webpack不要捆綁這些模組或其任何子模組
    }),
    plugins: [
        new VueSSRServerPlugin(),
    ]
});

module.exports = config;
複製程式碼

webpack.client.conf.js配置
客戶端構建和伺服器端類似,是通過VueSSRClientPlugin外掛來生成客戶端構建清單vue-ssr-client-manifest.json,裡面包含了所有客戶端需要的靜態資源以及依賴關係。因此可以自動推斷和注入資源以及資料預取等。

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.base.conf');
const UploadPlugin = require('@q/hj-webpack-upload'); // 將首次載入和按需載入的資源上傳到cdn(在開源基礎上二次開發)
const path = require('path');
const resolve = (src = '') => path.resolve(__dirname, './', src);

const config = merge(baseConfig, {
  ...baseConfig,
  entry: {
    app: ['./src/entry-client.js'],
  },
  target: 'web',
  output: {
    filename: '[name].js',
    path: resolve('./dist'),
    publicPath: '',
    libraryTarget: 'var',
  },
  plugins: [
    new VueSSRClientPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new UploadPlugin(cdn, {
        enableCache: true, // 快取檔案
        logLocal: false,
        src: path.resolve(__dirname, '..', Source.output),
        dist: path.resolve(__dirname, '..', Source.output),
        beforeUpload: (content, location) => {
            if (path.extname(location) === '.js') {
                return UglifyJs.minify(content, {
                    compress: true,
                    toplevel: true,
                }).code;
            }
            return content;
        },
        compilerHooks: 'done',
        onError(e) {
            console.log(e);
        },
    }),
  ],
});

module.exports = config;

複製程式碼

5、SSR伺服器端實現
下面是基於koa實現的ssr伺服器端,app.js 主要是搭建伺服器環境,ssr的實現是在ssr.js中,通過一箇中介軟體的形式和主程式關聯。

// ssr.js

//...

// 將bundle渲染為字串。
async render(context) {
    const renderer = await this.getRenderer();
    return new Promise((resolve, reject) => {
    	// 獲取到首次渲染的字串
        renderer.renderToString(context, (err, html) => {
            if (err) {
                reject(err);
            } else {
                resolve(html);
            }
        });
    });
}
// 獲取renderer物件
getRenderer() {
    return new Promise((resolve, reject) => {
    	// 讀取模板檔案和之前通過構建生成的伺服器端和客戶端json檔案
        const htmlPath = `${this.base}/index.html`;
        const bundlePath = `${this.base}/vue-ssr-server-bundle.json`;
        const clientPath = `${this.base}/vue-ssr-client-manifest.json`;

        fs.stat(htmlPath, (statErr) => {
            if (!statErr) {
                fs.readFile(htmlPath, 'utf-8', (err, template) => {
                    const bundle = require(bundlePath);
                    const clientManifest = require(clientPath);
                    // 生成renderer物件
                    const renderer = createBundleRenderer(bundle, {
                        template,
                        clientManifest,
                        runInNewContext: false,
                        shouldPrefetch: () => {
                            return false;
                        },
                        shouldPreload: (file, type) => {
                            return false;
                        },
                    });
                    resolve(renderer);
                });
            } else {
                reject(statErr);
            }
        });
    });
}
// ...

// app.js
const Koa = require('koa');
const server = new Koa();
const router = require('koa-router')();
const ssr = require('./ssr');

server.use(router.routes()).use(router.allowedMethods());
server.use(ssr(server));
// 錯誤處理
app.on('error', (err, ctx) => {
    console.error('server error', err, ctx);
});
module.exports = server;
複製程式碼

以上便是vue ssr的簡單實現,實際專案中需要完善各種專案需要的配置。
下面在此基礎上說幾個問題。

  1. 上面提到過,vue的生命週期函式中,只有beforeCreate和created會在伺服器端渲染時被呼叫,並且程式一直存在於伺服器並不會銷燬,擋在這兩個生命週期中產生副作用的程式碼時,比如在其中使用了setTimeout或setInterval就會產生副作用,為了避免這些問題,可以將產生副作用的程式碼放到vue的其他生命週期中。服務端沒有window、document物件, 如果再伺服器端使用就會報錯中斷,所以需要根據執行環境做相應的相容處理。
  2. 預取資料時cookie穿透的問題。
    在伺服器端asyncData預取資料時,不會把客戶端請求中的cookie帶上,所以需要手動將客戶端中的cookie在預取資料時加到請求頭部。
  3. 在spa中需要動態修改頁面的head標籤以便利於搜尋引擎,這裡推薦使用vue-meta。
// src/index.js
// ...
Vue.use(Meta);
// ...

// entry-server.js
// ...
context.meta = app.$meta();
// ...
複製程式碼

部署方案

在完成整體程式碼的開發後 , 我們還需要考慮部署問題 。 在之前的活動 SSR 改造中 , 我們通過外部負載均衡到各伺服器 , 在各伺服器上使用 PM2 對各個伺服器上的 Node 程式進行管理 。 這種方式在實際使用中存在一些問題 。

  • 執行環境

    • 人肉運維 。 手動在執行伺服器上配置相關環境 ( Node 、 PM2 ) 。 後續如果遇到需要擴容 、 更新環境依賴時 , 需要同步人工同步各伺服器之間環境 。
    • 本地開發環境與服務端環境需完全一致 。 出現過不一致導致的問題 。 概率較小但需謹慎對待
  • 運維

    • 回滾機制 , 現在的回滾機制是相當於釋出一個新版本到線上 , 重新觸發 CI 釋出流程 。 如果是執行環境出現了問題 , 是比較棘手的 。 沒辦法快速的先回滾到指定版本和環境 。

為了解決以上提到的一些問題 。 我們引入了新的技術方案 。

  1. Docker : 容器技術 。 輕量級 、 快速的 ”虛擬化“ 方案

  2. Kubernetes : 容器編排方案

使用 Docker 接入整個開發 、 生產 、 打包流程 , 保證各執行環境一致 。

使用 Kubernetes 作為容器編排方案。

整合後 , 大概方案流程如下

img

  1. 本地開發時使用 Docker 開發
  2. 推送程式碼至 Gitlab 觸發 CI
  3. CI 基於基礎映象打包 , 每個 COMMIT ID 對應一個映象 , 推送至私有倉庫 ,觸發 CD
  4. CD 通過 kubectl 控制 K8s 叢集更新應用

整個開發 、 打包 、 部署上都使用了 Docker , 以此來保證所有階段的環境一致 。

本地開發

img

在本地開發階段 , 我們將依賴下載及開發模式分開 。

# 依賴下載
docker run -it \
    -v $(pwd)/package.json:/opt/work/package.json \
    -v $(pwd)/yarn.lock:/opt/work/yarn.lock \
    -v $(pwd)/.yarnrc:/opt/work/.yarnrc \ 
    # 掛載 package.json 、 yarn.lock 、 .yarnrc 到 /opt/work/ 下
    -v mobile_node_modules:/opt/work/node_modules \ 
    # /opt/work/node_modules 掛載為 mobile_node_modules 資料卷
    --workdir /opt/work \
    --rm node:13-alpine \
    yarn
複製程式碼

在依賴下載中 , 思路是將 node_modules 目錄作為一個資料卷 。 在需要使用時將其掛載到指定目錄下 , 之後只需要將會影響到依賴下來的相關檔案掛載到容器中 , 將 node_modules 資料卷掛載到資料夾 。 這樣子就能持久化儲存依賴檔案 。

# 開發模式
docker run -it \
    -v $(pwd)/:/opt/work/ \ 
    # 掛載專案目錄至 / opt/work/ 下
    -v mobile_node_modules:/opt/work/node_modules \ 
    # 掛載 node_modules 資料捲到 /opt/work/node_modules 目錄下
    --expose 8081 -p 8081:8081 \ # HotReload Socket
    --expose 9229 -p 9229:9229 \ # debugger
    --expose 3003 -p 3003:3003 \ # Node Server
    # 暴露各個埠
    --workdir /opt/work \
    node:13-alpine \
    ./node_modules/.bin/nodemon --inspect=0.0.0.0:9229 --watch server server/bin/www
複製程式碼

開發模式下 , 我們只需要將之前的 node_modules 資料卷掛載到 node_modules 目錄 , 再將專案目錄掛載到容器中 。 暴露指定埠即可開始開發 。 這裡 8081 為寫死的 HotReload Socket 介面 、 3003 為 Node 服務介面 、 9229 為 debugger 介面 。 再把啟動命令設定為開發模式指令就可以正常開發 。

開發完成後 , 我們推送程式碼 , 觸發 CI 。

CI

img

上面是我們的 CI 流程 。

在 CI 階段 , 我們通過 Dockerfile 為每一次提交記錄都生成一個與之對應的映象 。 這樣做的好處在於我們能隨時通過提交記錄找到對應的映象進行回滾 。

FROM node:13-alpine

COPY package.json /opt/dependencies/package.json
COPY yarn.lock /opt/dependencies/yarn.lock
COPY .yarnrc /opt/dependencies/.yarnrc
RUN cd /opt/dependencies \
    && yarn install --frozen-lockfile \
    && yarn cache clean \
    && mkdir /opt/work \
    && ln -s /opt/dependencies/node_modules /opt/work/node_modules

# 具體檔案處理
COPY ci/docker/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
COPY ./ /opt/work/
RUN cd /opt/work \
    && yarn build

WORKDIR /opt/work
EXPOSE 3003
ENV NODE_ENV production

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server/bin/www"]
複製程式碼

上面是我們使用到的一個 Dockerfile 。

  1. 使用 node:13-alpine 作為基礎映象
  2. 複製依賴相關檔案到容器中下載依賴 , node_modules 軟連線到 /opt/work 下 。 清理安裝快取
  3. 複製專案檔案到容器中 , 執行客戶端程式碼打包命令
  4. 設定環境變數 , 對外暴露服務埠 , 設定映象啟動命令
docker build -f Dockerfile --tag frontend-mobile:COMMIT_SHA .
複製程式碼

最後使用以上命令將該版本打包為一個映象 , 推送至私有倉庫 。

我們在 Dockerfile 優化編譯速度及映象體積時使用到的一些技巧:

  1. 前置合併不變的操作 , 將下載依賴和編譯分開為兩個RUN 指令 , 可以利用 Docker 的層快取機制 。 在依賴不變的情況下 , 跳過依賴下載部分 , 直接使用之前的快取。
  2. 每次操作後清理不需要的檔案 , 如 yarn 生成的全域性快取 ,這些快取不會影響到我們程式的執行 。 還有很多包管理工具也會生成一些快取 , 按各種需要清理即可 。
  3. ‘.dockerignore’ 中忽略不影響到編譯結果的檔案 , 下次這些檔案變動時 , 打包會直接使用之前的映象 , 改個 README 或者一些 K8s 釋出配置時就不會重新打包映象 。

在打包完成後 , 我們推送映象至私有倉庫 , 觸發 CD 。

CD

部署階段 , 我們使用 Kubernetes 進行容器編排 。引用官方介紹

K8s 是用於自動化部署 , 擴充套件和管理容器化應用程式的開源系統 。

K8s 非常的靈活且智慧 。 我們只需要描述我們需要怎麼樣的應用程式 。 K8s 就會根據資源需求和其他約束自動放置容器 。括一些自動水平擴充套件 , 自我修復 。能方便我們去追蹤監視每個應用程式執行狀況 。

我們使用的目的很簡單 , 就是自動運維還有非侵入式日誌採集和應用監控 。

img

Deployment 表示一個期望狀態 。 描述需要的應用需求 。

Service 負責對外提供一個穩定的入口訪問我們的應用服務或一組 Pod 。

Ingress 路由 , 外部的請求會先到達 Ingress 。 由它按照已經制定好的規則分發到不同的服務 。

Pod 在叢集中執行的程式 , 是最小的基本執行單元 。

CD 容器通過 kubectl 控制 K8s 叢集 。在每個分支提交程式碼觸發 CD 之後 , 會為每個分支單獨建立一個 Deployment 。 對應每個分支環境 。通過 Service 暴露一組指定 Deployment 對應的 Pod 服務 , Pod 執行的是 Deployment 指定的應用映象 。最後使用 Ingress 根據域名區分環境對外提供服務 。

K8s 配置

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend-mobile  # deployment 名稱
  namespace: mobile # 名稱空間
  labels:
    app: frontend-mobile # 標籤
spec:
  selector:
    matchLabels:
     # 對應的 Pod 標籤, 被其選擇的 Pod 的現有副本集將受到此部署的影響
     app: frontend-mobile
  replicas: 8 # Pod 節點數量, 預設為 1
  template:   # 相當於 Pod 的配置
    metadata:
      name: frontend-mobile  # Pod 名稱
      labels:
        app: frontend-mobile # Pod 標籤
    spec:
      containers:
        - name: frontend-mobile
          image: nginx:latest
          ports:
            - containerPort: 3003
          resources: # 設定資源限制
            requests:
              memory: "256Mi"
              cpu: "250m"     # 0.25 個cpu
            limits:
              memory: "512Mi"
              cpu: "500m"     # 0.5 個cpu
          livenessProbe:
            httpGet:
              path: /api/serverCheck
              port: 3003
              httpHeaders:
                - name: X-Kubernetes-Health
                  value: health
            initialDelaySeconds: 15
            timeoutSeconds: 1
---
apiVersion: v1
kind: Service
metadata:
  name: frontend-mobile  # Service 名稱
  namespace: mobile # 名稱空間
  labels:
    app: frontend-mobile # 標籤
spec:
  selector:
    app: frontend-mobile # 對應的 Pod 標籤
  ports:
    - protocol: TCP
      port: 8081       # 服務埠
      targetPort: 3003 # 代理埠
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: frontend-mobile
  namespace: mobile # 名稱空間
  labels:
    app: frontend-mobile # 標籤
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - host: local-deploy.com
      http:
        paths:
          - path: /
            backend:
              serviceName: frontend-mobile # 引用的服務名稱
              servicePort: 8081         # 引用的服務埠, 對應 Service 中的 port
複製程式碼

在 Deployment 配置上選擇資源配額小 , 數量多的方式進行部署 。把單個 Pod 資源配額設定小的原因是 SSR 服務容易記憶體洩漏 , 設定小一些可以在出現記憶體洩漏問題時直接將 Pod 重啟 。 在排查到問題之前先解決暫時解決服務問題 。

其他配置可自行參考官方文件 , 不過多介紹 。

kubernetes.io/docs/refere…

至此 , 部署流程已全部結束 。

更多工作

Gitlab 聯動 Kubernetes

日誌收集

AliNode 接入

相關文章