货拉拉基于“声明式”的埋点方案实践

1. 背景

业务埋点的开发和维护一直以来都是研发环节中的一个痛点,对于产品来说埋点数据可以帮助判断需求上线后的运行情况,对于开发而言则需要在编写业务逻辑的同时,还需要增加一些数据采集上报埋点平台的任务,我们也一直在探索一些提效的解决方案,这里先简单概述下埋点开发中的几个痛点:

主要痛点概述:

  • 无法完全自动化: 埋点逻辑主要集中在业务数据采集,无法通过类似AOP等技术来解决全部的埋点需求
  • 埋点 的属性数据繁多: 主要属性都集中在业务逻辑数据中,和业务紧密相关
  • 埋点 逻辑掺杂 业务 逻辑: 业务逻辑中掺杂了很多埋点逻辑代码,不便于维护和排查问题
  • 新增 埋点 逻辑占用太多开发时间: 遇到埋点稍多的需求,会占用太多开发时间,需要一种高效的方式来让开发人员快速的增加埋点逻辑

上述的这些显而易见的问题,是我们一直以来不断去探索埋点方案的一个重要原因,在不断的需求开发节奏中,找出一种能快速实现埋点需求,又能和业务逻辑解耦合,同时还能提高我们的编写埋点代码的效率,这也是我们本文将要讨论的埋点方案和具体的实践过程。

2. 目前面临的问题

这部分主要从货拉拉APP开发中,针对埋点遇到的一些痛点,进行详细的描述,也让大家了解后续我们的方案主要解决的核心问题是什么,以及如何在现有的情况下,来通过我们这套方案去改善现状,优化我们的架构。

2.1 埋点现状架构图

在讲述痛点之前,我先用一张目前我们业务中埋点现状的架构图,从宏观的角度来看下当前的埋点逻辑情况。

从这幅架构简图中,我们可以看到项目在基于MVP架构模式开发下,对于埋点数据的采集、聚合、以及最后的上报,都没有一个合适的设计来支撑,也可能是项目在初期就对埋点这种逻辑不敏感,忽略了它在日后不断增量所带来的问题,因此造就了现在开发所面临的问题挑战。

2.2 痛点一:组件间数据传递繁琐

在组件化的架构模式下,大到模块之间的信息传递,小到组件内部的MVP(单元)之间的数据交互,组件之间的交互是非常频繁的,拿MVP模式的Protocol为例,通常使用协议来定义一个类的功能接口,每个类的Protocol 只开放和这个类功能息息相关的接口,符合"单一原则"和"开放封闭原则"。

那接下来我来看下业务中的埋点事件:

scss 复制代码
//埋点属性通常都是 key-value 的形式Map形式
事件名:test_eventName_key
属性值(map):
{
    key_a:value_a
    key_b:value_b    } 模块x(比如:车型、地址...)
    key_c:value_c
        ...
    key_d:value_d
    key_e:value_e    } 模块x(比如:支付方式、发票...)
    key_f:value_f
        ...
    key_z:value_z    } 模块x(比如:额外服务、订单备注、货物信息...)
}

埋点数据通常情况下覆盖了当前业务Page 中的大部分的模块,这在货拉拉APP中十分场景,比如:首页、确认下单页、订单详情页、等待应答页等页面,每个页面包含的组件少则5-6,多则10+种小模块,如果埋点要求上报这些模块内的数据,那就需要将这些数据先采集组合成map,然后上报给埋点平台,那么采集过程中,大部分的逻辑都落在了各个类的 Protocol 中,因为模块之间的交互都是通过接口来传递的,所以Protocol中就会出现一些埋点相关的接口。

scss 复制代码
@protocol xxx {
     func trace(x,y) 
          ...
}

因为埋点的触发逻辑可能在任意的类中任意地方出现,因此每个类都要做好获取上述所有埋点数据的能力准备,这就造成了我们业务代码中,本来就比较复杂的业务逻辑,再混入一些埋点相关的逻辑,就会变得更加的难以维护。

2.3 痛点二:埋点属性采集方式不统一

埋点事件中具体属性值的采集,严格来说应该是声明和修改的地方保持单一,通常由所属的模块负责,比如车型的名称和国标ID,都是由车型模块进行维护,其他模块使用的时候通过统一的方式读取即可,这样如果变量值有改动,则各个引用的地方会同步修改,而现实中这些值在上述讲到的模块传递问题中,会出现不同模块,传递的形式也不同,有的可能是作为方法的参数获取的,有的则是通过其他对象持有获取的,维护起来会特别麻烦。

2.4 小结

开发中上述痛点,是主要的影响点,数据的采集和组装占据了大部分的埋点开发工作量,同时这些痛点也会对架构有所腐蚀,因为需求的迭代和周期,有时候并不能同步到埋点中,这就导致维护滞后等副作用。

3. 埋点方案设计

在正式介绍方案之前呢,我们先看了解下目前业内APP埋点的一些主流方案:

代码埋点 可视化埋点 自动埋点
优势 灵活性强,精准控制,轻量 无需开发,实时生效,不依赖版本发布 实现简单,自动化,实时数据分析
劣势 工作量大、易出错,调试困难,无法动态下发 埋点数据存在局限性 灵活性差,数据冗余,隐私问题
适用场景 需要精细控制收集数据和埋点数据结构的情况。 比较规范和简单的页面,主要分析一些点击事件场景 数据收集需求较为标准的场景,如应用的日常运营、用户行为分析等

上述三类方案,基本涵盖了业内主流的埋点方案,而还有一种是采用混合(代码埋点 + 自动埋点)埋点,这也是一种主流的方案之一,货拉拉用户端也是采用了这种模式,通用的页面浏览和控件点击都通过自动埋点实现,而其余的则是通过代码埋点实现。

所以本次方案优化改造的核心是围绕代码埋点这部分进行的,宗旨是如何高效且尽量无侵入的方式,对代码埋点进行改造和优化。

3.1 方案整体概述

方案的大致核心功能点如下:

  • 独立出埋点管理模块,各组件间直接通过埋点管理模块读取和注册数据
  • 移除各个组件的Protocol文件中显式的埋点相关逻辑
  • 各组件间不再直接和埋点数据进行通讯,组件间只需要通过特定方式暴露自己的埋点所需数据即可
  • 埋点统一管理,引入Scope 概念

改造后的埋点架构简图:

上述架构图中可以看出,我们对埋点的采集和上报做了比较大的改动,去除了各个组件内关于埋点数据需要暴露出来的接口API,另外还引入了"公共数据"以及"Scope"概念,通过各个模块独立的埋点数据"声明",来将该模块的数据提前暴露出来,注意这个时候数据只是被"声明"出来,并不是提前保存,只有埋点用到时,采取实时获取确定的值。

下面就对这些概念进行详细说明。

3.2 独立出埋点管理模块(TrackifyKit)

如需从业务中剥离解耦埋点这部分逻辑,那么就需要独立出一个单独的模块来承接。模块作为二方库的形式引入进来,并且由一个TraceManager单例类来和业务进行交互,TraceManager会对外暴露出三种类型的API,来供业务模块去调用,这三种分别是:

swift 复制代码
    func traceUpdateCommonData(with dataDic:Dictionary<String, Any>)
    
    func traceRegisterCatcher(scope:TraceEventScope, 
                              host:AnyObject,
                              pageVC:UIViewController?,
                              catcher:@escaping Catcher)
    
    func traceTrigger(eventName:EventName,
                      withProperties:[PropertiesName:Any]?,
                      pageVC:UIViewController?)
    
    func traceTrigger(eventName:EventName,
                      withProperties:[PropertiesName:Any]?,
                      scopeKeys:[TraceEventScope:[PropertiesName]]?,
                      alias:[String:String]?,
                      pageVC:UIViewController?)

下面对这三类API做更详细的解释:

3.3 更新公共属性

考虑到项目中会有一些公共属性需要统一上报,比如:城市定位、个人信息等相关信息,这里为了方便业务使用,可以允许业务更新一些全局的数据,你可以把他理解为一个全局的map,方便后续埋点上报组合数据的的时候使用

swift 复制代码
/// 埋全局通用数据
/// - Parameter dataDic: [k,v]]
public func traceUpdateCommonData(with dataDic:Dictionary<String, Any>) {}

3.4 注册Catcher捕获器提前声明埋点字段

Catcher 概念是我们这次方案的核心,它的原理是通过业务组件,主动向 Eventbus 中注册一个 Catcher 对象,它并不会强引用业务组件类,而这个 Catcher 会在埋点触发的时候,自动去动态的获取注册类中的数据,这部分数据就会通过Catcher 对象,进入EventBus,从而和其他埋点数据进行组合、过滤、处理等操作,最终完成数据上报。

接下来看下如何在业务中注册 Catcher 捕获器:

less 复制代码
/// 注册一个埋点Catcher ⚠️注意 weak self
    /// - Parameters:
    ///   - scope: Catcher 所属的 Scope
    ///   - host: Catcher 所在的生命周期对象
    ///   - catcher: Catcher block
    ///   - pageVC: 关联的页面
    @objc public func traceRegisterCatcher(scope:TraceEventScope,
                                           host:AnyObject,
                                           pageVC:UIViewController?,
                                           catcher: @escaping Catcher) {
    }

业务中所有的类,只要是需要提供一些埋点属性,都可以在类的初始化时机,调用上面API,来进行埋点属性注册,通过这个注册,将内部需要的属性值提前准备好,等待埋点管理Eventbus内部使用。

swift 复制代码
    func registerTraceCatcher() {
        TraceManager.shared.traceRegisterCatcher(scope: "vehicleScope",
                                                    host: self,
                                                    pageVC: nil) { [weak self] in
            guard let self = self else { return nil }
            return [
                "key":"vaule",
                ...
                ...
            ]
        }
    }

这个方法首先需要入参一个 scope 参数,这个可以根据业务模块进行不同粒度的划分,host则是当前类对象,这个参数主要是在埋点管理器内部通过 host 对已经释放的业务类进行 catcher 清理使用,pageVC 参数是应对导航栈中可重复push多个同一类,则不同示例的页面,为了做socpe 区分,相同对象的内部数据必须进行区分获取。

3.5 埋点上报

说完了catcher 其实埋点的数据准备工作已经讲完了,各个模块类的内部已经通过 traceRegisterCatcher方式将各个埋点事件所需要的数据提前准备好了,接下来就看下埋点触发的逻辑。

下面是埋点触发所需API:

swift 复制代码
    /// 埋点上报
    /// - Parameters:
    ///   - eventName: 埋点事件名
    ///   - withProperties: 埋点属性[k,v]
    ///   - scopeKeys: scope-keys 对
    ///   - alias:字段别名
    ///   - pageVC: 关联的页面
    @objc public func traceTrigger(eventName:EventName,
                                   withProperties:[PropertiesName:Any]?,
                                   scopeKeys:[TraceEventScope:[PropertiesName]]?,
                                   alias:[String:String]?,
                                   pageVC:UIViewController?) {

    }

入参分析:

eventName: 埋点的事件名,也是埋点信息中必不可少。

withProperties:埋点的属性值,这里的属性值不要求必须传入埋点所有的必要字段,只需要传入当前类Context 中能够直接获取到的字段,那其余的字段怎么获取呢?这就用到我们前面 3.4 讲的Catcher 捕获器了,通过它去其他模块试试获取数据。

scopeKeys: 这个参数需要传入的类型是 Map<scope,[PropertiesName]>,key 代表 scope 也就是每个类在调用 traceRegisterCatcher方法注册 catcher 的时候声明的 scope 参数,有了这个scope ,埋点管理器才知道需要去哪个类中获取埋点字段,因此前提是你要提前在各个类中调用 traceRegisterCatcher 方法。[PropertiesName]这个数组就是需要的字段名称 key,根据埋点文档传入即可。

alias: 这个入参则是一个修改字段名称的别名方式,可能各个埋点会有相同数据,但是字段上报的key会不相同,这里可以做下别名配置。

pageVC: 这个入也是和 traceRegisterCatcher方法的入参中 pageVC 一一对应,用来区分导航栈中相同类不同实例的页面。

接下来再看下埋点上报的一个demo实例:

less 复制代码
        let scopeKeys = [            module_a_scope:[                "properties_name_x",                ...            ],
            module_b_scope:[                "properties_name_x",                ...            ],
            module_c_scope:[                "properties_name_x",                ...            ]
        ]
        TraceManager.shared.traceTrigger(eventName: "confirmorder_click",
                                            withProperties: ["module_name":moduleName],
                                            scopeKeys: scopeKeys)

这样上报埋点,字段的获取全部通过埋点管理器的 scope 去各个类中注册的 catcher 里面实时获取,然后在管理器内部进行过滤、组合、更名等操作后,再交给埋点平台上报,整个流程不需要再各个类的业务Interface 类中声明埋点相关的逻辑,这部分逻辑统一由管理器去管理,实现了和业务逻辑解耦的效果。

3.6 渐进式改造

新的方案并不需要项目中所有的埋点都进行同时改造,而是可以和之前的代码埋点逻辑并存,开发人员可以根据节奏逐渐替换,货拉拉用户端在接入该方案后,埋点代码逻辑更加的清晰,且和业务解耦,业务层只负责提供属于自己的那部分数据等待获取,而埋点触发的地方也只需要根据不同的key去取相应的数据即可。

某业务模块:

swift 复制代码
// MARK: -- Module_A_Presenter 模块
@class Module_A_Presenter {
    init() {
        registerTraceCatcher()
    }   
}

// MARK: -- 埋点注册
extension Module_A_Presenter {
    func registerTraceCatcher() {
        TraceManager.shared.traceRegisterCatcher(scope: "Module_A_Presenter_Scop",
                                                    host: self,
                                                    pageVC: nil) { [weak self] in
            guard let self = self else { return nil }
            return [
                "key_1":self.key_1,
                ...
            ]
        }
    }
}

这样就完成了该模块的改造,而无需像之前那种方式,把这些数据通过 Interface 暴露出去,造成埋点逻辑和业务逻辑混杂在一起。另外声明式的这种埋点方式,也大大提高了埋点逻辑的可维护性,各自模块各自维护,数据收集再分别去各个模块获取,减少了模块之间的交互频率。

4. 总结

以上内容就是本期给大家分享的一个基于"声明式"的管理复杂业务埋点的方案,该方案实现起来并不复杂,主要是改变出一种新的埋点思路,来去解决现有代码埋点的问题,俗话说"没有最好的架构,只有最适合的架构",适合的架构都是一点点优化重构堆砌出来的,就像本文所分享的方案,优化埋点的上报形式也是对项目架构的一种优化,让我们的产品迭代和维护都更加的高效。

相关推荐
河北小田10 小时前
AI 写公众号的坑有多少
程序员
京东云开发者13 小时前
深入理解分布式锁:原理、应用与挑战
程序员
京东云开发者13 小时前
前端调试实践
程序员
河北小田14 小时前
微信公众号如何快速进入流量池
程序员
木木黄木木15 小时前
Theos环境搭建与XM文件开发指南,以及iOS弹窗源码分享
ios·c#
木木黄木木15 小时前
iOS插件,Theos环境搭建与XM文件开发指南(完善版本)
ios
帅次15 小时前
Flutter:StatelessWidget vs StatefulWidget 深度解析
android·flutter·ios·小程序·swift·webview·android-studio
isfox17 小时前
Excel 后缀竟成 “拦路虎”?POI 读取报错原因大揭秘
程序员
帅次17 小时前
Flutter Widget 体系结构解析
android·flutter·ios·小程序·xcode·web app·dalvik