GrowingIO

GrowingIO 查看完整档案

北京编辑  |  填写毕业院校GrowingIO  |  技术VP 编辑 www.growingio.com/ 编辑
编辑

GrowingIO(官网网站www.growingio.com)的官方技术专栏,内容涵盖微服务架构,前端技术,数据可视化,DevOps,大数据方面的经验分享。

个人动态

GrowingIO 发布了文章 · 1月13日

GrowingIO Design 组件库搭建之开发工具

作者:潘杰
GrowingIO 工程经理,目前主要负责 GrowingIO Design 组件库搭建和微前端建设。

前言


在 20 世纪 60 年代,计算机技术开始超过软件编程的速度。计算机变得更快、更便宜,但是软件开发仍然缓慢,难以维护,并且容易出错。这种差距,以及如何应对,被称为“软件危机”。

1968 年,在北约软件工程会议上,Douglas McIlroy 提出了基于组件的开发作为解决这一困境的可能方法。基于组件的开发提供了一种通过使代码可重用来加快编程潜力的方法,从而使其更高效且更易于扩展。这降低了工作量,提高了软件开发的速度,使软件能够更好地利用现代计算机的力量。

现在,50 年后,我们正经历着类似的挑战,但这次是在设计方面。设计正在努力扩展它所支持的应用程序,因为设计仍然是针对单个问题的定制解决方案。你是否曾经执行过用户界面审计,发现你使用了几十种相似的蓝色色调,或者相同按钮的排列?将其乘以应用程序中的每个 UI,您就会开始意识到设计变得多么不一致、不完整和难以维护。

设计系统通过使设计可重复使用,使团队能够更快地制造更好的产品成为可能。这是设计系统的核心和主要价值。设计系统是可重用组件的集合,遵循明确的标准,可以将其组装在一起以构建任意数量的应用程序。

GrowingIO 产品研发团队也开始通过搭建自己的设计系统—— GrowingIO Design System,来提供交互体验一致的产品和提升研发效率。组件库做为设计系统的重要组成部分,前端团队通过系列文章来介绍如何一步步搭建组件库。本篇文章主要介绍组件库 GrowingIO Design 使用的开发工具,正所谓:工欲善其事,必先利其器。

TL;DR


本文按照工具的作用把工具分为:代码规范和代码管理两类,这两类主要用到的工具有:

  1. 代码规范工具

    a. ​Prettier​:用于代码格式化

    b. ​stylelint​:对样式文件( CSS 和 Less )进行规范检查

    c. ​ESLint​:对 ECMAScript/JavaScript 代码进行规范检查

  2. 代码管理工具

    a. ​Commitzen​:帮助编写规范的 commit message

    b. ​commitlint​:对 commit message 格式进行检查

    c. ​lint-stage​:只对 ​git​ 缓存的代码文件进行规范检查,提高速度

    d. ​husky​:整合所有的检查工具

husky 如何把检查工具都整合的,如下图所示:

image

其中:

  • pre-commit 阶段:执行 lint-staged ,会对暂存的文件执行 prettierstylelinteslint 三个命令。
  • commit-msg 阶段:执行 commitlint,检查 commit message 是否符合规范。

接下来,对这两类用到的工具进行详细介绍。

代码规范工具

代码规范相当于团队乃至公司的整个技术团队协作的契约,同时这些规范是经过许多项目实践出来的宝贵经验,可以在开发中少走很多的弯路。代码规范性靠人工在 Code Review 时候检查太费时费力,所以主要用工具来保障。这里主要使用的是 Prettierstylelint ESLint 三个工具。

Prettier

Prettier 是一个“有态度”的代码格式化工具,它支持:

  • JavaScript (包括实验性功能)
  • JSX
  • Angular
  • Vue
  • Flow
  • TypeScript
  • CSS、Less 和 SCSS
  • HTML
  • JSON
  • GraphQL
  • Markdown,包括 GFM 和 MDX
  • YAML

它移除了所有原始样式并确保输出的所有代码都符合一致的样式。它也会把代码行的长度也纳入考量,然后把代码重新打印出来。

例如,对于以下代码:

foo(arg1, arg2, arg3, arg4);

这段代码一行正合适,因此格式化后代码将保持原样。但是,我们都遇到过这种情况:

foo(reallyLongArg(), omgSoManyParameters(), IShouldRefactorThis(), isThereSeriouslyAnotherOne());

这段代码太长了,我们之前调用方法的格式就不适用了。Prettier 会做详尽的工作重新输出这段代码,像下面的代码一样:

foo(
  reallyLongArg(),
  omgSoManyParameters(),
  IShouldRefactorThis(),
  isThereSeriouslyAnotherOne()
);

安装

$ yarn add --dev --exact prettier

配置

在项目根目录创建配置文件:

$ echo {}> .prettierrc.js

下一步创建 .prettierignore 文件,用来告诉 Prettier CLI 或者编辑器哪些文件不需要代码格式化:

build
coverage

使用

运行一下命令来格式化现有代码:

$ yarn prettier --write .

这部分工作可以集成到 Git 流程中,详情见后文。

stylelint

stylelint 是一个强大的,现代的 linter,帮助你避免错误,并在你的风格中执行惯例。

它的强大之处:

  • 了解最新的 CSS 语法,包括自定义属性和 4 级选择器
  • HTMLmarkdown CSS-in-JS 对象和模板文字中提取嵌入样式
  • 解析类似 CSS 的语法,如 SCSS Sass LessSugarSS
  • 有超过 170个 内置规则来捕捉错误、应用限制和执行风格惯例
  • 支持插件,因此您可以创建自己的规则或使用社区编写的插件
  • 自动修复大多数风格违规
  • 通过超过 15000 个单元测试进行了良好的测试
  • 支持可扩展或创建的共享配置
  • 不固执己见,因此您可以根据自己的实际需要对其进行自定义
  • 拥有不断发展的社区,并被 Facebook、GitHub 和 WordPress 使用

安装
前文提到使用 Prettier 工具,为了和 Prettier 配置,这里额外安装 stylelint-config-prettier 插件:

$ yarn add --dev stylelint stylelint-config-standard stylelint-config-prettier

配置

在项目根目录创建配置文件 .stylelintrc.js

module.exports = {
  extends: ["stylelint-config-standard", "stylelint-config-prettier"],
};

使用

检查文件的命令:

$ npx stylelint --syntax less --fix

GrowingIO Design 使用 ​less​ 来编写样式,这里增加了 ​--syntax less​ 参数,通过 ​--fix​ 参数直接修改不符合规范的文件。

ESLint

ESLint 是在 ECMAScript/JavaScript 代码中识别和报告模式匹配的工具,它的目标是保证代码的一致性和避免错误。在许多方面,它和 JSLintJSHint 相似,除了少数的例外:

  • ESLint 使用 Espree 解析 JavaScript
  • ESLint 使用 AST 去分析代码中的模式;
  • ESLint 是完全插件化的,每一个规则都是一个插件并且你可以在运行时添加更多的规则。
Esprima 是用 ECMAScript 编写的高性能、标准兼容的 ECMAScript 解析器。

Espree 现在建立在 Acorn 之上,Acorn 具有模块化架构,允许扩展核心功能。Espree 的目标是通过类似的 API 产生类似于 Esprima 的输出,以便可以代替 Esprima 使用。

安装

$ yarn add --dev eslint

配置

这里用 ESLint 命令来初始化配置:

$ npx eslint --init
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · react
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · browser
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · airbnb
✔ What format do you want your config file to be in? · YAML
Checking peerDependencies of eslint-config-airbnb@latest
The config that you've selected requires the following dependencies:

eslint-plugin-react@^7.20.0 
@typescript-eslint/eslint-plugin@latest 
eslint-config-airbnb@latest 
eslint@^5.16.0 || ^6.8.0 || ^7.2.0 
eslint-plugin-import@^2.21.2 
eslint-plugin-jsx-a11y@^6.3.0 
eslint-plugin-react-hooks@^4 || ^3 || ^2.3.0 || ^1.7.0 
@typescript-eslint/parser@latest

同样,为了和 Prettier 配合,额外安装:

$ yarn add --dev eslint-plugin-prettier eslint-config-prettier

然后修改 .eslintrc.js 文件:

module.exports = {
  env: {
    browser: true,
    es2021: true,
    jest: true,
  },
  extends: ['plugin:react/recommended', 'airbnb', 'prettier', 'prettier/react'],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 12,
    sourceType: 'module',
  },
  plugins: ['react', '@typescript-eslint', 'prettier'],
  rules: {
    'prettier/prettier': 'error',
  },
};

使用

运行命令:

$ npx eslint --cache --fix
  • --cache :存储有关已处理文件的信息,以便仅对更改的文件进行操作。
  • --fix :对违反规则的代码,会根据规则进行修改。

最终效果

代码规范工具前面都介绍完了,把它们集成到 CI 流程中,如下图所示:
image
这里 CI 工具用的是 GitHub Actions,这样可以把结果注释到代码中,如下图所示:
image

代码管理工具

这部分主要介绍如何把前面介绍的代码规范工具集成到代码提交流程中,并且规范每次代码提交。本文以代码管理工具 Git 为例, Git 每次提交代码,都要写 Commit Message(提交说明),否则就不允许提交。格式化的 Commit Message,有几个好处:

  • 提供更多的历史信息,方便快速浏览。
  • 可以过滤某些 commit(比如文档改动),便于快速查找信息。
  • 可以直接从 commit 生成 Change log。

更多内容查看《Commit message 和 Change log 编写指南》文章。

Commitizen

​Commitizen 是一个撰写合格 Commit Message 的工具。

安装

命令如下:

$ yarn global add commitizen

然后,在项目目录里,运行下面的命令,使其支持规范的 Commit Message 格式:

$ commitizen init cz-conventional-changelog --yarn --dev --exact

使用

以后,凡是用到 git commit 命令,一律改为使用 git cz。这时,就会出现选项,用来生成符合格式的 Commit Message。

commitlint

commitlint 帮助您的团队遵守提交约定。通过支持npm安装的配置,可以轻松共享提交约定。

安装

yarn add --dev @commitlint/config-conventional @commitlint/cli

配置

创建 .lintstagedrc.js 文件:

module.exports = {
  "**/*": "prettier --write --ignore-unknown",
  "*.less": "stylelint --syntax less --fix",
  "*.(j|t)s?(x)": "eslint --cache --fix",
};

husky

husky 改善你的 ​commits​ 和更多 🐶 喔!您可以使用它来删除提交消息、运行测试、删除代码等。当您提交或推送时。husky 支持所有的 git hooks

安装

$ yarn add --dev husky

配置

这里通过配置 git hooks 把前文提到的工具全都串起来!

创建 .huskrc.js 文件:

module.exports = {
  hooks: {
    'pre-commit': 'lint-staged',
    'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS',
  },
};

使用

以后只要执行 git cz 后,就可以看到以下信息:

$ git cz
...
husky > pre-commit (node v12.20.0)
✔ Preparing...
✔ Running tasks...
✔ Applying modifications...
✔ Cleaning up...
husky > commit-msg (node v12.20.0)
...

小结


文章开头介绍了设计系统通过设计可重复使用,让团队能够更快更好地研发出产品。这也是 GrowingIO 产品研发团队搭建组件库的主要原因。后续详细介绍了组件库用到的开发工具,可以做为大家在搭建组件库时候的一份参考。

参考

查看原文

赞 1 收藏 1 评论 0

GrowingIO 发布了文章 · 2020-12-30

Bitmap 续篇-基于 Bitmap 瞅瞅不一样的 Percentile

作者:杨承波
GrowingIO 大数据工程师,主要负责 SaaS 分析和广告模块的技术设计与开发,目前专注于 GrowingIO 物化视图引擎的建设。

本篇文章主要讲解基于 GrowingIO 内部数据存储结构 Bitmap实现的 Percentile,并简单介绍一下 Hive Percentile,Spark Percentile,Bitmap Percentile 之间的差异。

讲解具体函数算法实现之前,先搞清楚下面两个问题:

  • Percentile 函数【以下简称:分位数】,在实际应用场景下的作用是什么?
  • 今天你是否还想起,Bitmap 是什么东西?

1. 分位数是个什么玩意儿🔢

1.1 分位数

😱分位数是啥?

针对一个从小到大的有序集合,找一个数来将集合按分位对应比例拆解成两个集合。

注意:分位数并非是指数据集合中的某个元素,而是找到一个数来拆解集合

🤔来点儿晕头转向的东西,90 分位怎么出?

这个数将样本集合分成左右两个集合,而且左边部分的集合占 90%,右边部分占 10%。

①元素个数为奇数的集合如何求 90 分位数?

原始集合:【35, 40, 41, 44, 45, 46, 49, 50, 53, 55, 58】

拆解集合:【35, 40, 41, 44, 45, 46, 49, 50, 53】, 55, 【58】

左边集合元素个数:9

右边集合元素个数:1

正好在原始集合中有这个数能将样本按 90 分位比例拆解成两个集合

所以上面样本的 90 分位是 55

②如果换成元素个数为偶数的集合呢?

原始集合:【40, 41, 44, 45, 46, 49, 50, 53, 55, 58】

拆解集合:【40, 41, 44, 45, 46, 49, 50, 53, 55】, X, 【58】

这个数存在于 【55 → 58】

X =  58 - (58 - 55) * 0.9 = 55.3

或 X =  55 + (58 - 55) * (1 - 0.9) = 55.3

90 分位含义:有90%的数据小于等于 55.3,有 10% 的数据大于等于 55.3

1.2 在业务场景中的应用

👉现有【订单支付成功】事件,获取过去七天每个用户做过该事件总次数的 90 分位值,如下图:

image

👉还是【订单支付成功】事件,获取昨天每个用户在该事件下实际购买金额的总和,求 75 分位值,如下图:

image

2.Percentile 哪家强?

2.1 性能对比

环境准备:Core[16], 内存 2G

对比测试:[基于 Bitmap 实现的 Percentile] VS [SparkSQL 内置 Percentile_approx]

场景:一定数量的用户以及随机生成对应的 count,随机生成分位进行计算分位数,获取百次平均消耗

image
x 轴含义: 数据量

y 轴含义: 计算时间, 单位毫秒。

  1. SparkSQL 中 Percentile 仅支持 Int, Long 数据类型,这里为通用考虑,使用 Percentile_approx 进行对比
  2. 以上 Bitmap 存储数据与 Percentile_approx 处理数据完全一致
2.2 Hive Percentile

Hive Sql 中 Percentile 求解时针对的是一列进行操作,即表里的某一个字段,面对动不动几千万的数据处理,如果把每条数据全都加载到内存中,结局只有一个——卡死。

所以 Hive 需要在 UDAF 的计算中将数据进行压缩或预处理,那么 Mapper 是需要在生成时不断通过聚合计算更新,其内部实现基于 histogram。

Hive 的 percentile_approx 实现灵感出自《A Streaming Parallel Decision Tree Algorithm》,这篇论文提出 On-line Histogram Building 算法。

什么是 histogram?定义如下:

A histogram is a set of B pairs (called bins) of real numbers {(p1,m1),...,(pB,mB)}, where B is a preset constant integer.

在 histogram 的定义里面,有一个用于标识 bins 数量的常量 B。为何一定要引入这个常量?假设我们有一个简单的 sample 数据集(元素可重复):

[1, 1, 1, 2, 2, 2, 3, 4, 4, 5, 6, 7, 8, 9, 9, 10, 10]

其 histogram 为:[(1, 3), (2, 3), (3, 1), (4, 2), (5, 1), (6, 1), (7, 1), (8, 1), (9, 2), (10, 2)]。

可以看出,这个 histogram 内部的 bins(数据点和频率一起构成的 pair) 数组长度实质上就是 sample 数据集的基数(不同元素的个数)。

histogram 的存储开销会随着 sample 数据集的基数线性增长,这意味着如果不做额外的优化,histogram 将无法适应超大规模数据集合的统计需求。常量 B 就是在这种背景下引入的,其目的在于控制 histogram 的 bins 数组长度(内存开销)。

Hive 的 Percentile_approx 由 GenericUDAFPercentileApprox 实现,其核心实现是在 histogram 的 bins 数组前面加上一个用于标识分位点的序列。其 merge 操作结果仅保留 histogram 序列,最后从 histogram 计算结果,源码如下:

/**
 * Gets an approximate quantile value from the current histogram. Some popular
 * quantiles are 0.5 (median), 0.95, and 0.98.
 *
 * @param q The requested quantile, must be strictly within the range (0,1).
 * @return The quantile value.
 */
 public double quantile(double q) {
 assert(bins != null && nusedbins > 0 && nbins > 0);
 double sum = 0, csum = 0;
 int b;
 for(b = 0; b < nusedbins; b++)  {
 sum += bins.get(b).y;
 }
 for(b = 0; b < nusedbins; b++) {
 csum += bins.get(b).y;
 if(csum / sum >= q) {
 if(b == 0) {
 return bins.get(b).x;
 }
 csum -= bins.get(b).y;
 double r = bins.get(b-1).x +
 (q*sum - csum) * (bins.get(b).x - bins.get(b-1).x)/(bins.get(b).y);
 return r;
 }
 }
 return -1; // for Xlint, code will never reach here
 }
2.3 Spark Percentile
Percentile
  • 仅接受 Int, Long,精确计算,底层用 OpenHashMap 计数,然后排序 key。

OpenHashMap 为了加快速度,增加了一个假设:

  • 所有数据只插入 Key /更新 Key,不删除 Key。
  • 这个假设在大数据处理/统计的场景下,大多都是成立的。
  • 可以去掉拉链表,使用线性探测的开放定址法来实现哈希表。

OpenHashMap 底层数据为 OpenHashSet,所以本质上是看 OpenHashSet 为啥快。

OpenHashSet 中用 BitSet (位图)来存储是否存在于集合中(位运算),另一个数组存储实际数据,结构如下:

protected var _bitset = new BitSet(_capacity)
protected var _data: Array[T] = _
 _data = new Array[T](_capacity)
  • 俩成员始终保持等长,_bitset 的下标 x 位置为 1 时,则 _data 的下标 x 位置中就有实际数据。
  • 插入数据时,hash(key) 生成 pos,看 _bitset 中对应 pos 是否被占用,有则 ++pos。

OpenHashSet 快的原因:

  1. 内存利用率高: 去掉了 8B 的指针结构,能够创建更大的哈希表,冲突减少。
  2. 内存紧凑: 位图操作快。
Percentile_approx
  • 接受 Int, Long, Double,近似计算。使用 GK 算法。

论文参见《Space-efficient Online Computation of Quantile Summaries

底层实现通过 QuantileSummaries 实现,主要有两个成员变量:

sample: Array[Stat] : 存放桶,超过1000个桶的时候就压缩(生成新的三元组);
headSampled: ArrayBuffer[Double]:缓冲区,每次达到5000个,就排序后更新到sample.

主要思想是减少空间占用,spark 实现 merge sample 时甚至未处理 samples 已经有序,直接 sortBy:

// TODO: could replace full sort by ordered merge, the two lists are known to be sorted already.
 val res = (sampled ++ other.sampled).sortBy(_.value)
 val comp = compressImmut(res, mergeThreshold = 2 * relativeError * count)
 new QuantileSummaries(other.compressThreshold, other.relativeError, comp, other.count + count)

Stat 的定义:

/**
 * Statistics from the Greenwald-Khanna paper.
 * @param value the sampled value
 * @param g the minimum rank jump from the previous value's minimum rank
 * @param delta the maximum span of the rank.
 */
 case class Stats(value: Double, g: Int, delta: Int)

插入函数:每 N 个数,排序至少 1 次(merge 还有 1 次),时间复杂度 O(NlogN):

def insert(x: Double): QuantileSummaries = {
 headSampled += x
 if (headSampled.size >= defaultHeadSize) {
 val result = this.withHeadBufferInserted
 if (result.sampled.length >= compressThreshold) {
 result.compress()
 } else {
 result
 }
 } else {
 this
 }
 }

获取结果: 时间复杂度 O(n)

// Target rank
 val rank = math.ceil(quantile * count).toInt
 val targetError = math.ceil(relativeError * count)
 // Minimum rank at current sample
 var minRank = 0
 var i = 1
 while (i < sampled.length - 1) {
 val curSample = sampled(i)
 minRank += curSample.g
 val maxRank = minRank + curSample.delta
 if (maxRank - targetError <= rank && rank <= minRank + targetError) {
 return Some(curSample.value)
 }
 i += 1
 }
2.4 BitMap 小插曲

干饭人,你是否还记得凯哥之前的分享《GrowingIO 基于 BitMap 的海量数据分析

这里只是简单回顾 CBitmap 和补充一丢丢权重相关的东西

非数值型指标 Bitmap 怎么存储?

这里只用单个事件 + 单个维度分析,多维度的可参考之前的分享文档,中华传统功夫以点到为止。

image

生成 CBitmap 统计结果。

CBitmap: Map(short → Map(dimsSortId →  RoaringBitmap(uid)))

image

数值型指标 Bitmap 怎么存储?

上面我们讲述的都是统计的某个指标发生的次数比较小的整数类型数据,那么问题来了:

①当我统计的结果不是个整数呢,换句话说,我们统计的指标是订单金额呢?

②当我统计的结果都是整数,但是吧,有些东西喜欢按”K”作为单位,用你的小脑袋瓜想一想,统计结果中次数从 0 → 1024【bit位 0 → 10】就存了个寂寞

计算精度,期望对所有值统计出一个公约数,从而减少存储量,这里引出权重 Weight 的概念:

例如
 对于 100,200,300, 可以提出一个 100 作为公约数,只保存 1 2 3,
 同理 0.01 0.02 0.03 也可以提出 0.01
以下部分不再详细讲解,有兴趣可以瞅瞅
/**
 * 但是我们实现无法知道数据的分布,只能预估一个值,具体预估流程:
 *   1. 计算一个 high 和 low, high 可以认为是求 log10 + 1,也就是和 1 差的数量级,
 *      low 可以认为是保证精度在 1e-4(精度可以修改) 以内至少要保留的位数,对于整数来说 low = 0
 *   2. 根据所有的 high 和 low,统计一个相对合理的 high 和 low,只要这个 high 对应的数据占比高于平均占比的 1/10 即可
 *   3. high 代表了最大需要将数据放大多少个数量级,low 代表最小可以将数据缩小多少个数量级,
 *      求一个折中值 Math.max(high-n, -1*low) 这里的 n 会影响整数的精度,一开始是 6,后来改为了 7
 *
 * 例如有一系列数字:
 *     10,000 100 10 0.1 0.01
 * 可以求得对应的 high 和 low:
 *     5       3   2  -1  -2
 *     0       0   0   1   2
 * 然后求得合并的 high = 5, low = 2
 * 最后得到 weight = 0.1(n=6), 0.01(n=7)
 *
 * 再举一个极端的例子:
 *     10000...(20个0) 0.00000...1(20个0)
 * 求得的 high 和 low:
 *     21           -21
 *     0             0
 * 求得合并的 high = 21, low = 0
 * 合并得到 weight= 100...(21-7个0)
 */
2.5 Bitmap 下实现 Percentile

计算逻辑

  1. 将 n 个变量从小到大排序为数组 x, p 是分位,x[i] 表示 x 数组的第 i 个元素
  2. 设(n - 1) * p% = integerPart + decimalPart,integerPart 为整数部分,decimalPart 为小数部分,这里的 integerPart 其实是 x 的数组下标,是从 0 开始的,所以 i = integerPart + 1
  3. 当 decimalPart  = 0时,分位数 = x[i]
  4. 当 decimalPart != 0时,分位数 = x[i] + decimalPart * (x[i+1] - x[i])

如果忽略 dimsSortId 的存在,得到一个新的 CBitmap 结构:

CBitmap:

Map(short → Map(dimsSortId →  RoaringBitmap(uid)))

转化为 Map(short → RoaringBitmap(uid))

之前生成事件指标的Cbitmap如下:

{

1 → {0 → (1), 1 → (1)},

0 → {0 → (2, 3, 4)}

}

转化后的CBitmap如下:

{

1 → (1),

0 → (2, 3, 4)

}

Bitmap 分位数到底咋算

🤔 给点数据,给个需求,先来个简单的,数据如下:

cBitmap = 
{
 3 -> (1001, 1006)
 2 -> (1003, 1005, 1006)
 1 -> (1004)
 0 -> (1001, 1002, 1003)
}
求这个CBitmap的75分位数?

🤔 把每个用户对应的 cnt 按照顺序拿出来,再按照公式求分位数?

cBitmap转成cnt从小到大排序后的映射关系(uid -> cnt)
【
 (1002 -> 1), 
 (1004 -> 2),
 (1005 -> 4),
 (1003 -> 5),
 (1001 -> 9),
 (1006 -> 12)
】
75分位数求解:
x = (1, 2, 4, 5, 9, 12)
(6 - 1) * 0.75 = 3.75
分位数 = x[i] + decimalPart * (x[i+1] - x[i]) = 5 + 0.75 * (9 - 5) = 8

看似木得问题,实则慢得一匹。。。

从上到下依次遍历每一个 C 位获取每一个用户对应的 cnt,得到 cnt 的排序数组,最后再根据公式才能求出结果。

🤔 简单点儿,求解的方式简单点儿?

既然 CBitmap 本身就是计次且有序的,为啥不充分利用起来?

对于 cbitmap 求分位数,前提就是获取排序后第 i 个人和第 i + 1 个人对应的 cnt 数

① 计算终究需要知道 integerPart 是多少?

(6 - 1) * 0.75 = 3.75

i = 3 且有小数部分,需要获取 x[i] 和 x[i+1]

② 由于 cbitmap 中高位的数据一定比低位的数据大,所以 cbitmap 计算第 i 个人可以从高位开始遍历排除数据,拿到第 i 个人的 c 位

  • 先记录几个必要的变量

totalRbm = cbitmap 去重后用户集合

currIdx = 当前遍历的 c 位

currRbm = 当前指针位置对应的 roaringBitmap

persistRbm = 一定是小于当前指针位置的这部分用户 = totalRbm andNot currRbm

preDiscardRbm = 本次遍历准备排除的高位的用户 = totalRbm and currRbm

cBitmap = 
{
 3 -> (1001, 1006)
 2 -> (1003, 1005, 1006)
 1 -> (1004)
 0 -> (1001, 1002, 1003)
}
totalRbm = (1001, 1002, 1003, 1004, 1005, 1006)
currIdx -> currRbm = 3 -> (1001, 1006)
persistRbm = (1002, 1003, 1004, 1005)
preDiscardRbm = (1001, 1006)
  • 排除算法,找到第 i 个用户的 c 位
  1. 如果 persistRbm 的人数 >= i,说明我们要找的第 i 个人还在 persistRbm 中,并将 persistRbm 置为 totalRbm, currIdx -= 1,重新计算那几个重要变量进入排除算法。
  2. 如果 persistRbm 的人数 < i,说明第 i 个人就在当前的 preDiscardRbm 中,此时需要一个 resCnt 记录当前 currIdx 对应的 count 值【1 << currIdx】,如果之前有值,则需要累加。

1) 下一步是在 perDiscardRbm 中找到需要的那个人,新的位置 =  之前的 i - persistRbm 人数。

2) 并将 preDiscardRbm 置为 totalRbm, currIdx -= 1,重新计算重要变量进入排除算法。

  1. 遍历完 c 位,即 currIdx = 0 时,累加的 resCnt 即为 x[i] 的结果。
  • 来吧,展示:

image

  • 当还需要获取第 i+1 个位置的用户是否还得重新遍历一次?

将接收变量换成数组,在排除算法中一次遍历获取第 i 个用户和第 i+1 个用户,只需要考虑以下两个情形:

1)当第 i 个用户和第 i+1 个用户在同一 cnt 位上,则后续排除算法的判断逻辑无差异。

2)当出现第 i+1 个用户在 currIdx,而第 i 个用户在 currIdx - 1时,导致 totalRbm 不一致,需要分开进行计算。

  • 最后整合 x[i] 和 x[i+1] 的结果,得到分位数:

分位数 = (x[i] + decimalPart (x[i+1] - x[i])) weight

总结

本篇主要揭晓基于 BitMap 来作为底层的数据模型实现的 Percentile 算法的优势,Bitmap 的高度压缩在存储方面带来巨大优势的同时,还能根据其数据结构灵活计算统计数据,快速计算许多类似 Percentile 的需求。

BitMap 还有很多的扩展性和亮点,下面列举几个,敬请期待:

  • BitMap 黑科技:避免反序列化使用字节进行 BitMap 运算
  • BitMap 转置算法:不一样的 count 求解方式

参考资料:

  1. https://www.jianshu.com/p/e27...
  2. https://xiaoyue26.github.io/2...
  3. https://www.jmlr.org/papers/v...
  4. http://dx.doi.org/10.1145/375...

关于 GrowingIO

GrowingIO 是国内领先的一站式数字化增长整体方案服务商。为产品、运营、市场、数据团队及管理者提供客户数据平台、广告分析、产品分析、智能运营等产品和咨询服务,帮助企业在数字化转型的路上,提升数据驱动能力,实现更好的增长。

点击「此处」,获取 GrowingIO 15 天免费试用!

查看原文

赞 0 收藏 0 评论 0

GrowingIO 发布了文章 · 2020-11-23

iOS AOP 方案的对比与思考

AOP 思想

AOP:Aspect Oriented Programming,译为面向切面编程,是可以通过预编译的方式和运行期动态实现,在不修改源代码的情况下,给程序动态统一添加功能的技术。

面向对象编程(OOP)适合定义从上到下的关系,但不适用于从左到右,计算机中任何一门新技术或者新概念的出现都是为了解决一个特定的问题的,我们看下AOP解决了什么样的问题。

例如一个电商系统,有很多业务模块的功能,使用OOP来实现核心业务是合理的,我们需要实现一个日志系统,和模块功能不同,日志系统不属于业务代码。如果新建一个工具类,封装日志打印方法,再在原有类中进行调用,就增加了耦合性,我们需要从业务代码中抽离日志系统,然后独立到非业务的功能代码中,这样我们改变这些行为时,就不会影响现有业务代码。

当我们使用各种技术来拦截方法,在方法执行前后做你想做的事,例如日志打印,就是所谓的AOP。

主流的AOP 方案

Method Swizzle

说到iOS中AOP的方案第一个想到的应该就是 Method Swizzle

得益于Objective-C这门语言的动态性,我们可以让程序在运行时做出一些改变,进而调用我们自己定义的方法。使用Runtime 交换方法的核心就是:method_exchangeImplementations, 它实际上将两个方法的实现进行交换:

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class aClass = [self class];
        
        SEL originalSelector = @selector(method_original:);
        SEL swizzledSelector = @selector(method_swizzle:);
        
        Method originalMethod = class_getInstanceMethod(aClass, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(aClass, swizzledSelector);
        BOOL didAddMethod = class_addMethod(aClass,
                                                                originalSelector,
                                                              method_getImplementation(swizzledMethod),
                                                                method_getTypeEncoding(swizzledMethod));
        
        if (didAddMethod) {
            class_replaceMethod(aClass,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

作为我们常说的黑魔法 Method Swizzle 到底危险不危险,有没有最佳实践。

这里可以通过这篇回答一起深入理解下。这里列出了一些 Method Swizzling 的陷阱:

  • Method swizzling is not atomic

你会把 Method Swizzling 修改方法实现的操作放在一个加号方法 +(void)load 里,并在应用程序的一开始就调用执行,通常放在 dispatch_once() 里面来调用。你绝大多数情况将不会碰到并发问题。

  • Changes behavior of un-owned code

这是 Method Swizzling 的一个问题。我们的目标是改变某些代码。当你不只是对一个UIButton类的实例进行了修改,而是程序中所有的UIButton实例,对原来的类侵入较大。

  • Possible naming conflicts

命名冲突贯穿整个 Cocoa 的问题. 我们常常在类名和类别方法名前加上前缀。不幸的是,命名冲突仍是个折磨。但是swizzling其实也不必过多考虑这个问题。我们只需要在原始方法命名前做小小的改动来命名就好,比如通常我们这样命名:

@interface UIView : NSObject
- (void)setFrame:(NSRect)frame;
@end
 
@implementation UIView (MyViewAdditions)
 
- (void)my_setFrame:(NSRect)frame {
    // do custom work
    [self my_setFrame:frame];
} 

+ (void)load {
    [self swizzle:@selector(setFrame:) with:@selector(my_setFrame:)];
}
  
@end

这段代码运行是没问题的,但是如果 my_setFrame: 在别处被定义了会发生什么呢?比如在别的分类中,当然这个问题不仅仅存在于swizzling 中,其他地方也可能会出现,这里可以有个变通的方法,利用函数指针来定义

@implementation UIView (MyViewAdditions)
 
static void MySetFrame(id self, SEL _cmd, NSRect frame);
static void (*SetFrameIMP)(id self, SEL _cmd, NSRect frame);
 
static void MySetFrame(id self, SEL _cmd, NSRect frame) {
    // do custom work
    SetFrameIMP(self, _cmd, frame);
}
 
+ (void)load {
    [self swizzle:@selector(setFrame:) with:(IMP)MySetFrame store:(IMP *)&SetFrameIMP];
}
 
@end
  • Swizzling changes the method's arguments

我认为这是最大的问题。想正常调用 Method Swizzling 的方法将会是个问题。比如我想调用 my_setFrame:

[self my_setFrame:frame];

Runtime 做的是 objc_msgSend(self, @selector(my_setFrame:), frame); Runtime去寻找my_setFrame:的方法实现,但因为已经被交换了,事实上找到的方法实现是原始的 setFrame: 的,如果想调用 Method Swizzling 的方法,可以通过上面的函数的方式来定义,不走Runtime 的消息发送流程。不过这种需求场景很少见。

  • The order of swizzles matters

多个swizzle方法的执行顺序也需要注意。假设 setFrame: 只定义在 UIivew 中,想像一下按照下面的顺序执行:

[UIView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
[UIControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[UIButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];

这里需要注意的是swizzle的顺序,多个有继承关系的类的对象swizzle时,先从父对象开始。 这样才能保证子类方法拿到父类中的被swizzle的实现。在+(void)load中swizzle不会出错,就是因为load类方法会默认从父类开始调用,不过这种场景很少,一般会选择一个类进行swizzle。

  • Difficult to understand (looks recursive)

新方法的实现里面会调用自己同名的方法,看起来像递归,但是看看上面已经给出的 swizzling 封装方法, 使用起来就很易读懂,这个问题是已完全解决的了!

  • Difficult to debug

调试时不管通过bt 命令还是 [NSThread callStackSymbols] 打印调用栈,其中掺杂着被swizzle的方法名,会显得一团槽!上面介绍的swizzle方案,使backtrace中打印出的方法名还是很清晰的。但仍然很难去debug,因为很难记住swizzling影响过什么。给你的代码写好文档(即使只有你一个人会看到),统一管理一些swizzling的方法,而不是分散到业务的各个模块。相对于调试多线程问题 Method Swizzling 要简单很多。

Aspects

Aspects 是 iOS 上的一个轻量级 AOP 库。它利用 Method Swizzling 技术为已有的类或者实例方法添加额外的代码,使用起来是很方便:

/// Adds a block of code before/instead/after the current `selector` for a specific class.
+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
                           withOptions:(AspectOptions)options
                            usingBlock:(id)block
                                     error:(NSError **)error;

/// Adds a block of code before/instead/after the current `selector` for a specific instance.
- (id<AspectToken>)aspect_hookSelector:(SEL)selector
                               withOptions:(AspectOptions)options
                                   usingBlock:(id)block
                                     error:(NSError **)error;

Aspects 提供了2个 AOP 方法,一个用于类,一个用于实例。在确定 hook 的 方法之后, Aspects 允许我们选择 hook 的时机是在方法执行之前,还是方法执行之后,甚至可以直接替换掉方法的实现。网上有很多介绍其实现原理的文章,在iOS开源社区中算是少有的精品代码,对深入理解掌握ObjC 的消息发送机制很有帮助。但其存在的缺陷就是性能较差,如官方所说

Aspects uses Objective-C message forwarding to hook into messages. This will create some overhead. Don't add aspects to methods that are called a lot. Aspects is meant for view/controller code that is not called a 1000 times per second.

Aspects hooks deep into the class hierarchy and creates dynamic subclasses, much like KVO. There's known issues with this approach, and to this date (February 2019) I STRICTLY DO NOT RECOMMEND TO USE Aspects IN PRODUCTION CODE. We use it for partial test mocks in, PSPDFKit, an iOS PDF framework that ships with apps like Dropbox or Evernote, it's also very useful for quickly hacking something up.

官方强烈不推荐在生产环境中使用,一般用来在单测中做一些mock操作。我们这边的性能测试也证明了这一点:在iPhone 6 真机上,循环100w次的方法调用(已经通过 Aspects hook 的方法)中会直接报 Terminated due to memory issue crash 错误信息。

MPSwizzler

MPSwizzler 这个是开源数据分析SDK MixPanel 中采用的一种 AOP 方案,原理不是很复杂,主要还是基于ObjC 的运行时。

  1. 支持运行时取消对应的hook,这里可以满足一些需求场景的
  2. 通过 block 的方式来执行方法块,避免方法命名的冲突
+ (void)swizzleSelector:(SEL)aSelector onClass:(Class)aClass withBlock:(swizzleBlock)aBlock named:(NSString *)aName
{
    Method aMethod = class_getInstanceMethod(aClass, aSelector);
    if (aMethod) {
        uint numArgs = method_getNumberOfArguments(aMethod);
        if (numArgs >= MIN_ARGS && numArgs <= MAX_ARGS) {
                
            // 判断该方法是否在自己类的方法列表中,而不是父类
            BOOL isLocal = [self isLocallyDefinedMethod:aMethod onClass:aClass];
            IMP swizzledMethod = (IMP)mp_swizzledMethods[numArgs - 2];
            MPSwizzle *swizzle = [self swizzleForMethod:aMethod];
                
            if (isLocal) {
                if (!swizzle) {
                    IMP originalMethod = method_getImplementation(aMethod);
                        
                    // Replace the local implementation of this method with the swizzled one
                    method_setImplementation(aMethod,swizzledMethod);
                        
                    // Create and add the swizzle
                    swizzle = [[MPSwizzle alloc] initWithBlock:aBlock named:aName forClass:aClass selector:aSelector originalMethod:originalMethod withNumArgs:numArgs];
                    [self setSwizzle:swizzle forMethod:aMethod];
                        
                } else {
                    [swizzle.blocks setObject:aBlock forKey:aName];
                }
            } else {
            // 如果是父类的方法会添加到自身,避免对父类侵入
                IMP originalMethod = swizzle ? swizzle.originalMethod : method_getImplementation(aMethod);
                    
                // Add the swizzle as a new local method on the class.
                if (!class_addMethod(aClass, aSelector, swizzledMethod, method_getTypeEncoding(aMethod))) {
                    NSAssert(NO, @"SwizzlerAssert: Could not add swizzled for %@::%@, even though it didn't already exist locally", NSStringFromClass(aClass), NSStringFromSelector(aSelector));
                    return;
                }
                // Now re-get the Method, it should be the one we just added.
                Method newMethod = class_getInstanceMethod(aClass, aSelector);
                if (aMethod == newMethod) {
                    NSAssert(NO, @"SwizzlerAssert: Newly added method for %@::%@ was the same as the old method", NSStringFromClass(aClass), NSStringFromSelector(aSelector));
                    return;
                }
                    
                MPSwizzle *newSwizzle = [[MPSwizzle alloc] initWithBlock:aBlock named:aName forClass:aClass selector:aSelector originalMethod:originalMethod withNumArgs:numArgs];
                [self setSwizzle:newSwizzle forMethod:newMethod];
            }
        } else {
            NSAssert(NO, @"SwizzlerAssert: Cannot swizzle method with %d args", numArgs);
        }
    } else {
        NSAssert(NO, @"SwizzlerAssert: Cannot find method for %@ on %@", NSStringFromSelector(aSelector), NSStringFromClass(aClass));
    }
}

其中最主要的就是 method_setImplementation(aMethod,swizzledMethod); 其中 swizzledMethod 是根据原来方法的参数匹配到对应的如下几个函数:

  1. static void mp_swizzledMethod_2(id self, SEL _cmd)
  2. static void mp_swizzledMethod_3(id self, SEL _cmd, id arg)
  3. static void mp_swizzledMethod_4(id self, SEL _cmd, id arg, id arg2)
  4. static void mp_swizzledMethod_5(id self, SEL _cmd, id arg, id arg2, id arg3)

这个几个函数内部实现大体一样的,以 mp_swizzledMethod_4 为例:

static void mp_swizzledMethod_4(id self, SEL _cmd, id arg, id arg2)
{
    Method aMethod = class_getInstanceMethod([self class], _cmd);
    // 1. 获取保存hook 的实体类
    MPSwizzle *swizzle = (MPSwizzle *)[swizzles objectForKey:(__bridge id)((void *)aMethod)];
    if (swizzle) {
    // 2. 先调用原来的方法
        ((void(*)(id, SEL, id, id))swizzle.originalMethod)(self, _cmd, arg, arg2);

        NSEnumerator *blocks = [swizzle.blocks objectEnumerator];
        swizzleBlock block;
    // 3. 再循环调用 hook 的方法块,可能绑定了多个
        while ((block = [blocks nextObject])) {
            block(self, _cmd, arg, arg2);
        }
    }
}

这个AOP的方案在多数SDK中也均采用了,比如 FBSDKSwizzlerSASwizzler,相比于Aspects 性能好太多、但与 朴素的 Method Swizzling 相比还有差距。

ISA-swizzle KVO

利用 KVO 的运行时 ISA-swizzle 原理,动态创建子类、并重写相关方法,并且添加我们想要的方法,然后在这个方法中调用原来的方法,从而达到 hook 的目的。这里以 ReactiveCocoa 的作为示例。


internal func swizzle(_ pairs: (Selector, Any)..., key hasSwizzledKey: AssociationKey<Bool>) {

        // 动态创建子类
        let subclass: AnyClass = swizzleClass(self)

        ReactiveCocoa.synchronized(subclass) {
            let subclassAssociations = Associations(subclass as AnyObject)

            if !subclassAssociations.value(forKey: hasSwizzledKey) {
                subclassAssociations.setValue(true, forKey: hasSwizzledKey)

                for (selector, body) in pairs {
                    let method = class_getInstanceMethod(subclass, selector)!
                    let typeEncoding = method_getTypeEncoding(method)!

                    if method_getImplementation(method) == _rac_objc_msgForward {
                        let succeeds = class_addMethod(subclass, selector.interopAlias, imp_implementationWithBlock(body), typeEncoding)
                        precondition(succeeds, "RAC attempts to swizzle a selector that has message forwarding enabled with a runtime injected implementation. This is unsupported in the current version.")
                    } else {    
                        // 通过 block 生成一个新的 IMP,为生成的子类添加该方法实现。
                        let succeeds = class_addMethod(subclass, selector, imp_implementationWithBlock(body), typeEncoding)
                        precondition(succeeds, "RAC attempts to swizzle a selector that has already a runtime injected implementation. This is unsupported in the current version.")
                    }
                }
            }
        }
    }

internal func swizzleClass(_ instance: NSObject) -> AnyClass {
    if let knownSubclass = instance.associations.value(forKey: knownRuntimeSubclassKey) {
        return knownSubclass
    }

    let perceivedClass: AnyClass = instance.objcClass
    let realClass: AnyClass = object_getClass(instance)!
    let realClassAssociations = Associations(realClass as AnyObject)

    if perceivedClass != realClass {
        // If the class is already lying about what it is, it's probably a KVO
        // dynamic subclass or something else that we shouldn't subclass at runtime.
        synchronized(realClass) {
            let isSwizzled = realClassAssociations.value(forKey: runtimeSubclassedKey)
            if !isSwizzled {
                // 重写类的 -class 和 +class 方法,隐藏真实的子类类型
                replaceGetClass(in: realClass, decoy: perceivedClass)
                realClassAssociations.setValue(true, forKey: runtimeSubclassedKey)
            }
        }

        return realClass
    } else {
        let name = subclassName(of: perceivedClass)
        let subclass: AnyClass = name.withCString { cString in
            if let existingClass = objc_getClass(cString) as! AnyClass? {
                return existingClass
            } else {
                let subclass: AnyClass = objc_allocateClassPair(perceivedClass, cString, 0)!
                // 重写类的 -class 和 +class 方法,隐藏真实的子类类型
                replaceGetClass(in: subclass, decoy: perceivedClass)
                objc_registerClassPair(subclass)
                return subclass
            }
        }

        object_setClass(instance, subclass)
        instance.associations.setValue(subclass, forKey: knownRuntimeSubclassKey)
        return subclass
    }
}

其中RxSwift 中的 _RXObjCRuntime 也提供了类似的思路。
当然也可以不用自己通过objc_registerClassPair() 创建类,直接通过 KVO 由系统帮我们生成子类,例如:


static void growing_viewDidAppear(UIViewController *kvo_self, SEL _sel, BOOL animated) {
    Class kvo_cls = object_getClass(kvo_self);
    Class origin_cls = class_getSuperclass(kvo_cls);
   
    IMP origin_imp = method_getImplementation(class_getInstanceMethod(origin_cls, _sel));
    assert(origin_imp != NULL);
    void (*origin_method)(UIViewController *, SEL, BOOL) = (void (*)(UIViewController *, SEL, BOOL))origin_imp;
    
    // 调用原来的方法
    origin_method(kvo_self, _sel, animated);
    
    // Do something 
}

- (void)createKVOClass {
    [self addObserver:[GrowingKVOObserver shared] forKeyPath:kooUniqueKeyPath options:NSKeyValueObservingOptionNew context:nil];

    GrowingKVORemover *remover = [[GrowingKVORemover alloc] init];
    remover.target = self;
    remover.keyPath = growingUniqueKeyPath;
    objc_setAssociatedObject(self, &growingAssociatedRemoverKey, remover, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

    // 通过object_getClass 取到的class 是由系统生成的前缀为 NSKVONotifying_ 的类型
    Class kvoCls = object_getClass(self);

    Class originCls = class_getSuperclass(kvoCls);

    const char *originViewDidAppearEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewDidAppear:)));

    // 添加我们自己的实现 growing_viewDidAppear
    class_addMethod(kvoCls, @selector(viewDidAppear:), (IMP)growing_viewDidAppear, originViewDidAppearEncoding);
}

这种利用KVO动态生成子类的AOP方案对原来的类侵入最小,因为它没有改变原始类的方法和实现的映射关系,也就不会影响到由原始类定义的其他的实例的方法调用。在一些比如更精确的计算页面加载时间的场景中会发挥很好的作用。但是这个AOP 的方案和其他一些SDK有冲突的情形,比如信鸽、Firebase 以及上面说的 RxSwift,在 RxSwift 中所有的消息机制都被统一成了信号,框架不推荐你使用 Delegate、KVO、Notification,尤其 KVO 会有异常错误的。

Fishhook

提高 iOS 的 AOP方案就不得不提到大名鼎鼎的 Fishook,它在做一些性能分析或者越狱分析中经常被用到。

大家都知道 ObjC 的方法之所以可以 Hook 是因为它的运行时特性,ObjC 的方法调用在底层都是 objc_msgSend(id, SEL) 的形式,这为我们提供了交换方法实现(IMP)的机会,但 C 函数在编译链接时就确定了函数指针的地址偏移量(Offset),这个偏移量在编译好的可执行文件中是固定的,而可执行文件每次被重新装载到内存中时被系统分配的起始地址(在 lldb 中用命令image List获取)是不断变化的。运行中的静态函数指针地址其实就等于上述 Offset + Mach0 文件在内存中的首地址。

既然 C 函数的指针地址是相对固定且不可修改的,那么 fishhook 又是怎么实现 对 C 函数的 Hook 呢?其实内部/自定义的 C 函数 fishhook 也 Hook 不了,它只能Hook Mach-O 外部(共享缓存库中)的函数,比如 NSLog、objc_msgSend 等动态符号表中的符号。

fishhook 利用了 MachO 的动态绑定机制,苹果的共享缓存库不会被编译进我们的 MachO 文件,而是在动态链接(依靠动态连接器 dyld)时才去重新绑定。苹果采用了PIC(Position-independent code)技术成功让 C 的底层也能有动态的表现:

  • 编译时在 Mach-O 文件 _DATA 段的符号表中为每一个被引用的系统 C 函数建立一个指针(8字节的数据,放的全是0),这个指针用于动态绑定时重定位到共享库中的函数实现。
  • 在运行时当系统 C 函数被第一次调用时会动态绑定一次,然后将 Mach-O 中的 _DATA 段符号表中对应的指针,指向外部函数(其在共享库中的实际内存地址)。

fishhook 正是利用了 PIC 技术做了这么两个操作:

  • 将指向系统方法(外部函数)的指针重新进行绑定指向内部函数/自定义 C 函数。
  • 将内部函数的指针在动态链接时指向系统方法的地址。

这是Facebook 提供的官方示意图:

image

Lazy Symbol Pointer Table --> Indirect Symbol Table --> Symbol Table --> String Table

这张图主要在描述如何由一个字符串(比如 "NSLog"),根据它在 MachO 文件的懒加载表中对应的指针,一步步的找到该指针指向的函数实现地址,我们通过 MachOView 工具来分析下这个步骤:

_la_sysmbol_ptr 该section 表示 懒加载的符号指针,其中的 value,是对保留字段的解析,表示在 Indirect Symbol Table 中的索引

image

通过 reserve1 找到 对应 section __la_symbol_ptr 在动态符号表(Indirect Symbols)中的位置,比如下图:#14 就是 __la_symbol_ptr section 所在的起始位置。

image

符号个数计算 是通过 sizeof(void (* )) 指针在64位上时8个字节大小,所要这个__la_symbol_ptr section 有 104 / 8 = 13 个符号,_NSLog 只是其中之一。

image

注意 Indirect Symbols 动态符号表,其中的Data 值 0x00CO (#192) 表示该符号在符号表中的索引

image

符号表中的第192号就是 _NSLog 符号,这个Data 0x00CE 就是字符串表中的索引

image
上面的索引 0x00CE 加上这个字符串表的起始值 0xD2B4 就是该符号在符号表中的位置,如下图所示:
image

以上梳理了fishhook 大概的流程,之后看代码的实现就不是很抽象了,需要对 MachO 文件的结构有较深入的理解。既然fishhook 可以hook 系统静态的C 函数,那么也可以hook ObjC 中的 Runtime 相关的方法,比如 objc_msgSendmethod_getImplementationmethod_setImplementationmethod_exchangeImplementations 可以做一些有趣的攻防探索、其中越狱中常用的 Cydia Substrate 其中的 MobileHooker 底层就是调用 fishhook 和 ObjC 的 Runtime 来替换系统或者目标应用的函数。对其封装较好的 theos 或者 MonkeyDev 开发工具方便越狱进行hook 分析。需要注意的是 fishhook 对于变参函数的处理比较麻烦,不太方便拿到所有的可变的参数,需要借助汇编来操作栈和寄存器。关于这部分可以参见:TimeProfilerAppleTrace

Thunk 技术

让我们把镜头进一步向前推进,了解下 Thunk 技术。

Thunk 程序中文翻译为形实转换程序,简而言之Thunk程序就是一段代码块,这段代码块可以在调用真正的函数前后进行一些附加的计算和逻辑处理,或者提供将对原函数的直接调用转化为间接调用的能力。Thunk程序在有的地方又被称为跳板(trampoline)程序,Thunk程序不会破坏原始被调用函数的栈参数结构,只是提供了一个原始调用的hook的能力。Thunk技术可以在编译时和运行时两种场景下被使用。其主要的思想就是在运行时我们自己在内存中构造一段指令让CPU执行。关于 Thunk 思想在iOS 中的实现可以参见 Thunk程序的实现原理以及在iOS中的应用Thunk程序的实现原理以及在iOS中的应用 从背景理论到实践来分析这一思想。

关于Thunk 思想的具体实现可以参见下面几个三方库以相关的博客:

其中核心都会利用到 libffi 这个库,底层是汇编写的,libfii 可以理解为实现了C语言上的 Runtime。

Clang 插桩

以上iOS AOP 方案中大多是基于运行时的,fishhook 是基于链接阶段的,而编译阶段能否实现AOP呢,插入我们想要的代码呢?

作为 Xcode 内置的编译器 Clang 其实是提供了一套插桩机制,用于代码覆盖检测,官方文档如下:Clang自带的代码覆盖工具,关于Clang 插桩的一个应用可以详见这篇文章,最终是由编译器在指定的位置帮我们加上了特定的指令,生成最终的可执行文件,编写更多的自定义的插桩规则需要自己手写 llvm pass

这种依赖编译器做的AOP 方案,适用于与开发、测试阶段做一些检测工具,例如:代码覆盖、Code Lint、静态分析等。

总结

以上介绍了iOS 中主流的 AOP 的方案和一些知名的框架,有编译期、链接期、运行时的,从源代码到程序装载到内存执行,整个过程的不同阶段都可以有相应的方案进行选择。我们的工具箱又多出了一些可供选择,同时进一步加深对静态和动态语言的理解,也对程序从静态到动态整个过程理解更加深入。

同时我们Android 和 iOS 无埋点SDK 3.0 均已开源,有兴趣可以关注下面github 仓库,了解我们最新的开发进展。

Android:https://github.com/growingio/growingio-sdk-android-autotracker

iOS:https://github.com/growingio/growingio-sdk-ios-autotracker

关于 GrowingIO

GrowingIO 是国内领先的一站式数字化增长整体方案服务商。为产品、运营、市场、数据团队及管理者提供客户数据平台(CDP)、广告分析、产品分析、智能运营等产品和咨询服务,帮助企业在数字化转型的路上,提升数据驱动能力,实现更好的增长。

点击「此处」,注册 GrowingIO 15 天免费试用!

查看原文

赞 0 收藏 0 评论 0

GrowingIO 发布了文章 · 2020-11-06

GrowingIO 响应式编程探索和实践

作者:林生生,GrowingIO 运营产品线研发经理,主要负责 GrowingIO 智能运营产品线研发管理工作。

背景

GrowingIO 是一家提供增长平台的公司。在 2018 年初我们推出了基于底层数据能力的智能运营平台,结合精准的用户分群,数据采集以及多种运营方式,帮助企业客户用数据驱动用户运营,随时验证假设,助力产品增长。产品有以下特点:

  • 支持多种触达用户的渠道 :站内:弹窗、资源位,站外:Push、短信、Webhook。
  • 多平台支持,弹窗支持:App、Web、H5和小程序。
  • 轻松建立数据运营的闭环。

下图是运营平台站外触达业务流程图。用户可以随时发起一次站外运营活动,通常是一个站外的触点(推送、短信、Webhook)。后台系统需要查询底层的数据平台接口,获取此次活动对应的人群信息,同时组装活动数据并对外投递任务。
image

在这个业务场景,需要解决如下几个问题:

  1. 系统外界输入是突发的,无法提前预估量级,系统需要在不断变化的负载中保持即时响应。
  2. 依赖底层数据服务,如果外部系统无法工作,为了保证回弹性需要有熔断和恢复机制。
  3. 业务流程较长,为保证及时响应需要对任务进行异步处理。

综上,为了最大化利用服务器资源、提高服务稳定性和优化终端用户体验,GrowingIO 服务端团队在异步与反应式编程上做了一些实践。本文将介绍在优化过程中的探索与思考,希望能为读者带来帮助。

异步与响应式

传统服务端程序一般采用同步阻塞模型,通过分配更多线程来支撑更多请求,这符合常人思维模式,但在突发流量的情况下,同步模型可能会导致线程池耗尽,基于一个请求一个线程的服务模式无法做到动态伸缩。
image

而异步编程的做法是基于一个共享的线程池,所有操作都是回调。如果遇到耗时的操作,线程并不会阻塞等待操作完成,而是会被释放回线程池中继续接受新的请求。等到耗时操作完成后(一般都是IO操作),通过消息机制重新向线程池申请线程恢复之前的请求代码。
image

我们可以简单写个程序简单实验一下,实现相同逻辑:1. 查询 db 2. 查询外部系统 3. 组装信息返回。唯一区别是一个是同步调用的实现,另一个是采用完全异步的方式实现。

本地使用相同的 jmeter 参数模拟并发测试,得到结果如下,从左到右每列的含义分别为:请求名称、请求数目、失败请求数目、错误率(本次测试中出现错误的请求的数量/请求的总数)、平均响应时间、最短响应时间、最大响应时间、90%用户响应时间、95%用户响应时间、99%用户响应时间、吞吐量。

总体测试结果如下:

image

image
同步代码测试结果

image
异步代码测试结果

同步代码总共完成了 260 次请求,平均响应时间约 5 秒,因为阻塞程序耗尽了线程池导致程序出现了拒绝服务的情况,产生了 13% 的错误率。
异步代码整体吞吐量有明显提升,相同时间内完成了 3000 次请求。错误率为 0 ,并且整体没有出现拒绝服务的情况。

可以看到基于消息驱动机制的异步系统能极大提高资源利用率,提高系统的吞吐量。而响应式系统则在消息驱动的基础上增加了三个要求:及时响应性、回弹性和可扩展性。

简单来说具备以下四个特点的系统可以称为一个响应式系统:

  1. 即时响应性,这个是响应式系统的核心目标。一个具有响应性的系统就是一个无论在什么情况下都能快速对客户的操作做出反馈的系统,包括事件、用户请求、失败场景,最终目的是保证客户良好的体验。
  2. 回弹性,指的是系统从故障灾难中恢复的能力。主要分两部分,一个是系统需要考虑失败的情况,二是系统要能从失败中恢复回来。
  3. 扩展性(弹性),指的是系统在不断变化的工作负载之下依然保持即时响应性。可扩展分为单机纵向扩展和横向线性扩展。这里主要指的是系统可以通过分片、复制等方式进行横向扩展,从而避免系统产生明显的性能瓶颈。
  4. 消息驱动的,这是响应式系统的基础。从上面异步系统的优势和原理分析可以看到,基于消息驱动的程序能最大化利用机器资源,同时松散耦合的设计创建了一个能让业务逻辑保持清洁的环境,显式的隔离失败有利于系统自动恢复。

image
对应的响应式编程是一种程序设计思想,在 java 8 中首次引入了响应式流的规范,即 Reactive Streams 接口。Reactive Streams 非常类似于 JPA 或 JDBC,都是 API 规范,实际使用时需要采用对应的具体实现。JDK 提供的 Reactive Streams 接口:

image

Reactive Streams API 的范围是找到一组最小的接口,这些接口将描述必要的操作和实体,从而实现具有非阻塞背压的异步数据流。社区对于 Reactive Streams 的实现比较多,这里做一个简单的汇总和对比。

image
总结一下,如果是移动设备使用 rxjava 是比较合适的选择。如果是在服务端使用 spring 框架做开发,采用基于 reactor 实现的 webflux 更合适。如果是对性能要求很高,业务相对简单的场景,选择 vertx 可以最大限度发挥机器性能。而 gio 的真实场景是服务端的复杂业务系统,同时使用 scala 作为开发语言并且使用 play 作为 web 开发框架。所以在系统构建之初很自然的选择了 akka 作为我们的响应式系统的实现基础。

使用 Actor 构建反应式系统

在最初的时候并没有直接采用 akka-stream,而是选择更为简单,建模能力更强的 akka-actor 作为系统实现的基础。Akka-actor 是基于 actor 模型构建的异步工具包, 使用 akka-actor 可以很轻松的进行基于消息驱动的异步编程。Actor 的基础就是消息传递,一个 actor 可以认为是一个基本的计算单元,它能接收消息并执行运算,它也可以发送消息给其他 actor。Actors 之间相互隔离,它们之间并不共享内存,所以 Actor 不需要去关注锁和内存原子性等一系列多线程常见的问题。
image

Akka-actor 最核心的实现包含三个部分:

  1. Mailbox:可以是一个有界或者无界的消息队列,用于存放所有收到的消息。
  2. Behavior:具体的消息处理逻辑。
  3. State:actor 包含的状态,每个 actor 的状态都是独立的避免锁竞争。

Actor 本身是不绑定线程的,相同进程的 actor 共享一个线程池,mailbox 是一个 runnable 对象,核心逻辑就是从队列中取出消息调用 behavior 进行处理。

` override final def run(): Unit = {

try {
  if (!isClosed) { //Volatile read, needed here
    processAllSystemMessages() // 先处理系统级别消息
    processMailbox() // 然后处理普通消息
  }
} finally {
  setAsIdle() //Volatile write, needed here
  dispatcher.registerForExecution(this, false, false)
}

}`

在同一个进程中,可以通过调整 akka-actor 线程池大小来进行纵向负载伸缩。同时,akka-actor 支持在一个系统中绑定不同类型、数量的线程池。比如在一些耗时较长的 IO 场景下可以单独配置一个线程池起到隔离的目的。对需要横向扩展的场景,akka 提供了基于 gossip 协议的点对点去中心化集群解决方案 akka-cluster。
image

Akka-cluster 通过 gossip 协议进行成员之间的发现和状态同步,同时提供了更高层的集群工具:

  1. Cluster Singleton:全局唯一实例,能保证实例的全局唯一性,同时在实例出现问题的时 clsuter 能在另一个节点上重建它。
  2. Cluster Sharding:通过 sharding,集群中的 actor 能跨越多个节点通过 actorRef 标识进行交互,不需要关心它们在集群中的物理定位。
  3. Distributed Data当需要在一个cluster的节点之间共享数据时,Distributed Data 提供了 k/v 存储 API。
  4. Distributed Publish Subscribe集群中的 actor 可以发布订阅点对点的广播消息。

理论上使用 akka-actor 和 akka-cluster 可以使系统具备极强的扩展性(弹性)。但是在实际使用中我们并没有采用 akka-cluster 去扩展系统,原因也很简单,akka-cluster 生产案例太少,功能上过于复杂,不利于大规模推广。最终我们使用了传统的消息中间件作为系统横向扩展的解决方案。在单机内使用 akka-actor,涉及到跨节点通信的场景使用消息中间件进行通信。
image

在系统回弹性方面,akka-actor 提供了基于层级的监督机制。可以把整个 actor 系统看做是一棵树,每个 actor 实例都是树中的一个节点。监督机制指的是每个 actor 都是其子 actor 的监督者,需要针对子 actor 制定一个错误处理策略。
image

对应到具体的业务系统里,我们将整个流程分割成多个 actor 实现,为了实现监督与错误恢复,需要创建一个顶层 route actor 来引用所有具体的业务 actor 。如果某个业务actor 遇到问题并抛出了异常,异常会被监管者 route actor 来处理。监管者可以选择恢复出现问题的 actor 或者重启,也可能会将其停止掉,这依赖于问题的严重程度和恢复策略。Akka-actor 中有以下 4 种错误处理策略:

  1. 恢复子节点,保持子节点当前积累的内部状态。
  2. 重启子节点,清除子节点的内部状态。
  3. 永久地停止子节点。
  4. 抛出错误向上传递错误,由更高级的节点处理。

最终我们基于 actor 实现了整个业务流程:当一个用户发起一次站外活动请求,主应用(基于 play)会将活动的元数据写入数据库中然后立马返回结果到前端,达到及时响应的目的。同时将活动请求封装成一个 actor 消息,异步的投递给 route actor 进行后续的任务处理。Route actor 会根据接收到的具体消息类型进行路由分发,分别是 User Insight Actor(查询人群信息) - Build Push Task Actor(查询 db 组装 task) - Checkpoint Actor(存储 task 信息)- Publish Task Actor(发布 task 到 kafka)。
image

采用消息驱动的方式设计系统取得了一些好处:

  1. 程序之间耦合性更低,每个 actor 只需要维护好一小段逻辑。
  2. 整个流程是异步处理的,用户体验良好。
  3. 消息的生产和消费可以跨服务器,横向扩展变得简单。

从 Actor 到 Stream

上文提到我们创建了一个 route actor 将所有业务 actor 组织到一起,这样既能起到一个监督的作用,也可以知道全局的逻辑视图。但是这种实现方式也会带来一个问题,整体编排较为复杂。对于带有分支与合并逻辑的处理流更是难以描述,对后续新增流程也没有约束,只能人为约定一个顺序,比如在上面的比较靠前,可维护性比较差。
image

又因为整个流程中 User Insight Actor 部分依赖外部数据查询系统,比较容易成为整个系统的瓶颈。在负载不断变化的情况下,外部查询可能会失败,从而对系统整体可用性造成影响。针对这个问题需要设计对应的限流机制和重试机制。 上面提到 Actor 的 mailbox 本身就是一个队列,如果在负载过高的情况下消息是可以丢弃的,只需要指定 actor 的 maibox 类型为有界队列即可。假如消息不能被丢弃,可以采用令牌桶算法实现限流功能。对于重试机制,User Insight Actor 本身是无状态的,这里很自然想到在失败时重新发送试消息到 User Insight Actor 本身进行重试。
image

这个方案比较简单,如果要满足一些特殊场景下的需求,比如设定重试次数,延迟执行重试请求,指定重试失败后的降级策略,只能通过定制一些逻辑实现,但是要做到代码灵活复用需要花费大量时间进行设计。

上述方案都能满足业务需求,总体来讲通过 actor 模型可以快速实现轻量业务异步封装,但面对相对复杂业务逻辑时还是存在一些局限:

  1. 难以简单优雅实现多异步任务编排,路由方案过于复杂,不直观。
  2. 重试机制、限流机制等和业务无关的功能复用性不高。

这也是为什么后来采用了 akka-stream 来对处理流程进行重构。Akka-stream 是基于 akka-actor 的 Reactive Streams 规范实现,具备以下特点:

  • 具有处理无限数量的元素的能力
  • 异步地按序处理元素
  • 实现了非阻塞的背压

并在上层提供了更加抽象灵活的 DSL 封装,即 source、sink、flow 组件。

Source 即响应流的源头,源头具有一个数据出口。我们可以通过各种数据来创建一个 Source:

`val sourceFromRange = Source(1 to 10)
val sourceFromIterable = Source(List(1, 2, 3))
val sourceFromFuture = Source.fromFuture(Future.successful("hello"))`

Sink 就是流的最终目的地,包含一个数据入口,我们可以如下来创建 Sink:

`val sinkPrintingOutElements = Sink.foreachString)
val sinkCalculatingASumOfElements = Sink.foldInt, Int(_ + _)`

Flow 就是流的中间组件,包含一个数据入口和数据出口。我们可以这样来创建 Flow:

`val flowDoublingElements = Flow[Int].map(_ * 2)
val flowFilteringOutOddElements = Flow[Int].filter(_ % 2 == 0)
val flowBatchingElements = Flow[Int].grouped(10)`

而整个业务流可以通过基础组件构成的图和网络来表示:
image

流式操作可以类比成流水线,每个算子都是一道处理工序,数据源就是加工原材料,经过多道工序处理后最后输出一个成品。

上文提到为了实现对系统速率的控制,引入了限流的逻辑,比如基于令牌桶算法的实现,只有程序拿到了令牌才能进入下一段处理逻辑,本质上这种实现方式是同步阻塞的,而且真实情况下下游节点可能完全能承载更多的请求。为了解决数据源和下游节点处理速度不一致的问题,在 Reactive Streams 的规范里引入了背压机制,本质上是一种由处理者向数据源发起数据请求,从而进行速度调整的一种方式。Akka-Stream 提供了一套开箱即用的背压功能,其实现方式和 Reactive Streams 一致,下游 subscriber 通过发送 subscription 到上游的 publisher 主动请求需要处理的元素数量。这样就能从整个数据流的源头进行速率控制,采用 pull 而不是 push 的模式能让系统按需保持最大的处理能力,同时又不会崩溃。
image

下面是基于 akka stream 重构后的处理流,简单对比 akka actor 的实现方式,基于操作符的组合代码更加清晰易读,可以轻松实现复杂任务编排。
image

从底层实现来讲,akka-stream 底层还是基于 akka-actor 进行工作的,只是在上层提供了更高视角的 DSL 封装 。这种灵活的编程方式能极大提高代码复用性和可维护性。

总结

本文记录了 GrowingIO 服务团队在针对具体业务场景进行反应式系统设计的实践总结,从异步编程到使用 actor 模型构建基于消息驱动的系统,为了降低系统复杂度提高可维护性又引入了 akka-stram 作为反应式流的编排框架。最后,希望能与对反应式技术感兴趣的同学多多交流,打个小广告:我们的工程团队持续在招聘中~ 服务端、前端、大数据各种攻城狮都缺,感兴趣朋友欢迎砸简历 https://www.growingio.com/joinus

参考资料:

https://info.lightbend.com/rs/558-NCX-702/images/COLL-ebook-Reactive-Microservices-Architecture.pdf

https://learning.oreilly.com/library/view/applied-akka-patterns

https://freecontent.manning.com/akka-in-action-why-use-clustering/

https://doc.akka.io/

关于 GrowingIO
GrowingIO 是国内领先的一站式数字化增长整体方案服务商。为产品、运营、市场、数据团队及管理者提供客户数据平台、广告分析、产品分析、智能运营等产品和咨询服务,帮助企业在数字化转型的路上,提升数据驱动能力,实现更好的增长。

查看原文

赞 0 收藏 0 评论 0

GrowingIO 发布了文章 · 2020-09-17

GrowingIO 智能运营产品微前端实践

作者:俞展弘
GrowingIO 前端开发工程师,主要负责智能运营团队前端开发、gio-design 开发

引言

GrowingIO 智能运营产品,是 GrowingIO 为客户运营团队提供的一站式、精细化运营管理与数据分析平台。

近期,GrowingIO 智能运营产品团队需要将站内触点等运营功能从 SaaS 平台移植到增长平台上,以支持私有化部署(On-Premise,简称 OP)方案在客户环境中的进行,以此为背景,GrowingIO 运营前端团队在微前端的实践上走了自己小小的一步。

1.什么是微前端

虽然「微前端」三个字这两年已经在前端领域被大家所广泛熟知,但本着严谨写文章的精神还是需要在这里简单说明一下。

微前端的概念脱胎于服务端的「微服务」概念。最近几年前端世界的趋势就是构建一个功能丰富、强大的浏览器应用程序(SPA)。

一般一个前端应用通常会由多个独立的团队开发,随着时间的推移,前端应用的代码规模会不断增长,并且表现得越来越难以维护,成为一个谁看都头疼的巨石应用。

微前端出现的意义在于将这样一个单一的巨石应用,转换为多个较为小型的前端应用再聚合为一。各个小型应用可以独立开发、部署,乃至于独立选择自身的技术栈与依赖,而不影响其他兄弟应用与父应用。

在此基础上简单总结一下微前端需要做到(或者说带来的好处):

  • 应用自治:只需要遵循统一的接口规范或者框架,各个应用可以集成到一起,同时相互之间是不存在依赖关系的。
  • 单一职责:每个前端应用可以只关注于自己所需要完成的功能。
  • 技术栈无关:可以在使用 Angular 的同时,又可以使用 React 和 Vue。

2. 为什么要拆分微前端

如同上一段所说,目前的 GrowingIO SaaS 主站前端应用也是一个伴随迭代变得越来越大的巨石应用,但对于运营的前端团队来说,除了公共方法平时不会触及到大多数不相关模块。

其次,在当前以及可预见的未来,在 SaaS 环境与 OP 环境中运营的业务场景在大部分情况下是相同的。但是对于前端团队来说,OP 与 SaaS 差别包括但不限于:

  • 采用了全新脚手架
  • 用 GraphQL 取代了 RESTful API 获取基础资源
  • 基础组件版本与路由等基础设施与 SaaS 有破坏性更新
  • OP 不需要全部的 SaaS 功能

如果采取最原始暴力的开发方式(copy),就需要开发维护两套运营平台前端代码,一套在 SaaS 前端仓库,另一套在 OP 前端仓库。

这样会导致同个功能需要维护两套 codebase,时间久了 OP 仓库的代码会离 SaaS 主干代码越来越远,导致维护困难,同时有概率会一个功能写两次,这对于一个团队来说是不可接受的。

从实际需求出发,我们当然希望能做到一套代码适配两个环境,对于同一个功能开发、bug 修复都只需要一次工作量,这样能大大提高人效。所以在功能移植前需要将前端项目进行微前端改造。

3.智能运营产品微前端一小步

实现智能运营产品(下称 Marketing)的微前端应用过程大致划分为两步走:拆 → 装,与此同时处理各种冲突覆盖和兼容问题。

3.1 拆分

拆分的过程中我们需要对 Marketing 应用与 SaaS 应用进行解耦,并针对 OP 环境进行差异屏蔽。

OP 和 SaaS 当中,存在一些接口不相同,所以需要做一层兼容处理,这部分放在了 @gio-bootstrap 以及 resourceService 当中实现,目的是使下层业务对环境无感知。

@gio-bootstrap

从 SaaS 独立出来 Marketing 仓库的最初承袭 SaaS,权限、项目、用户等基础数据在 .ejs 模板文件中进行获取,不容易阅读与维护不说,且复用困难,并阻碍了 webpack 的升级优化。

因此我们抽取了一个统一的 bootstrap 方法实现基座应用不同时的基础资源请求:

 type GioBootstrap = (platform: 'saas'|'op') => Promise<ResourceMap>

方法内根据与基座应用团队约定确认的资源列表以及接口进行每个资源的获取。针对单个资源,使用一个 requestGenerator 来进行资源请求函数的创建:

/**
 * 单个资源请求前会遍历 dependencies 列表资源是否都已存在,针对未请求的资源进行请求获取,已请求过的资源会从 sessionStorage 中进行获取;
 * 为了兼容现有代码,@gio-bootstrap 同时在 sesstionStorage 与 window 中存储了资源。待未来时机成熟可以从 window 这种全局污染比较严重的方式中切换出来,运营前端相对于基座应用进一步独立。
 */
interface IRequestGenerator {
 // 资源名称
 name: string;
 // 请求默认使用的url列表
 endpoints: (projectId: string, dependencies?: { [key: string]: any }) => string[];
 // 默认方式外的额外请求方式
 manual?: (projectId: string, dependencies?: { [key: string]: any }) => any;
 // 根据环境决定是否使用 manual进行请求,将屏蔽 endpoints
 useManual?: boolean;
 // 前置依赖资源列表
 dependencies?: TResource[];
 // 资源在前端的持久化,不配置将默认同时存储在 sessionStorage 与 window 中
 persistence?: (resource: any) => void;
 // 请求后的手动操作
 afterRequest?: (resource: any) => void;
 // 是否缓存
 noCache?: boolean;
}

resourceService 增强

resourceService 是 GrowingIO SaaS 前端应用中进行数据获取的一个统一封装,还包含了 Redux 注入的过程,因此我们在不影响原有功能的基础上,针对数据获取的每个暴露 API 新增了类似 @gio-bootstrap 中的 manual 字段,以进行对应资源在 OP 中的 GraphQL 或新 RESTful API 的请求。

3.2 集成

一般来说微前端实现架构是这样:

image

基座层作为主框架的核心成员,充当调度者的角色,由它来决定在不同的条件下激活不同的子应用。因此主框架的定位则仅仅是:导航路由+资源加载框架。

对应的 GrowingIO 实际情况是,Marketing 作为一个独立的子应用,需要嵌入 SaaS 和 OP 当中:

image

按照上图架构来做,则需要把 SaaS 和 OP 前端应用(暂不考虑整个拆成微前端架构)当做基座应用 。

因此在考虑集成方案时,我们做了一个集成方案对比:

(点击查看大图)
在我们开始考虑集成的时候,两个基座应用的依赖情况举个例子就是:

SaaS     Dependencies     react: 16.8.3,    antd: ^2.13.3
OP       Dependencies     react: 16.8.6,    antd: ^3.20.5 
antd     PeerDependencies react: "~0.14.0 || >=15.0.0"

由于 Marketing 需要集成进两个平台,如果采用构建时集成,则会造成类似 SaaS 应用编译后过大,无法很好地兼容两个应用具有破坏性的依赖版本不一致等一系列令人头疼与困惑的问题。

同时,构建时集成所考虑的依赖 hoist(具体概念可以参考 lerna hoist 进行理解) 以及 npm 来解决依赖冲突的概念,与微前端所需要达成的「技术栈无关」、「应用自治」理念背道而驰。

「技术栈无关」应该是我们考虑实施微前端所首先要达成的目标。

综合上面的几点考虑,我们选择了采用运行时 HTML Entry 的集成方式,尽管牺牲了一部分打包加载优化,但换取了更加一致的开发体验以及无感独立发布的优点。

于是我们的微前端集成方案可以大概描述成这样:

image
由于我们目前项目及环境的特殊性,SaaS 和 OP 的入口文件会有所不同(分别打包各自所需模块代码),但是打包方式以及集成方式是一样的。

3.3 全局变量处理

微前端里微应用在很多情况下也会去修改全局变量,这时候就可能造成全局变量的命名冲突。可能的处理方式有如下:

image

综合考虑子应用的独立性、环境的纯洁性,以及方案的执行成本,约定 namespace 方案相对较好,减少犯错成本同时又不过于复杂。

由于历史遗留原因,SaaS 应用在 window 全局环境中存储或重新定义了很多东西,例如 window.fetch,window.can 方法以及大量的全局变量,并且在最初拆分运营前端仓库时候无法一次性全部处理掉。

因此这一部分问题只能是分步骤迭代,一步步将全局变量、方法收束为 namespace 下的变量或者子应用下的模块导出。

3.4 样式覆盖

GrowingIO SaaS 应用最早是在 antd2.X 的基础上重新封装,并且一直没有跟随社区升级;OP 应用使用了最新版本的 antd,这就使得 Marketing 在集成到 OP 上时会出现 antd 类名的冲突与覆盖。

同时 OP 和 SaaS 在我们自己的组件类名上也会有不同的设计语言,更进一步加剧了样式的冲突。

应对这样的样式冲突,其实只需要修改 webpack 打包时 style-loader的默认行为就可以。

目前 style-loader 默认将样式插入 head 当中,那么我们就在基座应用的子应用入口处自行制定插入 dom,将样式插入到自己的作用域下,随着应用的切换,顺带将样式卸载。

对于使用到的 styled-components 也利用库自己的 API 做同样处理。

不过就算这样处理了还是会偶尔发现样式不协调的地方,这方面的坑还需要再踩一踩才会有更新的体会。

4.结语

从一个巨石应用中拆分微前端不是一个可以一蹴而就的事情,总有各种各样的问题会突然出现。

不同产品,不同项目,会遇到的问题不尽相同,但大体思路与解决办法基本一致,只要围绕最核心的「技术栈无关」,选择一个合适项目的方案,然后一步步做到依赖、样式、全局变量等的隔离与独立,最终达到能够独立打包发布又不影响基座应用与兄弟微应用。

关于 GrowingIO

GrowingIO 是国内领先的数据驱动增长解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。

点击「此处」获取 GrowingIO 15 天免费试用!

查看原文

赞 1 收藏 1 评论 0

GrowingIO 发布了文章 · 2020-08-28

GrowingIO AWS 成本优化之路

作者:邢建辉
GrowingIO 运维开发工程师,主要负责平台化,自动化方向的设计与开发。

背景

营收和成本是任何一家企业都需要关注的问题。

当一家互联网公司发展到一定规模时,服务器成本会变成一项重大的开支,优化服务器成本也将变成一件提上日程的任务。GrowingIO 主要服务运行在 AWS 上,下面主要针对 GrowingIO 在 AWS 上的一些优化。

成本分析

1. 概览分析

在开始成本优化之前,需要先做规划,通过规划来确定实施方案,而不是拍脑袋直接去做,这样可能最后就是花了很大力气,成本降低的效果却不是很明显。

做规划需要通过数据做依据,通过数据分析来确定优化的优先级,例如哪些优化方案会使总体成本下降最快,哪些成本花费的并不合理。下图为 AWS 中的账单中一些概览。

通过 AWS 的账单概览中我们可以发现费用排名依次为 EC2,DataTransfer 等,下面针对 EC2,DataTransfer 等我们需要做账单的详细分析。

2. 细节分析

(1)数据收集

AWS 的账单中只能做到对总体的一个概览,例如只显示到 c5.xlarge 机型的 Reserved Instances 和 On Demand 的花费,而流量费用也只显示到 out of CNN1 的总数,这些信息只能让我们总体的看到费用的大致花费构成,对于庞大的资源,这些信息并不能帮助我们精准定位到优化具体资源上。

这里我们通过 Cost Management Preferences 中将详细账单文件保存到 S3 中,账单中的详细信息 https://docs.aws.amazon.com/c...

(2)数据标签

这里需要说明的是在做详细账单时最好提前将 AWS 的所有资源打上统一 tag,我们根据自己的情况打了主要如下四个标签:

打完标签后需要将 AWS 的账单中的 tag 激活,否则生成的详细账单中不会包含 tag 信息,具体步骤为在 AWS 账单页面的 cost allocation tags 中将对应 tag  active,这样在生成的账单文件中会包含 active 的 tag,如 user:App 等类似字段。

(3)数据可视化

GrowingIO 通过 Azkaban 任务自动化对账单数据进行可视化,流程大致如下。

  • 预处理阶段,将账单文件导出,并进行拆分与清洗后存入 PG 中。
  • 统计计算阶段,对于预处理中的原始数据进行按团队,应用等维度进行聚合计算后存入到 PG 中。
  • 数据可视化阶段,通过 Grafana 等图形工具对统计后的数据做图形展示。

通过数据可视化基于 App(服务类别),AWS:Usage(费用类别),Env(环境)等维度,进行费用的排序,这样可以使我们比较直观的找到费用较高的花费。

例如对于应用的维度,发现 Hadoop 的成本是最高的,而 Hadoop 的存储,计算等维度使用量也是排名靠前,那么第一优化的就是 Hadoop 的存储和计算。

下面是部分的展示图表,分别展示了 S3 的各 Bucket,各应用服务,各 ELB 的费用等部分信息(费用列由于比较敏感,已被隐藏)。

只有账单的结果还不够,我们还需要根据监控的数据来做实际使用情况的计算,例如可以导出一段时间内的 CPU,内存,磁盘的平均值,95 分位,方差等数值,并将账单和监控数据进行聚合来获取资源的真实使用情况,例如 Hadoop 的计算,内存使用率都比较高,毛峰一般出现在整点计算,Kafka,Hadoop,API 的磁盘使用量和吞吐量较大,但是 IOPS 并不高,API 的流量较高等情况。

通过这些数据,我们可以开始有针对性的做成本优化。

针对性优化

1. EC2 计算实例优化

(1)EC2 收费模式

在开始 EC2 的优化前我们需要熟悉一下 AWS EC2 的一些收费模式。

  1. AWS 对于实例收费分为 Reserved Instance(RI)  和 On Demand(OD) 两种方式,其中 RI 为用户向 AWS 承诺使用某一机型一段的时间,同时 AWS 会给出相应的折扣,一般情况下 RI 相比 OD 都会节省 60%多的费用,所以对于稳定且运行时间较长的业务一定要选择 RI。

  2. 不同代的机型收费不一样,例如 RI 中 m5.xlarge 的 ¥516.11/月与 m4.xlarge 的 ¥764.12/月对比节省了大约 32.5%,所以要关注不同代机型的价格,基本都是最新一代的比上一代的便宜,升级过程中需要注意 kernel 版本较低的需要先升级 kernel 再替换机型,否则更换机型会失败。

  3. t3 系列机型的计费使用积分方式,如 CPU 空闲时积分,忙时消耗积分,总的使用不到一定分数的情况则不额外收费,对于业务有峰值,但平时使用较低的推荐使用 t3 系列,t3 计费详细模式为https://aws.amazon.com/cn/abo...

  4. 对于机型的选择一定要根据服务的特性来选择,不同机型的实例价格差距比较大,下面列了一些机型的价格对比和使用场景,机型中的 a 代表 AMD。

(2)EC2 实例费用的可优化检测

针对 AWS 中 RI 和 OD 的特性,并且新一代相比老一代的机型费用降低的情况,我们做了如下的功能。

  • 根据 RI 的实例类型和数量对比实际使用的实例类型和数量计算得出当前需要预留的实例类型。

  • 根据 RI 的过期时间计算出未来一段时间陆续到期的 RI 机型。

  • 通过机型的价格对比(如 m3,m5 和 t3)和监控中的资源使用情况计算,得出实例可使用的机型,并且给出对应的费用情况。

举个例子,自动推荐计算出当前 kafka01 节点的预留实例还有 7 天时间将会到期,并且 kafka01 使用的是 m3.xlarge 机型, 同时发现 kafka01 CPU 平均使用率并不高。则发出通知,kafka01 的预留实例 m3.xlarge 7 天后将到期,当前费用 xxx/月,使用 m5.xlarge 费用 xxx/月,t3.xlarge 费用 xxx/月。

(3)Yarn 计算资源的优化

从之前的分析来看 Hadoop 中 Yarn 的计算成本是最大的一块,针对 Yarn 我们做了如下的一些事情。

  • 合并 Spark 任务,减少 driver 的数量

Spark On Yarn 分为 client 和 cluster 两种模式,cluster 模式中 driver 会跑在 yarn application master 上,目前我们在使用的大部分都是 cluster 的模式,由于现在 spark 任务较多,这样会导致同时启动大量的 driver 来进行任务的管理,由于 driver 对于资源的使用率并不高,这样就会导致一定的资源浪费,所以我们将一些资源较小的任务进行合并进而减少 driver 的数量。

  • 根据实际使用量,适当降低任务请求的资源数量
  • 优化数据模型,加快计算速度,降低资源使用量
  • 升级 AWS 机型,降低 EC2 使用成本

通过数据上面机型的对比,我们决定将 Nodemanager 的机型从 m3 和 m4 切换到 m5,根据计算总体计算成本大概会降低 30%+,在机型变更的过程中要考虑 RI 的到期情况,否则花双份钱就得不偿失了。

(4)资源使用不充分的实例优化

GrowingIO 是一家针对企业提供数据服务的公司,所以产品的流量也主要集中在数据采集和计算这块,面向用户这块会有部分服务的压力不是很高,但是同时又要保证服务的高可用而部署至少双节点。

针对这种情况并结合上方针对 AWS 的机型分析,我们对这部分服务采用了 t3 的机型,一方面费用较低,另一方面 t3 机型可以针对短时的业务压力可以具备一定的计算能力,没有采用 t3a 的原因是 t3a 相比 t3 的计算能力下降 20%而价格只便宜了 10%,导致我们认为 t3a 的性价比并不高。

2. EBS 存储优化

(1)EBS 类型和收费对比

我们在来看一下 AWS 的 EBS 类型和性能,以下部分引用官方数据。

(2)HDFS 存储优化

前面已经对 Hadoop 的计算做过了优化,HDFS 的存储也是成本的大头。

下面根据监控和数据端同事提供的数据分析可以得出一些以下结论。

  • 磁盘的 IOPS 只有在小时任务运行时比较高,平时相对较低。
  • 最近 XX 天的数据访问频率较高。
  • 大于 XX 个月的历史数据的访问频率非常低。

根据以上现象我们可以发现 HDFS 不同的数据对磁盘的需求并不一样。

可以总结出数据类型分为 Hot,Standard 和 Archive。

分析完了 HDFS 的存储特性,这时需要引入 HDFS 的 Storage Types 的特性了,链接 https://hadoop.apache.org/doc...

这里 HDFS 的存储类型分为 RAM_DISK,SSD,DISK 和 ARCHIVE。

RAM_DISK 使用基于特定场景,根据实际业务判断是否需要。而对于 SSD,DISK 和 ARCHIVE 则分别对应上方的 Hot,Standard 和 Archive 的不同场景。

这里我们选择了 gp2,st1 和 sc1 分别对应 Hot,Standard 和 Archive 的业务场景。

最后根据规划将 HDFS 的磁盘转成不同的磁盘类型。

这里说一下 AWS 比较强大的功能,磁盘类型的在线转换,例如 gp2 可以直接转成 st1 类型的同时提供服务,但是在类型转换时有 6 小时的冷却期。

(3)其他方面的一些优化

  • 例如 Kafka,API 等顺序读写的场景,如果之前使用的是 gp2 磁盘,那么统一替换成 st1 类型的磁盘。
  • 在 EBS 的类型对比中我们发现 Magnetic 是比较适合磁盘大小和性能都要求不高的场景,例如程序放置目录,所以这一波中将这种类型的磁盘统一替换成 Magnetic 类型。

3. 流量优化

(1)流量收费模式

  • NAT 网关使用费用 ¥0.427/小时,同时数据处理费用为 ¥0.427/GB。
  • 数据自 AWS 传出到 Internet 费用为 ¥0.933/GB。
  • 数据自 Internet 出入到 AWS 费用为 ¥0.000/GB。
  • 使用公有或弹性 IP 的费用 ¥ 0.067/GB。
  • 数据跨 Region 费用为 ¥0.067/GB。

(2)HTTP2.0 Header 压缩

在开始具体流量优化之前,我们先来简单的介绍一下 HTTP2.0 的 Header 压缩技术。

HTTP2.0 针对现在每个网页大量的 HTTP 请求而导致大部分流量都消耗在 HTTP Header 上的情况增加了 Header 压缩,具体可以参考 https://httpwg.org/specs/rfc7...,原理大致为服务端和客户端同时维护一份固定静态表和一份动态表,通过传输过程中只传输对应 Header 的索引来达到优化流量的目的,并且 HTTP2.0 协议向下兼容。

(3)服务流量的优化

GrowingIO 是国内领先的数据驱动增长解决方案供应商,流量费用也十分非常庞大。

由于 GrowingIO 服务场景的特殊性,以下方法并不完全适合所有公司。

首先我们先分析一下服务流量的场景和特性。

  1. 大部分流量为进站请求,出站的请求携带数据量较小。
  2. 出站的流量大部分为 TCP 和 HTTP 的 Header 数据。
  3. AWS 的流量只计算出站流量费用,入站的免费。

针对以上的情况,我们对于进站的流量不需要关心,注意力主要集中在出站流量上即可。

根据上面介绍的 HTTP2.0 Header 压缩的特性,我们决定将服务切换到 HTTP2.0,经过一定测试后,我们将 AWS ELB 替换成 Application Load Balancer 模式后默认会开启 HTTP2.0,根据后期的观察出站流量也确实下降了有 30%+。

4. S3 可以优化的方面

S3 为 AWS 的对象存储,根据每个公司的业务用法并不一样,这里简单说一下 S3 使用时可能带来额外费用的地方:

  • 尽量将 S3 和 EC2 放在同一 Region 中,否则 S3 和 EC2 之间的传输将产生流量费用。
  • 如果将 S3 作为静态 web 服务器,并且流量较大,建议将 S3 作为 CDN 源站,S3 对于互联网的请求流量和请求次数同时收费。
  • 除非明确的归档和备份文件,尽量不要选择 Glacier 类型,在访问 Glacier 对象时费用非常高昂。
  • 定期清理不再需要的文件。

未来可做的事情

1. 后期还可以针对 API 的 Response Header 进行一些裁剪,将 Header 进行优化,从而进一步降低出站的流量。

2. 使用 Auto Scaling 功能将一些服务进行动态伸缩,从而将资源使用分配更为合理。

3. 将一些服务搬到 Kubernetes 上进行更合理的资源分配。

4. 根据业务团队将账单详细拆分,将账单跟监控数据关联做成自动化,定期生成报告并进行 review 从而推动整个团队的成本意识。

总结

对于成本优化,每个公司具体的优化措施需要针对具体的业务场景来制定方案,以上只是列出了一些 GrowingIO 在成本优化时一些通用的点。成本优化是一个平衡的过程,在成本,性能,稳定性,系统冗余中相互均衡后的一个结果,并且是一个长期战斗。

关于 GrowingIO

GrowingIO 是国内领先的数据驱动增长解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。

点击「此处」获取 GrowingIO 15 天免费试用!

查看原文

赞 1 收藏 1 评论 0

GrowingIO 发布了文章 · 2020-08-21

GrowingIO 数据采集 iOS SDK 测试实践

作者:吕雨强

GrowingIO iOS 测试工程师,主要负责 iOS SDK 功能测试、自动化测试 。

GrowingIO 是基于用户行为数据的增长平台,精准采集用户行为数据是公司业务的基石,只有及时、准确、可靠的采集到数据,才能支撑上层的数据分析,用户画像,运营等业务,所以公司一直非常注重数据采集 SDK(Software Development Kit) 的质量保证工作。

为了满足客户的各种业务与技术的需求,GrowingIO 提供了 Web、Android、 iOS、Hybrid、各种小程序(微信、支付宝、头条、QQ 等 )、微信内嵌页等多种平台,以及 React Native、Flutter 、Cordova、Weex 、API Cloud 、AppCan 众多开发框架的 SDK,这无疑为 SDK 的测试工作带来的巨大的挑战。

本文主要介绍 GrowingIO 在 iOS SDK 测试方面的具体实践,希望对从事 iOS 测试的同学提供一些参考。

1. 数据采集 SDK 是如何工作的?

要测试一个软件或系统首先必须要先了解其业务逻辑和技术实现,接下来我们简单看下数据采集 SDK 是如何工作的。

GrowingIO 的数据采集 SDK 支持无埋点(全埋点)数据采集以及埋点数据采集,以满足不同的业务需求,其简易结构如下:

在用户打开 App ,浏览不同的页面,点击不同的元素(如按钮,文本框,图片),关闭 App 时,无埋点事件采集模块会将用户的具体行为自动采集并保存到手机的本地存储(关于无埋点数据采集的具体实现,欢迎关注 GrowigIO 后续的文章分享,这里不再详述)。

埋点事件采集与之类似,不同之处是埋点事件是由 App 主动调用 SDK 的埋点 API 触发事件采集,当然不同事件的具体数据格式有所不同。

接下来是数据发送模块,其主要负责将数据通过 HTTP API 上报到数据接收服务。需要说明的是,为了节约用户的数据流量和电量,发送程序并不是实时上报的,它会根据设备的电量、网络类型、以及数据量进行发送时机的选择,而且发送前还会对数据进行压缩和混淆以降低传输数据量并提升数据安全性。当然数据发送程序还会处理数据上报中的各种数据发送失败,网络异常等错误,采取适当的重试机制。

2. 如何测试?

通过以上结构分析,可以看出数据发送模块跟核心的数据采集业务关系不大,并且很稳定,几乎不会改动,因此我们测试的重点主要是数据采集部分,尤其是无埋点数据采集。

要测试数据采集首先需要有一个包含各种页面和元素的 Demo App,然后切换不同的页面,操作页面上的元素或触发埋点事件,然后检查采集到的事件数据是否正确。

检查事件数据有两种方式,一种是将事件详细数据通过日志打印出来观察日志检查;一种是通过抓包程序获取数据发送然后检查。

后者一般需要配置复杂的代理工具,而且数据量多,还需要考虑数据的解压缩、解密,比较难以精准定位到事件数据。所以在实际测试中一般使用前者。

image

上图是一个采集数据的日志截图,通过图中的事件数据我们发现,其字段众多,且一些字段可读性不高,人工检查耗时较长。

此外 SDK 数据采集的主要逻辑基本不变,但是每次修改都必须进行足够的回归覆盖,以免遗漏错误。

一旦遗漏了缺陷到线上,其造成的损失是极其昂贵的,即使在后续版本修复了缺陷,其造成的影响也很难消除,因为移动端 App 的升级周期是很难控制的。再加上 GrowingIO 数据采集 SDK 兼容 iOS 8 及以上版本,需要对各个版本系统做兼容性测试,其测试工作量显而易见。

幸运的是 SDK  的业务变化很少,断言内容比较机械,特别适合自动化测试。而且回归测试工作量巨大,采用自动化测试可以极大的提升生产率和质量。

3. 选择测试框架

工欲善其事必先利其器,开展自动化测试之前必须选择一款合适的自动化测试框架。选择框架的时候有几个方面要考虑:

  • 开发的成本(支持语言,是否可调试,代码补全)
  • 维护成本(框架的稳定性)
  • 是否需要源代码
  • WebView 的支持(很多App都用到了H5)
  • 对操作系统,开发工具的支持情况( 是否支持 iOS 8)
  • 测试用例执行效率
  • 测试报告(截图,代码覆盖率,…)
  • 是否支持CI(持续集成)
  • ……

当前支持 iOS  UI 自动化测试的主要框架对比如下:

image

考虑选择测试框架的几种影响因素。

首先,使用的语言和框架决定了测试人员的持续性学习成本,iOS SDK测试人员对 Objective-C 熟悉和掌握程度高,不需要消耗额外的学习成本,测试与开发同一技术栈。

其次,测试 App 程序根据需求时有调整,使用开发效率高、调试方便的测试框架能使我们在适应新 UI 变化、新需求时获得更小的投入产出比。

综合以上考虑,KIF 框架已经展现了他的优势,并且 KIF 使用 XCTest 框架,使得其测试流程 iOS 程序的单测无异,可完全复用单测的持续集成流程,维护持续集成的成本相对降低;另外,KIF 是一个活跃的开源测试框架,可扩展性好,升级更新快,有活跃社区来探讨和解决使用过程中遇到的问题。

鉴于上述优势,我们选择了 KIF 作为 iOS 的 UI 自动化测试框架。

KIF 的全称是 Keep it Functional,它是一个建立在 XCTest 的 UI 测试框架,通过 Accessibility 来定位具体的控件,再利用私有的 API 来操作UI。由于是建立在 XCTest 上的,所以你可以完美的借助 XCode 的测试相关工具。

4. 自动化测试的实施

语言与工具

  • 语言:Objective-C
  • IDE:Xcode
  • 测试框架:KIF

搭建测试环境

1.在现有工程中添加 Target 实现,选择 File → New → Target… 菜单项,从中选择 iOS → Test 中的 UI Testing Bundle 模板,如下图所示:

2.单击下一步,进入 Target 选项页面,设置测试工程相关项

  • Product Name: KIF 测试工程名,可以自由命名,最好是测试工程名 + "Tests"。
  • Organization Name,Organization Identifier,Bundle Identifier,根据需要自行命名即可。
  • Language: 编码语言,有 Objective-C 和 Swift , 根据需要选择,我们使用的是 OC。
  • Project 和 Target to be Tested:为对应的要测试的工程名,一定要保证是正确的。

image

3.完成 Target 设置后,点击 「Finish」按钮,创建成功。
4.安装pod,在命令行终端输入以下命令。

sudo gem install cocoapods

5.修改或创建工程的 pod 文件 Podfile

target 'GrowingIOTest' do
  pod 'SDCycleScrollView', '~> 1.75'
  pod 'MJRefresh'
  pod 'MBProgressHUD'
end
 
target 'GIOAutoTests' do
  pod 'KIF','~> 3.5.1'
end

其中如下一段内容为新添加的

target 'GIOAutoTests' do
  pod 'KIF','~> 3.5.1'
end

6.在项目目录中执行以下安装命令, 安装相应的依赖,测试项目准备完成

pod install

7.准备好被测程序,在测试 Demo 项目中集成需要被测试版本的数据采集 SDK

编写测试用例

测试环境搭建完成后,接下来就是编写具体的测试用例了,一般测试用例的主要步骤为:

  1. 准备测试环境
  2. 执行测试步骤
  3. 测试结果断言
  4. 测试结果报告
  5. 清理测试环境

下面以 SDK 的无埋点元素点击事件自动化测试用例为例,说明自动化用例的编写。

测试用例:

启动 App,模拟用户滚动屏幕找到对话框按钮,然后点击对话框按钮,显示对话框后点击关闭按钮, 校验点击事件发送数据,发送内容正确。

代码实现:

\- (void)setUp { 
// 一些初始化操作
} 

-(void)tearDown{ 
    // 一些结束动作 
}

-(void)testDialogBtnCheck{
    /**
    function:对话框按钮点击,检测点击事件,
    **/ 
    [[viewTester usingLabel:@"UI界面"\] tap\];
    //添加向下滚动操作 
    [tester scrollViewWithAccessibilityLabel:@"CollectionView" byFractionOfSizeHorizontal:0.0f vertical:10.0f\];
    [tester waitForTimeInterval:1\];
    [[viewTester usingLabel:@"LabelAttribute"\] tap\]; 
    [[viewTester usingLabel:@"ShowAlert"\] tap\];
    [tester waitForTimeInterval:1\]; 
    [[viewTester usingLabel:@"取消"\] tap\];
    [tester waitForTimeInterval:3\];
    NSArray \*clckEventArray \= \[MockEventQueue eventsFor:@"clck"\]; 
    //是否发送clck事件
    if(clckEventArray.count\>=2)
    { 
        //判断点击事件是否字段发送正确 
        NSDictionary \*chevent\=\[clckEventArray objectAtIndex:clckEventArray.count\-1\];
        NSDictionary \*clkchr\=\[NoburPoMeaProCheck ClckEventCheck:chevent\]; 
        //NSLog(@"Check Result:%@",clkchr); 
        XCTAssertEqual(clkchr\[@"KeysCheck"\]\[@"chres"\], @"Passed"); 
        NSLog(@"对话框按钮点击,检测clck事件测试通过---Passed!"); 
    } 
    else
    { 
        NSLog(@"对话框按钮点击,检测clck事件测试不通过:%@!",clckEventArray);
        XCTAssertEqual(1, 0); 
    }
}

由于我们主要测试 SDK 的功能,测试 Demo 是我们自己设计的,主要覆盖各种 UI 元素和事件,其业务逻辑比大多数的业务类型 App 都简单,没有什么特别介绍的。

这里介绍下断言的设计。前文介绍过,我们自动化测试的重点是数据采集规则正确,不关注数据存储与发送。

SDK 在采集数据时会将所有事件先加入一个队列,然后再保存到 DB,所以在执行测试时,只需要监听事件队列,即可在监听的事件队列中按照需要保存和获取需要断言的事件。点击事件发送的数据结构大致如下:

image

对事件数据的校验,首先保证字段完整且每个字段不为空,即数据的 Schema 正确;其次根据需要对事件的具体字段做校验,比如点击事件的类型 t 应该为 clck 。这些检查被封装到了单独的 Check 方法中,如果检查通过则方法返回 Passed。

这里是通过 ClckEventCheck 方法完成了具体的业务校验。

执行测试用例

主要介绍下如何通过命令行执行测试。

安装 Command Line Tools(命令行工具包),App Store 安装 Xcode 默认不会安装 Command Line Tools,可以通过在命令行输入以下命令进行单独安装。

xcode-select --install

在使用命令行执行测试之前,还需要将项目设置成 Shared。打开 Product → Scheme → Manage schemes,查看项目是否是 Shared,如果不是,则选中后面的复选框将其共享。

image

命令行执行所有的测试用例

xcodebuild -workspace Growing.xcworkspace  \
-scheme GrowingIOTest test \
-sdk "iphonesimulator13.5" \
-destination platform='iOS Simulator',OS=13.5,name='iPhone 11'

执行单个用例

xcodebuild -workspace Growing.xcworkspace \
-scheme GrowingIOTest test \
-only-testing:GIOAutoTests/ClckEventsTest/test7DialogBtnCheck \
-sdk "iphonesimulator13.5" \
-destination platform='iOS Simulator',OS=13.5,name='iPhone 11' 

更多 xcodebuild 的使用方法可以参考其使用说明。

man xcodebuild

美化测试报告

xcodebuild 的输出阅读起来不是太直观,使用 xcpretty 可以解决这个问题,并且它还能完成测试报告生成。

xcpretty 是一个高速灵活的 xcodebuild 输出格式化工具,其使用如下

# 命令行安装 xcpretty
gem install xcpretty

命令行执行

xcodebuild -workspace Growing.xcworkspace \
-scheme GrowingIOTest test \
-sdk "iphonesimulator13.5" \
-destination platform='iOS Simulator',OS=13.5,name='iPhone 11' \
| xcpretty --report html

生成的测试报告如下:

默认输出 html 报表在 build/reports/tests.html

5. 覆盖率统计

在执行自动化测试的时候,通常我们想获取测试覆盖率报告,以度量自动化测试的覆盖情况。因为 KIF 是直接基于 XCTest 实现的,所以可以很容易地使用 Xcode 自带的覆盖率统计工具。其设置如下: 

Product → Scheme → Edit Scheme,Code Coverage  把需要统计覆盖率的被测程序加入到 Targets 中。

image

测试完成后可以拿到覆盖率统计报告。

6. 持续集成

自动化测试的最大价值在于可以替代人工进行更高效、更频繁的测试。因此要发挥自动化测试的价值,最理想的方案是,将自动化测试加入到持续集成环节中,每当有代码变更时,就自动的执行测试,快速反馈结果。

我们利用 Jenkins 监控代码仓库变更,当有新的 commit 提交时,Jenkins 会自动拉去最新的代码,并调用命令行执行相应的自动化测试用例,收集相应的测试报告,并将测试结果通过钉钉机器人及时的通知给相关的开发和测试人员。

当测试失败时,相关人员可以第一时间收到结果,并及时解决。

7. 总结

本文以 iOS 平台为例系统的介绍了 GrowingIO 数据采集 SDK 主要工作原理,测试方案的设计以及自动化测试框架的选型与自动化测试实施。希望对从事 SDK 测试工作的同学有所启发。

后面我们还会分享 GrowingIO 用户触达 SDK 的自动化测试,Android SDK 自动化测试等相关的内容,请大家持续关注我们。

参考资料

关于 GrowingIO

GrowingIO 是基于用户行为数据的增长平台,国内领先的数据运营解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。

如果对 GrowingIO 感兴趣,欢迎点击此处了解更多!

查看原文

赞 0 收藏 0 评论 0

GrowingIO 发布了文章 · 2020-07-11

如何基于 BitMap 进行海量数据分析

作者:陈凯

GrowingIO 数据开发工程师,主要负责 SaaS 和 OP 产品数据平台的开发和设计,目前专攻于微服务、数仓建设方向。

GrowingIO 每天需要处理近千亿的用户行为数据,平台的「事件分析」模块是使用比较频繁的功能,简单且强大。在事件分析中,客户可以很灵活地使用多种维度组合去查看某个指标,并且查询的速度也十分可观。

本文抽取 GrowingIO 在事件分析中的通用数据模型,揭晓该功能背后的存储模型和实现原理。

在用户行为的数据分析中,无论是无埋点,还是埋点,对于某一条行为数据的表达形式往往是:「某人」于「某个时间」在「某个维度」下做了「某个动作」「多少次」。

所以在数据统计中,这种表达形式可以拆解成「指标量」和「维度」,指标量可以是用户量、页面浏览量、某个埋点的次数等,维度可以是时间、城市、浏览器、用户属性等。

在海量数据的背景下,如何比较高效地完成指标+维度的计算,一直是大数据分析领域比较热门的话题,下面将讲述在 GrowingIO ,我们是如何高效解决的。

1.从一个数据需求说起📈

假设给定如下一组用户行为的原始数据:

数据含义: 表示某个用户的某次访问记录。(这里仅列举了地区和设备维度,当然还会存在浏览器、平台、版本等维度,这里不一一列举了。)

1.1 使用 SQL 分析统计

🤔 现在业务想计算「过去7天」在「地区」维度下,「设备: Mac」的人数是多少?So Easy,一个 SQL 搞定

使用 GrowingIO 平台的分析工具可以表示如下:

但是通过 SQL 这种现查的方式,随着数据量的越来越大,几十亿或上百亿的时候,对计算所需要的资源和响应时间也会线性地增长,此时客户在使用平台工具最直观的感受就是“菊花”转转转,图表一直加载不出来。

1.2 如何使查询更加高效

1.2.1 堆机器,加资源

最直接粗暴的方式,就是增加更多的计算资源,或者对查询的结果进行缓存、预热。但是对于 SaaS 产品来讲,在查询并发比较高的时候,再多的计算资源也会因为查询排队而遇到性能瓶颈。

1.2.2 数据分层

😼 在数仓的分层架构中,对于经常使用的查询结果,我们可以通过离线计算的方式生成了一个结果表「过去7天-地区-设备-指标表」,示例如下:

这样在特性的查询场景中,只需要查询结果表就行,很大的减少了计算量,响应时间也短了不少。

😱 但是业务那边的需求往往是千变万化的,然后一堆统计的需求砸到了你的脑袋上

  • 「过去 30 天」的「总用户的量」和「总访问量」是多少?
  • 「昨天」「设备: Windows」的「用户量」是多少?
  • 「获取 30 天」按「平台」拆分的「用户量」是多少?
  • 「11 月- 11 日」来了哪些用户?请导出这个用户列表
  • 其他更多漏斗、留存、画像等数不尽的需求让人头晕目眩。

         ......

😫 你看了看生成结果表,发现并不能完全解决这些问题,你觉得需要生成更多的结果表来满足更多的需求。然而到最后你发现有些表居然仅仅只使用了一次,数仓里面堆了一堆垃圾。

1.2.3 数据预聚合

和数仓分层的理念类似,对数据进行预计算、预聚合,使用空间换时间的思想加快计算。这也是目前一些主流开源框架的解决思路,比如 SparkSQL 的物化视图、Kylin的 Cube、Druid 的 Segment 、Carbondata 的 MV 等。

下面使用一张图展示主要区别:

基于我们所追求的方式,我们首先需要寻找一种高效并且灵活的存储模型。

1.3 优化存储模型

基于上节中数据预聚合的思路,从预聚合的结果中,我们不难发现,其中有几个没有摆脱的点:

  • 多个维度依然是横向存储在一起
  • 维度和指标绑定在一条记录中
  • 因为存在这种局限性,刚好限制了我们灵活进行「维度+指标」的任意组装和扩展

🤔 如何才能更好的让维度和指标随心所欲地组合呢?我们在预聚合结果的基础之上做了一些改进:

  • 将每个维度纵向存储
  • 将维度和指标分开存储

2. 基于 BitMap 的存储模型 💻

2.1 纵向存储维度(人数)

依然以开篇的那组数据为例,此时将维度进行纵向存储:

此时想取「地区: 北京」和「设备: Mac」的「用户量」

OK!这样可以很灵活的解决各种维度组合起来的问题了,而且连用户的群体也能直接获取。

😇 但是从表格中发现,用户存储「用户集合」的数据结构还没有解决。那么既能以类似数组的方式存储整数值,还能使用交集(and)操作,还需要达到更好的数据压缩和计算。

此时你应该想到了 BitMap 这种数据结构

至于为何选用 BitMap 的数据结构,以及 BitMap 的功能和基本使用,这里不再探讨。可以参考 java 的实现 BitSet 以及优化的库 RoaringBitmap。

2.2 存储指标量(次数)

为了解决存储指标次数的存储问题,你需要用一个Map 的结构来存储「总的次数」: Map<Int, BitMap> (其中key为次数,value为符合访问次数的人)

访问量表示: 总共访问「1次」有哪些人,访问「2次」的有哪些人等等。

此时计算「地区: 北京」和「设备: Mac」的「访问量」

2.3 使用更优雅的方式存储次数

在 Map<Int, BitMap> 这个结构中,key 存储的是 10 进制的数字。这就会导致 Map 的 key 变得特别特别多,所以需要有一种方式来优化一下结构。

方式就是用将 10 进制转化为 2 进制的方式去存储次数,此时 Map 的 key 存储是 二进制为 1 的位置:

比如 2 的二进制是: 「10」,从右向左分别表示(下标i从0开始)「第0位是0」,「第1位是1」。所以将key为1的 bitmap 中存储这个人。

比如5的二进制是: 「101」,从右向左分别表示「第0位是1」,「第1位是0」,「第2位是1」。所以将 key 为 0 和 2 的 bitmap 中存储这个人。

然后将上节 2.2 中结果表示如下:

此时计算「地区: 北京」和「设备: Mac」的「访问量」

3. 多维度交叉的问题 ⚔️

理想是美好的,但是现实很残酷。在 2.1 小节的例子中,每个用户的维度组合只有一种,但是现实中往往一个用户行为可能会存在多种维度组合的情况。

那么什么是维度组合: 一条数据中唯一的所有维度值,称为一个组合。

PS: 如果你的系统中某个 ID 的维度组合只有一种。比如某个订单,一旦生成了,他的价格,商品,物流等信息基本都是固定的。那么之前的模型基本都能满足大多场景了。

3.1 面临的问题

🤔 那么会导致什么问题呢?此时回到起点,又来了一批用户行为的数据如下:

此时多了一个「用户1」在「杭州」使用了「Windows」。如果按照之前的模型存储如下:

此时计算「地区: 北京」的用户:直接返回 [ 1 , 2 , 3 ],问题不大

此时计算「地区: 北京」和「设备: Windows」的用户

❌ 你会发现,得出的结果是错的,应该只有「用户 3 」满足才对。

3.2 使用维度组合编号的方式解决

其实问题出在将维度分开进行存储的时候,丢失了「维度组合关系」这个重要的衡量条件。「用户 1 」虽然在「北京」待过,也使用过「Windows」,但是他却没有同时满足这个条件,这就是问题所在。

所以需要一种方式来存储「维度组合关系」这一重要信息。

将每个人维度组合进行顺序编号,得到如下结果:

注意:编号是对应到每个人的,相同的维度组合,编号是一样的。

此时对应的存储结构也发生了变化:Map<Short, BitMap>( key 代表编号,value 代表人的集合

此时我们再来计算「地区: 北京」和 「设备: Windows」维度的用户

最后得到的结果就是「用户3」,算出来的数据就变准确了。

3.3 多维度情况下计算次数

其实稍微想一下就是两层的 Map 结构:Map<Int, Map<Short, BitMap>>,比如刚刚的那组数据表示如下:

比如「用户1」这个用户,在「编号0」发生的 2 次,在「编号1」发生了 1 次

此时我们来计算「地区: 北京」和 「设备: Mac」维度的访问量

4. 简单的性能对比

环境准备:SparkSQL(local[16], 内存4G), BitMap 单线程计算(内存4G)

场景:简单的 2 ~ 3 个维度组合求人数、次数,按照值的降序取 Top 1000

x轴含义: 数据量/用户量。

y轴含义: 计算时间, 单位毫秒。

可以看到随着数据量的不断递增,SparkSQL 的计算时间也在不断递增,但是 BitMap 的计算时间却相对比较稳定。

5. 总结

BitMap 是一个兼并计算和存储优势的数据结构,在存储上百万甚至上千万的 ID 时,也能得到很好的计算效果。

并且当你使用 BitMap 存储的时候,就已经天然支持很多的业务场景,比如分群计算、标签计算、漏斗分析、留存分析、用户触达等,因为无需再重新计算人群。

本篇主要揭晓我们是如何基于 BitMap 来作为底层的数据模型,当然在实际应用过程中还有很多的挑战,由于篇幅原因,这里就不展开讲述了。

以下列出一些我们后续的工作内容和攻克方向:

bitmap 是以 int 值进行存储的,但是在实际生产中,你的 ID 数据可能是类似 UUID 的这种字符串,那么需要解决 string 转唯一 int 的问题。

  • 如何使 bitmap 在分布式环境下达到更好的计算效果
  • 如何解决高基维度所带来的挑战
  • 如何在实时、图表分析、分群画像以及更多的业务场景中进行使用
  • 如何设计一个类 SQL 语言来兼容这种数据模型

         ......

关于 GrowingIO

GrowingIO 是基于用户行为数据的增长平台,国内领先的数据运营解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。

如果对我们的产品感兴趣,欢迎点击此处领取 15 天免费试用!

查看原文

赞 0 收藏 0 评论 0

GrowingIO 发布了文章 · 2020-06-11

Android 无埋点从入门到放弃:了解 Java 字节码

微信图片_20200610100121.png

作者:李嘉辉
GrowingIO 大前端开发工程师。负责 GrowingIO Android 和 iOS 无埋点 SDK 的设计和开发,目前专注在 Android AOP 开发。

引言

Android 无埋点的本质是通过技术手段来改写 Java 字节码,达到“自动写代码”的目的,从而实现业务目标,简单来说就是一种 Hook 形式。但是在进行之前,我们需要了解下 Java 字节码,以免出现错误的修改,导致无埋点变成“crash 点”。这就好比我们写文档前,需要先识字一样。

本文讲述的内容虽然比较难懂和枯燥,但是我们尽量用生动的对话形式和例子来描述。如果您一次阅读后无法消化,请收藏,之后可以多次阅读,跟着文章中的步骤动手操作。相信这篇文章会对您接触 Android 无埋点的实践有所帮助。

本文涉及到的工具如下(mac/windows)

  • IntelliJ IDEA
  • jclasslib Bytecode viewer
  • 010 Editor

场景还原

今天是小 O 到 G 公司上班的第一天,公司决定让老 I 带小 O 熟悉公司的代码。

老 I:可以先熟悉一下公司的代码。

小 O:好的,我这就去。

1.字节码

小 O:我看到代码中很多常量都来自于 ASM,这个框架有什么作用呢?

int ACC\_PUBLIC = 0x0001; // class, field, method

int ACC\_PRIVATE = 0x0002; // class, field, method

int ACC\_PROTECTED = 0x0004; // class, field, method

int ACC\_STATIC = 0x0008; // field, method

int ACC\_FINAL = 0x0010; // class, field, method, parameter

int ACC\_SUPER = 0x0020; // class

...

老 I:ASM 是一个通用的 Java 字节码操控和分析框架。

小 O:操作 Java 字节码?

老 I:用《Java 虚拟机规范》上的说法,Java 字节码就是编译后被 Java 虚拟机所执行的代码,使用了一种平台中立(不依赖特定硬件及操作系统)的二进制格式来表示,并且经常(但并非绝对)以文件的形式存储,因此这种格式称为 class 文件格式。

小 O:那我要学会 ASM,应该先了解字节码吗?

老 I:是的,我先跟你介绍一下字节码的基本结构,以下面的代码为例。

public class Main{

 public static void main(String\[\] args) {

 System.out.println("Hello World!");

 }

}

老 I:一个标准的 ClassFile 结构如下:

ClassFile {

 u4              magic;

 u2              minor\_version;

 u2              major\_version;

 u2              constant\_pool\_count;

 cp\_info         constant\_pool\[constant\_pool\_count-1\];

 u2              access\_flags;

 u2              this\_class;

 u2              super\_class;

 u2              interfaces\_count;

 u2              interfaces\[interfaces\_count\];

 u2              fields\_count;

 filed\_info      fields\[fields\_count\];

 u2              methods\_count;

 method\_info     methods\[methods\_count\];

 u2              attributes\_count;

 attribute\_info  attributes\[attributes\_count\];

 }

老 I:让我们通过一个更加易懂的方式继续,我们通过 010Editor 打开上述代码编译后的 class 文件可以看到如下信息。

2.magic

小 O:这个开头的数字 0xCAFEBABE 很有意思,是有某种特殊含义吗?

老 I:这是一个魔数,用于判断是否是一个 class 文件,是一个固定值,也就是 0xCAFEBABE(魔数背后的故事 https://en.wikipedia.org/wiki..._class_file),至于为什么使用这个魔数,可以看一下文章里 James Gosling 的描述。

3.minor_version

小 O:紧跟着魔数的 0x00000034 看图好像分为了 minor 和 major,是表示版本号吗?

老 I:对的,minor 表示本地 java 环境副版本号,你可以通过 java -version 查看本地 java 版本,比如我的本地版本,1.8 为主版本号,0 为副版本号,如图所示 0x0000,171 为 update。

4.major_version

老 I:major 表示本地 java 环境主版本号,0x0034 即 52 对应 1.8,0x0033 即 51 对应 1.7,依此类推。

5.constant_pool_count

小 O:下面这个 constant_pool_count 有 34 个, 010Editor 中显示的 0-32 就是他的成员了吗,那为什么 count 是 34 个呢?

老 I:这是因为 010Editor 默认使用的 class 解析模版(template)是从 0-32,但是实际上常量池使用 1 ~ constant_pool_count-1 作为索引,count 则为常量池中成员数 +1,0 用于保留作为其他用途,这个可以使用 IDEA 的 jclasslib Bytecode viewer 插件查看,它是符合从 1 开始计数的规范的,并且提供了对各种类型的过滤。

小 O:那 0 的保留用途具体是什么呢?

老 I:这是一个好问题,0 的保留用途可以参照一些例子, 比如说:

  • java/lang/Object时的superclass
  • 表示捕获所有异常(等价于catch java/lang/Throwable)时的catch_type
  • 类为顶层类、顶层接口、本地类、匿名类时的outer_class_info_index
  • 匿名类时的inner_name_index
  • 当前类不是直接包含在某个方法或构造器时的method_index

6.constant_pool[constant_pool_count-1]

小 O:那后面紧跟着的就是所谓的常量池成员了吗?

老 I:对的,常量池包含所有字符串常量、类或接口名、字段名和其它常量,常量池数组中不同元素类型不同,结构不同,但是均使用第一个字节作为类型标记(tag byte),涉及的内容比较多,我们先整体上了解一下,跳过常量池继续往下看。

7.access_flags

小 O:这个 flag 是 0x0021,这是什么意思呢?

老 I:还记得最初你在 ASM 框架中看到的常量吗,这个 flag 定义如下。

微信截图_20200611111008.png

老 I:你图中看到的 0x0021 即 ACC_SUPER & ACC_PUBLIC。

8.this_class

小 O:那这个 this_class(0x0005) 也是某种常量吗?

老 I:不是的,这里的 0x0005 是一个指向常量池中某个成员的有效索引,常量池中该索引处成员必须为 CONSTANT_Class_info 类型结构体,表示这个 class 文件所定义的类或接口。

老 I:在图中可以看到 0x0005 指向常量池中如下位置,你应该还记得 010Editor中template 解析常量池数组的 name 显示是从 0 开始,而实际常量池的索引从 1 开始,所以 0x0005 在 010Editor 中对应 constant_pool[4]),即下面图中这个位置。

老 I:然后根据该常量池成员结构的 name_index,继续查找常量池中对应 index,0x001A。

老 I:如果觉得 010Editor 不够清晰或者不好查找,可以使用 Intellij 的插件 jclasslib Bytecode viewer,支持直接跳转,方便快速查找。

9.super_class

小 O:那后面这个 0x0006 也是一个指向常量池的索引吗?

老 I:对的,父类索引,常量池中该索引处成员必须为 CONSTANT_Class_info 类型结构,表示这个 class 文件所定义的类的直接超类,如果为 0,即当前类为 java/lang/Object。

老 I:由图中可以看到 0x0006 指向常量池中如下位置。

老 I:根据 name_index,继续查找常量池中对应 index,0x001B。

老 I:这里我们可以通过 jclasslib 插件查看,支持直接跳转查看父类,还记得我们之前说过常量池 0 的保留用途吗?

老 I:从这里我们可以看到 Object 的 super_class 指向常量池中 0 这个位置(即无效索引)。

10.interfaces_count

小 O:我知道了,那这个 0x0000,表示当前类或接口的直接超接口数量,即 interfaces 表中成员个数是 0。

老 I:没错,就是这个道理。

11.interfaces[interfaces_count]

老 I:因为我们之前的类是没有超接口的,所以在原 class 文件增加两个接口,则可以看到:

老 I:在这个数组中,每个成员的值必须是一个有效索引,常量池中该索引处成员必须为 CONSTANT_Class_info 类型结构体,表示这个 class 文件所定义的类或接口的直接超接口,与源码定义接口从左往右顺序一致。

12.fields_count

小 O:那后面这个 0x0000 表示当前 class 文件 fields表中成员个数为 0。

老 I:对的。

13.fields[fields_count]

老 I:在这个数组中,每一个成员都是由 filed_info 结构所定义。

field\_info {

 u2             access\_flags;

 u2             name\_index;

 u2             descriptor\_index;

 u2             attributes\_count;

 attribute\_info attributes\[attributes\_count\];

}

老 I:之前的代码没有定义成员,所以我们使用如下代码来看这个结构。

public class Main {

 int i = 0;

}

(1)access_flags

小 O:我知道,这个 flag 也是一些常量标识。

老 I:对的,这些常量标识的定义如下。

微信截图_20200611111506.png

老 I:因为我们代码中使用的是默认 access_flags,所以在 010Editor 中显示为 0x0000。

(2)name_index

小 O:那这个结构中的 name_index、descriptor_index ,带着 index,应该都是指向常量池中某个成员的索引吧。

老 I:对,name_index 是一个有效索引,常量池中该索引处成员必须为 CONSTANT_Utf8_info 结构, 根据图中索引为 0x0005,找到常量池如下位置。

(3)descriptor_index

老 I:descriptor_index 也是一个有效索引,常量池中该索引处成员必须是 CONSTANT_Utf8_info 结构,为字段的描述符,根据图中索引 0x0006,找到常量池如下位置。

(4)attributes_count

小 O:那接下来的这个 attributes 是做什么的呢? 为什么这里是 0x0000?

老 I:这个表示当前字段的附加属性表中成员的个数。

(5)attributes[attributes_count]

老 I:属性表的成员,每一个成员为 attribute_info 结构,举个例子,可以在字段上增加 @Deprecated,如下图所示,具体的可以在之后看 attribute_info 时分析。

14.methods_count

小 O:这个应该是表示当前 class 文件 methods 表成员个数,最初那个例子中,0x0002 表示有两个函数,但是我们不是只定义了 main 函数吗?

老 I:对的,在最开始使用的例子中,0x0002 表示有两个函数。

15.methods[methods_count]

老 I:紧跟着 methods_count 的是方法表的成员,每一个成员为 method_info 结构,用于表示当前类或接口中某个方法的完整描述,结构如下。

method\_info {

 u2             access\_flags;

 u2             name\_index;

 u2             descriptor\_index;

 u2             attributes\_count;

 attribute\_info attributes\[attributes\_count\];

}

老 I:我们使用如下代码来看这个结构,也顺道解释为什么之前的代码虽然只定义了一个 main 函数,count 却是 0x0002。

public class Main {

}

老 I:我们可以通过 jclasslib 来看,比较清晰。实际上会有一个编译器提供的默认构造函数。

(1)access_flags

小 O:嗯,这个 method_info 跟 field_info 很接近,让我来试着解释一下。

老 I:好的,请开始你的表演,这个 flag 的定义我先列出来。

微信截图_20200611111853.png

小 O:因为默认生成的构造函数是 public,所以对应 010Editor 中显示为 0x0001,即 ACC_PUBLIC。

(2)name_index

小O:name_index 是一个有效索引,常量池中该索引处成员必须为 CONSTANT_Utf8_info 结构,表示一个特殊方法名或有效非限定名,根据图中索引为 0x0004, 就可以找到常量池在如下位置。

(3)descriptor_index

小O:descriptor_index 也是一个有效索引,常量池中该索引处成员必须为 CONSTANT_Utf8_info 结构,表示方法的描述符,根据图中索引为 0x0004,就可以找到常量池在如下位置。

(4)attributes_count

小 O:这个 attributes_count 表示当前方法的附加属性表中成员的个数。

(5)attributes[attributes_count]

小 O:属性表的成员,每一个成员应该也为 attribute_info 结构。

老 I:嗯,对的,厉害了。这部分我补充个例子,比如 Code_attribute,可以看到如下图所示,具体在之后看 attribute_info 时分析。

16.attributes_count

小 O:这个 attributes_count 应该表示的是当前 class 文件属性表的成员个数。

老 I:嗯,没错。

17.attributes[attributes_count]

老I:这个文件属性表成员,每项都必须是attribute_info 结构,以 SourceFile_attribute 为例,我们通过如下代码来看。

public class Main {

}

老 I:如下图所示。

老 I:其结构如下:

SourceFile\_attribute {

 u2 attribute\_name\_index;

 u4 attribute\_length;

 u2 sourcefile\_index;

}

(1)attribute_name_index

小 O:这个 index 肯定是一个有效索引。

老 I:对的,常量池中该索引处成员必须为 CONSTANT_Utf8_info 结构,表示字符串 "SourceFile"。

(2)attribute_length

老 I:SourceFile_attribute 的 length 为固定值 2,即图中 0x00000002。

(3)sourcefile_index

老 I:最后的 index 也是一个有效索引,常量池中该索引处成员必须为 CONSTANT_Utf8_info 结构,表示被编译的 class 文件的源文件名。

等你先熟练掌握今天介绍的内容,我再给你继续讲解其他的结构。常量池、属性(filed, method 中的 attribute_info 也将放在一起)涉及较多内容,掌握字节码对后续了解及使用 ASM(一个通用的 Java 字节码操控和分析框架)有很大帮助,也可以让你更快熟悉代码。

小 O:好的,让我再分析几个 class 文件熟悉一下。

小结

理解 Java 字节码并不单单对了解无埋点插桩( ASM 等框架)有帮助,在一些常见的情境下它也能发挥自己的作用。所以对 Java 字节码的掌握有利于我们更加深入地了解 Java 的各种机制,比如说很常见的面试题(可以看看自己是否能够回答上来)

  1. String 在编译期与运行期最大长度
  2. synchronized 语义
  3. new 语句中 dup 指令作用

参考文档:

关于 GrowingIO

GrowingIO 是基于用户行为数据的增长平台,国内领先的数据运营解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。

点击此处,获取 15 天免费试用!

查看原文

赞 0 收藏 0 评论 0

GrowingIO 发布了文章 · 2020-05-29

GrowingIO 大数据多维分析自动化测试实践

作者:郝阔君
GrowingIO QA Lead,曾任职于中国惠普、奇虎 360。带领 QA 团队负责 GrowingIO 全产品线质量保证工作,目前专注于 DevOps 实践,帮助团队提升质量和效率。

1.问题背景

「事件分析」是 GrowingIO 为用户提供的一个非常灵活、强大的基于大数据平台的交互式多维分析工具,下面是一个简单的单指标、单维度、带过滤条件的事件分析图表:

如何对事件分析产生的图表进行测试呢?经过分析,我们发现一个事件分析图表包含指标、维度、目标用户、过滤条件、时间范围、粒度、排序多个因素,这些因素会影响其查询结果。

而每个因素又有多个取值,有的取值多达几十种,即使每个因素都使用等价类划分取最小值,其组合产生的测试用例数为:

指标数(23) * 维度数(9) * 目标用户数(6) * 过滤条件(无过滤条件(1) + 维度数(9) * 过滤类型(6)=55) * 时间范围(14) * 粒度(4) * 排序(5) = 22314600。

即使仅选取其中最关键的指标、维度、目标用户、过滤条件 4 个因素,其组合数也高达 23 * 9 * 7 * 55 = 79695 。如此多的测试用例数量,如何测试呢?

2.寻找测试方案

显然,通过手工测试的方式是无法完成如此海量的测试任务的,必然需要使用自动化测试的方式。但如果采用 UI 自动化方式,其实现成本高,执行慢,不稳定,结果判定难, API 测试显然更合适。

GrowingIO 的可视化图表数据都来自 Charts 服务,Charts 服务定义了一种强大灵活的被称为 GQL(GrowinIO Query Language) 的 DSL,用于描述图表查询(可以类比 SQL)。以上面事件分析图表为例其 GQL 简化的表达如下:

{

 "metrics": \[

 {

 "id": "pv",

 "name": "页面浏览量"

 }

 \],

 "dimensions": \[

 "city"

 \],

 "granularities": \[

 {

 "id": "tm",

 "interval": 86400000

 }

 \],

 "filter": {

 "op": "and",

 "exprs": \[

 {

 "key": "bw",

 "op": "=",

 "values": \[

 "Chrome"

 \],

 "name": "浏览器"

 }

 \]

 },

 "orders": null,

 "timeRange": "day:8,1",

 "targetUser": "uv",

 "limit": 20

}

返回结果如下:

{

 "data": \[

 \[

 "北京",

 1111

 \],

 \[

 "上海",

 2222

 \],

 \[

 "深圳",

 3333

 \],

 \[

 "杭州",

 4444

 \],

 \[

 "广州",

 5555

 \]

 \],

 "meta": {

 "columns": \[

 {

 "id": "city",

 "name": "城市",

 "isDim": true,

 "isRate": false,

 "isDuration": false

 },

 {

 "id": "pv",

 "name": "页面浏览量",

 "isDim": false,

 "isRate": false,

 "isDuration": false

 }

 \]

 }

}

熟悉了接口请求的结构,实现接口自动化就比较容易了,只需选取合适的测试数据,构造请求,发送请求,验证结果。

但在实现自动化测试的时候,不免疑惑:这真的是好的方案吗?

经过简单计算我们发现,即使选择较少的覆盖 79695 条用例,假设平均每个执行 10 秒钟,也需要耗时 221.375 个小时。当然可以采用并行执行加速,并行 100 个进程,每次也需要执行 2.2 小时。

2 个小时似乎还可以接受,但是实际执行发现,测试执行的稳定性很难保证,主要原因是分析数据都来自后端的 OLAP 系统,其数据量相当庞大,很难承受如此高密度的频繁查询。

此外,如此多的用例真的都是有效覆盖吗?执行这么多用例实际发现缺陷的可能性又有多少?ROI 又是多少?实际上,这几个问题很难给出精确的答案,需要不断地执行测试,统计其发现的缺陷才能给出答案。

3.更好的解决方案

使用最少的用例,发现尽可能多的缺陷,是测试用例设计追求的目标,也是测试工程师价值所在。

上面的测试用例使用了全组合的方式进行覆盖,如果使用更少的因素组合必将大大减少用例数量,但是减少组合数能否保证测试效果?答案是肯定的。

据了解,IEEE 根据交付给客户的软件系统中漏测缺陷的特征分析,发表了回顾性研究结果:

其研究结果指出,很少有缺陷是由 4 个和 5 个参数相互作用引起的,6 个参数相互作用引起的缺陷更是罕见。

微信图片_20200529101738.jpg

关键结论:平均超过 90% 以上缺陷是由 3 个或更少的参数组合引起的。因此任何测试设计中都应该至少保证两因素组合的 100% 的覆盖测试。有着高可靠性需求的应用,比如医疗设备或者航空电子设备,应该保证至少 3-way 因素组合的 100% 的覆盖测试。

由于两因素组合测试在测试用例个数和错误检测能力上达到了较好的平衡,它是目前主流的组合测试方法。

那么如何有效地生成两因素组合的测试用例呢?这个已经有前辈给出了方法和工具。对于多输入参数组合类的测试方法,目前业界主要有两种测试用例设计方法,成对测试 (Pairwise Testing) 和正交表测试 (OAT: Orthogonal Array Testing)。

由于我们现有的自动化 API 测试都是使用 Python 语言实现的,并且有一个开源的 Python 库 allpairspy 可以很容易的实现成对测试用例生,所以很自然的就选择了这个工具。

4.Pairwise Testing 应用

Pairwise Testing 又被称为全对测试(All-Pairs Testing), 是软件测试的组合方法,对于软件系统中的每对输入参数对,它们都将测试这些参数的所有可能离散组合。

通过“并行化”参数对的测试,使用精心选择的测试向量,可以比穷举搜索所有参数的所有组合更快地完成操作。简单地说,就是保证所因素的的变量都至少两两组合一次。

Pairwise Testing 例子

仍然以上面事件分析为例,为了方便说明做一些简化,假设有下面指标、维度和目标用户组合。

微信图片_20200529101742.jpg

采用全组合将生成 27 条测试用例:

微信图片_20200529101749.jpg

使用 Pairwise Testing 筛选后的组合,保证指标和维度,指标与目标用户,维度与目标用户两两组合都覆盖,用例数减少到 9 条,下面是其中一种排列方式,需要注意的是同一组输入可以输出多组个满足 Pairwise 的结果,其测试效果等价。

微信图片_20200529101745.jpg

使用 allpairspy 来生成组合,其实例代码如下:

from allpairspy import AllPairs

M = \['M1','M2','M3'\]

D = \['D1','D2','D3'\]

U = \['U1','U2','U3'\]

parameters = \[M,D,U\]

print("PAIRWISE:")

for i, pairs in enumerate(AllPairs(parameters)):

 print("|{}|{}|{}|{}|".format(i, \*pairs))

使用 piarwise 对事件分析用例进行优化

下面是一段简单的测试脚本,对事件分析所有组合用例使用 AllPairs 生成的用例数进行计算。经计算其组合数为 1269, 仅占原来 22314600 种组合的 0.0057%,极大的减少了用例数。

from allpairspy import AllPairs

def get\_arrays(\*values):

 r = \[\]

 for d in values:

 r.append(range(d))

 return r

print(len(list(AllPairs(get\_arrays(23,9,7,55,14,4,5),filter\_func=is\_valid\_combination))))

5.进一步优化

通过上面实验,测试用例里数已经大大减少了,但是根据实际业务和测试需要我们可以进行进一步的优化,来提升测试效果。

根据业务规则过滤

上面用例没有考虑各个因素之间的相互关系,比如仅有指标为埋点事件时,维度值和过滤条件才可以为事件变量。allpairspy 支持对组合结果进行自定义过滤。下面是一段模拟代码,模拟量非法组合的过滤,经过计算有效组合为 954 个,又在原来基础上减少了近 1/4 。

from allpairspy import AllPairs

def get\_arrays(\*values):

 r = \[\]

 for v in values:

 r.append(range(v))

 return r

def is\_valid\_combination(row):

 n = len(row)

 if n > 1:

 # 假设指标 1 是埋点事件,维度 1 为事件变量

 if 1 == row\[0\] and 1 != row\[1\]:

 return False

 if n > 3:

 # 假设指标 1 是埋点事件,过滤条件中有 6 个是埋点事件相关

 if 1 == row\[0\] and row\[3\] < 6:

 return False

 return True

print(len(list(AllPairs(get\_arrays(23,9,7,55,14,4,5)))))

每次生成不同的组合

同样的因素变量满足 Pairwise 的组合不止一种,如果每次生成的测试用例组合都不一样,那么随着测试的执行次数增加,会不断的测试新的组合,更有可能发现某些特定组合的缺陷,提升测试效果。很遗憾 allpairspy 没有提供随机种子的参数,每次生成的用例组合都相同。

不过在生成测试用例前先随机打乱每个因素的变量值顺序,即可实现。可以用一下代码验证,每次生成的用例组合都会发生变化。

from allpairspy import AllPairs

from random import shuffle

M = \['M1','M2','M3'\]

D = \['D1','D2','D3'\]

U = \['U1','U2','U3'\]

shuffle(M)

shuffle(D)

shuffle(U)

parameters = \[M,D,U\]

print("PAIRWISE:")

for i, pairs in enumerate(AllPairs(parameters)):

 print("|{}|{}|{}|{}|".format(i, \*pairs))

N-wise 组合覆盖

上面都是 Pairwise (即 2-wise) 组合覆盖,如果因素和变量较少时可以增加到 3-wise 甚至更多组合,以提升覆盖率。allpairspy 支持生成 n-wise 组合,只需要在其构造参数添加 n=x 参数即可,示例如下:

from allpairspy import AllPairs

M = \['M1','M2','M3'\]

D = \['D1','D2','D3'\]

U = \['U1','U2','U3'\]

F = \['F1','F2','F3','F4'\]

parameters = \[M,D,U,F\]

print("PAIRWISE:")

for i, pairs in enumerate(AllPairs(parameters,n=2)):

 print("|{}|{}|{}|{}|{}|".format(i, \*pairs))

print("3-WISE:")

for i, pairs in enumerate(AllPairs(parameters,n=3)):

 print("|{}|{}|{}|{}|{}|".format(i, \*pairs))

6.Pairwise 的不足

没有银弹,任何测试方法都不可能 100% 的发现所有缺陷,Pairwise 方法也存在一定的不足。

  1. Pairwise 对于因素的选择,需要对业务很熟悉,了解各个因素对输出的影响,以及各个因素之间的相互约束关系。如果构造的输入不正确,也难达到预期测试效果。
  2. Pairwise 算法对于多于 2 个因素相互作用所产生的 Bug 没有覆盖到,可以使用 N-wise 一定程度上解决,但是又会增加用例数,提高测试成本,实际使用中要综合考量。

但是综合考虑成本、效率和测试覆盖等因素,当前其仍然是组合测试领域的优秀解决方案。

参考文档:

  • 《Pairwise Testing》
  • 《allpairspy 》
  • 《OATS PK Pairwise Testing》
  • 《深入浅出 pairwise 算法》
  • 《组合测试从理论到实践——从吃货的角度实现组合测试用例的自动设计》

关于 GrowingIO

GrowingIO 是基于用户行为数据的增长平台,国内领先的数据运营解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提升数据驱动能力,实现更好的增长。

点击此处,获取 15 天免费试用!

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 4 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-05-09
个人主页被 602 人浏览