前言
当前xxx项目埋点长期处于混乱状态,存在埋点方案不统一、字段不明确、点位不清晰、数据丢失等诸多历史性问题。同时老的埋点 SDK 也存在代码结构不清晰、接口上报逻辑复杂导致 bug 排查难度大等杂症,再加上当前埋点 SDK 是由异常上报、性能监控、自定义事件、埋点、A/B实验等功能合并而来,进一步增加了埋点 SDK 的复杂度,SDK的维护已非常困难。刚好数据平台部提出了新的埋点协议,新的埋点协议对老埋点协议的改造较大,在老SDK上适配新的埋点协议已几乎不可能,故借新版埋点协议更新时机重新开发一套新的埋点SDK便水到渠成。
设计目标
当前移动端行业内埋点有非常多的成熟埋点技术方案,如无埋点技术、可视化埋点技术等。但受限于不同公司的业务类型及技术水平(包含但不限于大数据处理、大数据分析等)不存在一套通用的埋点方案,每家公司的埋点都存在或大或小的差异。从新版本埋点协议来看,新版埋点 SDK 无法使用无埋点、可视化埋点等技术。原因在于新版协议仍然需要在业务层或拼接或透传大量业务参数,并且还需要前端串联埋点事件流,业务侵入性大。基于此原因 新版 SDK 侧重点在数据处理、上报的准确性与稳定性。
适配新埋点协议只是埋点SDK基础功能,重点是提升准确性并解决当前老 SDK 存在的问题,所以在项目初始需要设立如下目标:
- 适配的新埋点协议:在适配新协议的同时兼容老版本协议
- 只包含埋点相关功能:如无必要勿增实体,单一功能能减少SDK复杂度,易于开发及维护
- 合理的功能模块拆分:老埋点SDK在一个文件内完成了埋点上报相关的逻辑,逻辑复杂、灵活性差、极难维护
- 简洁的接口设计:埋点动作简单,接口单一,复杂的接口设计会增大业务开发的理解成本,易造成埋点错误
- 足够的灵活性:将可能变化的部分尽可能的设计为可配置项,业务可灵话配置SDK内部行为同时接入不同业务时不需要调整SDK
- 最佳的可靠性:保证埋点数据不丢失,包含埋点字段的准确性与单个埋点事件在不同网络条件下不会丢失
- 性能影响最小:埋点涉及复杂的数据处理,但埋点又不属于业务功能,如果为了收集埋点数据导致界面卡顿影响用户体验将得不偿失
- 开发友好:尽可能对业务开发屏蔽复杂的埋点协议,避免增加业务开发对埋点的理解成本,只需关注单点埋点数据本身
我详细列出了以上目标为项目的开发设定了基础限制,避免开发过程偏离最初设定,也方便项目后期校验目标达成率。
模块拆分
在设计整个架构之前需要充分理解埋点协议,并根据「常规」埋点需要的流程规划一些核心模块。
首先埋点数据肯定是需要进行网络上报,那么我们需要一个专门进行「数据上报」的模块。单纯的上报似乎只需要写一个上报接口就已满足需求,但是只有一个上报逻辑的模块显得单薄了点。为了满足设计目标的可靠性与灵活性需要为上报模块添加更多功能:
- 上报之前需要对埋点数据做定制处理(不同接口对数据结构要求不同),那需要截拦回调把上报前最后的数据处理流程抛出去
- 上报成功与否的判定也需要抛给外部(应用层)决定,原因同上:不同接口的响应结构也不尽相同
- 支持批量上报且上报数据的数量应该是可控的,那就需要外部可指定单次上报数据的条数
- 为减少服务器压力增加上报成功率,不管外部如何调用接口,上报接口之间应该保证串行
- 用户端的网络环境多变, 网络抖动非常常见所以需要支持重试, 且至少适配 POST/GET 两种方式的请求
上报模块需要支持批量上报,上报之前应该需要一个缓存模块支持埋点数据的缓存,当缓存到一定数量之后将数据丢给上报模块进行上报。常用的缓存为内存缓存 与磁盘缓存两种,内存缓存适合用来存储量少且存活时间较短的数据,磁盘缓存适合存储量大且需长时间保存的数据。在埋点的场景下可以同时种用这两种缓存,内缓存用来存待上报数据上报成功之后就可从内存缓存中删掉,磁盘缓存用来记录所有埋点数据,当上报失败时下次启动仍可从磁盘缓存读取未上报数据。
因此缓存又可拆分为两个模块:「内存缓存」 + 「磁盘缓存」,缓存模块只需添加常规增删改查功能即可。
目前为止我们已经拆分了3个模块,这3个模块都是埋点数据处理流程中后段,数据来源部分也就是SDK接口部分还没有进行拆分。SDK接口用于接收埋点业务数据,如果只是接收业务数据似乎也没有必要进行拆分,但埋点数据除了业务层传过来的业务数据还有用户及设备等公共数据以及用于串流的数据。因此「SDK API 模块」包含对外接口 以及埋点完整数据组装。
以上几个模块都是各自独立的模块,各模块之间需要进行数据的流转因此还需要一个负责组合各模块之间通信的 manager 模块角色。
上面我们已经拆分了5个大模块,这5个模块相互配合就可以完成埋点数据的处理流程:接收-组装-缓存-上报。另外补充一些埋点数据获取的辅助工具模块即可组成我们完整的埋点模块拆分方案。
补:在实际开发过程中还需要拆分一个基础模块,这是在方案设计阶段没有考虑到的;不管是数据接收、还是组装、缓存都依赖埋点触发的时序,只有保证了埋点的时序才能正确的处理事件串流。因此需要设计一个栅栏队列(串行单任务队列),以保证在某个任务在执行时不会有其它任务被执行。上一个埋点处理任务还没有处理完成,下一个埋点处理在队列里处理 pending 状态。
整体架构
模块分析
考虑到模块的复用性与可替换性,每个模块都需要抽象出一个接口类。接口类用于描述模块向外提供的能力与模块能接收的外部数据,模块内部实现接口并隐藏内部复杂逻辑。这样处理之后如果需要替换某一模块不用改动依赖此模块的其它模块代码,只需再根据抽象接口再新建一个实现即可。如此内部的改动不会影响外部,将影响范围控制在模块内部,同时模块提供给外部时更加清晰。模块抽像示例如下:
dart
abstract class LocalPersistentService {
factory LocalPersistentService() => _SqlPersistentService.singleton();
Future<void> dispose();
Future<void> initializer({
required String fileName,
LaunchCallback? onLaunch,
int expiredDays = 30,
});
Future<PrimaryKey?> writeEntity(TrackEventEntity entity);
}
栅栏队列
这是在开发过程中抽象出来的一个模块。由于需要保证每个模块的独立可用性(时序保证),因此此功能模块会被其它模块所引用。
栅栏队列的功能是保证加入队列的所有任务在任意时刻有且仅有一个任务在执行,且在执行的任务不会被其它任务中断。举个栗子,有任务一、任务二、任务三,三个独立任务,将它们依次添加到栅栏队列中。这个三个任务将依次执行,任务一执行结束时通知任务二开始执行,任务二结束时通知任务三执行,最终执行的顺序为添加到队列中的顺序。用代码示例如下:
dart
final task1 = await taskQueue.enqueue(() async {
// 随机延时
await Future.delayed(Duration(milliseconds: Random().nextInt(20)));
print('任务1');
return 1;
});
final task2 = await taskQueue.enqueue(() async {
// 随机延时
await Future.delayed(Duration(milliseconds: Random().nextInt(20)));
print('任务2');
return 2;
});
final task3 = await taskQueue.enqueue(() async {
// 随机延时
await Future.delayed(Duration(milliseconds: Random().nextInt(20)));
print('任务3');
return 3;
});
print('$task1,$task2,$task3');
// 打印输出:
// 任务1
// 任务2
// 任务3
// 1,2,3
实现方式是借助 dart 异步编程类 Completer,添加任务到队列中时返回 completer.future 对象,返回时给 completer.future 添加 whenComplete 回调(回调中处理下一个任务),这样就可以在 completer 结束时从队列中取出下个任务重复以上过程。实现思路核心代码如下:
dart
class _Task {
final Function? task;
final Completer completer = Completer();
}
class TaskSerialQueue {
final List _queue
_Task? _processingTask;
Future enqueue(Function fun) async {
final t = _Task(fun);
t.future.whenComplete(_processNextTask);
_queue.add(t);
_processNextTask();
return t.future;
}
FutureOr<void> _processNextTask() async {
if (_queue.isEmpty && _processingTask != null) return;
_processingTask = _queue.removeFirst();
try {
_processingTask?.complete(await _processingTask?.task?.call());
} catch (e) {
_processingTask?.completeError(e);
} finally {
_processingTask = null;
}
}
}
为了方便理解实现原理上面的代码做了精减,在实际项目中还要需要支持泛型,因为需要支持不同类型返回值任务。为了保持功能完整性还需要添加任务暂停、恢复等功能。具体细节可以参看项目代码。
磁盘持久化(磁盘缓存)
针对埋数据量大、且结构较为固定的特点,项目中磁盘持久化的类型选择了 SQLite 数据库。如前所述,磁盘缓存也进行了接口抽象,SQLite 数据库缓存埋点数据只是抽象类的一种实现,后续如果需要换成其它类型缓存也只需新增一个抽象类实现即可,做好单元测试的情况下对上层业务与埋点可靠性几乎没有影响。
选择数据库做库做为磁盘缓存,如果直接用字符串拼接 SQL 语言显然太低效了,用 ORM(对象-关系映射*)是更为合适。Dart 语言环境下成熟 ORM 库不多,floor 库用户量稍多。使用 floor ORM 库需只需要在定义表字段模型时添加对应注解即可生成对应的建表SQL语句,增删改查也只需要添加对应注解。下面是简单的使用示例,详细的用法可以参考官方文档。
dart
@entity
class Person {
@primaryKey
final int id;
final String name;
Person(this.id, this.name);
}
@dao
abstract class PersonDao {
@Query('SELECT * FROM Person')
Future<List<Person>> findAllPeople();
@insert
Future<void> insertPerson(Person person);
}
SQLite 的使用本身没有难度,埋点使用数据库也不会有复杂的操作,但使用 SQLite 需要注意数据库文件大小问题。对于已落库的数据,需要定期删除。删除表记录只需执行 DELETE 语句,但删除表记录并不会释放对应空间导致文件 size 不会变小。原因在于 SQLite 基于 B+ 树,如果释放叶子节点空间则需要移动节点后面所有的数据,这样就会使得SQLite性能底下。SQLite (其它关系型数据库同理)选择只清除对应记录但保留记录对应的存储空间。
要解决文件大小的问题就要定期执行 VACUUM 命令
VACUUM 命令会重排整个数据库文件,相当耗时,且重排期间不允许数据库读写,因此 VACUUM 不适合频繁执行。在项目里可以选择定期或超过设定大小时执行 VACUUM 命令。
我们可以在磁盘缓存模块里添加数据过期时间的设置,数据上报后超过一定时间(以天为单位)即视为过期,打开数据库时清理过期数据。因此实际项目中 VACUUM 命令的执行策略是当次数据文件打开有数据删除且当前数据库文件超过了设定大小。由此可以平衡数据库文件大小与性能影响。
内存缓存
内存缓存相对简单,用数组即可, 但在当前项目的场景下需要做一些特殊处理。要支持数据取出与恢复,并且恢复的数据需要保证相对顺序不变。这样的限定是为了支持接口上报失败时将数据顺序恢复至缓存,由于埋点数据有先后顺序为了保证上报时的顺序尽量与实际顺序一至就需要保证相对恢复时的相对顺序。
数据流转管理
数据流转管理模块的定义的是:作为管理模块,管理数据在各模块间的流转策略逻辑。此模块做为项目的核心模块,其对数据的处理流程如下图:
埋点协议处理重点&难点
事件ID
每个埋点都需要返回一个唯一的字符串ID, 此 ID 需要全局唯一(所有用户端)
在 App 中可以用 uuid 生成此唯一id,但需要注意标准 uuid 的生成是基于时间与随机数,存在小概率的重复可能。为了减小重复概率还需要加入一些用户特征与设备特征(如用户id、设备类型/网络等)来生成 uuid。
类似:Uuid().v5(Uuid.NAMESPACE_OID, 用户名+设备MAC地址);
事件串流
每个页面的所有事件均需带上该页面打开来源的事件Id,例如点A页面的按钮打开了B页面,B 页面后续的所有事件都需要带上A页面该按钮点击动作的埋点事件id。同时,每个事件需要带上个页面与当前页面的名称。
方案一、由业务层来串流事件:将每个埋点事件id 返回给业务层,当页面跳转时将点击事件的id与页面名称在业务层传给下一个页面的所有埋点
方案二、由SDK内部串流事件:SDK 内部记录当前页面与上一个页面,并记录当前页面的所有点击事件,当跳转下一个页面时即可取出当前页面最后一次点击事件的 id 赋给下一个页面的所有事件即可。由于只取最后一次点击事件,事实上只需要记录一个点击事件,每次点击都更新此事件即可。
方案一的优点在于设计简单,但增加了开发对埋点协议的理解过程,且事件串流准确性由业务层决定风险不可控。方案二的优点是开发无需理解复杂的埋点协议,串流故障点单一质量可控,缺点是会增加埋点SDK设计复杂度。
了解到小程序端使用方案一,但App综合考虑开发成本、便利性与可靠性本项目采用方案二的进行设计与开发。
H5/原生事件串流
H5与原生页(Flutter页面)之间跳转时也需要串流事件
原生页面跳转H5页面时只需要将当前页面最后一次点击事件id传递给H5,H5从query参数取值,后续埋点上带上此id即可(H5 跳原生同理)。难点在于如何与串流方案二进行整合,方案二每次跳转新页面只需要取上一个页面最后一个点击事件。直接在接口层传递 id 需要在接口层添加此 id 入参并在原生串流逻辑之外再写一套逻辑,何且这样又会把串流参数引入到了业务层,与简洁、单一接口设计接口原则相背。
为了解决这个问题需要引入「虚拟点击事件」这一概念。虚拟点击事件用于承载 H5 点击 id, 并记录在当前 webview 页面事件上。虚拟点击事件不参与上报,只作为webview页面最后一次点击记录。当跳转原生页面时只需取上一个webview页面记录的最后一个点击事件id。在业层单独写一个接收虚拟事件id的接口,就可以统一H5与原生页面的串流方案。