1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        如何搭建適合自己團隊的構(gòu)建部署平臺

        共 23886字,需瀏覽 48分鐘

         ·

        2021-08-07 09:26

        ??  這是第 108 篇不摻水的原創(chuàng),想要了解更多,請戳上方藍色字體:政采云前端團隊 關(guān)注我們吧~

        本文首發(fā)于政采云前端團隊博客:如何搭建適合自己團隊的構(gòu)建部署平臺

        https://www.zoo.team/article/webbuild

        前端業(yè)界現(xiàn)有的構(gòu)建部署方案,常用的應(yīng)該是 Jenkins,Docker,GitHub Actions 這些,而恰巧,我們公司現(xiàn)在就并存了前兩種方案。既然已經(jīng)有了穩(wěn)定的構(gòu)建部署方式,為什么還要自己做一套前端自己的構(gòu)建平臺呢?當然不是為了好玩啊,原因聽我慢慢分析。

        前端構(gòu)建使用的時候可能會碰到各種各樣問題,比如:

        • Eslint 跳過校驗——公司里面的前端項目,隨著時間的推移,不同階段,通過新老腳手架創(chuàng)建出來的項目可能風格各異,并且校驗規(guī)則可能也不一定統(tǒng)一,雖然項目本身可以有著各種的 Eslint,Stylelint 等校驗攔截,但阻止不了開發(fā)者跳過這些代碼校驗。
        • npm 版本升級不兼容——對于依賴的 npm 版本必須的一些兼容性校驗,如果某些 npm 插件突然升級了不兼容的一些版本,代碼上線后就會報錯出錯,典型的就是各類 IE 兼容。
        • 無法自由添加自己想要的功能——想要優(yōu)化前端構(gòu)建的流程,或者方便前端使用的功能優(yōu)化,但因為依賴運維平臺的構(gòu)建應(yīng)用,想加點自己的功能需要等別人排期。

        而這些問題,如果有了自己的構(gòu)建平臺,這都將不是問題,所以也就有了現(xiàn)在的——云長。

        為何起名叫“云長“呢,當然是希望這個平臺能像”關(guān)云長“一樣,一夫當關(guān)萬夫莫開。那云長又能給我們提供什么樣的一些能力呢?

        云長能力

        構(gòu)建部署

        這當然是必備的基本能力了,云長提供了公司不同前端項目類型,例如 Pampas、React、Vue、Uniapp 等的構(gòu)建能力。整個流程其實也并不復(fù)雜,開始構(gòu)建后,云長的服務(wù)端,獲取到要構(gòu)建的項目名,分支,要部署的環(huán)境等信息后,開始進行項目的代碼更新,依賴安裝,之后代碼打包,最后將生成的代碼再打包成鏡像文件,然后將這份鏡像上傳到鏡像倉庫后,并且將項目的一些資源靜態(tài)文件都可以上傳 CDN,方便前端之后的調(diào)用,最后調(diào)用 K8S 的鏡像部署服務(wù),進行鏡像按環(huán)境的部署,一個線上構(gòu)建部署的流程也就完成了。

        可插拔的構(gòu)建流程

        如果是使用別人的構(gòu)建平臺, 很多前端自己想加入的腳本功能就依賴別人的服務(wù)來實現(xiàn),而如果走云長,則可以提供開放型的接口,讓前端可以自由定制自己的插件式服務(wù)。

        比如這個線上構(gòu)建打包的過程當中,就可以處理一些前文提到過的問題,痛點,例如:

        • 代碼的各類 Eslint、Tslint 等合規(guī)性校驗,再也不怕被人跳過檢驗步驟。
        • 項目構(gòu)建前還可以做 npm 包版本的檢測,防止代碼上線后的兼容性報錯等等。
        • 代碼打包后也能做一些全局性質(zhì)的前端資源注入,例如埋點,錯誤監(jiān)控,消息推送等等類型。

        審核發(fā)布流程

        公司現(xiàn)有的平臺發(fā)布流程管控靠的是運維的名單維護,每個項目都會管理一個可發(fā)布人的名單,所以基本項目發(fā)版都需要發(fā)布人當晚跟隨進行發(fā)布,而云長為了解決這個問題,提供了一個審核流的概念。

        也就是當項目在預(yù)發(fā)環(huán)境測試完成之后,代碼開發(fā)者可以提起一個真線的發(fā)布申請單,之后這個項目的可發(fā)布人會通過釘釘收到一個需要審核的申請單,可以通過網(wǎng)頁端,或者釘釘消息直接操作,同意或者拒絕這次發(fā)布申請,在申請經(jīng)過同意后,代碼開發(fā)者到了可發(fā)布時間后,就能自己部署項目發(fā)布真線,發(fā)布真線后,后續(xù)會為這個項目創(chuàng)建一個代碼的 Merge Request 請求,方便后續(xù)代碼的歸檔整理。

        這么做的好處呢,一方面可以由前端來進行項目構(gòu)建發(fā)布的權(quán)限管控,讓發(fā)布權(quán)限可以進行收攏,另一方面也可以解放了項目發(fā)布者,讓開發(fā)者可以更方便的進行代碼上線,而又開放了項目的發(fā)布。

        能力對外輸出

        云長可以對外輸出一些構(gòu)建更新的能力,也就讓第三方插件接入構(gòu)建流程成為了可能,我們貼心的為開發(fā)者提供了 VsCode 插件,讓你在開發(fā)過程中可以進行自由的代碼更新,省去打開網(wǎng)頁進行構(gòu)建的時間,足不出戶,在編輯器中進行代碼的構(gòu)建更新,常用環(huán)境更是提供了一鍵更新的快捷方式,進一步省去中間這些操作時間,這個時候多寫兩行代碼不是更開心嗎。

        我們的 VsCode 插件不僅僅提供了云長的一些構(gòu)建能力,還有小程序構(gòu)建,路由查找,等等功能,期待這個插件分享的話,請期待我們后續(xù)的文章哦。

        云長架構(gòu)

        上面講過云長的構(gòu)建流程,云長是依賴于 K8S 提供的一個部署鏡像的能力,云長的客戶端與服務(wù)端都是跑在 Docker 中的服務(wù),所以云長是采用了Docker In Docker 的設(shè)計方案,也就是由 Docker 中的服務(wù)來進行一個 Docker 鏡像的打包。

        針對代碼的構(gòu)建,云長服務(wù)端部分引入了進程池的處理,每個在云長中構(gòu)建的項目都是進程池中的一個獨立的實例,都有獨立的打包進程,而打包過程的進度跟進則是靠 Redis 的定時任務(wù)查詢來進行,也就實現(xiàn)了云長多實例并行構(gòu)建的架構(gòu)。

        云長客戶端與服務(wù)端的接口通信則是正常的 HTTP 請求和 Websocket 請求,客戶端發(fā)起請求后,服務(wù)端則通過 MySQL 數(shù)據(jù)存儲一些應(yīng)用,用戶,構(gòu)建信息等數(shù)據(jù)。

        外部的資源交互則是,構(gòu)建的過程中也會上傳一些靜態(tài)資源還有打包的鏡像到 cdn 和鏡像倉庫,最后則是會調(diào)用 K8S 的部署接口進行項目的部署操作。

        前端構(gòu)建的 0-1

        上面看過了“云長”的一些功能介紹,以及“云長”的架構(gòu)設(shè)計,相信很多朋友也想自己做一個類似于“云長”的前端構(gòu)建發(fā)布平臺,那需要怎么做呢,隨我來看看前端構(gòu)建平臺主要模塊的設(shè)計思路吧。

        構(gòu)建流程

        前端構(gòu)建平臺的主要核心模塊肯定是構(gòu)建打包,構(gòu)建部署流程可以分為以下幾個步驟:

        • 每一次構(gòu)建開始后,需要保存本次構(gòu)建的一些信息數(shù)據(jù),所以需要創(chuàng)建構(gòu)建發(fā)布記錄,發(fā)布記錄會存儲本次發(fā)布的發(fā)布信息,例如發(fā)布項目的名稱,分支,commitId,commit 信息,操作人數(shù)據(jù),需要更新的發(fā)布環(huán)境等,這時我們會需要一張構(gòu)建發(fā)布記錄表,而如果你需要項目以及操作人的一些數(shù)據(jù),你就又需要應(yīng)用表以及用戶表來存儲相關(guān)數(shù)據(jù)進行關(guān)聯(lián)。
        • 構(gòu)建發(fā)布記錄創(chuàng)建以后,開始了前端構(gòu)建流程,構(gòu)建流程可以 pipeline 的流程來進行,流程可以參考以下例子
          // 構(gòu)建的流程
          async run() {
            const app = this.app;
            const processData = {};
            const pipeline = [{
              handler: context => app.fetchUpdate(context), // Git 更新代碼
              name: 'codeUpdate',
              progress: 10 // 這里是當前構(gòu)建的進度
            }, {
              handler: context => app.installDependency(context), // npm install 安裝依賴
              name: 'dependency',
              progress: 30
            }, {
              handler: context => app.check(context), // 構(gòu)建的前置校驗(非必須):代碼檢測,eslint,package.json 版本等
              name: 'check',
              progress: 40
            }, {
              handler: context => app.pack(context), // npm run build 的打包邏輯,如果有其他的項目類型,例如 gulp 之類,也可以在這一步進行處理
              name: 'pack'
              progress: 70
            }, {
              handler: context => app.injectScript(context), // 構(gòu)建的后置步驟(非必須):打包后的資源注入
              name: 'injectRes',
              progress: 80
            }, { // docker image build
              handler: context => app.buildImage(context), // 生成 docker 鏡像文件,鏡像上傳倉庫,以及之后調(diào)用 K8S 能力進行部署
              name: 'buildImage',
              progress: 90
            }];
            // 循環(huán)執(zhí)行每一步構(gòu)建流程
            for (let i = 0; i < pipeline.length; i++) {
              const task = pipeline[i];
              const [ err, response ] = await to(this.execProcess({
                ...task,
                step: i
              }));
              if (response) {
                processData[task.name] = response;
              }
            }
            return Promise.resolve(processData);
          }
          // 執(zhí)行構(gòu)建中的 handler 操作
          async execProcess(task) {
            this.step(task.name, { status: 'start' });
            const result = await task.handler(this.buildContext);
            this.progress(task.progress);
            this.step(task.name, { status: 'end', taskMeta: result });
            return result;
          }
        • 構(gòu)建的步驟,上面構(gòu)建的一些流程,相比大家也想知道在服務(wù)端如何跑構(gòu)建流程當中的一些腳本,其實思路就是通過 node 的child_process 模塊執(zhí)行 shell 腳本,下面是代碼的一些示例:
        import { spawn } from 'child_process';
        // git clone 
        execCmd(`git clone ${url} ${dir}`, {
          cwd: this.root,
          verbose: this.verbose
        });
        // npm run build
        const cmd = ['npm run build', cmdOption].filter(Boolean).join(' ');
        execCmd(cmd, options);
        // 執(zhí)行 shell 命令
        function execCmd(cmd: string, options:any = {}): Promise<any{
          const [ shell, ...args ] = cmd.split(' ').filter(Boolean);
          const { verbose, ...others } = options;
          return new Promise((resolve, reject) => {
            let child: any = spawn(shell, args, others);
            let stdout = '';
            let stderr = '';
            child.stdout && child.stdout.on('data'(buf: Buffer) => {
              stdout = `${stdout}${buf}`;
              if (verbose) {
                logger.info(`${buf}`);
              }
            });
            child.stderr && child.stderr.on('data'(buf: Buffer) => {
              stderr = `${stderr}${buf}`;
              if (verbose) {
                logger.error(`${buf}`);
              }
            });
            child.on('exit'(code: number) => {
              if (code !== 0) {
                const reason = stderr || 'some unknown error';
                reject(`exited with code ${code} due to ${reason}`);
              } else {
                resolve({stdout,  stderr});
              }
              child.kill();
              child = null;
            });
            child.on('error'err => {
              reject(err.message);
              child.kill();
              child = null;
            });
          });
        };
        • 而例如我們想在構(gòu)建前想加入 Eslint 校驗操作,也可以在構(gòu)建流程中加入,也就可以在線上構(gòu)建的環(huán)節(jié)中加入攔截型的校驗,控制上線構(gòu)建代碼質(zhì)量。
        import { CLIEngine } from 'eslint';
        export function lintOnFiles(context{
          const { root } = context;
          const [ err ] = createPluginSymLink(root);
          if (err) {
            return [ err ];
          }
          const linter = new CLIEngine({
            envs: [ 'browser' ],
            useEslintrc: true,
            cwd: root,
            configFile: path.join(__dirname, 'LintConfig.js'),
            ignorePattern: ['**/router-config.js']
          });
          let report = linter.executeOnFiles(['src']);
          const errorReport = CLIEngine.getErrorResults(report.results);
          const errorList = errorReport.map(item => {
            const file = path.relative(root, item.filePath);
            return {
              file,
              errorCount: item.errorCount,
              warningCount: item.warningCount,
              messages: item.messages
            };
          });
          const result = {
            errorList,
            errorCount: report.errorCount,
            warningCount: report.warningCount
          }
          return [ null, result ];
        };
        • 構(gòu)建部署完成后,可根據(jù)構(gòu)建情況,來更新這條構(gòu)建記錄的更新狀態(tài)信息,本次構(gòu)建生成的 Docker 鏡像,上傳鏡像倉庫后,也需要信息記錄,方便后期可用之前構(gòu)建的鏡像再次進行更新或者回滾操作,所以需要添加一張鏡像表,下面為 Docker 鏡像生成的一些實例代碼。
        import Docker = require('dockerode');
        // 保證服務(wù)端中有一個基本的 dockerfile 鏡像文件
        const docker = new Docker({ socketPath: '/var/run/docker.sock' });
        const image = '鏡像打包名稱'
        let buildStream;
        [ err, buildStream ] = await to(
          docker.buildImage({
            context: outputDir
          }, { t: image })
        );
        let pushStream;
        // authconfig 鏡像倉庫的一些驗證信息
        const authconfig = {
          serveraddress: "鏡像倉庫地址"
        };
        // 向遠端私有倉庫推送鏡像
        const dockerImage = docker.getImage(image);
        [ err, pushStream ] = await to(dockerImage.push({
          authconfig,
          tag
        }));
        // 3s 打印一次進度信息
        const progressLog = _.throttle((msg) => logger.info(msg), 3000); 
        const pushPromise = new Promise((resolve, reject) => {
          docker.modem.followProgress(pushStream, (err, res) => {
            err ? reject(err) : resolve(res);
          }, e => {
            if (e.error) {
              reject(e.error);
            } else {
              const { id, status, progressDetail } = e;
              if (progressDetail && !_.isEmpty(progressDetail)) {
                const { current, total } = progressDetail;
                const percent = Math.floor(current / total * 100);
                progressLog(`${id} : pushing progress ${percent}%`);
                if (percent === 100) { // 進度完成
                  progressLog.flush();
                }
              } else if (id && status) {
                logger.info(`${id} : ${status}`);
              }
            }
          });
        });
        await to(pushPromise);
        • 每一次的構(gòu)建需要保存一些構(gòu)建進度,日志等信息,可以再加一張日志表來進行日志的保存。

        多個構(gòu)建實例的運行

        到這里一個項目的構(gòu)建流程就已經(jīng)成功跑通了,但一個構(gòu)建平臺肯定不能每次只能構(gòu)建更新一個項目啊,所以這時候可以引入一個進程池,讓你的構(gòu)建平臺可以同時構(gòu)建多個項目。

        Node 是單線程模型,當需要執(zhí)行多個獨立且耗時任務(wù)的時候,只能通過 child_process 來分發(fā)任務(wù),提高處理速度,所以也需要實現(xiàn)一個進程池,用來控制多構(gòu)建進程運行的問題,進程池思路是主進程創(chuàng)建任務(wù)隊列,控制子進程數(shù)量,當子進程完成任務(wù)后,通過進程的任務(wù)隊列,來繼續(xù)添加新的子進程,以此來控制并發(fā)進程的運行,流程實現(xiàn)如下。

        ProcessPool.ts 以下是進程池的部分代碼,主要展示思路。

        import * as child_process from 'child_process';
        import { cpus } from 'os';
        import { EventEmitter } from 'events';
        import TaskQueue from './TaskQueue';
        import TaskMap from './TaskMap';
        import { to } from '../util/tool';
        export default class ProcessPool extends EventEmitter {
          private jobQueue: TaskQueue;
          private depth: number;
          private processorFile: string;
          private workerPath: string;
          private runningJobMap: TaskMap;
          private idlePool: Array<number>;
          private workPool: Map<anyany>;
          constructor(options: any = {}) {
            super();
            this.jobQueue = new TaskQueue('fap_pack_task_queue');
            this.runningJobMap = new TaskMap('fap_running_pack_task');
            this.depth = options.depth || cpus().length; // 最大的實例進程數(shù)量
            this.workerPath = options.workerPath;
            this.idlePool = []; // 工作進程  pid 數(shù)組
            this.workPool = new Map();  // 工作實例進程池
            this.init();
          }
          /**
           * @func init 初始化進程,
           */

          init() {
            while (this.workPool.size < this.depth) {
              this.forkProcess();
            }
          }
          /**
           * @func forkProcess fork 子進程,創(chuàng)建任務(wù)實例
           */

          forkProcess() {
            let worker: any = child_process.fork(this.workerPath);
            const pid = worker.pid;
            this.workPool.set(pid, worker);
            worker.on('message'async (data) => {
              const { cmd } = data;
              // 根據(jù) cmd 狀態(tài) 返回日志狀態(tài)或者結(jié)束后清理掉任務(wù)隊列
              if (cmd === 'log') {
              }
              if (cmd === 'finish' || cmd === 'fail') {
                this.killProcess();//結(jié)束后清除任務(wù)
              }
            });
            worker.on('exit'() => {
              // 結(jié)束后,清理實例隊列,開啟下一個任務(wù)
              this.workPool.delete(pid);
              worker = null;
              this.forkProcess();
              this.startNextJob();
            });
            return worker;
          }
          // 根據(jù)任務(wù)隊列,獲取下一個要進行的實例,開始任務(wù)
          async startNextJob() {
            this.run();
          }
          /**
           * @func add 添加構(gòu)建任務(wù)
           * @param task 運行的構(gòu)建程序
           */

          async add(task) {
            const inJobQueue = await this.jobQueue.isInQueue(task.appId); // 任務(wù)隊列
            const isRunningTask = await this.runningJobMap.has(task.appId); // 正在運行的任務(wù)
            const existed = inJobQueue || isRunningTask;
            if (!existed) {
              const len = await this.jobQueue.enqueue(task, task.appId);
              // 執(zhí)行任務(wù)
              const [err] = await to(this.run());
              if (err) {
                return Promise.reject(err);
              }
            } else {
              return Promise.reject(new Error('DuplicateTask'));
            }
          }
          /**
           * @func initChild 開始構(gòu)建任務(wù)
           * @param child 子進程引用
           * @param processFile 運行的構(gòu)建程序文件
           */

          initChild(child, processFile) {
            return new Promise(resolve => {
              child.send({ cmd: 'init', value: processFile }, resolve);
            });
          }
          /**
           * @func startChild 開始構(gòu)建任務(wù)
           * @param child 子進程引用
           * @param task 構(gòu)建任務(wù)
           */

          startChild(child, task) {
            child.send({ cmd: 'start', task });
          }
          /**
           * @func run 開始隊列任務(wù)運行
           */

          async run() {
            const jobQueue = this.jobQueue;
            const isEmpty = await jobQueue.isEmpty();
            // 有空閑資源并且任務(wù)隊列不為空
            if (this.idlePool.length > 0 && !isEmpty) {
              // 獲取空閑構(gòu)建子進程實例
              const taskProcess = this.getFreeProcess();
              await this.initChild(taskProcess, this.processorFile);
              const task = await jobQueue.dequeue();
              if (task) {
                await this.runningJobMap.set(task.appId, task);
                this.startChild(taskProcess, task);
                return task;
              }
            } else {
              return Promise.reject(new Error('NoIdleResource'));
            }
          }
          /**
           * @func getFreeProcess 獲取空閑構(gòu)建子進程
           */

          getFreeProcess() {
            if (this.idlePool.length) {
              const pid = this.idlePool.shift();
              return this.workPool.get(pid);
            }
            return null;
          }
          
          /**
           * @func killProcess 殺死某個子進程,原因:釋放構(gòu)建運行時占用的內(nèi)存
           * @param pid 進程 pid
           */

          killProcess(pid) {
            let child = this.workPool.get(pid);
            child.disconnect();
            child && child.kill();
            this.workPool.delete(pid);
            child = null;
          }
        }

        Build.ts

        import ProcessPool from './ProcessPool';
        import TaskMap from './TaskMap';
        import * as path from 'path';
        // 日志存儲
        const runningPackTaskLog = new TaskMap('fap_running_pack_task_log');
        //初始化進程池
        const packQueue = new ProcessPool({
          workerPath: path.join(__dirname, '../../task/func/worker'),
          depth: 3
        });
        // 初始化構(gòu)建文件
        packQueue.process(path.join(__dirname, '../../task/func/server-build'));
        let key: string;
        packQueue.on('message'async data => {
          // 根據(jù)項目 id,部署記錄 id,以及用戶 id 來設(shè)定 redis 緩存的 key 值,之后進行日志存儲
          key = `${appId}_${deployId}_${deployer.userId}`;
          const { cmd, value } = data;
          if(cmd === 'log') { // 構(gòu)建任務(wù)日志
            runningPackTaskLog.set(key,value);
          } else if (cmd === 'finish') { // 構(gòu)建完成
            runningPackTaskLog.delete(key);
            // 后續(xù)日志可以進行數(shù)據(jù)庫存儲
          } else if (cmd === 'fail') { // 構(gòu)建失敗
            runningPackTaskLog.delete(key);
            // 后續(xù)日志可以進行數(shù)據(jù)庫存儲
          }
          // 可以通過 websocket 將進度同步給前臺展示
        });
        //添加新的構(gòu)建任務(wù)
        let [ err ] = await to(packQueue.add({
          ...appAttrs, // 構(gòu)建所需信息
        }));

        有了進程池處理了多進程構(gòu)建之后,如何記錄每個進程構(gòu)建進度呢,我這邊選擇用了 Redis 數(shù)據(jù)庫進行構(gòu)建進度狀態(tài)的緩存,同時通過Websocket 同步前臺的進度展示,在構(gòu)建完成后,進行日志的本地存儲。上面代碼簡單介紹了進程池的實現(xiàn)以及使用,當然具體的應(yīng)用還要看自己設(shè)計思路了,有了進程池的幫助下,剩下的思路其實就是具體代碼實現(xiàn)了。

        前端構(gòu)建的未來

        最后來聊聊我們對于前端構(gòu)建未來的一些想法吧,首先前端構(gòu)建必須保證的是更加穩(wěn)定的構(gòu)建,在穩(wěn)定的前提下,來達到更快的構(gòu)建,對于 CI/CD 方向,比如更加完整的構(gòu)建流程,在更新完生成線上環(huán)境以后,自動處理代碼的歸檔,歸檔后最新的 Master 代碼重新合入各個開發(fā)分支,再更新全部的測試環(huán)境等等。

        而對于服務(wù)端性能方面,我們考慮過能不能將云端構(gòu)建的能力來靠每臺開發(fā)的電腦來完成,實現(xiàn)本地構(gòu)建,云端部署的離岸云端構(gòu)建,將服務(wù)器壓力分散到各自的電腦上,這樣也能減輕服務(wù)端構(gòu)建的壓力,服務(wù)端只做最后的部署服務(wù)即可。

        還有比如我們的開發(fā)同學很想要項目按組的維度進行打包發(fā)布的功能,一次發(fā)布的版本中,選定好要一起更新發(fā)布的項目以及版本分支,統(tǒng)一發(fā)布更新。

        小結(jié)

        所以有了自己的構(gòu)建發(fā)布平臺,自己想要的功能都可以自己操作起來,可以做前端自己想要的各類功能,豈不是美滋滋。我猜很多同學可能會對我們做的 VsCode 插件感興趣吧,除了構(gòu)建項目,當然還有一些其他的功能,比如公司測試賬號的管理,小程序的快速構(gòu)建等等輔助開發(fā)的功能,是不是想進一步了解這個插件的功能呢,請期待我們之后的分享吧。

        參考文檔

        node child_process 文檔 (http://nodejs.cn/api/child_process.html#child_process_child_process_fork_modulepath_args_options)

        深入理解Node.js 進程與線程 (https://blog.csdn.net/xgangzai/article/details/98919412)

        淺析 Node 進程與線程 (https://juejin.cn/post/6844904033640169486?utm_source=weibo&utm_campaign=admin)


        內(nèi)推社群


        我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進行面試相關(guān)的答疑、聊聊面試的故事、并且在你準備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。


        瀏覽 45
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            国产麻豆一级片 | 国产精品a久久久久 | 日本AAA片免费观看 | 欧美黄色片一级 | 女生胸无遮挡 | b站暴躁少女csgo视频 | 成人AV中文解说水果派 | wwwxxx在线观看 | 国产精品免费久久久久久久久久中文 | 探花系列在线 |