Thursday, June 9, 2022

SegmentFault 最新的文章

SegmentFault 最新的文章


🔥前端脚手架辣么多,那我也要写一个玩玩

Posted: 12 Apr 2022 03:38 AM PDT

前言

  2022年已经过了四分之一还多了,之前说好的每个月一片文章好像也没有让自己兑现。最近公司在做一些前端工程化相关的东西,虽然准备做组件库的事情被领导给毙了,不过在这之前写了一个脚手架的工具,毕竟现在这个环境下,脚手架工具泛滥,所以当然也要写一写玩玩。

最终效果

cliInit

支持功能

  • 自主选择web端或移动端;
  • 自主选择项目框架react或vue;
  • 自主选择是否设置远程git地址;
  • 支持在项目模板中自定义变量替换;
  • 自主选择是否自动安装依赖,可选择npm、cnpm、yarn;
  • 支持使用update命令在线更新;
  • 自主选择已存在文件目录是否覆盖;

开发

初始化项目

  那么接下来就开始开发了,首先我们来新建一个项目文件夹就叫new-cli吧,在项目文件夹中新建package.json文件,设置常用的字段,设置完成后如下:

{   "name": "new-cli",   "version": "1.0.0",   "description": "a react project cli, help you create a react project quickly",   "bin": {     "new-cli": "bin/www.js"   },   "dependencies": {     "boxen": "^5.1.2",     "chalk": "^4.1.2",     "commander": "^9.1.0",     "consolidate": "^0.16.0",     "cross-spawn": "^7.0.3",     "download-git-repo": "^3.0.2",     "ejs": "^3.1.6",     "fs-extra": "^10.0.1",     "inquirer": "^8.2.1",     "metalsmith": "^2.4.2",     "ora": "^5.4.1",     "figlet": "^1.5.2",     "semver": "^7.3.5",     "shelljs": "^0.8.5"   },   "repository": {     "type": "git",     "url": "https://github.com/BoWang816/new-cli.git"   },   "keywords": [     "cli",     "react"   ],   "author": "恪晨",   "publishConfig": {     "registry": "私有仓库地址"   },   "engines": {     "node":"^12.20.0 || >=14"    } }

  通过以上设置以后,我们的脚手架名字就叫new-cli,也就是说到时候安装的时候就是通过npm install -g new-cli进行安装。bin下面设置的名称就是为了设置脚手架执行的命令,并且是从bin/www.js文件作为了入口文件;dependencies中为我们需要的项目依赖,值得注意的是像boxen、chalk、figlet这一类的依赖包在最新版本中已经不支持requier方式引入了所以这里我们需要安装低版本的包;publishConfig中可以设置到时候需要发布的npm地址,如果你搭建了npm私服则通过设置registry就可以发布到你的私服了。

设置项目入口

  建好package.json以后我们就开始建入口文件,也就是bin下面的www.js,事实上你的入口文件放置在根目录也是可以的,可以根据自己的喜好,当然如果放置在了根目录,则bin下面就要改为new-cli: './www.js'。www.js中主要是引入commander、inquirer等工具包,进行脚手架工具的初始化。因为www.js将要作为一个node脚本来运行,因此需要在最上方声明环境:#! /usr/bin/env node,我写的这个脚手架中涉及到了init、update、help这三个命令,并且help是commander本身就支持的,这里只是做了一点定制化。

  • 初始化init命令、update命令、help命令

    首先需要引入commander,使用它的program,const {program} = require("commander");,脚手架工具的主体就是它了,我们初始化相关的命令:

      #! /usr/bin/env node   // 引入commander   const {program} = require("commander");      // 初始化init命令, project-name就是你的项目名称与项目文件夹名称   program.command("init <project-name>")           // init命令描述          .description("create a new project name is <project-name>")          // init命令参数项,因为后续会设置支持覆盖文件夹,所以这里提供一个-f参数          .option("-f, --force", "overwrite target directory if it exists")          // init命名执行后做的事情          .action(() => {                console.log('doSomething');           });             program.command("update")           .description("update the cli to latest version")           // update命令执行后做的事情,自动检测更新           .action(async () => {               // await checkUpdate();               console.log('update');           });      program.on("--help", () => {       // 监听--help命令,输出一个提示       console.log(figlet.textSync("new-cli", {           font: "Standard",           horizontalLayout: 'full',           verticalLayout: 'fitted',           width: 120,           whitespaceBreak: true       }));   });         // 这个一定不能忘,且必须在最后!!!   program.parse(process.argv);      

  通过设置以上内容,其实我们就可以使用基本的命令了。本地调试的方式有两种,一种是通过npm link命令将我们写的脚手架工具直接链接到本地的全局npm中,一种则是直接通过node bin/www.js直接执行这个js文件,这里我们使用后者就可以了。
initCommand

  • 扩展init命令

      接下来我们就需要扩展init命名,也就是在action做一些事情了。首先,我们提供了-f的参数选项,目的是为了在初始化项目的时候检测到有同名文件夹则进行覆盖,因此在初始化项目的第一步我们就需要检测当前路径下是否存在同名的文件夹,并且在没有设置-f的时候给出提示信息,同时在设置了-f后给出二次提示,同意覆盖则开始初始化项目。因此action函数中将要执行的以下内容,这里我们就需要引入chalk,paht,fs-extray以及后续我们自己写的create。

const chalk = require("chalk"); const path = require("path"); const fs = require('fs-extra'); const figlet = require('figlet'); const create = require('../utils/create');      program     .command("init <project-name>")     .description("create a new project name is <project-name>")     .option("-f, --force", "overwrite target directory if it exists")     .action(async (projectName, options) => {         const cwd = process.cwd();         // 拼接到目标文件夹         const targetDirectory = path.join(cwd, projectName);         // 如果目标文件夹已存在         if (fs.existsSync(targetDirectory)) {             if (!options.force) {             // 如果没有设置-f则提示,并退出                 console.error(chalk.red(`Project already exist! Please change your project name or use ${chalk.greenBright(`new-cli create ${projectName} -f`)} to create`))                 return;             }             // 如果设置了-f则二次询问是否覆盖原文件夹             const {isOverWrite} = await inquirer.prompt([{                 name: "isOverWrite",                 type: "confirm",                 message: "Target directory already exists, Would you like to overwrite it?",                 choices: [                     {name: "Yes", value: true},                     {name: "No", value: false}                 ]             }]);             // 如需覆盖则开始执行删除原文件夹的操作             if (isOverWrite) {                 const spinner = ora(chalk.blackBright('The project is Deleting, wait a moment...'));                 spinner.start();                 await fs.removeSync(targetDirectory);                 spinner.succeed();                 console.info(chalk.green("✨ Deleted Successfully, start init project..."));                 console.log();                 // 删除成功后,开始初始化项目                 // await create(projectName);                 console.log('init project overwrite');                 return;             }             console.error(chalk.green("You cancel to create project"));             return;         }         // 如果当前路径中不存在同名文件夹,则直接初始化项目         // await create(projectName);         console.log('init project');     });

我们再来查看现在的效果:
initProject

  • 创建create方法

      在上一步操作中,我们覆盖同名文件后,使用了await create(projectName)方法开始初始化项目,接下来我们开始开发create方法。在根目录新建一个文件夹叫utils,当然你可以随意叫lib或者✨点赞都行,在utils下面新建一个文件叫create.js,在这个文件中,我们将设置下载初始化项目中一些问题询问的执行。内容主要有以下:

      const inquirer = require("inquirer");   const chalk = require("chalk");   const path = require("path");   const fs = require("fs");   const boxen = require("boxen");   const renderTemplate = require("./renderTemplate");   const downloadTemplate = require('./download');   const install = require('./install');   const setRegistry = require('./setRegistry');   const {baseUrl, promptList} = require('./constants');         const go = (downloadPath, projectRoot) => {       return downloadTemplate(downloadPath, projectRoot).then(target => {       //下载模版           return {               downloadTemp: target           }       })   }   module.exports = async function create(projectName) {       // 校验项目名称合法性,项目名称仅支持字符串、数字,因为后续这个名称会用到项目中的package.json以及其他很多地方,所以不能存在特殊字符       const pattern = /^[a-zA-Z0-9]*$/;       if (!pattern.test(projectName.trim())) {           console.log(`\n${chalk.redBright('You need to provide a projectName, and projectName type must be string or number!\n')}`);           return;       }       // 询问       inquirer.prompt(promptList).then(async answers => {           // 目标文件夹           const destDir = path.join(process.cwd(), projectName);           // 下载地址           const downloadPath = `direct:${baseUrl}/${answers.type}-${answers.frame}-template.git#master`           // 创建文件夹           fs.mkdir(destDir, {recursive: true}, (err) => {               if (err) throw err;           });              console.log(`\nYou select project template url is ${downloadPath} \n`);           // 开始下载           const data = await go(downloadPath, destDir);           // 开始渲染           await renderTemplate(data.downloadTemp, projectName);           // 是否需要自动安装依赖,默认否           const {isInstall, installTool} = await inquirer.prompt([               {                   name: "isInstall",                   type: "confirm",                   default: "No",                   message: "Would you like to help you install dependencies?",                   choices: [                       {name: "Yes", value: true},                       {name: "No", value: false}                   ]               },               // 选择了安装依赖,则使用哪一个包管理工具               {                   name: "installTool",                   type: "list",                   default: "npm",                   message: 'Which package manager you want to use for the project?',                   choices: ["npm", "cnpm", "yarn"],                   when: function (answers) {                       return answers.isInstall;                   }               }           ]);                      // 开始安装依赖           if (isInstall) {               await install({projectName, installTool});           }              // 是否设置了仓库地址           if (answers.setRegistry) {               setRegistry(projectName, answers.gitRemote);           }            // 项目下载成功           downloadSuccessfully(projectName);       });   }
    • create.js文件中,我们首先判断了初始化的项目名称是否包含特殊字符,如果包含则给出错误提示,并终止项目初始化。如果项目名称合法,则开始询问用户需要的项目模板:
      askTpl
        我们将这些询问的list抽离为常量,同时也将模板的地址抽离为常量,因此需要在utils文件夹下建立一个constants.js的文件,里面的内容如下:

      /**  * constants.js  * @author kechen  * @since 2022/3/25  */  const { version } = require('../package.json');  const baseUrl = 'https://github.com/BoWangBlog'; const promptList = [     {         name: 'type',         message: 'Which build tool to use for the project?',         type: 'list',         default: 'webpack',         choices: ['webpack', 'vite'],     },     {         name: 'frame',         message: 'Which framework to use for the project?',         type: 'list',         default: 'react',         choices: ['react', 'vue'],     },     {         name: 'setRegistry',         message: "Would you like to help you set registry remote?",         type: 'confirm',         default: false,         choices: [             {name: "Yes", value: true},             {name: "No", value: false}         ]     },     {         name: 'gitRemote',         message: 'Input git registry for the project: ',         type: 'input',         when: (answers) => {             return answers.setRegistry;         },         validate: function (input) {             const done = this.async();             setTimeout(function () {                 // 校验是否为空,是否是字符串                 if (!input.trim()) {                     done('You should provide a git remote url');                     return;                 }                 const pattern = /^(http(s)?:\/\/([^\/]+?\/){2}|git@[^:]+:[^\/]+?\/).*?.git$/;                 if (!pattern.test(input.trim())) {                     done(                         'The git remote url is validate',                     );                     return;                 }                 done(null, true);             }, 500);         },     } ];  module.exports = {     version,     baseUrl,     promptList }

        其中version为我们的脚手架版本号,baseUrl为项目模板下载的基础地址,promptList为询问用户的问题列表,promptList的具体写法是根据inquirer.prompt()方法来写的,具体的怎么写后面我都会将官方文档地址附上,大家可以自己发挥。

    • 通过inquirer.prompt()获取到用户反馈的结果以后,我们会拿到相关的字段值,然后去拼接出下载的项目模板地址,接下来就是开始下载项目模板了。这里我们写了go函数和renderTemplate俩个函数,一个用于下载项目模板一个用于渲染项目模板(因为涉及到变量的替换)。go函数中其实是使用了从外部引入的downloadTemplate方法,因此我们需要去关注downloadTemplaterenderTemplate方法,也就是接下来要讲的重点了。
  • 创建download方法

    utils文件夹下,新建一个名称为download.js的文件,文件内容如下:

    /**  * 下载  * download.js  * @author kechen  * @since 2022/3/25  */  const download = require('download-git-repo') const path = require("path") const ora = require('ora') const chalk = require("chalk"); const fs = require("fs-extra");  module.exports = function (downloadPath, target) {     target = path.join(target);     return new Promise(function (resolve, reject) {         const spinner = ora(chalk.greenBright('Downloading template, wait a moment...\r\n'));         spinner.start();          download(downloadPath, target, {clone: true}, async function (err) {             if (err) {                 spinner.fail();                 reject(err);                 console.error(chalk.red(`${err}download template failed, please check your network connection and try again`));                 await fs.removeSync(target);                 process.exit(1);             } else {                 spinner.succeed(chalk.greenBright('✨ Download template successfully, start to config it: \n'));                 resolve(target);             }         })     }) }

      该文件中,我们使用了download-git-repo这个第三方的工具库,用于下载项目模板,因为download-git-repo的返回结果是下载成功或者失败,我们在使用异步的方式的时候如果直接使用会存在问题,因此这里封装为promise,当err的时候给用户抛出异常提示,成功则将目标文件夹路径返回用于后续使用。在create.js中我们使用了go函数,在go函数执行成功后会返回一个data,里面拿到了项目要下载到具体的文件夹的路径,其实主要是为了获取在download中的promise的resolve结果,拿到目标文件夹的路径后,其实项目模板已经下载到了该文件夹中,就可以开始renderTemplate了。

  • 创建renderTemplate方法

  在utils文件夹下,新建一个文件叫renderTemplate.js,该函数的主要目的是为了将初始化的项目中设置的变量进行替换,主要使用了metalSmithconsolidate这两个第三方的包,通过遍历初始化项目中的文件,将其转换为ejs模板,并替换相关的变量。这个方法是参考了vww-cli的方式,通过读取项目模板中的ask.ts文件,获取项目模板中自定义的询问列表,然后再进行文件模板引擎渲染替换相关设置好的变量,主要内容如下:

   /**     * 渲染模板     * renderTemplate.js     * @author kechen     * @since 2022/3/24     */    const MetalSmith = require('metalsmith');     const {render} = require('consolidate').ejs;    const {promisify} = require('util');    const path = require("path");    const inquirer = require('inquirer');    const renderPro = promisify(render);    const fs = require('fs-extra');        module.exports = async function renderTemplate(result, projectName) {        if (!result) {            return Promise.reject(new Error(`无效的目录:${result}`))        }            await new Promise((resolve, reject) => {            MetalSmith(__dirname)                .clean(false)                .source(result)                .destination(path.resolve(projectName))                .use(async (files, metal, done) => {                    const a = require(path.join(result, 'ask.ts'));                    // 读取ask.ts文件中设置好的询问列表                    let r = await inquirer.prompt(a);                    Object.keys(r).forEach(key => {                        // 将输入内容前后空格清除,不然安装依赖时package.json读取会报错                        r[key] = r[key]?.trim() || '';                    })                    const m = metal.metadata();                    const tmp = {                        ...r,                        // 将使用到的name全部转换为小写字母                        name: projectName.trim().toLocaleLowerCase()                    }                    Object.assign(m, tmp);                    // 完成后删除模板中的文件                    if (files['ask.ts']) {                        delete files['ask.ts'];                        await fs.removeSync(result);                    }                    done()                })                .use((files, metal, done) => {                    const meta = metal.metadata();                    // 需要替换的文件的后缀名集合                    const fileTypeList = ['.ts', '.json', '.conf', '.xml', 'Dockerfile', '.json'];                    Object.keys(files).forEach(async (file) => {                        let c = files[file].contents.toString();                        // 找到项目模板中设置好的变量进行替换                        for (const type of fileTypeList) {                            if (file.includes(type) && c.includes('<%')) {                                c = await renderPro(c, meta);                                files[file].contents = Buffer.from(c);                            }                        }                    });                    done()                })                .build((err) => {                    err ? reject(err) : resolve({resolve, projectName});                })        });    };    

通过renderTemplate方法,我们基本就完成我们脚手架的主要功能了。我们就可以实现使用init命令创建项目了。这里我遇到一个问题,就是在删除ask.ts文件的时候,如果后面不加await fs.removeSync(result);这个文件就无法删除,但是加上按理说又不合理,具体原因没有找到,有知道的朋友可以留言解释一下,十分感谢。至此,我们初始化项目的功能已经完成,接下来就是一些扩展了。

  • 创建setRegistry方法

      在utils文件夹下,新建一个文件叫setRegistry.js,主要是为了帮助用户初始化项目的git地址,在用户创建是选择是否需要自动设置项目仓库地址,如果设置了项目地址,则这里会自动初始化git,并设置项目地址,具体内容如下:

      /**    * 设置仓库地址    * setRegistry.js    * @author kechen    * @since 2022/3/28    */      const shell = require("shelljs");   const chalk = require("chalk");      module.exports = function setRegistry(projectName, gitRemote) {       shell.cd(projectName);       if (shell.exec('git init').code === 0) {           if (shell.exec(`git remote add origin ${gitRemote}`).code === 0) {               console.log(chalk.green(`✨ \n Set registry Successfully, now your local gitRemote is ${gitRemote} \n`));               return;           }           console.log(chalk.red('Failed to set.'));           shell.exit(1);       }   };  
  • 创建install方法

      在utils文件夹下,新建一个文件叫install.js,主要是为了帮助用户自动安装依赖,主要内容如下:

     /**    * 安装依赖    * install.js    * @author kechen    * @since 2022/3/22    */   const spawn = require("cross-spawn");      module.exports = function install(options) {       const cwd = options.projectName || process.cwd();       return new Promise((resolve, reject) => {           const command = options.installTool;           const args = ["install", "--save", "--save-exact", "--loglevel", "error"];           const child = spawn(command, args, {cwd, stdio: ["pipe", process.stdout, process.stderr]});              child.once("close", code => {               if (code !== 0) {                   reject({                       command: `${command} ${args.join(" ")}`                   });                   return;               }               resolve();           });           child.once("error", reject);       });   };
  • 创建checkUpdate方法

      在utils文件夹下,新建一个文件叫checkUpdate.js,主要是为了帮助用户自动检测并进行脚手架更新,主要内容如下:

      /**    * 检查更新    * checkUpdate.js    * @author kechen    * @since 2022/3/23    */   const pkg = require('../package.json');   const shell = require('shelljs');   const semver = require('semver');   const chalk = require('chalk');   const inquirer = require("inquirer");   const ora = require("ora");      const updateNewVersion = (remoteVersionStr) => {       const spinner = ora(chalk.blackBright('The cli is updating, wait a moment...'));       spinner.start();       const shellScript = shell.exec("npm -g install new-cli");       if (!shellScript.code) {           spinner.succeed(chalk.green(`Update Successfully, now your local version is latestVersion: ${remoteVersionStr}`));           return;       }       spinner.stop();       console.log(chalk.red('\n\r Failed to install the cli latest version, Please check your network or vpn'));   };      module.exports = async function checkUpdate() {       const localVersion = pkg.version;       const pkgName = pkg.name;       const remoteVersionStr = shell.exec(           `npm info ${pkgName}@latest version`,           {               silent: true,           }       ).stdout;          if (!remoteVersionStr) {           console.log(chalk.red('Failed to get the cli version, Please check your network'));           process.exit(1);       }       const remoteVersion = semver.clean(remoteVersionStr, null);          if (remoteVersion !== localVersion) {           // 检测本地安装版本是否是最新版本,如果不是则询问是否自动更新           console.log(`Latest version is ${chalk.greenBright(remoteVersion)}, Local version is ${chalk.blackBright(localVersion)} \n\r`)              const {isUpdate} = await inquirer.prompt([               {                   name: "isUpdate",                   type: "confirm",                   message: "Would you like to update it?",                   choices: [                       {name: "Yes", value: true},                       {name: "No", value: false}                   ]               }           ]);           if (isUpdate) {               updateNewVersion(remoteVersionStr);           } else {               console.log();               console.log(`Ok, you can run ${chalk.greenBright('wb-cli update')} command to update latest version in the feature`);           }           return;       }       console.info(chalk.green("Great! Your local version is latest!"));   };

    这里需要注意的是,因为脚手架是全局安装的,涉及到权限的问题,因此在mac下需要使用sudo new-cli update进行更新,而在windows中需要以管理员身份打开命令行工具执行new-cli update进行更新。到这里,我们的脚手架基本就完成啦。

其他花里胡哨的东东

  主要功能基本就是上面这些啦,另外我们需要加一个项目创建成功之后的提示,在上文的create.js中最后面有一个downloadSuccessfully的方法,其实就是创建成功后的提示,主要内容如下:

const downloadSuccessfully = (projectName) => {     const END_MSG = `${chalk.blue("🎉 created project " + chalk.greenBright(projectName) + " Successfully")}\n\n 🙏 Thanks for using wb-cli !`;     const BOXEN_CONFIG = {         padding: 1,         margin: {top: 1, bottom: 1},         borderColor: 'cyan',         align: 'center',         borderStyle: 'double',         title: '🚀 Congratulations',         titleAlignment: 'center'     }      const showEndMessage = () => process.stdout.write(boxen(END_MSG, BOXEN_CONFIG))     showEndMessage();      console.log('👉 Get started with the following commands:');     console.log(`\n\r\r cd ${chalk.cyan(projectName)}`);     console.log("\r\r npm install");     console.log("\r\r npm run start \r\n"); }

具体的实现效果就是这样的,这里我是截了之前做好的图。
createSuccess

项目模板

  我们需要创建一个项目模板,里面需要在根目录下包含一个ask.ts文件,其他的就和正常项目一样就好了,aks.ts的文件内容示例如下,

/**  * demo  * aks.ts  * @author kechen  * @since 2022/3/24  */  module.exports = [   {     name: 'description',     message: 'Please enter project description:',   },   {     name: 'author',     message: 'Please enter project author:',   },   {     name: 'apiPrefix',     message: 'Please enter project apiPrefix:',     default: 'api/1.0',     // @ts-ignore     validate: function (input) {       const done = this.async();       setTimeout(function () {         // 校验是否为空,是否是字符串         if (!input.trim()) {           done(             'You can provide a apiPrefix, or not it will be default【api/1.0】',           );           return;         }         const pattern = /[a-zA-Z0-9]$/;         if (!pattern.test(input.trim())) {           done(             'The apiPrefix is must end with letter or number, like default 【api/1.0】',           );           return;         }         done(null, true);       }, 300);     },   },   {     name: 'proxy',     message: 'Please enter project proxy:',     default: 'https://www.test.com',     // @ts-ignore     validate: function (input) {       const done = this.async();       setTimeout(function () {         // 校验是否为空,是否是字符串         if (!input.trim()) {           done(             'You can provide a proxy, or not it will be default【https://www.test.com】',           );           return;         }         const pattern =           /(http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?/;         if (!pattern.test(input.trim())) {           done(             'The proxy is must end with letter or number, like default 【https://www.test.com】',           );           return;         }         done(null, true);       }, 300);     },   }, ];

  这里我设置了四个变量分别是description、author、apiPrefix、proxy,在使用时只需要通过<%= var %>这种方式就可以了,var可以是你在ask.ts中设置的任何变量,具体使用demo如下,当然要替换的文件类型必须是在上面我们提到的renderTemplate函数中设置了后缀名的文件才可以。使用这种方式,你就可以在项目模板中自由添加变量,且不需要更新脚手架工具。

{   "name": "xasrd-fe-mobile",   "description": "<%= description %>",   "private": true,   "author": "<%= author %>" } 

  至此,我们的脚手架就全部开发完成啦,接下来就是怎么发布到npm或者npm私服了。

发布

  在上面我们讲过,如果需要发布的npm私服,则需要在package.json中配置publishConfig并指向npm私服的地址,发布的时候则需要通过以下命令进行发布:

当然需要注意的是,发布的时候,package.json中的version版本号不能重复哈!!!

总结

  到这里,我们就完整的开发了一个比较简单前端脚手架工具,并可以发布使用了。其实具体的做法并不是很难,有很多第三方的工具包可以用,当然因为这个工具的交互相对来说比较简单,各位也可以自己奇思妙想,做一些更加花里胡哨的功能进行扩展。示例的demo就不放啦,基本所有的内容都在上面提到了,大家可以自由发挥。当然基于这套我自己也写了一个地址是https://www.npmjs.com/package/wb-fe-cli,不过因为最近实在没时间,所以项目模板还没有,暂时还不能完整的跑起来,后续会慢慢更新的。

参考

结语

  最后希望看完本篇文章后,对你有所帮助,勤快的话可以自己动手手写一写啦。另外希望大家能够关注一下我的Github,哈哈哈哈,带你们看贪吃蛇!

  也可以关注一下GridManager这个好用的表格插件,支持React、Vue,非常好用哦!

  下期,我将会给大家带来一些我常用的Mac软件的介绍,能够帮助你在日常开发与工作中大大提升工作效率!!!可以先预览一下 恪晨的Mac软件推荐

感谢大家的阅读!!

Redis HyperLogLog 是什么?这些场景使用它,让我枪出如龙,一笑破苍穹

Posted: 12 Apr 2022 08:38 PM PDT

在移动互联网的业务场景中,数据量很大,我们需要保存这样的信息:一个 key 关联了一个数据集合,同时对这个数据集合做统计。

比如:

  • 统计一个 APP 的日活、月活数;
  • 统计一个页面的每天被多少个不同账户访问量(Unique Visitor,UV));
  • 统计用户每天搜索不同词条的个数;
  • 统计注册 IP 数。

通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。

今天「码哥」分别使用不同的数据类型来实现:统计一个页面的每天被多少个不同账户访问量这个功能,循序渐进的引出 HyperLogLog的原理与 Java 中整合 Redission 实战。

告诉大家一个技巧,Redis 官方网站现在能在线运行 Redis 指令了:https://redis.io/。如图:

Redis 在线运行

使用 Set 实现

一个用户一天内多次访问一个网站只能算作一次,所以很容易就想到通过 Redis 的 Set 集合来实现。

比如微信 ID为「肖菜鸡」访问 「Redis为什么这么快」这篇文章时,我们把这个信息存到 Set 中。

SADD Redis为什么这么快:uv 肖菜鸡 谢霸哥 肖菜鸡 (integer) 1

「肖菜鸡」多次访问「Redis为什么这么快」页面,Set 的去重功能保证不会重复记录同一个「微信 ID」。

通过 SCARD 命令,统计「Redis 为什么这么快」页面 UV。指令返回一个集合的元素个数(也就是用户 ID)。

SCARD Redis为什么这么快:uv (integer) 2

使用 Hash 实现

码老湿,还可以利用 Hash 类型实现,将用户 ID 作为 Hash 集合的 key,访问页面则执行 HSET 命令将 value 设置成 1。

即使「肖菜鸡」重复访问页面,重复执行命令,也只会把 key 等于「肖菜鸡」的 value 设置成 1。

最后,利用 HLEN 命令统计 Hash 集合中的元素个数就是 UV。

如下:

HSET Redis为什么这么快 肖菜鸡 1 // 统计 UV HLEN Redis为什么这么快

使用 Bitmap 实现

Bitmap 的底层数据结构用的是 String 类型的 SDS 数据结构来保存位数组,Redis 把每个字节数组的 8 个 bit 位利用起来,每个 bit 位 表示一个元素的二值状态(不是 0 就是 1)。

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。

可以将 Bitmap 看成是一个 bit 为单位的数组,数组的每个单元只能存储 0 或者 1,数组的下标在 Bitmap 中叫做 offset 偏移量。

为了直观展示,我们可以理解成 buf 数组的每个字节用一行表示,每一行有 8 个 bit 位,8 个格子分别表示这个字节中的 8 个 bit 位,如下图所示:

Bitmap

8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节省存储空间。 这就是 Bitmap 的优势。

如何使用 Bitmap 来统计页面的独立用户访问量呢?

Bitmap 提供了 SETBIT 和 BITCOUNT 操作,前者通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行写操作,需要注意的是 offset 从 0 开始。

后者统计给定指定的 bit 数组中,值 = 1 的 bit 位的数量。

需要注意的事,我们需要把「微信 ID」转换成数字,因为offset 是下标。

假设我们将「肖菜鸡」转换成编码6

第一步,执行下面指令表示「肖菜鸡」的编码为 6 并 访问「巧用Redis 数据类型实现亿级数据统计」这篇文章。

SETBIT 巧用Redis数据类型实现亿级数据统计 6 1

第二步,统计页面访问次数,使用 BITCOUNT 指令。该指令用于统计给定的 bit 数组中,值 = 1 的 bit 位的数量。

BITCOUNT 巧用Redis数据类型实现亿级数据统计

HyperLogLog 王者方案

Set 虽好,如果文章非常火爆达到千万级别,一个 Set 就保存了千万个用户的 ID,页面多了消耗的内存也太大了。

同理,Hash数据类型也是如此。

至于 Bitmap,它更适合于「二值状态统计」的使用场景,统计精度高,虽然内存占用要比HashMap少,但是对于大量数据还是会占用较大内存。

咋办呢?

这些就是典型的「基数统计」应用场景,基数统计:统计一个集合中不重复元素的个数。

HyperLogLog 的优点在于它所需的内存并不会因为集合的大小而改变,无论集合包含的元素有多少个,HyperLogLog进行计算所需的内存总是固定的,并且是非常少的

每个 HyperLogLog 最多只需要花费 12KB 内存,在标准误差 0.81%的前提下,就可以计算 2 的 64 次方个元素的基数。

Redis 实战

HyperLogLog 使用太简单了。PFADD、PFCOUNT、PFMERGE三个指令打天下。

PFADD

将访问页面的每个用户 ID 添加到 HyperLogLog 中。

PFADD Redis主从同步原理:uv userID1 userID 2 useID3

PFCOUNT

利用 PFCOUNT 获取 「Redis主从同步原理」文章的 UV值。

PFCOUNT Redis主从同步原理:uv

PFMERGE 使用场景

HyperLogLog` 除了上面的 `PFADD` 和 `PFCOIUNT` 外,还提供了 `PFMERGE

语法

PFMERGE destkey sourcekey [sourcekey ...]

比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。

其中页面的 UV 访问量也需要合并,那这个时候 PFMERGE 就可以派上用场了,也就是同样的用户访问这两个页面则只算做一次

如下所示:Redis、MySQL 两个 HyperLogLog 集合分别保存了两个页面用户访问数据。

PFADD Redis数据 user1 user2 user3 PFADD MySQL数据 user1 user2 user4 PFMERGE 数据库 Redis数据 MySQL数据 PFCOUNT 数据库 // 返回值 = 4

将多个 HyperLogLog 合并(merge)为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合(observed set)的并集。

user1、user2 都访问了 Redis 和 MySQL,只算访问了一次。

Redission 实战

详细源码「码哥」上传到 GitHub 了:https://github.com/MageByte-Z...

pom 依赖

<dependency>   <groupId>org.redisson</groupId>   <artifactId>redisson-spring-boot-starter</artifactId>   <version>3.16.7</version> </dependency>

添加数据到 Log

// 添加单个元素 public <T> void add(String logName, T item) {   RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);   hyperLogLog.add(item); }  // 将集合数据添加到 HyperLogLog public <T> void addAll(String logName, List<T> items) {   RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);   hyperLogLog.addAll(items); }

合并

/**  * 将 otherLogNames 的 log 合并到 logName  *  * @param logName       当前 log  * @param otherLogNames 需要合并到当前 log 的其他 logs  * @param <T>  */ public <T> void merge(String logName, String... otherLogNames) {   RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);   hyperLogLog.mergeWith(otherLogNames); }

统计基数

public <T> long count(String logName) {   RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);   return hyperLogLog.count(); }

单元测试

@Slf4j @RunWith(SpringRunner.class) @SpringBootTest(classes = RedissionApplication.class) public class HyperLogLogTest {      @Autowired     private HyperLogLogService hyperLogLogService;      @Test     public void testAdd() {         String logName = "码哥字节:Redis为什么这么快:uv";         String item = "肖菜鸡";         hyperLogLogService.add(logName, item);         log.info("添加元素My7OulCbyNEuDXbm6UMSjDVbRak到 log My7OulCbyNEuDXbm6UMSjDVbRak 中。", item, logName);     }      @Test     public void testCount() {         String logName = "码哥字节:Redis为什么这么快:uv";         long count = hyperLogLogService.count(logName);         log.info("logName = {} count = {}.", logName, count);     }      @Test     public void testMerge() {         ArrayList<String> items = new ArrayList<>();         items.add("肖菜鸡");         items.add("谢霸哥");         items.add("陈小白");          String otherLogName = "码哥字节:Redis多线程模型原理与实战:uv";         hyperLogLogService.addAll(otherLogName, items);         log.info("添加 {} 个元素到 log My7OulCbyNEuDXbm6UMSjDVbRak 中。", items.size(), otherLogName);          String logName = "码哥字节:Redis为什么这么快:uv";         hyperLogLogService.merge(logName, otherLogName);         log.info("将 {} 合并到 {}.", otherLogName, logName);          long count = hyperLogLogService.count(logName);         log.info("合并后的 count = {}.", count);     } } 

基本原理

HyperLogLog 是一种概率数据结构,它使用概率算法来统计集合的近似基数。而它算法的最本源则是伯努利过程。

伯努利过程就是一个抛硬币实验的过程。抛一枚正常硬币,落地可能是正面,也可能是反面,二者的概率都是 1/2

伯努利过程就是一直抛硬币,直到落地时出现正面位置,并记录下抛掷次数k

比如说,抛一次硬币就出现正面了,此时 k1; 第一次抛硬币是反面,则继续抛,直到第三次才出现正面,此时 k 为 3。

对于 n 次伯努利过程,我们会得到 n 个出现正面的投掷次数值 k1, k2 ... kn , 其中这里的最大值是 k_max

根据一顿数学推导,我们可以得出一个结论: 2^{k_ max} 来作为n的估计值。

也就是说你可以根据最大投掷次数近似的推算出进行了几次伯努利过程。

所以 HyperLogLog 的基本思想是利用集合中数字的比特串第一个 1 出现位置的最大值来预估整体基数,但是这种预估方法存在较大误差,为了改善误差情况,HyperLogLog中引入分桶平均的概念,计算 m 个桶的调和平均值。

Redis 中 HyperLogLog 一共分了 2^14 个桶,也就是 16384 个桶。每个桶中是一个 6 bit 的数组,如下图所示。

图片来源:程序员历小冰

关于 HyperLogLog 的原理过于复杂,如果想要了解的请移步:

Redis 对 HyperLogLog 的存储进行了优化,在计数比较小的时候,存储空间采用系数矩阵,占用空间很小。

只有在计数很大,稀疏矩阵占用的空间超过了阈值才会转变成稠密矩阵,占用 12KB 空间。

为何只需要 12 KB 呀?

HyperLogLog 实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最大可以表示 maxbits=63,于是总共占用内存就是2^14 * 6 / 8 = 12k字节。

总结

分别使用了 HashBitmapHyperLogLog 来实现:

  • Hash:算法简单,统计精度高,少量数据下使用,对于海量数据会占据大量内存;
  • Bitmap:位图算法,适合用于「二值统计场景」,具体可参考我这篇文章,对于大量不同页面数据统计还是会占用较大内存。
  • Set:利用去重特性实现,一个 Set 就保存了千万个用户的 ID,页面多了消耗的内存也太大了。在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。
  • HyperLogLog是一种算法,并非 Redis 独有
  • 目的是做基数统计,故不是集合,不会保存元数据,只记录数量而不是数值
  • 耗空间极小,支持输入非常体积的数据量
  • 核心是基数估算算法,主要表现为计算时内存的使用和数据合并的处理。最终数值存在一定误差
  • Redis中每个Hyperloglog key占用了12K的内存用于标记基数(官方文档)
  • pfadd 命令并不会一次性分配12k内存,而是随着基数的增加而逐渐增加内存分配;而pfmerge操作则会将sourcekey合并后存储在12k大小的key中,由hyperloglog合并操作的原理(两个Hyperloglog合并时需要单独比较每个桶的值)可以很容易理解。
  • 误差说明:基数估计的结果是一个带有 0.81% 标准错误(standard error)的近似值。是可接受的范围
  • RedisHyperLogLog 的存储进行优化,在计数比较小时,存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间

好文推荐

参考资料

还在使用定时器吗?CSS 也能实现电子时钟

Posted: 05 Apr 2022 08:59 PM PDT

欢迎关注我的公众号:前端侦探

通常要做一个时钟,肯定离不开 JS 定时器。今天换一种思路,用 CSS 来实现一个时钟,如下:

Kapture 2022-03-04 at 16.28.36

你也可以访问这个CSS time (codepen.io)查看实际效果

当然借用了一点点 JS 用于初始化时间,整个时钟的运行都是由 CSS 完成的,有很多你可能不知道的小技巧,一起看看吧

一、数字的变换

先看看数字是如何变换的。

在以前,如果要实现数字的递增变化,可能需要提前准备好这些数字,例如像这样

<span>     <i>1</i>   <i>2</i>   ...   <i>59</i> </span>

然后通过改变位移来实现。

但是,现在有更简洁的方式可以实现了,那就是 CSS @property,不了解这个的可以参考这篇文章:CSS @property,让不可能变可能。这是干什么的呢?简单来讲,可以自定义属性,在这个例子中,可以让数字像颜色一样进行过渡和动画,可能不太懂,直接看例子吧

假设 HTML 是这样的

<span style="--num: 0"></span>

我们让这个自定义变量在页面中展示出来,单纯的 content无法直接显示自定义变量,需要借助定时器,有兴趣的可以参考这篇文章:小tips: 如何借助content属性显示CSS var变量值

span::after{   counter-reset: num var(--num);   content: counter(num); }

image-20220304165629730

然后,可以通过:hover改变这个数字

span:hover::after{   --num: 59 }

Kapture 2022-03-04 at 17.09.17

很生硬的从 0 变成 59 了,非常符合常规。如果利用 CSS property,情况就不一样了,需要改造的地方很少,先定义一下--h,然后给这个变量一个过渡时间,如下

@property --h {    syntax: '<integer>';   inherits: false;   initial-value: 0; } span::after{   transition: 1s --num; }

神奇的一幕发生了

Kapture 2022-03-04 at 17.14.07

看着好像不可思议?可以这么理解,通过@property定义后,这个变量本身可以单独设置过渡了,而不再取决于一些仅支持过渡的属性(colorwidth等)。甚至还能加上动画,需要用到steps方法,设置动画周期为无限,如下

@keyframes num {   to {     --num: 10   } } span{   animation: num 1s infinite steps(10); }

时钟的基本运行原理就是这样了,一个无限循环的 CSS 动画!

Kapture 2022-03-04 at 17.26.23

二、时、分、秒

下面来看具体时、分、秒的实现,HTML 如下

<div class="time">   <span class="hour"></span>   <a class="split">:</a>   <span class="minitus"></span>   <a class="split">:</a>   <span class="seconds"></span> </div>

给时、分、秒附上初始值

@property --h {    syntax: '<integer>';   inherits: false;   initial-value: 0; } @property --m {    syntax: '<integer>';   inherits: false;   initial-value: 0; } @property --s {    syntax: '<integer>';   inherits: false;   initial-value: 0; } .hour::after{   counter-reset: hour var(--h);   content: counter(hour); } .minitus::after{   counter-reset: minitus var(--m);   content: counter(minitus); } .seconds::after{   counter-reset: seconds var(--s);   content: counter(seconds); }

image-20220304183029959

这里的时、分、秒并没有联动关系,所以各自都需要单独的动画。下面就需要思考一下🤔,如果用 CSS 动画来实现,每个的动画起始点和时长是多少呢?

没错,就是你想的,时针是0-23,时长24h,分针是0-59,时长60min,秒针是0-59,时长60s,但是 CSS 中的时间单位只支持秒(s)或者毫秒(ms),所以这里需要转换一下,时长分别是60s*60*2460s*6060s,具体实现如下:

@keyframes hour {   to {     --h: 24   } } @keyframes minitus {   to {     --m: 60   } } @keyframes seconds {   to {     --s: 60   } } .hour::after{   counter-reset: hour var(--h);   content: counter(hour);   animation: hour calc(60s * 60 * 24) infinite steps(24); } .minitus::after{   counter-reset: minitus var(--m);   content: counter(minitus);   animation: minitus calc(60s * 60) infinite steps(60); } .seconds::after{   counter-reset: seconds var(--s);   content: counter(seconds);   animation: seconds 60s infinite steps(60); }

这里为了便于观察,将时间调快了10倍(60s => 6s),如下

Kapture 2022-03-04 at 18.57.48

三、时、分、秒自动补零

上面的布局有个问题,1 位数和 2 位数宽度变化导致时钟整体都在"晃动",所以需要在1位数时补上一个"0"。关于 CSS 补零,之前在这篇文章:CSS 也能自动补全字符串?中提到了 3 种方案,由于这里用了计数器,所以直接选择更改计数器样式的方法,通过decimal-leading-zero来实现,具体做法如下

.hour::after{   /**/   content: counter(hour, decimal-leading-zero);/*添加计数器样式*/ }

这样就和谐多了

Kapture 2022-03-04 at 19.04.13

四、时间初始化

刚才都从00:00:00开始了,所以需要手动指定一下初始时间。假设现在是19:26:30,如何初始化呢?

这里需要用animation-delay来提前运动到未来指定位置,为了方便控制,使用三个变量--dh--dm--ds来表示初始时间,注意,由于animation-delay也只支持秒(s)或者毫秒(ms),所以也同样需要转换,实现如下

:root{   --dh: 19;   --dm: 26;   --ds: 30; } .hour::after{   /**/   animation: hour calc(60s * 60 * 24) infinite steps(24);   animation-delay: calc( -60s * 60 * var(--dh) ); } .minitus::after{   /**/   animation: minitus calc(60s * 60) infinite steps(60);   animation-delay: calc( -60s * var(--dm) ); } .seconds::after{   /**/   animation: seconds 60s infinite steps(60);   animation-delay: calc( -1s * var(--ds) ); }

Kapture 2022-03-04 at 19.36.13

是不是有点奇怪?分钟在秒钟走到 30 的时候才变化,晚了半分钟。原因是这样的,虽然从数字上看,分钟是 26,但是还要考虑到秒钟的运动情况,比如像这种情况,分钟其实已经走了一半,应该是26.5(26 + 30 / 60),所以在计算时还需要加上偏移量。下面我们通过 JS 获取真实的时间,并修复偏移

const d = new Date() const h = d.getHours(); const m = d.getMinutes(); const s = d.getSeconds(); document.body.style.setProperty('--ds', s) document.body.style.setProperty('--dm', m + s/60) document.body.style.setProperty('--dh', h + m/60 + s/3600)

这样就正常了

Kapture 2022-03-04 at 19.45.04

五、闪烁的分隔符

为了时钟看起来更加"动感",可以给分隔符加上闪烁动画,代码如下

@keyframes shark {   0%, 100%{     opacity: 1;   }   50%{     opacity: 0;   } } .split{   animation: shark 1s step-end infinite; }

现在看下最终的效果

Kapture 2022-03-04 at 19.49.03

完整代码可以访问 CSS time (codepen.io)

六、总结一下

想不到实现一个时钟效果,用到了那么多 CSS 知识和技巧,简单总结一下吧

  1. CSS 实现本质是无限循环的 CSS 动画
  2. 灵活运用 CSS calc 计算
  3. CSS 计数器可以将 CSS 变量通过 content 显示在页面
  4. 数字的变化现在可以通过 CSS @property 配合动画实现
  5. 时分秒的区别在于各自的动画时长、动画起始点不同
  6. CSS 自动补零可以参考之前的文章,这里采用 decimal-leading-zero 实现
  7. 时间初始化其实就是指定动画 delay 值
  8. 指定初始值时还需要考虑到各自的偏移量,例如 19:30:30,此时的时针数字其实是 30.5
  9. 分隔符的闪烁动画

其实整个实现过程就是一个不断思考、学习的过程,比如为了实现数字的变化,就必须去学习 @property 相关,为了实现补零,就需要去了解更深层次的计数器相关,还有用到的各种动画。最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发❤❤❤

欢迎关注我的公众号:前端侦探

XTask与RxJava的使用比较

Posted: 10 Apr 2022 12:44 AM PDT

简介

RxJava

RxJava是一个在Java VM上使用可观测的序列来组成异步的、基于事件的程序的库。RxJava本质上是一个实现异步操作的库。

项目地址: https://github.com/ReactiveX/RxJava

XTask

XTask是一个拓展性极强的Android任务执行框架。通过它,你可以自由定义和组合任务来实现你想要的功能,尤其适用于处理复杂的业务流程,可灵活添加前置任务或者调整执行顺序。

项目的地址: https://github.com/xuexiangjys/XTask

背景

XTask是我基于RxJava的设计思想,并结合实际项目中使用的经验所创造出来的一个开源项目,其目的就是要代替RxJava在Android中的部分使用场景,提升开发的体验和可维护性。

相信使用过RxJava的人都知道RxJava有很多硬伤,下面我哦简单列举几个:

  • RxJava最初并不是最先在Android中使用的,所以它一开始就设计的相当的复杂且笨重,一个库常常能达到3M左右,相对于移动端而已,这还是非常占应用体积的。
  • 远远超过百种的操作符也常常让使用者摸不着头脑,稀里糊涂的使用很容易带来一些致命性的问题,例如内存泄漏等。
  • 由于RxJava是一个基于事件的程序库,缺少一些关键执行任务的日志信息,这就导致出了问题后会很难排查出来。

而XTask就是为了能够解决上述问题而被我开源出来的。

使用对比

首先,RxJava作为一个优秀的开源框架这点是毋庸置疑的,XTask并不是用来代替RxJava的,我没有这种能力,同样google也没有。

但是在某些小且常用的场景下,我们是完全可以替换掉RxJava的使用的。例如如下两种场景:

  • 复杂串行任务处理
  • 复杂并发任务处理

下面我就通过两个小例子来给大家呈现它们的不同。

复杂串行任务

相信我们在平时的开发过程中一定会遇到很多复杂的业务流程,而这些流程很多都是一环套着一环,需要一步一步走下去才行,中间有任何错误都将停止执行。

下面我就以 [高仿网红产品] 的案例流程为例,简单讲解如何通过RxJavaXTask去实现这一流程。

案例分析

高仿网红产品的流程

1.获取产品信息 -> 2.查询可生产的工厂 -> 3.联系工厂生产产品 -> 4.送去市场部门评估售价 -> 5.产品上市

实体类设计

这里主要涉及3个实体类: Product、ProductInfo和ProductFactory。

/**  * 产品  */ public class Product {     /**      * 产品信息      */     private ProductInfo info;     /**      * 产品生产地址      */     private String address;     /**      * 产品价格      */     private String price;     /**      * 产品发布时间      */     private String publicTime; }  /**  * 产品信息  */ public class ProductInfo {     /**      * 编号      */     private String id;     /**      * 品牌      */     private String brand;     /**      * 质量      */     private String quality; }  /**  * 产品工厂  */ public class ProductFactory {     /**      * 工厂id      */     private String id;     /**      * 工厂地址      */     private String address; }

案例实现

业务流程处理

上述共有5个业务流程,我们将其简化分为以下4个处理器进行处理。

  • 1.获取产品信息: GetProductInfoProcessor (productId -> ProductInfo)
  • 2.查找相关的工厂: SearchFactoryProcessor (ProductInfo -> ProductFactory)
  • 3.评估产品,给出价格: GivePriceProcessor (Product -> Product)
  • 4.产品发布: PublicProductProcessor (Product -> Product)

业务流程串联

  • 普通写法

普通写法我们直接使用接口回调的方式, 一层层执行。

AppExecutors.get().singleIO().execute(() -> {     // 1.获取产品信息     new GetProductInfoProcessor(logger, productId).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<ProductInfo>() {         @Override         public void onSuccess(final ProductInfo productInfo) {             // 2.查询可生产的工厂             new SearchFactoryProcessor(logger, productInfo).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<ProductFactory>() {                 @Override                 public void onSuccess(final ProductFactory factory) {                     // 3.联系工厂生产产品                     log("开始生产产品...");                     Product product = factory.produce(productInfo);                     // 4.送去市场部门评估售价                     new GivePriceProcessor(logger, product).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<Product>() {                         @Override                         public void onSuccess(Product product) {                             // 5.产品上市                             PublicProductProcessor publicProductProcessor = new PublicProductProcessor(logger, product);                             publicProductProcessor.setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<Product>() {                                 @Override                                 public void onSuccess(Product product) {                                     log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms");                                     log("仿冒生产网红产品完成, " + product);                                 }                             }).process();                         }                     }).process();                 }             }).process();         }     }).process(); });
  • RxJava写法

RxJava中执行串行任务,一般使用map或者flatMap,这里由于是一对一,所以使用map执行即可。

disposable = Observable.just(productId)         // 1.获取产品信息         .map(id -> new GetProductInfoProcessor(logger, id).process())         // 2.查询可生产的工厂         .map(productInfo -> new Pair<>(new SearchFactoryProcessor(logger, productInfo).process(), productInfo))         .map(productPair -> {             // 3.联系工厂生产产品             log("开始生产产品...");             Product product = productPair.first.produce(productPair.second);             // 4.送去市场部门评估售价             return new GivePriceProcessor(logger, product).process();         })         // 5.产品上市         .map(product -> new PublicProductProcessor(logger, product).process())         .subscribeOn(Schedulers.io())         .observeOn(AndroidSchedulers.mainThread())         .subscribe(product -> {             log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms");             log("仿冒生产网红产品完成, " + product);         });
  • XTask写法

与普通写法和RxJava写法不同的是,XTask是把所有的业务处理器都封装在了一个一个的Task中,然后按任务的执行顺序依次添加对应的Task即可完成。

XTask.getTaskChain()         .setTaskParam(TaskParam.get(ProductTaskConstants.KEY_PRODUCT_ID, productId))         // 1.获取产品信息         .addTask(new GetProductInfoTask(logger))         // 2.查询可生产的工厂, 3.联系工厂生产产品         .addTask(new SearchFactoryTask(logger))         // 4.送去市场部门评估售价         .addTask(new GivePriceTask(logger))         // 5.产品上市         .addTask(new PublicProductTask(logger))         .setTaskChainCallback(new TaskChainCallbackAdapter() {             @Override             public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {                 log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms");                 Product product = result.getDataStore().getObject(ProductTaskConstants.KEY_PRODUCT, Product.class);                 log("仿冒生产网红产品完成, " + product);             }         }).start();

案例执行结果

  • 程序执行结果

  • XTask执行日志一览


复杂并行任务

除了上面我们讨论到的常见串行任务,我们在平时的开发过程中也会遇到一些复杂的并行流程。这些流程往往是单独可执行的,虽说前后关联不大,但是又是同时为了某个目标去执行的流程。

下面我就以常见的 [展示商品详细信息] 的案例流程为例,简单讲解如何通过RxJavaXTask去实现这一流程。

案例分析

展示商品详细信息的流程

  • 1.根据商品的唯一号ID获取商品简要信息
  • 2.获取商品的详细信息:

    • 2.1 获取商品的生产信息
    • 2.2 获取商品的价格信息
    • 2.3 获取商品的促销信息
    • 2.4 获取商品的富文本信息
  • 3.进行商品信息的展示

其中步骤2中的4个子步骤是可以同时进行,互不影响的并发流程。

实体类设计

这里主要涉及6个实体类: BriefInfo、Product、FactoryInfo、PriceInfo、PromotionInfo 和 RichInfo。

/**  * 产品简要信息  */ public class BriefInfo {     private String id;      protected String name;      private String factoryId;      private String priceId;      private String promotionId;      private String richId; }  /**  * 产品  */ public class Product extends BriefInfo {     /**      * 生产信息      */     private FactoryInfo factory;     /**      * 价格信息      */     private PriceInfo price;     /**      * 促销信息      */     private PromotionInfo promotion;     /**      * 富文本信息      */     private RichInfo rich; }  /**  * 工厂生产信息  */ public class FactoryInfo {     private String id;     /**      * 生产地址      */     private String address;     /**      * 生产日期      */     private String productDate;     /**      * 过期日期      */     private String expirationDate; }  /**  * 价格信息  */ public class PriceInfo {     private String id;     /**      * 出厂价      */     private float factoryPrice;     /**      * 批发价      */     private float wholesalePrice;     /**      * 零售价      */     private float retailPrice; }  /**  * 产品促销信息  */ public class PromotionInfo {     private String id;     /**      * 促销类型      */     private int type;     /**      * 促销内容      */     private String content;     /**      * 生效日期      */     private String effectiveDate;     /**      * 失效日期      */     private String expirationDate; }  /**  * 富文本信息  */ public class RichInfo {     private String id;     /**      * 描述信息      */     private String description;     /**      * 图片链接      */     private String imgUrl;     /**      * 视频链接      */     private String videoUrl; }

案例实现

业务流程处理

上述共有3个大业务流程,4个子业务流程,我们将其简化分为以下5个处理器进行处理。

  • 1.获取商品简要信息: GetBriefInfoProcessor (productId -> BriefInfo)
  • 2.获取商品的生产信息: GetFactoryInfoProcessor (factoryId -> FactoryInfo)
  • 3.获取商品的价格信息: GetPriceInfoProcessor (priceId -> PriceInfo)
  • 4.获取商品的促销信息: GetPromotionInfoProcessor (promotionId -> PromotionInfo)
  • 5.获取商品的富文本信息: GetRichInfoProcessor (richId -> RichInfo)

业务流程串联

  • 普通写法

普通写法我们需要通过接口回调+同步锁的方式, 实现任务的并发和协同。

AppExecutors.get().singleIO().execute(() -> {     new GetBriefInfoProcessor(logger, productId).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<BriefInfo>() {         @Override         public void onSuccess(BriefInfo briefInfo) {             final Product product = new Product(briefInfo);             CountDownLatch latch = new CountDownLatch(4);              // 2.1 获取商品的生产信息             AppExecutors.get().networkIO().execute(() -> {                 new GetFactoryInfoProcessor(logger, product.getFactoryId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<FactoryInfo>() {                     @Override                     public void onSuccess(FactoryInfo result) {                         product.setFactory(result);                         latch.countDown();                     }                 }).process();             });             // 2.2 获取商品的价格信息             AppExecutors.get().networkIO().execute(() -> {                 new GetPriceInfoProcessor(logger, product.getPriceId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<PriceInfo>() {                     @Override                     public void onSuccess(PriceInfo result) {                         product.setPrice(result);                         latch.countDown();                     }                 }).process();             });             // 2.3 获取商品的促销信息             AppExecutors.get().networkIO().execute(() -> {                 new GetPromotionInfoProcessor(logger, product.getPromotionId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<PromotionInfo>() {                     @Override                     public void onSuccess(PromotionInfo result) {                         product.setPromotion(result);                         latch.countDown();                     }                 }).process();             });             // 2.4 获取商品的富文本信息             AppExecutors.get().networkIO().execute(() -> {                 new GetRichInfoProcessor(logger, product.getRichId()).setProcessorCallback(new AbstractProcessor.ProcessorCallbackAdapter<RichInfo>() {                     @Override                     public void onSuccess(RichInfo result) {                         product.setRich(result);                         latch.countDown();                     }                 }).process();             });             try {                 latch.await();                 log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms");                 log("查询商品信息完成, " + product);             } catch (InterruptedException e) {                 e.printStackTrace();             }         }     }).process(); });
  • RxJava写法

RxJava中执行并行任务,一般使用merge或者zip,这里由于需要协同,所以使用zip对任务流进行合并。

disposable = Observable.just(productId)         // 1.获取商品简要信息         .map(id -> new GetBriefInfoProcessor(logger, id).process())         .map(Product::new)         .flatMap(product ->                 Observable.zip(                         // 2.1 获取商品的生产信息                         Observable.fromCallable(() -> new GetFactoryInfoProcessor(logger, product.getFactoryId()).process()).subscribeOn(Schedulers.io()),                         // 2.2 获取商品的价格信息                         Observable.fromCallable(() -> new GetPriceInfoProcessor(logger, product.getPriceId()).process()).subscribeOn(Schedulers.io()),                         // 2.3 获取商品的促销信息                         Observable.fromCallable(() -> new GetPromotionInfoProcessor(logger, product.getPromotionId()).process()).subscribeOn(Schedulers.io()),                         // 2.4 获取商品的富文本信息                         Observable.fromCallable(() -> new GetRichInfoProcessor(logger, product.getRichId()).process()).subscribeOn(Schedulers.io()), (factoryInfo, priceInfo, promotionInfo, richInfo) -> product.setFactory(factoryInfo)                                 .setPrice(priceInfo)                                 .setPromotion(promotionInfo)                                 .setRich(richInfo)                 )         )         .subscribeOn(Schedulers.io())         .observeOn(AndroidSchedulers.mainThread())         .subscribe(product -> {             log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms");             log("查询商品信息完成, " + product);         });
  • XTask写法

XTask是把所有的业务处理器都封装在了一个一个的Task中,然后并行的任务需要通过一个ConcurrentGroupTask(同步组任务)进行包裹,其他按正常执行顺序添加Task即可。

XTask.getTaskChain()         .setTaskParam(TaskParam.get(ProductTaskConstants.KEY_PRODUCT_ID, productId))         // 1.获取商品简要信息         .addTask(new GetBriefInfoTask(logger))         .addTask(XTask.getConcurrentGroupTask(ThreadType.SYNC)                 // 2.1 获取商品的生产信息                 .addTask(new GetFactoryInfoTask(logger))                 // 2.2 获取商品的价格信息                 .addTask(new GetPriceInfoTask(logger))                 // 2.3 获取商品的促销信息                 .addTask(new GetPromotionInfoTask(logger))                 // 2.4 获取商品的富文本信息                 .addTask(new GetRichInfoTask(logger)))         .setTaskChainCallback(new TaskChainCallbackAdapter() {             @Override             public void onTaskChainCompleted(@NonNull ITaskChainEngine engine, @NonNull ITaskResult result) {                 log("总共耗时:" + (System.currentTimeMillis() - startTime) + "ms");                 Product product = result.getDataStore().getObject(ProductTaskConstants.KEY_PRODUCT, Product.class);                 log("查询商品信息完成, " + product);             }         }).start();

案例执行结果

  • 程序执行结果

  • XTask执行日志一览


使用对比总结

从上面的使用对比来看,我们可以简单归纳总结以下几点:

编程方式

1.RxJava遵循的是函数响应式编程的原则,处理过程都是基于数据流的处理。这样的好处就是,我们可以最直观有效的感受到数据的变化过程,当然缺点就是太过于细化和具体,不符合面向对象的设计模式原则,增加了日后的代码维护成本。当然如果数据的结构相对稳定的话,这样的编程方式还可以接受,但如果数据或者业务频繁发生变动的话,这样的编程方式简直就是地狱。

2.XTask遵循的是面向对象的编程原则,每个处理过程都对应了一个具体或者抽象的Task。这样的好处就是,减少了业务和数据结构之间的耦合,同时也减少了各个业务之间的耦合。这样即使你的数据结构或者业务流程出现大的变动,功能实现的主体也不会产生大的改动,更多的只是每个子业务Task内部的改动和调整,真正实现了高复用低耦合。

总结: 两种不同的编程方式,遵循两种不同的编程原则,无法进行对比。

上手难度

如果你是一名RxJava的开发老鸟的话,这样就没什么可比性了,这里我只是从初学者的角度来说。

1.RxJava拥有庞大复杂的操作符,上百种操作符一定会让初学者摸不着头脑,如果在不熟悉的情况下强行使用,很容易导致误用而产生很多意想不到的问题(比如内存泄漏或者OOM等)。

2.XTask作为专为Android设计的任务执行框架,功能相对单一。没有复杂的操作符,有的只是"任务链、任务、组任务、任务参数和执行结果"这五个组成要素,使用起来相对简单容易上手。

总结: 整体比较下来,XTask要优于RxJava。

开发效率

1.RxJava的开发效率主要取决于开发者对RxJava操作符使用的熟练程度。越是能够熟练使用操作符,开发效率就越高,出问题的概率也越小。

2.XTask相对而言就平滑了许多,开发效率和使用的熟练程度关系不大(主要还是上手难度不高)。但是由于每个业务子步骤都需要写一个Task类,对于那些使用RxJava比较熟练的人而言,效率是明显会低一些。

总结: 整体比较下来,从长期而言,RxJava要优于XTask。

可维护性

1.RxJava遵循的是函数响应式编程的原则,本质上还是面向过程式的编程。所有的业务流程都和数据有着比较强的耦合,当数据结构或者业务流程发生变动的时候,必然会影响到主干代码的变动。而且对于初入项目的开发人员接手项目的时候,能看到的往往是局部业务数据流的变动,无法从全局的视角去理解项目主体业务,很容易产生局部修改影响全局的结果。

2.XTask遵循的是面向对象的编程原则,设计之初就严格遵循面向对象的设计模式原则。充分减少业务与业务、业务与数据流之间的耦合,这样即使你的数据结构或者业务流程出现重大的变化,主干代码也不会有很大的变动。而且XTask拥有较强的日志记录系统,能够非常清晰的记录你当前任务链的执行过程和所在线程的信息(自动的),当任务执行出现问题的时候,便能很快地定位出问题产生的位置。而对于初入项目的开发人员来说,也能快速从任务执行过程的日志中去理解项目的主体业务。待主体业务流程有了清楚的认知后再去仔细看子业务,这样才能全方位理解项目的业务,也更利于项目的维护。

总结: 整体比较下来,XTask完胜RxJava。

性能

在性能上,XTask为了实现业务与数据之间的隔离,设计了共享数据的结构,相比较RxJava而言,多了数据拷贝以及数据存储的过程,所以无论是在时间还是空间上而言,RxJava都是较优于XTask的。

最后

综合以上的论述,XTask和RxJava各有各的优势。正如我文章开头所说: XTask并不是用来代替RxJava的。XTask只是作为RxJava在Android任务执行流程上的一种补充,喜欢的朋友可以关注XTask的项目主页: https://github.com/xuexiangjys/XTask

我是xuexiangjys,一枚热爱学习,爱好编程,致力于Android架构研究以及开源项目经验分享的技术up主。获取更多资讯,欢迎微信搜索公众号:【我的Android开源之旅】

No comments:

Post a Comment