Athon

Athon 查看完整档案

北京编辑北京科技大学  |  计算机科学与技术 编辑Threatbook  |  前端开发工程师 编辑 helloathon.club/ 编辑
编辑

前端,热衷于React开发,前端工程化,交流微信:w554091944

个人动态

Athon 回答了问题 · 2019-12-30

React Hook 使用中的警告怎么处理

login setUserLoginState两个函数每次都是新定义的,会导致下面的onsubmit和useEffect每次render重新执行,把这俩也加上useCallback就行了。。你看下警告的具体内容啊。。

关注 2 回答 1

Athon 赞了文章 · 2019-12-30

使用 pkg 打包 ThinkJS 项目

在 ThinkJS 的用户群里,经常有开发者提出需要对源码进行加密保护的需求。我们知道 JavaScript 是一门动态语言,不像其他静态语言可以编译成二进制包防止源码泄露。所以就出现了 pkgnexe 之类的工具,支持将 JS 代码连同 Node 一块打包成一个可执行文件,一来解决了环境依赖的问题,二来解决了大家关心的源码保护的问题。

pkg 模块的 README 中,罗列了它的几大用处,如果你有下面的几个需求的话建议不妨试试。

  • 为应用提供商业发行版而不用暴露源码
  • 为应用提供 demo 而不用暴露源码
  • 一键打包所有平台可执行文件而不需要对应平台环境依赖
  • 提供自解压或自安装的解决方案
  • 运行应用不需要安装 Node.js 和 npm
  • 部署仅需要一份单文件,不需要通过 npm 安装大量的依赖
  • 资源打包后让应用迁移起来更加方便
  • 在指定 Node.js 版本下对应用进行测试而不需要安装对应的版本

如何使用

关于 pkg 模块的基础使用,大家可以看 《把你的NodeJS程序给没有NodeJS的人运行》 这篇文章。通过 npm install -g pkg 在全局安装上模块后就可以在命令行中使用 pkg 命令了。pkg 除了支持在命令行中指定参数之外,还支持在 package.json 中进行配置。

{
  ...
  "bin": "production.js",
  "scripts": {
    "pkg": "pkg . --out-path=dist/"
  },
  "pkg": {
    "scripts": [...]
    "assets": [...],
    "targets": [...]
  },
  ...
}

以上就是一个简单的配置。bin 用来指定最终打包的入口文件,pkg.scriptspkg.assets 用来指定除了入口文件之外需要打包进可执行文件中的内容,其中前者用来指定其他 .js 文件,后者用来指定非.js的资源。pkg.targets 则是用来指定需要打包的平台,平台名称结构如下,node${version}-${platform}-${arch}version 用来指定具体 Node 的版本,platform 用来指定编译的平台,可以是 freebsd, linux, alpine, macos 或者 win,最后 arch 用来指定编译平台的架构,可以是 x64, x86, armv6 或者 armv7。例如 node10-macos-x64 表示的就是基于 Node 10 打包在 MacOS 平台上执行的可执行程序。scripts, assetstargets 都支持数组配置多个。

将入口文件、依赖的脚本和资源、需要编译的平台配置好之后,执行 npm run pkg 即可完成编译。

如何打包 ThinkJS

pkg 的原理大概是提供一个虚拟的文件系统,将 __filename, __dirname 等变量以及官方 API 中的 IO 操作方法指向本地文件系统的变量修改成指向虚拟系统。通过该虚拟文件系统读取压缩打包后的程序源码,提供脚本执行的环境。需要注意的是该虚拟文件系统是只读的,所以如果程序中有基于 __dirname 进行读写操作的方法,需要规避规避掉。

代码预处理

在 ThinkJS 项目中会有以下两个地方有文件写入操作:

  1. 项目启动后会在 runtime/config/${env}.json 下写入最终的配置文件
  2. 生产环境下默认会在 logs/ 目录中写入线上日志

这些目录默认都是基于当前项目文件夹的,所以基于之前的理论都需要规避。pkgREADME 中告诉我们 process.cwd() 还是会指向到真实的环境中,所以我们可以修改以上目录的位置到 process.cwd() 来解决这个问题。

//pkg.js
const path = require('path');
const Application = require('thinkjs');

const instance = new Application({
  //在启动文件中可以自定义配置 runtime 目录
  RUNTIME_PATH: path.join(process.cwd(), 'runtime'), 
  ROOT_PATH: __dirname,
  proxy: true,
  env: 'pkg',
});

instance.run();

基于 production.js 我们新建一个 pkg.js 启动文件,定义项目启动后的 RUNTIME_PATH 路径,并将 env 赋值为 pkg,方便后续的配置中通过 think.env === 'pkg' 来切换配置。

//src/config/adapter.js
const {Console, DateFile} = require('think-logger3');
const isDev = think.env === 'development';
const isPkg = think.env === 'pkg';
exports.logger = {
  type: isDev ? 'console' : 'dateFile',
  console: {
    handle: Console
  },
  dateFile: {
    handle: DateFile,
    level: 'ALL',
    absolute: true,
    pattern: '-yyyy-MM-dd',
    alwaysIncludePattern: true,
    filename: path.join(isPkg ? process.cwd() : think.ROOT_PATH, 'logs/app.log')
  }
};

在 adapter 配置中我们将原来基于 think.ROOT_PATH 的路径修改成基于 process.cwd()。除了日志服务之外,如果业务中有使用到 cache 和 session 等服务,它们如果也是基于文件存储的话,也需要修改对应的文件存储配置。当然这些都是 ThinkJS 自带的一些服务,如果项目中有用到其它的一些服务,或者说本身的业务逻辑中有涉及到文件写入的也都需要修改配置。

打包配置

项目的写入操作规避掉之后我们就可以正常的配置 pkg 然后进行打包处理了。一份简单的 pkg 模块的配置大概是这样的:

//package.json
{
  "bin": "pkg.js",
  "pkg": {
    "assets": [
      "src/**/*",
      "view/**/*",
      "www/**/*"
    ],
    "targets": [
      "node10-linux-x64",
      "node10-macos-x64",
      "node10-win-x64"
    ]
  }
}

这里我们指定了 pkg.js 为打包的入口文件,指定了需要编译出 linux, macos, win 三个平台的可执行脚本,同时指定了需要将 src/, view/, www/ 三个目录作为资源一块打包进去。这是因为 ThinkJS 是动态 require 的项目,具体的业务逻辑都是在执行的时候通过遍历文件目录读取文件的形式载入的,对于 pkg 模块打包来说无法在编译的时候知道这些依赖关系,所以需要作为启动依赖的“资源”一块打包进去。

配置好后直接在项目目录下执行 pkg .,如果一切 OK 的话应该能在当前目录中看到三个可执行文件,直接执行对应平台的二进制文件即可启动服务了。

➜  www.thinkjs.org git:(master) npm run pkg-build

> thinkjs-official@1.2.0 pkg-build /Users/lizheming/workspace/thinkjs/www.thinkjs.org
> pkg ./ --out-path=dist

> pkg@4.4.0
➜  www.thinkjs.org git:(master) ✗ ls -alh dist
total 577096
drwxr-xr-x   5 lizheming  staff   160B 12 28 17:35 .
drwxr-xr-x@ 30 lizheming  staff   960B 12 28 17:34 ..
-rwxr-xr-x   1 lizheming  staff    87M 12 28 17:34 thinkjs-official-linux
-rwxr-xr-x   1 lizheming  staff    87M 12 28 17:35 thinkjs-official-macos
-rw-r--r--   1 lizheming  staff    82M 12 28 17:35 thinkjs-official-win.exe
➜  www.thinkjs.org git:(master) ✗

后记

项目打包后有一个问题是配置没办法修改了,如果有动态配置的需求的话就不是很方便了。这里提供两个思路解决该问题:

  1. 将动态的配置配置到环境变量中,程序通过读取环境变量覆盖默认的配置。
  2. 利用 ThinkJS 提供的 beforeStartServer() 钩子在启动前读取真实目录下的配置文件进行配置覆盖。

    //pkg.js
    const path = require('path');
    think.beforeStartServer(() => {
      const configFile = path.join(process.cwd(), 'config.js');
      const config = require(configFile);
      think.config(config);
    });

另外随着项目的复杂度提高,业务内可能会引入大量的第三方模块。前文只是解决了 ThinkJS 项目本身的动态引入问题,如果引入的第三方模块也有动态引入的话也需要在 pkg.assets 配置中显示指定出来。还有就是针对 C++ 模块,pkg 目前还没有办法做到自动引入,同样需要在 pkg.assets 中指定依赖资源。

//package.json
{
  "pkg": {
    "assets": [
      //以 node-sqlite3 模块为例
      "node_modules/sqlite3/lib/binding/node-v64-darwin-x64/node_sqlite3.node"
    ]
  }
}

其中 node-v64-darwin-x64 可能会根据平台不一样导致名字不太一样。无法引入 .node 模块的原因是因为 C++ 模块安装的时候会通过 node-gyp 进行动态编译,该操作是和平台相关的。也就是说该特性和 pkg 模块在一个平台上能打包所有平台的二进制包特性是冲突的,毕竟 pkg 模块也没办法在 Mac 平台上编译 Windows 平台的模块。所以在这种情况下除了需要手动引入编译后的 .node 模块之外,还需要注意引入的该 .node 模块和 pkg.targets 指定的编译平台的一致性。

获取 .node 模块除了在对应平台模块安装之外,也可以选择下载其它同学提供编译好的模块。淘宝源上提供了很多二进制模块的编译后结果,以 node-sqlite3 为例,它的所有编译模块可以在 https://npm.taobao.org/mirror... 这里下载,自行选择对应的版本和平台即可。

本文说的打包配置都已在 ThinkJS 官网 项目中实现,想要尝试的同学可以直接克隆官网项目,安装完依赖后执行 npm run pkg-build 即可在 dist/ 目录中获得二进制可执行文件。

查看原文

赞 4 收藏 1 评论 0

Athon 收藏了文章 · 2019-12-11

羚珑项目自动化测试方案实践

分享内容及技术栈

本文将分享结合 京东智能设计平台羚珑 项目自身情况搭建的测试工作流的实践经验,针对于 Node.js 服务端应用的工具方法和接口的单元测试、集成测试等。实践经验能给你带来:

  1. 利用 Jest 搭建一套开发体验友好的测试工作流。
  2. 书写一个高效的单元测试用例,及集成测试用例。
  3. 利用封装技术实现模块间的分离,简化测试代码。
  4. 使用 SuperTest 完成应用进程与测试进程的合并。
  5. 创建高效的数据库内存服务,实现彼此隔离的测试套件运行机制。
  6. 了解模拟(Mock)、快照(snapshot)与测试覆盖率等功能的使用。
  7. 理解 TDD 与 BDD。
  8. ...

文中涉及的基础技术栈有(需要了解的知识):

  1. TypeScript: JavaScript 语言的超集,提供类型系统和新 ES 语法支持。
  2. SuperTest: HTTP 代理及断言工具。
  3. MongoDB: NoSQL 分布式文件存储数据库。
  4. Mongoose: MongoDB 对象关系映射操作库(ORM)。
  5. Koa: 基础 Web 应用程序框架。
  6. Jest: 功能丰富的 JavaScript 测试框架。
  7. lodash: JavaScript 工具函数库。

关于羚珑

羚珑Logo

羚珑 是京东旗下智能设计平台,提供在线设计服务,主要包括大类如:

  • 图片设计:快速合成广告图,主图,公众号配图,海报,传单,物流面单等线上与线下设计服务。
  • 视频设计:快速合成主图视频,抖音短视频,自定义视频等设计服务。
  • 页面设计:快速搭建活动页,营销页,小游戏,小程序等设计服务。
  • 实用工具:批量抠图、改尺寸、配色、加水印等。

基于行业领先技术,为商家、用户提供丰富的设计能力,实现快速产出。

羚珑架构及测试框架选型

先介绍下羚珑项目的架构,方便后续的描述和理解。羚珑项目采用前后端分离的机制,前端采用 React Family 的基础架构,再加上 Next.js 服务端渲染以提供更好的用户体验及 SEO 排名。后端架构则如下图所示,流程大概是 浏览器或第三方应用访问项目 Nginx 集群,Nginx 集群再通过负载均衡转发到羚珑应用服务器,应用服务器再通过对接外部服务或内部服务等,或读写缓存、数据库,逻辑处理后通过 HTTP 返回到前端正确的数据。

羚珑服务端架构图

主流测试框架对比

接下来,根据项目所需我们对比下当下 Node.js 端主流的测试框架。

JestMochaAVAJasmine
GitHub Stars28.5K18.7K17.1K14.6K
GitHub Used by1.5M926K46.6K5.3K
文档友好优秀良好良好良好
模拟功能(Mock)支持外置外置外置
快照功能(Snapshot)支持外置支持外置
支持 TypeScriptts-jestts-mochats-nodejasmine-ts
详细的错误输出支持支持支持未知
支持并行与串行支持外置支持外置
每个测试进程隔离支持不支持支持未知

*文档友好:文档结构组织有序,API 阐述完整,以及示例丰富。

分析:

  1. 之所以 Mocha GitHub 使用率很高,很有可能是因为出现的最早(2011年),并由 Node.js 届顶级开发者 TJ 领导开发的(后转向Go语言),所以早期项目选择了 Mocha 做为测试框架,而 JestAVA 则是后起之秀(2014年),并且 Stars 数量都在攀升,预计新项目都会在这两个框架中挑选。
  2. 相比外置功能,内置支持可能会与框架融合的更好,理念更趋近,维护更频繁,使用更省心。
  3. Jest 模拟功能可以实现方法模拟,定时器模拟,模块/文件依赖模拟,在实际编写测试用例中,模拟模块功能(mock modules)被常常用到,它可以确保测试用例快速响应并且不会变化无常。下文也会谈到如何使用它,为什么需要使用它。

综上,我们选择了 Jest 作为基础测试框架。

从0到1落地实践

Jest 框架配置

接下来,我们从 0 到 1 开始实践,首先是搭建测试流,虽然 Jest 可以达到开箱即用,然而项目架构不尽相同,大多时候需要根据实际情况做些基础配置工作。以下是根据羚珑项目提取出来的简化版项目目录结构,如下。

├─ dist                 # TS 编译结果目录
├─ src                  # TS 源码目录
│   ├─ app.ts              # 应用主文件,类似 Express 框架的 /app.js 文件
│   └─ index.ts            # 应用启动文件,类似 Express 框架的 /bin/www 文件
├─ test                 # 测试文件目录
│   ├─ @fixtures           # 测试固定数据
│   ├─ @helpers            # 测试工具方法
│   ├─ module1             # 模块1的测试套件集合
│   │  └─ test-suite.ts       # 测试套件,一类测试用例集合
│   └─ module2             # 模块2的测试套件集合
├─ package.json           
└─ yarn.lock

这里有两个小点:

  1. @ 开头的目录,我们定义为特殊文件目录,用于提供些测试辅助工具方法、配置文件等,平级的其他目录则是测试用例所在的目录,按业务模块或功能划分。以 @ 开头可以清晰的显示在同级目录最上方,很容易开发定位,凑巧也方便了编写正则匹配。
  2. test-suite.ts 是项目内最小测试文件单元,我们称之为测试套件,表示同一类测试用例的集合,可以是某个通用函数的多个测试用例集合,也可以是一个系列的单元测试用例集合。

首先安装测试框架。

yarn add --dev jest ts-jest @types/jest

因为项目是用 TypeScript 编写,所以这里同时安装 ts-jest @types/jest。然后在根目录新建 jest.config.js 配置文件,并做如下小许配置。

module.exports = {
  // preset: 'ts-jest',
  globals: {
    'ts-jest': {
      tsConfig: 'tsconfig.test.json',
    },
  },
  testEnvironment: 'node',
  roots: ['<rootDir>/src/', '<rootDir>/test/'],
  testMatch: ['<rootDir>/test/**/*.ts'],
  testPathIgnorePatterns: ['<rootDir>/test/@.+/'],
  moduleNameMapper: {
    '^~/(.*)': '<rootDir>/src/$1',
  },
}

preset: 预设测试运行环境,多数情况设置为 ts-jest 即可,如果需要为 ts-jest 指定些参数,如上面指定 TS 配置为 tsconfig.test.json,则需要像上面这样的写法,将 ts-jest 挂载到 globals 属性上,更多配置可以移步其官方文档,这里

testEnvironment: 基于预设再设置测试环境,Node.js 需要设置为 node,因为默认值为浏览器环境 jsdom

roots: 用于设定测试监听的目录,如果匹配到的目录的文件有所改动,就会自动运行测试用例。<rootDir> 表示项目根目录,即与 package.json 同级的目录。这里我们监听 srctest 两个目录。

testMatch:Glob 模式设置匹配的测试文件,当然也可以是正则模式,这里我们匹配 test 目录下的所有文件,匹配到的文件才会当做测试用例执行。

testPathIgnorePatterns: 设置已经匹配到的但需要被忽略的文件,这里我们设置以 @ 开头的目录及其所有文件都不当做测试用例。

moduleNameMapper: 这个与 TS pathsWebpack alias 雷同,用于设置目录别名,可以减少引用文件时的出错率并且提高开发效率。这里我们设置以 ~ 开头的模块名指向 src 目录。

第一个单元测试用例

搭建好测试运行环境,于是便可着手编写测试用例了,下面我们编写一个接口单元测试用例,比方说测试首页轮播图接口的正确性。我们将测试用例放在 test/homepage/carousel.ts 文件内,代码如下。

import { forEach, isArray } from 'lodash’
import { JFSRegex, URLRegex } from '~/utils/regex'
import request from 'request-promise'

const baseUrl = 'http://ling-dev.jd.com/server/api'

// 声明一个测试用例
test('轮播图个数应该返回 5,并且数据正确', async () => {
  // 对接口发送 HTTP 请求
  const res = await request.get(baseUrl + '/carousel/pictures')
  
  // 校验返回状态码为 200
  expect(res.statusCode).toBe(200)
  
  // 校验返回数据是数组并且长度为 5
  expect(isArray(res.body)).toBe(true)
  expect(res.body.length).toBe(5)
  
  // 校验数据每一项都是包含正确的 url, href 属性的对象
  forEach(res.body, picture => {
    expect(picture).toMatchObject({
      url: expect.stringMatching(JFSRegex),
      href: expect.stringMatching(URLRegex),
    })
  })
})

编写好测试用例后,第一步需要启动应用服务器:

应用服务运行截图

第二步运行测试,在命令行窗口输入:npx jest,如下图可以看到用例测试通过。

测试用例通过截图

当然最佳实践则是把命令封装到 package.json 里,如下:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
  }
}

之后便可使用 yarn test 来运行测试,通过 yarn test:watch 来启动监听式测试服务。

SuperTest 增强

虽然上面已经完成基本的测试流程开发,但很明显的一个问题是每次运行测试,我们需要先启动应用服务,共启动两个进程,并且需要提前配置 ling-dev.jd.com 指向 127.0.0.1:3800,这是一个繁琐的过程。所以我们引入了 SuperTest,它可以把应用服务集成到测试服务一起启动,并且不需要指定 HTTP 请求的主机地址。

我们封装一个公共的 request 方法,将它放在 @helpers/agent.ts 文件内,如下。

import http from 'http'
import supertest from 'supertest'
import app from '~/app'

export const request = supertest(http.createServer(app.callback()))

解释:

  1. 使用 app.callback() 而不是 app.listen(),是因为它可以将同一个 app 同时作为 HTTP 和 HTTPS 或多个地址。app.callback() 返回适用于 http.createServer() 方法的回调函数来处理请求。
  2. 之后,http.createServer() 创建一个未监听的 HTTP 对象给 SuperTest,当然 SuperTest 内部也会调用 listen(0) 这样的特殊端口,让操作系统提供可用的随机端口来启动应用服务器。

所以上面的测试用例我们可以改写成这样:

import { forEach, isArray } from 'lodash’
import { JFSRegex, URLRegex } from '~/utils/regex'

// 引入公共的 request 方法
import { request } from '../@helpers/agent'

test('轮播图个数应该返回 5,并且数据正确', async () => {
  const res = await request.get('/api/carousel/pictures')
  expect(res.status).toBe(200)
  // 同样的校验...
})

因为 SuperTest 内部已经帮我们包装好了主机地址并自动启动应用服务,所以请求接口时只需书写具体的接口,如 /api/carousel/pictures,也只需运行一条命令 yarn test,就可以完成整个测试工作。

数据库内存服务

项目架构中可以看到数据库使用的是 MongoDB,在测试时,几乎所有的接口都需要与数据库连接。此时可通过环境变量区分并新建 test 数据库,用于运行测试用例。有点不好的是测试套件执行完成后需要对 test 数据库进行清空,以避免脏数据影响下个测试套件,尤其是在并发运行时,需要保持数据隔离。

使用 MongoDB Memory Server 是更好的选择,它会启动独立的 MongoDB 实例(每个实例大约占用非常低的 7MB 内存),而测试套件将运行在这个独立的实例里。假如并发为 3,那就创建 3 个实例分别运行 3 个测试套件,这样可以很好的保持数据隔离,并且数据都保存在内存中,这使得运行速度会非常快,当测试套件完成后则自动销毁实例。

MongoDB Memory Server

接下来我们把 MongoDB Memory Server 引入实际测试中,最佳方式是把它写进 Jest 环境配置里,这样只需要一次书写,自动运行在每个测试套件中。所以替换 jest.config.js 配置文件的 testEnvironment 为自定义环境 <rootDir>/test/@helpers/jest-env.js

编写自定义环境 @helpers/jest-env.js

const NodeEnvironment = require('jest-environment-node')
const { MongoMemoryServer } = require('mongodb-memory-server')
const child_process = require('child_process')

// 继承 Node 环境
class CustomEnvironment extends NodeEnvironment {
  // 在测试套件启动前,获取本地开发 MongoDB Uri 并注入 global 对象
  async setup() {
    const uri = await getMongoUri()
    this.global.testConfig = {
      mongo: { uri },
    }
    await super.setup()
  }
}

async function getMongoUri() {
  // 通过 which mongod 命令拿到本地 MongoDB 二进制文件路径
  const mongodPath = await new Promise((resolve, reject) => {
    child_process.exec(
      'which mongod',
      { encoding: 'utf8' },
      (err, stdout, stderr) => {
        if (err || stderr) {
          return reject(
            new Error('找不到系统的 mongod,请确保 `which mongod` 可以指向 mongod')
          )
        }
        resolve(stdout.trim())
      }
    )
  })

  // 使用本地 MongoDB 二进制文件创建内存服务实例
  const mongod = new MongoMemoryServer({
    binary: { systemBinary: mongodPath },
  })
  
  // 得到创建成功的实例 Uri 地址
  const uri = await mongod.getConnectionString()
  return uri
}

// 导出自定义环境类
module.exports = CustomEnvironment

Mongoose 中便可以这样连接:

await mongoose.connect((global as any).testConfig.mongo.uri, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})

当然在 package.json 里需要禁用 MongoDB Memory Server 去下载二进制包,因为上面已经使用了本地二进制包。

"config": {
  "mongodbMemoryServer": {
    "version": "4.0",
    // 禁止在 yarn install 时下载二进制包
    "disablePostinstall": "1",
    "md5Check": "1"
  }
}

登录功能封装与使用

大多时候接口是需要登录后才能访问的,所以我们需要把整块登录功能抽离出来,封装成通用方法,同时借此初始化一些测试专用数据。

为了使 API 易用,我希望登录 API 长这样:

import { login } from '../@helpers/login'

// 调用登录方法,根据传递的角色创建用户,并返回该用户登录的 request 对象。
// 支持多参数,根据参数不同自动初始化测试数据。
const request = await login({
  role: 'user',
})

// 使用已登录的 request 对象访问需要登录的用户接口,
// 应当是登录态,并正确返回当前登录的用户信息。
const res = await request.get('/api/user/info')

开发登录方法:

// @helpers/agent.ts 
// 新添加 makeAgent 方法
export function makeAgent() {
  // 使用 supertest.agent 支持 cookie 持久化
  return supertest.agent(http.createServer(app.callback()))
}
// @helpers/login.ts
import { assign, cloneDeep, pick } from 'lodash'
import { makeAgent } from './agent'

export async function login(userData: UserDataType): Promise<RequestAgent> {
  userData = cloneDeep(userData)
  
  // 如果没有用户名,自动创建用户名
  if (!userData.username) {
    userData.username = chance.word({ length: 8 })
  }

  // 如果没有昵称,自动创建昵称
  if (!userData.nickname) {
    userData.nickname = chance.word({ length: 8 })
  }

  // 得到支持 cookie 持久化的 request 对象
  const request: any = makeAgent()

  // 发送登录请求,这里为测试专门设计一个登录接口
  // 包含正常登录功能,但还会根据传参不同初始化测试专用数据
  const res = await request.post('/api/login-test').send(userData)

  // 将登录返回的数据赋值到 request 对象上
  assign(request, pick(res.body, ['user', 'otherValidKey...']))

  // 返回 request 对象
  return request as RequestAgent
}

实际用例中就像上面示例方式使用。

模拟功能使用

从项目架构中可以看到项目也会调用较多外部服务。比方说创建文件夹的接口,内部代码需要调用外部服务去鉴定文件夹名称是否包含敏感词,就像这样:

import { detectText } from '~/utils/detect'

// 调用外部服务检测文件夹名称是否包含敏感词
const { ok, sensitiveWords } = await detectText(folderName)
if (!ok) {
  throw new Error(`检测到敏感词: ${sensitiveWords}`)
}

实际测试的时候并不需要所有测试用例运行时都调用外部服务,这样会拖慢测试用例的响应时间以及不稳定性。我们可以建立个更好的机制,新建一个测试套件专门用于验证 detectText 工具方法的正确性,而其他测试套件运行时 detectText 方法直接返回 OK 即可,这样既保证了 detectText 方法被验证到,也保证了其他测试套件得到快速响应。

模拟功能(Mock)就是为这样的情景而诞生的。我们只需要在 detectText 方法的路径 utils/detect.ts 同级新建__mocks__/detect.ts 模拟文件即可,内容如下,直接返回结果:

export async function detectText(
  text: string
): Promise<{ ok: boolean; sensitive: boolean; sensitiveWords?: string }> {
  // 删除所有代码,直接返回 OK
  return { ok: true, sensitive: false }
}

之后每个需要模拟的测试套件顶部加上下面一句代码即可。

jest.mock('~/utils/detect.ts')

在验证 detectText 工具方法的测试套件里,则只需 jest.unmock 即可恢复真实的方法。

jest.unmock('~/utils/detect.ts')

当然应该把 jest.mock 写在 setupFiles 配置里,因为需要模拟的测试套件占绝大多数,写在配置里会让它们在运行前自动加载该文件,这样开发就不必每处测试套件都加上一段同样的代码,可以有效提高开发效率。

// jest.config.js
setupFiles: ['<rootDir>/test/@helpers/jest-setup.ts']
// @helpers/jest-setup.ts
jest.mock('~/utils/detect.ts')

模拟功能还有方法模拟,定时器模拟等,可以查阅其文档了解更多示例。

快照功能使用

快照功能(Snapshot)可以帮我们测试大型对象,从而简化测试用例。

举个例子,项目的模板解析接口,该接口会将 PSD 模板文件进行解析,然后吐出一个较大的 JSON 数据,如果挨个校验对象的属性是否正确可能很不理想,所以可以使用快照功能,就是第一次运行测试用例时,会把 JSON 数据存储到本地文件,称之为快照文件,第二次运行时,就会将第二次返回的数据与快照文件进行比较,如果两个快照匹配,则表示测试成功,反之测试失败。

而使用方式很简单:

// 请求模板解析接口
const res = await request.post('/api/secret/parser')

// 断言快照是否匹配
expect(res.body).toMatchSnapshot()

更新快照也是敏捷的,运行命令 jest --updateSnapshot 或在监听模式输入 u 来更新。

集成测试

集成测试的概念是在单元测试的基础上,将所有模块按照一定要求或流程关系进行串联测试。比方说,一些模块虽然能够单独工作,但并不能保证连接起来也能正常工作,一些局部反映不出来的问题,在全局上很可能暴露出来。

因为测试框架 Jest 对于每个测试套件是并行运行的,而套件内的用例则是串行运行的,所以编写集成测试很方便,下面我们用文件夹的使用流程示例如何完成集成测试的编写。

import { request } from '../@helpers/agent'
import { login } from '../@helpers/login'

const urlCreateFolder = '/api/secret/folder'      // POST
const urlFolderDetails = '/api/secret/folder'     // GET
const urlFetchFolders = '/api/secret/folders'     // GET
const urlDeleteFolder = '/api/secret/folder'      // DELETE
const urlRenameFolder = '/api/secret/folder/rename'     // PUT

const folders: ObjectAny[] = []
let globalReq: ObjectAny

test('没有权限创建文件夹应该返回 403 错误', async () => {
  const res = await request.post(urlCreateFolder).send({
    name: '我的文件夹',
  })
  expect(res.status).toBe(403)
})

test('确保创建 3 个文件夹', async () => {
  // 登录有权限创建文件夹的用户,比如设计师
  globalReq = await login({ role: 'designer' })
  
  for (let i = 0; i < 3; i++) {
    const res = await globalReq.post(urlCreateFolder).send({
      name: '我的文件夹' + i,
    })
    
    // 将创建成功的文件夹置入 folders 常量里
    folders.push(res.body)
    
    expect(res.status).toBe(200)
    // 更多验证规则...
  }
})

test('重命名第 2 个文件夹', async () => {
  const res = await globalReq.put(urlRenameFolder).send({
    id: folders[1].id,
    name: '新文件夹名称',
  })
  expect(res.status).toBe(200)
})

test('第 2 个文件夹的名称应该是【新文件夹名称】', async () => {
  const res = await globalReq.get(urlFolderDetails).query({
    id: folders[1].id,
  })
  expect(res.status).toBe(200)
  expect(res.body.name).toBe('新文件夹名称')
  // 更多验证规则...
})

test('获取文件夹列表应该返回 3 条数据', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

test('删除最后一个文件夹', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

test('再次获取文件夹列表应该返回 2 条数据', async () => {
  // 与上雷同,鉴于代码过多,先行省略...
})

测试覆盖率

测试覆盖率是对测试完成程度的评测,基于文件被测试的情况来反馈测试的质量。

运行命令 jest --coverage 即可生成测试覆盖率报告,打开生成的 coverage/lcov-report/index.html 文件,各项指标一览无余。因为 Jest 内部使用 Istanbul 生成覆盖率报告,所以各项指标依然参考 Istanbul

测试覆盖率报告

持续集成

写完这么多测试用例之后,或者是开发完功能代码后,我们是不是希望每次将代码推送到托管平台,如 GitLab,托管平台能自动帮我们运行所有测试用例,如果测试失败就邮件通知我们修复,如果测试通过则把开发分支合并到主分支?

答案是必须的。这就与持续集成(Continuous Integration)不谋而合,通俗的讲就是经常性地将代码合并到主干分支,每次合并前都需要运行自动化测试以验证代码的正确性。

所以我们配置一些自动化测试任务,按顺序执行安装、编译、测试等命令,测试命令则是运行编写好的测试用例。一个 GitLab 的配置任务(.gitlab-ci.yml)可能像下面这样,仅作参考。

# 每个 job 之前执行的命令
before_script:
  - echo "`whoami` ($0 $SHELL)"
  - echo "`which node` (`node -v`)"
  - echo $CI_PROJECT_DIR

# 定义 job 所属 test 阶段及执行的命令等
test:
  stage: test
  except:
    - test
  cache:
    paths:
      - node_modules/
  script:
    - yarn
    - yarn lint
    - yarn test

# 定义 job 所属 deploy 阶段及执行的命令等
deploy-stage:
  stage: deploy
  only:
    - test
  script:
    - cd /app 
    - make BRANCH=origin/${CI_COMMIT_REF_NAME} deploy-stage

持续集成的好处:

  1. 快速发现错误。
  2. 防止分支大幅偏离主干分支。
  3. 让产品可以快速迭代,同时还能保持高质量。

TDD与BDD引入

TDD 全称测试驱动开发(Test-driven development),是敏捷开发中的一种设计方法论,强调先将需求转换为具体的测试用例,然后再开发代码以使测试通过。

BDD 全称行为驱动开发(Behavior-driven development),也是一种敏捷开发设计方法论,它没有强调具体的形式如何,而是强调【作为什么角色,想要什么功能,以便收益什么】这样的用户故事指定行为的论点。

两者都是很好的开发模式,结合实际情况,我们的测试更像是 BDD,不过并没有完全摒弃 TDD,我们的建议是如果觉得先写测试可以帮助更快的写好代码,那就先写测试,如果觉得先写代码再写测试,或一边开发一边测试更好,则采用自己的方式,而结果是编码功能和测试用例都需要完成,并且运行通过,最后通过 Code Review 对代码质量做进一步审查与把控。

笔者称之为【师夷长技,聚于自身】:结合项目自身的实际情况,灵活变通,形成一套适合自身项目发展的模式驱动开发。

结论

自动化测试提供了一种有保障的机制检测整个系统,可以频繁地进行回归测试,有效提高系统稳定性。当然编写与维护测试用例需要耗费一定的成本,需要考虑投入与产出效益之间的平衡。

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

13-横_1575377567139.jpg

查看原文

Athon 赞了文章 · 2019-11-26

双 11 模块 79.34% 的代码是怎样智能生成的?

导读:作为今年阿里经济体前端委员会的四大技术方向之一,前端智能化方向一被提及,就不免有人好奇:前端结合 AI 能做些什么,怎么做,未来会不会对前端产生很大的冲击等等。本篇文章将围绕这些问题,以「设计稿自动生成代码」场景为例,从背景分析、竞品分析、问题拆解、技术方案等几个角度切入,细述相关思考及过程实践。

背景分析

业界机器学习之势如火如荼,「AI 是未来的共识」频频出现在各大媒体上。李开复老师也在《AI·未来》指出,将近 50% 的人类工作将在 15 年内被人工智能所取代,尤其是简单的、重复性的工作。并且,白领比蓝领的工作更容易被取代;因为蓝领的工作可能还需要机器人和软硬件相关技术都突破才能被取代,而白领工作一般只需要软件技术突破就可以被取代。那我们前端这个“白领”工作会不会被取代,什么时候能被取代多少?

回看 2010 年,软件几乎吞噬了所有行业,带来近几年软件行业的繁荣;而到了 2019 年,软件开发行业本身却又在被 AI 所吞噬。你看:DBA 领域出现了 Question-to-SQL,针对某个领域只要问问题就可以生成 SQL 语句;基于机器学习的源码分析工具 TabNine 可以辅助代码生成;设计师行业也出了 P5 Banner 智能设计师“鹿班”,测试领域的智能化结合也精彩纷呈。那前端领域呢?

那就不得不提一个我们再熟悉不过的场景了,它就是设计稿自动生成代码(Design2Code,以下简称 D2C),阿里经济体前端委员会-前端智能化方向当前阶段就是聚焦在如何让 AI 助力前端这个职能角色提效升级,杜绝简单重复性工作,让前端工程师专注更有挑战性的工作内容!

竞品分析

2017 年,一篇关于图像转代码的 Pix2Code 论文掀起了业内激烈讨论的波澜,讲述如何从设计原型直接生成源代码。随后社区也不断涌现出基于此思想的类似 Screenshot2Code 的作品,2018 年微软 AI Lab 开源了草图转代码 工具 Sketch2Code,同年年底,设计稿智能生成前端代码的新秀 Yotako 也初露锋芒, 机器学习首次以不可小觑的姿态正式进入了前端开发者的视野。

基于上述竞品分析,我们能够得到以下几点启发:

1、深度学习目前在图片方面的目标检测能力适合较大颗粒度的可复用的物料识别(模块识别、基础组件识别、业务组件识别)。

2、完整的直接由图片生成代码的端到端模型复杂度高,生成的代码可用度不高,要达到所生成代码工业级可用,需要更细的分层拆解和多级子网络模型协同,短期可通过设计稿生成代码来做 D2C 体系建设。

3、当模型的识别能力无法达到预期准确度时,可以借助设计稿人工的打底规则协议;一方面人工规则协议可以帮助用户强干预得到想要的结果,另一方面这些人工规则协议其实也是高质量的样本标注,可以当成训练样本优化模型识别准确度。

问题分解

设计稿生成代码的目标是让 AI 助力前端这个职能角色提效升级,杜绝简单重复性工作内容。那我们先来分析下,“常规”前端尤其是面向 C 端业务的同学,一般的工作流程日常工作内容大致如下:

“常规”前端一般的开发工作量,主要集中在视图代码、逻辑代码和数据联调(甚至是数据接口开发,研发 Serveless 产品化时可期)这几大块,接下来,我们逐块拆解分析。

视图代码

视图代码研发,一般是根据视觉稿编写 HTML 和 CSS 代码。如何提效,当面对 UI 视图开发重复性的工作时,自然想到组件化、模块化等封装复用物料的解决方案,基于此解决方案会有各种 UI 库的沉淀,甚至是可视化拼装搭建的更 High Level 的产品化封装,但复用的物料不能解决所有场景问题。个性化业务、个性化视图遍地开花,直面问题本身,直接生成可用的 HTML 和 CSS 代码是否可行?

这是业界一直在不断尝试的命题,通过设计工具的开发插件可以导出图层的基本信息,但这里的主要难点还是对设计稿的要求高、生成代码可维护性差,这是核心问题,我们来继续拆解。

▐ 设计稿要求高问题

对设计稿的要求高,会导致设计师的成本加大,相当于前端的工作量转嫁给了设计师,导致推广难度会非常大。一种可行的办法是采用 CV(ComputerVision, 计算机视觉) 结合导出图层信息的方式,以去除设计稿的约束,当然对设计稿的要求最好是直接导出一张图片,那样对设计师没有任何要求,也是我们梦寐以求的方案,我们也一直在尝试从静态图片中分离出各个适合的图层,但目前在生产环境可用度不够(小目标识别精准度问题、复杂背景提取问题仍待解决),毕竟设计稿自带的元信息,比一张图片提取处理的元信息要更多更精准。

▐ 可维护性问题

生成的代码结构一般都会面临可维护性方面的挑战:

  • 合理布局嵌套:包括绝对定位转相对定位、冗余节点删除、合理分组、循环判断等方面;
  • 元素自适应:元素本身扩展性、元素间对齐关系、元素最大宽高容错性;
  • 语义化:Classname 的多级语义化;
  • 样式 CSS 表达:背景色、圆角、线条等能用 CV 等方式分析提取样式,尽可能用 CSS 表达样式代替使用图片;

将这些问题拆解后,分门别类专项解决,解决起来看似没完没了,但好在目前发现的大类问题基本解决。很多人质疑道,这部分的能力的实现看起来和智能化一点关系都没有,顶多算个布局算法相关的专家规则测量系统。没错,现阶段这部分比较适合规则系统,对用户而言布局算法需要接近 100% 的可用度,另外这里涉及的大部分是无数属性值组合问题,当前用规则更可控。如果非要使用模型,那这个可被定义为多分类问题。同时,任何深度学习模型的应用都是需要先有清晰的问题定义过程,把问题规则定义清楚本就是必备过程。

但在规则解决起来麻烦的情况下,可以使用模型来辅助解决。比如 合理分组(如下图) 和 循环项 的判断,实践中我们发现,在各种情况下还是误判不断,算法规则难以枚举,这里需要跨元素间的上下文语义识别,这也是接下来正在用模型解决的重点问题。

逻辑代码

逻辑代码开发主要包括数据绑定、动效、业务逻辑代码编写。可提效的部分,一般在于复用的动效和业务逻辑代码可沉淀基础组件、业务组件等。

  • 接口字段绑定:从可行性上分析还是高的,根据视觉稿的文本或图片判断所属哪个候选字段,可以做,但性价比不高,因为接口数据字段绑定太业务,通用性不强。
  • 动效:这部分的开发输入是交互稿,而且一般动效的交付形式各种各样参差不齐,有的是动画 gif 演示,有的是文字描述,有的是口述;动效代码的生成更适合用可视化的形式生成,直接智能生成没有参考依据,考虑投入产出比不在短期范围内。
  • 业务逻辑:这部分开发的依据来源主要是 PRD,甚至是 PD 口述的逻辑;想要智能生成这部分逻辑代码,欠缺的输入太多,具体得看看智能化在这个子领域能解决什么问题。

▐ 逻辑代码生成思考

理想方案当然是能像其他诗歌、绘画、音乐等艺术领域一样,学习历史数据,根据 PRD 文本输入,新逻辑代码能直接生成,但这样生成的代码能直接运行没有任何报错吗?

目前人工智能虽说发展迅速,但其能解决的问题还是有限,需要将问题定义成其擅长解决的问题类型。强化学习擅长策略优化问题,深度学习目前在计算机视觉方面擅长解决的是分类问题、目标检测问题,文本方面擅长 NLP(Natural Language Processing, 自然语言处理) 等。

关于业务逻辑代码,首先想到的是对历史源代码的函数块利用 LSTM(Long Short-Term Memory, 长短期记忆网络)和 NLP 等进行上下文分析,能得到代码函数块的语义,VS Code 智能代码提醒 和 TabNine 都是类似的思路。

另外,在分析问题中(如下图)我们还发现,智能化在业务逻辑领域还可以协助识别逻辑点在视图出现的位置(时机),并根据视图猜测出的逻辑语义。

综上,我们总结一下智能化现阶段的可助力点:

  • 由历史源代码分析猜测高频出现的函数块(逻辑块)的语义,实际得到的就是组件库或者基础函数块的语义,可在代码编辑时做代码块推荐等能力。
  • 由视觉稿猜测部分可复用的逻辑点,如这里的字段绑定逻辑,可根据这里文本语义 NLP 猜测所属字段,部分图片元素根据有限范围内的图片分类,猜测所属对应的图片字段(如下图示例中的,氛围修饰图片还是 Logo 图片);同时还可以识别可复用的业务逻辑组件,比如这里的领取优惠券逻辑。

所以,现阶段在业务逻辑生成中,可以解决的问题比较有限,尤其是新的业务逻辑点以新的逻辑编排出现时,这些参考依据都在 PD 的 PRD 或脑海中。所以针对业务逻辑的生成方案,现阶段的策略主要如下:

  • 字段绑定:智能识别设计稿中的文本和图片语义分类,特别是文字部分。
  • 可复用的业务逻辑点:根据视图智能识别,并由视图驱动自由组装,含小而美的逻辑点(一行表达式、或一般不足以封装成组件的数行代码)、基础组件、业务组件。
  • 无法复用的新业务逻辑:PRD 需求结构化(可视化)收集,这是一个高难度任务,还在尝试中。

小结

目前用智能化的方式自动生成 HTML + CSS + 部分 JS + 部分 DATA 的主要分析如上,是Design to Code 的主要过程 ,内部项目名称叫做 D2C ,后续系列文章会使用此简称,集团内外的落地产品名称为 imgcook。随着近几年主流设计工具(Sketch、PS、XD 等)的三方插件开发能力逐渐成熟,飞速发展的深度学习那甚至超过人类识别效果的趋势,这些都是 D2C 诞生和持续演进的强大后盾。

目标检测 2014-2019 年 paper

技术方案

基于上述前端智能化发展概括分析,我们对现有的 D2C 智能化技术体系做了能力概述分层,主要分为以下三部分:

  • 识别能力:即对设计稿的识别能力。智能从设计稿分析出包含的图层、基础组件、业务组件、布局、语义化、数据字段、业务逻辑等多维度的信息。如果智能识别不准,就可视化人工干预补充纠正,一方面是为了可视化低成本干预生成高可用代码,另一方面这些干预后的数据就是标注样本,反哺提升智能识别的准确率。
  • 表达能力:主要做数据输出以及对工程部分接入

通过 DSL 适配将标准的结构化描述做 Schema2Code

通过 IDE 插件能力做工程接入

  • 算法工程:为了更好的支撑 D2C 需要的智能化能力,将高频能力服务化,主要包含数据生成处理、模型服务部分

样本生成:主要处理各渠道来源样本数据并生成样本

模型服务:主要提供模型 API 封装服务以及数据回流

前端智能化 D2C 能力概要分层

在整个方案里,我们用同一套 数据协议规范(D2C Schema)来连接各层的能力,确保其中的识别能够映射到具体对应的字段上,在表达阶段也能正确地通过出码引擎等方案生成代码。

智能识别技术分层

在整个 D2C 项目中,最核心的是上述识别能力部分的 机器智能识别 部分,这层的具体再分解如下,后续系列文章会围绕这些细分的分层展开内部实现原理。

  • 物料识别层:主要通过图像识别能力识别图像中的物料(模块识别、原子模块识别、基础组件识别、业务组件识别)。
  • 图层处理层:主要将设计稿或者图像中图层进行分离处理,并结合上一层的识别结果,整理好图层元信息。
  • 图层再加工层:对图层处理层的图层数据做进一步的规范化处理。
  • 布局算法层:转换二维中的绝对定位图层布局为相对定位和 Flex 布局。
  • 语义化层:通过图层的多维特征对图层在生成代码端做语义化表达。
  • 字段绑定层:对图层里的静态数据结合数据接口做接口动态数据字段绑定映射。
  • 业务逻辑层:对已配置的业务逻辑通过业务逻辑识别和表达器来生成业务逻辑代码协议。
  • 出码引擎层:最后输出经过各层智能化处理好的代码协议,经过表达能力(协议转代码的引擎)输出各种 DSL 代码。

D2C 识别能力技术分层

技术痛点

当然,这其中的 识别不全面、识别准确度不高 一直是 D2C 老生常谈的话题,也是我们的核心技术痛点。我们尝试从这几个角度来分析引起这个问题的因素:

1、识别问题定义不准确:问题定义不准确是影响模型识别不准的首要因素,很多人认为样本和模型是主要因素,但在这之前,可能一开始的对问题的定义就出现了问题,我们需要判断我们的识别诉求模型是否合适做,如果合适那该怎么定义清楚这里面的规则等。

2、高质量的数据集样本缺乏:我们在识别层的各个机器智能识别能力需要依赖不同的样本,那我们的样本能覆盖多少前端开发场景,各个场景的样本数据质量怎么样,数据标准是否统一,特征工程处理是否统一,样本是否存在二义性,互通性如何,这是我们当下所面临的问题。

3、模型召回低、存在误判:我们往往会在样本里堆积许多不同场景下不同种类的样本作为训练,期望通过一个模型来解决所有的识别问题,但这却往往会让模型的部分分类召回率低,对于一些有二义性的分类也会存在误判。

▐ 问题定义

深度学习里的计算机视觉类模型目前比较适合解决的是分类问题和目标检测问题,我们判断一个识别问题是否该使用深度模型的前提是——我们自己是否能够判断和理解,这类问题是否有二义性等,如果人也无法准确地判断,那么这个识别问题可能就不太合适。

假如判断适合用深度学习的分类来做,那就需要继续定义清楚所有的分类,这些分类之间需要严谨、有排他性、可完整枚举。比如在做图片的语义化这个命题的时候,一般图片的 ClassName 通用常规命名有哪些,举个例子,其分析过程如下:

  • 第一步:尽可能多地找出相关的设计稿,一个个枚举里面的图片类型。
  • 第二步:对图片类型进行合理归纳归类,这是最容易有争论的地方,定义不好有二义性会导致最有模型准确度问题。
  • 第三步:分析每类图片的特征,这些特征是否典型,是否是最核心的特征点,因为关系到后续模型的推理泛化能力。
  • 第四步:每类图片的数据样本来源是否有,没有的话能否自动造;如果数据样本无法有,那就不适合用模型,可以改用算法规则等方式替代先看效果。

D2C 项目中很多这样的问题,问题本身的定义需要非常精准,并且需要有科学的参考依据,这一点本身就比较难,因为没有可借鉴的先例,只能先用已知的经验先试用,用户测试有问题后再修正,这是一个需要持续迭代持续完善的痛点。

▐ 样本质量

针对 样本 问题,我们需要对这部分数据集建立标准规范,分场景构建多维度的数据集,将收集的数据做统一的处理和提供,并以此期望能建立一套规范的数据体系。

在这套体系中,我们使用统一的样本数据存储格式,提供统一的针对不同问题(分类、目标检测)的样本评估工具来评估各项数据集的质量,针对部分特定模型可采取效果较好的特征工程(归一化、边缘放大等)来处理,同类问题的样本也期望能在后续不同的模型里能够流通作比较,来评估不同模型的准确度和效率。

数据样本工程体系

▐ 模型

针对模型的召回和误判问题,我们尝试 收敛场景来提高准确度。往往不同场景下的样本会存在一些特征上的相似点或者影响部分分类局部关键特征点,导致产生误判、召回低,我们期望能够通过收敛场景来做模式化的识别,提高模型准确度。我们把场景收敛到如下三个场景:无线 C 端营销频道场景、小程序场景以及 PC 中后台场景。这里面几个场景的设计模式都有自己的特色,针对各个场景来设计垂直的识别模型可以高效地提高单一场景的识别准确度。

D2C 场景

过程思考

既然使用了深度模型,有一个比较现实的问题是,模型的泛化能力不够,识别的准确率必然不能 100% 让用户满意,除了能够在后续不断地补充这批识别不到的样本数据外,在这之前我们能做什么?

在 D2C 的整个还原链路里,对于识别模型,我们还遵守一个方法论,即设计一套协议或者规则能通过其他方式来兜底深度模型的识别效果,保障在模型识别不准确的情况下用户仍可完成诉求:手动约定 > 规则策略 > 机器学习 > 深度模型。举个例子比如需要识别设计稿里的一个循环体:

  • 初期,我们可以在设计稿里手动约定循环体的协议来达成
  • 根据图层的上下文信息可做一些规则的判断,来判断是否是循环体
  • 利用机器学习的图层特征,可尝试做规则策略的更上游的策略优化
  • 生成循环体的一些正负样本来通过深度模型进行学习

其中手动约定的设计稿协议解析优先级最高,这样也能确保后续的流程不受阻塞和错误识别的干扰。

业务落地

2019 双十一落地

在今年的双十一场景中,我们的 D2C 覆盖了天猫淘宝会场的新增模块,包括主会场、行业会场、营销玩法会场、榜单会场等,包含了视图代码和部分逻辑代码的自动生成,在可统计范围内,D2C 代码生成占据了大比例。目前代码的人工改动的主要原因有:全新业务逻辑、动画、字段绑定猜测错误(字段标准化情况不佳)、循环猜测错误、样式自适应修改等,这些问题也都是接下来需要逐步完善的。

D2C 代码生成用户更改情况

整体落地情况

我们对外的产品 imgcook,截止 2019.11.09 日,相关的使用数据情况如下:

  • 模块数 12681 个, 周新增 540 个左右;
  • 用户数 4315 个,周新增 150 个左右;
  • 自定义DSL:总数 109 个;

目前可提供的服务能力如下:

  • 设计稿全链路还原:通过 Sketch、PhotoShop 插件一键导出设计稿图层,生成自定义的 DSL 代码。
  • 图像链路还原:支持用户自定义上传图片、文件或填写图片链接,直接还原生成自定义的 DSL 代码。

后续规划

  • 继续降低设计稿要求,争取设计稿 0 协议,其中分组、循环的智能识别准确度提升,减少视觉稿协议的人工干预成本。
  • 组件识别准确度提升,目前只有 72% 的准确度,业务应用可用率低。
  • 页面级和项目级还原能力可用率提升,依赖页面分割能力的准确度提升。
  • 小程序、中后台等领域的页面级还原能力提升,含复杂表单、表格、图表的整体还原能力。
  • 静态图片生成代码链路可用度提升,真正可用于生产环境。
  • 算法工程产品完善,样本生成渠道更多元,样本集更丰富。
  • D2C 能力开源。

未来我们希望能通过前端智能化 D2C 项目,让前端智能化技术方案普惠化,沉淀更具竞争力的样本和模型,提供准确度更高、可用度更高的代码智能生成服务;切实提高前端研发效率,减少简单的重复性工作,不加班少加班,一起专注更有挑战性的工作内容!

D2智能化专场

今年的 D2 智能化专场将通过行业的应用案例和实践经验的风向,让大家对智能化改变前端有切实的感受,欢迎大家来一起探讨。

Tensorflow.js 前端的机器学习平台

演讲嘉宾:王铁震
话题介绍:TensorFlow.js 是谷歌开发的机器学习加速平台。这个库用于在浏览器、Node.js 和其他 JavaScript 平台中训练和部署机器学习模型,为 JavaScript 开发者提供了简洁高效的API。在本讲中,您将了解到 TensorFlow.js 生态系统,如何将现有的机器学习模型植入到前端,同时还会探讨进一步优化的方式,未来发展的方向。

前端智能化实践 - 逻辑代码生成

演讲嘉宾:甄子
话题介绍:从生成UI代码到生成逻辑代码有多远?如何用页面结构和数据结构的视角去看待并解决代码生成问题?如何赋予开发者自定义的能力来解决业务问题?用实践检验前端智能化的力量,用设计去论证前端智能化的未来,诚邀您一起探讨前端在机器学习和人工智能领域的发展。

数据分析的人工智能画板 - 马良

演讲嘉宾:言顾
话题介绍:随着越来越多的企业重视数据可视化,通过降低工程门槛来帮助用户创建可视化大屏成为当前的一大趋势。然而除了工程成本,数据可视化的设计效率,极大影响着数据挖掘的效率。在此之上,由于多方技术人员的参与,沟通成本过大,导致流程耗时久,且难以迭代,极大限制了潜在用户以及潜在的可适用场景。对可视化大屏搭建平台来说,急需一款产品能够提高用户的数据可视设计能力,让用户突破模版限制,轻松创造属于自己的个性化大屏。


本文作者:妙净、波本

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

查看原文

赞 4 收藏 3 评论 1

Athon 回答了问题 · 2019-11-12

解决原生js代码优化

把事件代理到父节点,就不用对每一个子节点循环进行监听了,对应大列表页也可以这么处理,jquery也有这种委托方式绑定事件的api

关注 3 回答 2

Athon 发布了文章 · 2019-08-27

前端数据可视化--间断圆环图实现

最近用了一个多月的时间完成了一个数据可视化大屏,大概的效果是这个样子的,当然这只是三屏中的其中一屏,我会用几篇文章分别介绍各个点的实现方式和一些相应的知识点。

图片描述

首先这次会介绍右下角的间断圆环图的实现,圆环图会根据数据多少,进行相应的变化。并且,圆环的内容是间断的,且带有从浅到深的渐变。

实现初尝

看到图形,我们首先会想到它跟圆环的结构稍有类似,那我们可以按照实现圆环的思路可以通过以下方式实现,使用左右两个半圆,并控制其遮罩的旋转,来控制进度,类似以下这种:

clipboard.png

然后,我们可以将两边的半圆,换做我们的底图,进行拼接。这时候我们会发现,如果想要遮罩完全遮住底图的话,我们的整个图形就没办法做成透明的了。尴尬,只能换思路。

快乐的使用SVG

头痛之余,借鉴了一下网上的其他圆环的解决方案,发现了一篇张鑫旭大神的文章,很巧妙的用stroke-dasharray实现了圆环进度。

大概原理如下:

stroke-dasharray在SVG中表示描边是虚线,两个值,第一个是虚线的宽度,第二个是虚线之间的间距。所以,我们只要让这根线,保持只有一段实线+一段虚线就行了,也就是

实线长度(对应数据) + 虚线长度 = 圆环周长

代码如下(直接):

<svg width="440" height="440" viewbox="0 0 440 440">
    <circle cx="220" cy="220" r="170" stroke-width="50" stroke="#D1D3D7" fill="none"></circle>
    <circle cx="220" cy="220" r="170" stroke-width="50" stroke="#00A5E0" fill="none" transform="matrix(0,-1,1,0,0,440)" stroke-dasharray="0 1069"></circle>
</svg>

朝最终效果进发

按照前面的思路,实现一个间断圆环的思路也就顺理成章的出来了。我们可以把整个圆环分成N份,每份包含一段实线,一段虚线。例如我们要实现80%的圆环,对应的stroke-dasharray就是:

const dashItemLength = (周长 / (N * 2))
// 向上取整 保证有整数个数据
const dashItemNum = Math.ceil(N * 0.8)

// 这里 实线虚线的长度是相同的, 减1 是预留出最后的空白
const dashLine = `${dashItemLength} ${dashItemLength},`.repeat(dashItemNum - 1)

// 最后需要将最后的虚线长度进行补齐
const restLength = 周长 - (dashItemLength * dashItemNum)

const dashLineArray = dashLine.split(',')

dashLineArray.push(`${dashItemLength} ${restLength}`)

const result = dashLineArray.join(' ')

大概效果就是这样子:

clipboard.png

最终渐变

我们会发现,圆环的渐变不是简单的线性渐变,它是环绕着圆环进行的。这个时候,把里面的圆环直接加上渐变是达不到效果的,这时候,我们需要把整个圆环拆成两份,例如我想要从黄色渐变到红色,那就是,右边是黄色到中间色,左边是中间色到红色。 对应的虚线计算,也需要拆成两部分进行了。

具体代码就不在这里赘述,关键要实现以下几个步骤:

  1. 拆分成左右两个圆环
  2. 如果数值小于50%,则左侧全部为虚线,右边按照正常计算补齐
  3. 如果数值多余50%,则右侧全部为虚实线,左边按照正常补齐
  4. 需要考虑每个单位的宽度,使50%的时候,单元正好卡在虚线中间

最终效果:

clipboard.png

查看原文

赞 8 收藏 7 评论 4

Athon 回答了问题 · 2019-08-07

解决CSS div获取聚焦 问题

纯css可以考虑配合radio来实现,radio:checked ~ div这种

关注 3 回答 3

Athon 赞了文章 · 2019-07-29

不到 0.3s 完成渲染!360 信息流正文“闪开”优化实践

开篇之前先介绍一下场景。信息流是一个基于用户兴趣使用算法将用户感兴趣的新闻内容推荐给用户的一种业务。这种业务带有非常特色的场景就是用户有一个“永远”都刷不完的推荐流列表,点击列表中的新闻之后可以跳转到其详情页中查看新闻的正文内容。列表一般都是由客户端原生去实现的,而详情页这块由于新闻内容结构的复杂性,一般还是会使用 h5 来实现。这样就对我们 h5 的性能提出了要求,我们必须在用户切换的时候将切换的白屏时间尽量减少,这样才能提高用户的阅读体验。

本文就将为大家讲述一下我们是如何实现性能优化达到“闪开”的效果的。我们可以先看看效果
https://v.qq.com/x/page/j0900...,下图左边是正常版本,而右边的是优化后的版本。对比之下可以发现即使我已经悄咪咪的先点击左边的手机,同一篇新闻右边的打开速度明显比左边的要快很多。接下来就让我们看看这个是如何做到的吧!

目前现状

众所周知,网页中内容渲染往往根据渲染方式可以分为后渲染和前端渲染两种方式,最近几年由前端渲染又演化出了同构渲染,也就是大家经常说的 SSR。这几种渲染方式的主要优缺点大概整理了主要有如下几个方面。

  1. 后端渲染:

    • 优势:服务端直出首屏性能好,SEO好
    • 劣势:交互逻辑复杂需要两端维护结构
  2. 前端渲染:

    • 优势:前端交互易维护,数据渲染分离
    • 劣势:首屏性能问题以及 SEO 问题
  3. 同构渲染:

    • 优势:首屏性能好,SEO 好,一份代码多端运行
    • 劣势:代码维护成本,服务器性能和维护成本增加

当然本篇文章不是来讲各种渲染方式的优缺点的,主要是说因为种种原因我们的项目最后使用了前端 JS 渲染的方式。而 JS 渲染带来的性能问题主要是由于数据接口请求返回以及前端 JS 资源获取所带来的网络问题。为了解决这两个问题,一方面我们采用了服务端将数据注入到页面全局变量中的方式避免了数据请求,另一方面我们使用了 localStorage 缓存的方式将前端资源做了 LS 缓存避免了二次打开之后的前端资源请求,从而提高了前端渲染的首屏性能。

思考优化方案

虽然我们避免了前端渲染的一些问题对首屏的性能做了优化,但还远远不够。那目前还有哪些点可以进行优化呢?简单的整理了下可以有如下两个方面:

  • 首次进入以及线上代码有更新之后还是需要下载前端资源
  • 服务端页面的 ttfb 相应还有优化的空间
  • 客户端 WebView 打开的速度和性能还有优化的空间

从上面两个优化点我们可以看到所有的优化还是网络的优化,主要还是在移动端网络对性能的影响是远远大于其他方面的。那么是否有什么方案能够让我们免去这些网络请求呢,最终我们给的答案就是详情页本地化。通过本地化方案,我们将平均 820ms 的首屏渲染时间优化到了 260ms,整整提高了三倍多

详情页本地化就是客户端不走网络请求打开新闻的方案,解决上文中列举的所有网络请求相关的优化点。它除了能为我们带来首屏性能的进一步提升之外,由于它不走网络请求的特性,也为我们解决了复杂网络环境下页面劫持导致的详情页白页打不开的问题。同时还为我们带来了无网络环境下的离线阅读新闻的能力。

本地化实现

由于我们的这面是纯 JS 渲染的,所以我们一个最终的详情页主要是由新闻数据静态页面两者构成的。
鉴于对服务端的依赖非常的少,和大部分的 SPA 页面一样,本质上只要在客户端将我们的前端页面提前下载下来就能正常打开了。

详情页 = 静态页面 + 新闻数据

数据预下发

而如何在用户还没有打开新闻之前客户端就能把我们的页面资源下载下来呢?这里就不得不提一下我们的场景,因为在我们的信息流场景中,用户永远是通过流点击进入到详情页中。而在客户端的流中是需要加载服务端数据的,所以在这个时候其实我们就可以告知客户端让其提前下载好模板。当然大家不要忘记,除了页面之外我们还要有新闻数据,为了实现纯离线化同时也避免新闻数据接口的请求,在列表中还会将每条新闻的详细数据下发下去,保证必备要素的本地化。

如图所示,在列表请求的接口中,服务端会将需要缓存的静态页面地址以及每条新闻对应的新闻数据全部下发给客户端,客户端接收到请求之后会进行模板的下载。

客户端行为

需要的东西下发下去之后剩下的就是客户端进行渲染了。正常来说除了模板页面之外,服务端还需要下载其他相关的静态资源,然后启动一个 HTTP 服务将页面和资源文件进行关联,关联之后将数据注入到页面之后打开页面。但这对客户端的要求就非常多了,为了将客户端的工作量降低,我们将所有需要使用的静态资源通过编译内联到 HTML 文件内,客户端通过字符串拼接的形式将数据注入到页面的全局变量中。

如图所示所有静态资源都被标记了 inline 属性,我们的编译工具在读取到这个属性后会将当前资源给内联到 HTML 中。同时大家注意到该模板不是以 <html> 开头的,而是有一些截断。这是为了给客户端提供注入数据空间,客户端通过模板字符串拼接的形式将新闻数据注入到全局变量中最终完成整个新闻页面的获取。前端代码中则直接使用 __INJECT_DATA_FROM_CLIENT_DONT_MODIFY__ 全局变量获取注入的数据。

页面的更新

上面就是一套完整的本地化下发并打开的流程了,总的来讲就分为四步:

  1. 前端将页面处理成真·单页应用
  2. 服务端在列表时将数据和本地化模板下载地址通过接口下发给客户端
  3. 客户端获取到模板下载地址后进行下载
  4. 当用户打开新闻的时候客户端将数据和模板进行拼接打开即可

但是只要有资源的分发就会涉及到资源的同步更新问题,我们的本地化模板也是一样。在我们的线上更新的时候如何让客户端知晓并触发更新行为,也是我们需要去考虑的问题。实际上大家在前两张截图中可以看到,为了解决这个问题,我们是在服务端下发的接口中还增加了一个 version 字段,用来标记当前 HTML 的版本。而当前端进行代码发布的时候,我们的发布系统会有一个类似 npm 的 postpublish 的钩子,利用这个钩子我们告诉服务端发布成功更新版本号。最后,当客户端接收到新的版本号的时候则会重新下载新的模板,完成一次本地模板的更新。

跨域问题

在前端页面中,Cookie 和 LocalStorage 等大量的特性是和域名相关的,而不巧的是我们的页面中都有使用,所以跨域也是我们需要考虑到的问题。我们知道,本质上此种方案下客户端相当于使用 WebView 打开了一个本地页面,而在 Android 系统中 WebView 打开本地页面的话有三种方法:

  • loadUrl:本质上使用 file:///temp.html 的形式打开一个本地文件 URL
  • loadData:和 loadUrl 类型,好的地方在于不需要写成文件,可以直接加载页面字符串,不过此时加载完之后页面的 URL 是 about:blank
  • loadDataWithBaseURL:和 loadData 类似,好的地方在于提供了参数能够设置当前 URL 地址

从描述中可以看到,很明显最后一种 loadDataWithBaseURL 才是我们需要的。客户端通过这个方法加载,设置当前页面的 URL 为真实线上 URL,对于前端来说基本上就和线上环境无异了,本地化和线上 Cookie 和 LocalStorage 的共享都没有问题。不过这里需要注意,第一个参数 baseUrl 仅能管住当前页面,如果页面做了 history.pushState() 等前进后退操作的话当前页面地址又会变成 about:blank,此时需要再设置最后一个参数 historyUrl 才行。

后记

本文给大家讲述了实现本地化离线阅读的方案。除了以上列举的问题,我们还碰到了一些细微的问题。例如我们发现在网络不好的情况下客户端可能会下载模板失败缓存了不完整的代码,所以我们增加了模板的 md5 值一并下发给客户端用来校验模板是否下载完全。又如上文说了模板的更新,实际上内容也会有更新,特别是一些新闻的实时性会有比较高的要求,为了解决这个问题,我们会在页面打开后再次去检查一下文章的状态,如果发生变量会切换至线上版本用来规避这个问题。除了这些之外我们还做了完备的云控后退方案,能在方案出问题的时候完美回退到普通版本。

其实大家可以看到,本地化只是我们在特定场景下决绝性能问题的一种特定思路。它并不是使用于所有的场景,所以我在文章开头也特别强调了一下我们的应用场景方便大家去理解。但是我们只要理解这种方案的精髓,我相信在其它的一些特定场合总能发挥它的威力。

查看原文

赞 33 收藏 22 评论 9

Athon 关注了问题 · 2019-05-15

webpack打包之后运行报错,求帮忙解决或者提供个思路?

问题描述

在项目中引用一个npm包,打包后会报Super expression must either be null or a function错误,
但这个包在其他项目中引用没有问题

问题出现的环境背景及自己尝试过哪些方法

dev可以,打包过程没有报错,打包后执行js就会报出Super expression must either be null or a function。尝试过检查拼写问题、react版本。现在怀疑是存在循环引用的情况

相关代码

// 请把代码文本粘贴到下方(请勿用图片代替代码)
//webpack.common.js

module.exports = {
entry: {

index: './src/Index.jsx',

},
output: {

path: path.resolve(__dirname, 'dist'),
filename: '[hash].[name].bundle.js',

},
module: {

rules: [
  {
    test: /\.(scss|css)$/,
    use: [
      process.env.NODE_ENV === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader,
      { loader: 'css-loader', options: { importLoaders: 1 } },
      'postcss-loader',
      'sass-loader',
    ],
  },
  {
    test: /\.less$/,
    use: ['style-loader', 'css-loader', 'less-loader'],
  },
  {
    test: /\.(png|svg|jpg|gif)$/,
    include: path.resolve(__dirname, 'src'),
    use: ['file-loader?name=assets/[name].[ext]'],
    exclude: /node_modules/,
  },
  {
    test: /\.(woff|woff2|eot|ttf|otf)$/,
    use: ['file-loader'],
    exclude: /node_modules/,
  },
  {
    test: /\.(js|jsx)$/,
    use: {
      loader: 'babel-loader',
    },
    exclude: /node_modules/,
  },
],

},
optimization: {

splitChunks: {
  chunks: 'async',
  minSize: 30000,
  minChunks: 1,
  maxAsyncRequests: 5,
  maxInitialRequests: 3,
  name: false,
  cacheGroups: {
    styles: {
      name: 'styles',
      test: /\.css$/,
      chunks: 'all',
      reuseExistingChunk: true,
      enforce: true,
    },
  },
},

},
resolve: {

extensions: ['.js', '.jsx', '.ts', '.tsc'],

},
plugins: [

new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
  title: '支付',
  template: './src/template.html',
  inject: 'body',
  chunks: ['index', 'styles'],
}),
new MiniCssExtractPlugin({
  // JS中的CSS -> 单独的文件中
  filename: '[id].[contenthash:12].css',
  chunkFilename: '[id].[contenthash:12].css',
}),

],
};

//webpack.prod.js

module.exports = merge(common, {
devtool: 'source-map',
plugins: [

new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify('production'),
  BASE_WEBSITE: JSON.stringify('/'),
  BASE_PREFIX: JSON.stringify('/payment/'),
  BASE_URL: JSON.stringify('/payment/payment-bff'),
  COMMON_LOGIN_URL: JSON.stringify('/login/?directBack=true'),
}),
new BundleAnalyzerPlugin(),

],
optimization: {

minimizer: [
  new UglifyJsPlugin({
    cache: true,
    parallel: true,
    sourceMap: true,
    uglifyOptions: {
      warnings: false,
      compress: {
        drop_console: true,
        pure_funcs: ['console.log'],
      },
    },
  }),
  new OptimizeCSSAssetsPlugin({}),
],

},
performance: {

hints: false,

},
mode: 'production',
});

你期待的结果是什么?实际看到的错误信息又是什么?

执行chunk.js后报错,希望大佬们给个思路或者解决方案

clipboard.png

关注 3 回答 1

Athon 赞了问题 · 2019-05-15

webpack打包之后运行报错,求帮忙解决或者提供个思路?

问题描述

在项目中引用一个npm包,打包后会报Super expression must either be null or a function错误,
但这个包在其他项目中引用没有问题

问题出现的环境背景及自己尝试过哪些方法

dev可以,打包过程没有报错,打包后执行js就会报出Super expression must either be null or a function。尝试过检查拼写问题、react版本。现在怀疑是存在循环引用的情况

相关代码

// 请把代码文本粘贴到下方(请勿用图片代替代码)
//webpack.common.js

module.exports = {
entry: {

index: './src/Index.jsx',

},
output: {

path: path.resolve(__dirname, 'dist'),
filename: '[hash].[name].bundle.js',

},
module: {

rules: [
  {
    test: /\.(scss|css)$/,
    use: [
      process.env.NODE_ENV === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader,
      { loader: 'css-loader', options: { importLoaders: 1 } },
      'postcss-loader',
      'sass-loader',
    ],
  },
  {
    test: /\.less$/,
    use: ['style-loader', 'css-loader', 'less-loader'],
  },
  {
    test: /\.(png|svg|jpg|gif)$/,
    include: path.resolve(__dirname, 'src'),
    use: ['file-loader?name=assets/[name].[ext]'],
    exclude: /node_modules/,
  },
  {
    test: /\.(woff|woff2|eot|ttf|otf)$/,
    use: ['file-loader'],
    exclude: /node_modules/,
  },
  {
    test: /\.(js|jsx)$/,
    use: {
      loader: 'babel-loader',
    },
    exclude: /node_modules/,
  },
],

},
optimization: {

splitChunks: {
  chunks: 'async',
  minSize: 30000,
  minChunks: 1,
  maxAsyncRequests: 5,
  maxInitialRequests: 3,
  name: false,
  cacheGroups: {
    styles: {
      name: 'styles',
      test: /\.css$/,
      chunks: 'all',
      reuseExistingChunk: true,
      enforce: true,
    },
  },
},

},
resolve: {

extensions: ['.js', '.jsx', '.ts', '.tsc'],

},
plugins: [

new CleanWebpackPlugin(['dist']),
new HtmlWebpackPlugin({
  title: '支付',
  template: './src/template.html',
  inject: 'body',
  chunks: ['index', 'styles'],
}),
new MiniCssExtractPlugin({
  // JS中的CSS -> 单独的文件中
  filename: '[id].[contenthash:12].css',
  chunkFilename: '[id].[contenthash:12].css',
}),

],
};

//webpack.prod.js

module.exports = merge(common, {
devtool: 'source-map',
plugins: [

new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify('production'),
  BASE_WEBSITE: JSON.stringify('/'),
  BASE_PREFIX: JSON.stringify('/payment/'),
  BASE_URL: JSON.stringify('/payment/payment-bff'),
  COMMON_LOGIN_URL: JSON.stringify('/login/?directBack=true'),
}),
new BundleAnalyzerPlugin(),

],
optimization: {

minimizer: [
  new UglifyJsPlugin({
    cache: true,
    parallel: true,
    sourceMap: true,
    uglifyOptions: {
      warnings: false,
      compress: {
        drop_console: true,
        pure_funcs: ['console.log'],
      },
    },
  }),
  new OptimizeCSSAssetsPlugin({}),
],

},
performance: {

hints: false,

},
mode: 'production',
});

你期待的结果是什么?实际看到的错误信息又是什么?

执行chunk.js后报错,希望大佬们给个思路或者解决方案

clipboard.png

关注 3 回答 1

认证与成就

  • 获得 282 次点赞
  • 获得 8 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-10-28
个人主页被 2.6k 人浏览