头图

项目背景

随着业务的不断发展,研发链路的效能提升也是一个至关重要的指标,其中对前端工程基建而言,其上游部分主要是和设计师同学打交道,而在整个研发链路中,通常会有设计走查的流程来让设计师同学辅助测试同学完成UI测试。设计师在进行走查的过程中,肉眼的比对偶尔会忽略一些细微部分,同时也会耗费设计师大量的精力,为了辅助设计同学能够更高效的进行设计走查,本文旨在通过设计走查平台在后端侧的实践总结下对于视觉稿还原程度比对的一些思路。

方案

后端架构选型,对于前端基建部分的后端应用而言,通常是选择node.js来进行处理,虽然走查平台后端涉及到了图片的对比计算,但是在集团层面提供了各种云服务,因而可以利用云服务相关的各种中间件来保证前端基建后端服务的高可用与高性能。设计走查平台涉及到了图片上传后的临时存储,如果使用云存储,比如:对象存储等,势必涉及到大量的与云平台的交互,较为繁琐,而本应用业务主要是用于处理两张图片的比对,计算要求要高于存储要求,因而选择临时文件存储在本地系统中,但这就带来了一个问题,那就是大量对比需求可能会将服务搞崩,考虑到node.js服务的多进程单线程的特性,我们这里引入了pm2来进行进程管理,同时使用定时任务cron对临时文件进行定时清理(ps:这里也可以利用k8s的定时任务进行处理),保证业务的可用性。

目录

  • db

    • temp
  • server

    • routes

      • piper

        • compare
        • upload
        • index.js
    • app.js
  • ecosystem.config.js

实践

对图片比对部分,这里使用 looks-same 库来进行png图片的比对,其本质是通过(x,y)像素的差异比对进行pixel图片的覆盖描绘,最后输出一个对比叠加的图片,其他的库还有 pixel-match 以及 image-diff 等都可以来进行图片的比对

源码

piper

upload

用于图片的上传,使用 multermultipart/form-data 进行转换

const router = require('../../router');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const storage = multer.diskStorage({
  destination: function(req, file, cb) {
    if(file.mimetype == 'image/png') {
      cb(null, path.resolve(__dirname, '../../../../db/__temp__'))
    } else {
      cb({ error: 'Mime type not supported' })
    }
    
  },
  filename: function(req, file, cb) {
    cb(null, `${Date.now()}.${file.originalname}`)
  }
})

/**
 * @openapi
 * /piper/upload/putImage:
    post:
      summary: 上传图片
      tags: 
        - putImage
      requestBody:
        required: true
        content: 
          application/json: 
            schema: 
              $ref: '#/components/schemas/putImage'
      responses:  
        '200':
          content:
            application/json:
              example:
                code: "0"
                data: ""
                msg: "成功"
                success: true
 */
router.post('/putImage', multer({
    storage: storage
}).single('img'), async function (req, res) {
  console.log('putImage', req.file);
    // 定时删除上传的图片
    
    setTimeout(() => {
      if(fs.existsSync(path.resolve(__dirname, `../../../../db/__temp__/${req.file.filename}`))) {
        fs.unlink(path.resolve(__dirname, `../../../../db/__temp__/${req.file.filename}`), function (err) {
          if (err) {
            console.error(`删除文件 ${req.file.filename} 失败,失败原因:${err}`)
          }
          console.log(`删除文件 ${req.file.filename} 成功`)
        });
      } else {
        console.log(`文件 ${req.file.filename} 不存在`)
      }
    }, 120 * 1000)
    return res.json({
        code: "0",
        data: {
          filename: req.file.filename,
          size: req.file.size
        },
        msg: '成功',
        success: true
    })
});

module.exports = router;

compare

使用 looks-same 对图片进行比对,点击下载后可以获取对比的图片

const router = require('../../router');
const looksSame = require('looks-same');
const path = require('path');
const fs = require('fs');

/**
 * @openapi
 * /piper/compare/compareImage:
    post:
      summary: 比较图片
      tags: 
        - compareImage
      requestBody:
        required: true
        content: 
          application/json: 
            schema: 
              $ref: '#/components/schemas/compareImage'
      responses:  
        '200':
          content:
            application/json:
              example:
                code: "0"
                data: ""
                msg: "成功"
                success: true
 */
router.post('/compareImage', function (req, res) {
  console.log('compareImage', req.body);
  const {
    designName,
    codeName
  } = req.body;
  if (designName && codeName) {
    if (fs.existsSync(path.resolve(__dirname, `../../../../db/__temp__/${designName}`)) && fs.existsSync(path.resolve(__dirname, `../../../../db/__temp__/${codeName}`))) {
      const [, ...d] = designName.split('.'),
        [, ...c] = codeName.split('.');
      d.pop();
      c.pop();
      const dName = d.join(''),
        cName = c.join('');
      const compareName = `${Date.now()}.${dName}.${cName}.png`;
      looksSame.createDiff({
        reference: path.resolve(__dirname, `../../../../db/__temp__/${designName}`),
        current: path.resolve(__dirname, `../../../../db/__temp__/${codeName}`),
        diff: path.resolve(__dirname, `../../../../db/__temp__/${compareName}`),
        highlightColor: '#ff00ff', // color to highlight the differences
        strict: false, // strict comparsion
        tolerance: 2.5,
        antialiasingTolerance: 0,
        ignoreAntialiasing: true, // ignore antialising by default
        ignoreCaret: true // ignore caret by default
      }, function (error) {
        if (error) {
          return res.json({
            code: "-1",
            data: error,
            msg: '失败',
            success: false
          })
        } else {
          [codeName, designName].forEach(item => {
            fs.unlink(path.resolve(__dirname, `../../../../db/__temp__/${item}`), function (err) {
              if (err) {
                console.error(`删除文件 ${item} 失败,失败原因:${err}`)
              }
              console.log(`删除文件 ${item} 成功`)
            });
          })
          return res.json({
            code: "0",
            data: {
              compareName: compareName
            },
            msg: '成功',
            success: true
          })
        }
      });
    } else {
      return res.json({
        code: "-1",
        data: '所需对比图片不存在,请重新确认',
        msg: '失败',
        success: false
      })
    }
  } else {
    return res.json({
      code: "-1",
      data: '所需对比图片无法找到,请重新确认',
      msg: '失败',
      success: false
    })
  }
});

/**
 * @openapi
 * /piper/compare/downloadImage:
    post:
      summary: 下载比较图片
      tags: 
        - downloadImage
      requestBody:
        required: true
        content: 
          application/json: 
            schema: 
              $ref: '#/components/schemas/downloadImage'
      responses:  
        '200':
          content:
            application/json:
              example:
                code: "0"
                data: ""
                msg: "成功"
                success: true
 */
router.post('/downloadImage', function (req, res) {
  console.log('downloadImage', req.body);
  const {
    compareName
  } = req.body;
  if (compareName) {
    const f = fs.createReadStream(path.resolve(__dirname, `../../../../db/__temp__/${compareName}`));
    res.writeHead(200, {
      'Content-Type': 'application/force-download',
      'Content-Disposition': 'attachment; filename=' + compareName
    });   
    f.on('data', (data) => {
      res.write(data);
    }).on('end', () => {
      res.end();
      fs.unlink(path.resolve(__dirname, `../../../../db/__temp__/${compareName}`), function (err) {
        if (err) {
          console.error(`删除文件 ${compareName} 失败,失败原因:${err}`)
        }
        console.log(`删除文件 ${compareName} 成功`)
      });
    })
  } else {
    return res.json({
      code: "-1",
      data: '生成对比图片名称不正确,请重新确认',
      msg: '失败',
      success: false
    })
  }
});

module.exports = router;

app.js

定时任务,每天23点59分定时清理临时文件

// 每天23点59分删除临时文件
cron.schedule("59 23 * * *", function() {
    console.log("---------------------");
    console.log("Cron Job Start");
    const files = fs.readdirSync(path.resolve(__dirname, '../db/__temp__'));
    if(files.length > 0) {
        files.forEach(file => fs.unlinkSync(path.resolve(__dirname, `../db/__temp__/${file}`)));
    }
    console.log("Cron Job Done");
    console.log("---------------------");
});

ecosystem.config.js

pm2 相关的一些配置,这里开启了3个实例进行监听

module.exports = {
    apps: [
        {
            name: 'server',
            script: './server',
            exec_mode: 'cluster',
            instances: 3,
            max_restarts: 4,
            min_uptime: 5000,
            max_memory_restart: '1G'
        }
    ]
}

总结

前端设计走查平台的后端接口部分核心在于图片的比对,而对于图片相似度的比较,通常又会涉及到图像处理相关的内容,这里使用的是 looks-same 这个开源库,其本质利用像素之间的匹配来计算相似度,另外还有利用余弦相似度、哈希算法、直方图、SSIM、互信息等,除了这些传统方法外,还可以使用深度学习的方法来处理,常见的有如特征值提取+特征向量相似度计算的方法等等,这就涉及到了前端智能化的领域,对于这部分感兴趣的同学可以参看一下蚂蚁金服的蒙娜丽莎这个智能化的设计走查平台的实现(ps:更智能的视觉验收提效方案 - 申姜)。在前端智能化领域中,通常应用场景都是与上游设计部分的落地中,比如D2C和C2D领域,对于前端智能化方向感兴趣的同学,可以着重在这个角度多多研讨,共勉!!!

参考


维李设论
1.1k 声望4k 粉丝

专注大前端领域发展