浅谈元数据驱动架构下的数据模型、权限与读写链路

4,101 阅读19分钟

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

本文作者:飞书商业应用研发部 熊添翼

欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~

一、序言

在平台产品 SaaS 化的进程中,任何团队都必须直面一个问题,同时也是 SaaS 产品必须解决的问题:产品如何适配不同租户按需进行功能搭配组合的场景,满足不同租户从基础到专业的不同深度的业务需求? 该问题的核心是业务流程的抽象和业务功能的拆分。领域设计以及微服务思想在产品功能抽象拆分上提供了相对成熟的思路。由此催化了以纵向业务细分为领域划分的服务化方案乃至与之适配的组织架构。结果上该方案确实加速了业务的敏捷迭代,支持了产品的创新求变。但是软件工程没有银弹,由于服务细分功能域增多,整个业务链条上的咬合点增加,随之产生了越来越多的数据冗余、功能重复且发散等问题。从而使得业务串联难度增加,数据使用成本变高,在业务咬合点上表现尤为明显。

基于上述问题,元数据应运而生。所谓元数据即是在业务逻辑模型基础上二次抽象,定义出逻辑模型的定义数据,实现业务模型的数据化。简而言之,元数据是统一领域模型的数据描述。由此上游业务系统不再需要复杂的适配逻辑去处理模型物理存储与逻辑描述的映射,通过元数据的标准即可直接消费业务模型数据。

在平台产品基于元数据驱动架构之上,上层业务该如何结合权限消费相应的领域模型数据?下层服务又该如何处理上层业务生产的数据,并在上层无感的情况下完成相应的数据处理,高效支持上层业务消费?简而言之一个问题:在元数据驱动架构下,领域模型的数据如何生产如何消费? 本文基于飞书人事 -CoreHR 系统现有架构,将人员异动业务作为切入点并跟踪其背后的数据流,解答上述问题,以此作为它山之石服务其他团队。

二、CoreHR 简述

飞书人事是一款敏捷高效的人事管理应用,而 CoreHR 是整个人力系统的底层基座,负责人员与组织信息的管理与维护。Leader 作为其核心用户,HR 作为其高频用户,其主要业务场景如下所示:

UML 图.jpg

上图仅为读者能够了解 CoreHR 产品形态的全貌,不做展开。下面我们聚焦于员工异动服务,将此领域作为切入点,分析在元数据架构之上,其核心数据模型、结合权限规则的数据消费,以及相应的底层数据同步逻辑。

三、核心数据模型

在介绍员工异动系统主要数据模型之前,先简单了解异动系统的核心业务场景,以帮助理解其主要领域数据模型的构建。

异动业务简述

CoreHR 人员异动模块主要管理员工的职务调整(升职、降职)、岗位调动、调薪等流程。通过和其他系统的打通,HR 可以围绕人员的异动记录参与、追踪并及时跟进其异动行为。作为 HR 可在异动系统中进行如下动作:

  • 筛查权限范围内的异动记录,并查看详情
  • 发起员工异动
  • 审批员工异动

流程图 (6).jpg

主要领域模型

通过初步了解人员异动业务,能够发现其模块的主要领域对象为:异动记录。异动管理也就是针对异动记录这一领域对象的生命周期进行管理。我们可以简单思考该领域对象需要哪些字段呢?既然是员工异动,员工的基本信息肯定需要有。不过请等等,真的需要员工的基本信息吗?纵观整个 CoreHR 系统,除了异动业务,似乎离职、试用期、续约、自助服务等等业务都或多或少需要员工部分信息,比如员工姓名、工号、序列、职级、所在部门等等。如果将这些信息放入异动系统,架构上必定是不合理。既然员工基本信息如此基础,于是催生了主数据平台,它将统一管理 CoreHR 的核心数据如:人员、组织、序列、职级等等。

既然核心数据已经被统一管理,那么我们只需要思考异动记录本身需要哪些字段即可。异动业务上既然包含职务调整、岗位调动以及调薪,那么异动记录本身对于异动前后的部门、序列、职级、上级、工作地点等等信息都应该予以支持。而无论是部门、上级、职级、序列等都是作为核心对象存储在主数据平台之中。在元数据描述上,我们能够定义异动记录的部分字段,通过 Lookup 到相应的核心对象完成对象间的关联行为。

最后我们简化版的数据模型如下图所示:

UML 图 (1).jpg

四、数据读写分析

通过跟踪领域对象的数据流,从而了解在元数据架构上,数据是如何在元数据的驱动下进行流转。

基于权限规则的数据查询

基于权限规则的数据查询,乍看并不清晰。CoreHR 的权限是什么?权限规则从哪里来?领域对象数据查询与权限有何关系?不过先别着急,在回答这些问题前我们先设想下面两种场景:

场景一

在 A 公司内,一个 HR 点开了异动管理系统,开始浏览所在部门人员的异动信息。嗯,一切很丝滑,流程也很通顺。但是作为 SaaS 产品,只有一家公司使用也太尴尬了。幸运的是我们不止一家客户,于是我们看看 B 公司的 HR,Ta 同样点开了异动管理系统,不过 Ta 只能管理所在部门内研发同学的异动。如果你是异动服务的研发同学,你现在应该敏锐的发现了一个问题:我们无法预知下一个公司或者另一个部门的 HR Ta 所管理的异动记录范围。

场景二

在 A 公司内,HR 在管理权限内的异动记录,Ta 发现因为列表页缺少员工的「入职日期」这个字段影响了 Ta 的判断。而在 B 公司,公司 HR 觉得列表页展示员工的「工龄」是十分有必要的。而 C 公司觉得通过一个员工身上的自定义字段筛选异动记录十分必要。作为异动服务的研发同学,你同样敏锐的发现:我无法知道这些 HR 期望哪些字段出现在列表页之中,期望哪些字段支持筛选。

对于场景一,我们第一时间便可反应过来:让用户自己配置权限范围,将用户管理维度与异动记录查询逻辑解耦。而对于场景二直观的解法:让用户自己配置自己的视图,除几个固定的必要字段外,其他字段作为配置项,通过配置化的方案满足用户的个性化需求。而用户可配置字段的全集如何确定?目前 CoreHR 的做法则是在基于元数据定义的基础上通过表单元数据配置而成。如何支持不同字段搜筛排呢?CoreHR 目前通过将数据基于元数据基础上的聚合元数据描述统一同步到异构存储 ES 服务予以支持。

这时候我们回头来看开始时的几个问题。CoreHR 的权限即是用户的管理维度,针对不同的业务实体用户可以配置不同的管理维度。也就是说权限规则可以由用户配置,同时也可以由系统预置。而管理维度的配置则最终可以转换为对应权限实体的筛选规则。

基本概念

通过上述分析,在异动记录的查询流程分析前,我们引入以下的几个概念:

应用领域对象由业务定义,数据存储于业务系统,具有元数据描述的领域对象。> 如:异动记录、离职记录等。
核心领域对象会被应用领域对象关联,数据存储于主数据平台,具有元数据描述的领域对象。> 如:员工、部门、序列、职级等。
聚合对象以应用领域对象或者核心领域对象为根对象,根据业务需求将该对象的部分或者全部字段与所关联的其他对象的字段聚合而成。用于描述聚合对象的结构信息的数据称为聚合 元数据。> 以简化后的异动记录聚合对象为例,其结构如下所示:
前置鉴权通过理解权限系统下发的鉴权规则,转译成领域对象查询语法,从而查询得到符合权限规则的领域对象实体。鉴权动作不在权限系统,权限系统只提供数据规则。举例说明:已知鉴权规则为:对于用户A,只能查看「部门1」下人员的异动记录。异动服务转译成异动记录聚合对象查询语法:job_change.original_employment.department.wk_id = "{部门1ID}"
后置鉴权是指在获得领域对象实体数据的前提下,通过权限系统过滤不在权限范围内的实体对象或者部分实体字段数据。权限对象「异动记录记录」关联应用领域对象「异动记录」
权限规则由一系列左值、操作符、右值组成的用于圈定权限实体范围的表达式。其中左值能唯一关联权限实体对应的领域实体的字段。举例如下:1. 权限实体「员工」的左值「职级」能唯一关联领域实体「Employment」的字段「job_level」

对于上述的七个概念,可能前置与后置鉴权容易让人犯迷糊。为何需要前置与后置鉴权分开,有统一的可能吗?

我们可以先做个只有前置鉴权而没有后置鉴权的假设,结合我们的场景一与场景二,能够发现前置鉴权是将权限规则转成筛选语句,所以结合 ES 查询后我们能获取符合权限规则以及筛选要求的聚合根对象 ID 列表(对于异动业务而言则是异动记录 ID 列表)。

这时前置鉴权与数据筛选流程已结束。然后我们通过这些 ID 与配置的表现层元数据进行相应字段数据获取后返回给表现层。等一下,思考一个问题:这些字段数据都在用户的管理维度之中吗?答案显而为否。所以我们必须将没有权限的字段、行记录进行过滤!

通过上述假设我们知道后置鉴权的重要性,同时新的疑问将会产生:能不能不要前置鉴权只要后置鉴权?我们做类似的思考实验--假如没有前置鉴权,也就是用户筛选条件直接应用于 ES 查询,然后我们获得命中的聚合根对象 ID 列表,进而获取字段数据,过一遍后置鉴权,最后返给表现层。看起来一切都很顺利,但是回顾下我们的场景--HR 浏览异动记录列表。

对的,前端表现是列表页,数据一页一页的呈现给用户。如果没有前置鉴权,很有可能用户第一页没有任何数据,因为它们全部被后置鉴权过滤。同时用户看到的总数也不可信,因为大量的记录不在其管理范围内!综上分析,前置鉴权与后置鉴权在当前 CoreHR 的业务形态上都是必要的,缺一不可。

整体查询流程

在对齐上述七个基本概念之后,通过下面的流程图来串联整个异动记录的查询流程。

流程图 (7).jpg

不难发现整个查询流程主要分为四个阶段:查询参数构建、前置鉴权、数据获取以及后置鉴权。

查询参数构建

在第一阶段,前端基于表单元数据将用户行为转为异动业务可识别的基于元数据的查询请求,比如查询特定员工的异动记录查询请求参数如:job_change.employment = "emp_id_1"。所谓基于元数据则是指该等式的左值在元数据平台中存在相符合的描述,也就是异动记录有一个员工字段即 job_change 对象存在 employement 字段。换句话说用户所有的针对异动记录的行为都能够由元数据定义的左值、操作符、右值所表达。

前置鉴权

根据流程图可知异动服务通过从权限系统获取该用户对于异动记录管理范围的权限规则,通过将规则转译成查询条件与用户请求相组合,进而查询 ES 获取符合条件的 ID 列表。不难发现「将权限规则转译成查询条件」这一步骤最为关键。为何权限规则一定能转译成查询条件?根据权限规则定义:由一系列左值、操作符、右值组成的用于圈定权限实体范围的表达式,我们可以发现权限规则的表达式只要左值基于元数据,且 ES 索引构建也兼容元数据语法,则该转换逻辑的可行性是能够被证明的。当前 CoreHR 链路也确实是按照该思路构建。

数据获取

数据获取链路则较为简单,由于存储层(无论是主数据平台还是业务自建数据存储)都支持基于元数据的查询语法,所以异动业务无需感知具体的物理存储,只需要对异动记录对象进行数据查询填充即可。

后置鉴权

后置鉴权既是对前置鉴权的行数据做兜底,又是对数据列的判权动作,可以有效防止权限泄露。通过上述流程图可知判权动作在权限系统完成,而业务仅仅是根据判权结果对当前数据集进行行列过滤。而权限系统的设计在本文中不做展开,本文聚焦于站在业务的视角串联整个领域对象的读写流程。

数据同步

通过上文对于数据查询链路的介绍,我们发现为了支持上层数据查询,底层至少存在两种类型的持久化存储基建,CoreHR 这边选型是 MySQL 与 ES。用户的写入行为当然是直接持久化在 MySQL 之中,同样也一定存在一个链路将这些数据有选择的同步到 ES 之中。那么应该选择哪些字段进入 ES?新增的数据是如何同步到 ES?当数据变更时,又是通过何种方式通知 ES 及时地同步最新数据呢?我们先看下面的架构图:

UML 图 (2).jpg

上图为当前 CoreHR 同步链路的简化图,真实架构要远比此链路复杂。不过 CoreHR 同步链路演进思路确实按照上图的方向进行演进完善的。根据上图能看出数据同步链路主要分两条链路:一条是变更事件流,数据变更事件流从 DB 发出最终由 ETL 服务消费,结合聚合元数据信息之后用于指导后续真实的数据获取,而对账差异事件则从对账服务传递到 ETL 用于指导其数据补偿行为;第二种是数据流,用于数据同步时,数据流会从 DB 出发经过业务服务与主数据服务转成符合元数据描述的数据结构,再经过 ETL 的组装,最终持久化于 ES 之中,而在对账场景下则是从 ES 与 DB 出发最终到达对账服务,用于数据对比。

从功能上分析,整个同步链路分为三大模块:增量同步、扩散分析、数据对账。目前 CoreHR 同步链路架构的演化也是在此三大模块上针对稳定性、安全性以及合规性的优化治理。下面我们逐个简要介绍此三个模块。

增量同步

所谓增量同步即是指聚合对象根对象的字段发生变更后,将此变更同步到 ES 的过程。用户一般对该链路的时延敏感程度最高。比如异动记录状态的流转伴随着异动记录对象的状态字段的变更,而用户在列表页的查询往往会有针对该字段的筛选。如果时延过高,用户会在列表页第一时间发现异常。好在该链路也最为简单,ETL 对于根对象的变更只需要简单的回拉数据即可。由于数据是回拉而不是推送,所以数据同步链路不存在时序问题。

扩散分析

所谓扩散,即是指:对象 A(可以是业务对象也可以是核心对象)的部分字段被其他聚合对象所引用,当这些字段发生变化时,与其关联的所有聚合对象都需要同步变更,这个过程称之为扩散。比如下图的异动记录聚合对象,当人员(ID 为 person_1)的 preferred_name 字段从 name_1 变更成 name_2 时,ID 为 job_change_1 的聚合对象的 employment.person_id.preferred_name 字段也需要变更成 name_2

所以扩散分析也就是根据领域对象的变更事件,按照聚合元数据描述,逐层查询被扩散的 ID 列表直至根对象的 ID 列表。那么如果被扩散的数据特别多怎么办?如果扩散特别多会不会阻塞增量同步?会不会严重影响 DB 性能?是的,在扩散分析设计时,这些问题都是必须要解决的问题。目前 CoreHR 的思路是从两个方面进行治理。第一是对于聚合对象字段的治理,非必要的字段不允许配置进入聚合对象,这样能有效减少不必要的扩散,降低大量扩散的风险。第二是将扩散分析控频,扩散结果事件与增量事件链路拆分,使其不互相影响。同时针对扩散结果的数据回拉进行控速,减少下游的压力。

数据 对账

由于同步链路不可避免存在抖动,或者因需求变更而需要对聚合对象追加字段时,都需要对账将 ES 数据补齐。所以对于同步链路而言数据对账机制必不可少。对账的策略与对账粒度则直接决定了对账服务的优劣,不过无论是对账策略还是对账粒度都是结合当下的业务需求、数据量级来做设计。对于关键数据,其对账的粒度就应该较细、对账间隔时间就应该较短,而对于用户敏感程度较低的数据,其对账间隔则可适当增加。当然,无论何种对账策略与对账粒度都需要有控频控速,不能对增量同步造成干扰,同样不能对下游造成性能压力。

五、结束语

本文作为抛砖引玉,概要性地描述了在元数据架构驱动下领域对象的读写链路。其实不难发现,异动业务的读写链路与其他 CoreHR 模块有相当大的共性。其实 CoreHR 也确实完成了将共性节点的复用,通过公共的业务架构组件支持了上游各种业务系统。当然在真实的业务场景下,无论是读还是写链路都会更加复杂,如支持权限系统后置鉴权的数据是如何从业务系统或者主数据平台同步至权限系统?是否应该复用现有的数据同步链路?在 MG(跨 Geo 分布式存储) 场景下,读写链路又该如何设计?扩散分析如何与增量同步隔离?类似上述影响链路扩展性、稳定性的问题有很多,使得我们的架构也需要跟随业务的发展不断地优化迭代。还是那句话,软件工程没有银弹,我们需要根据不同的业务发展阶段及时动态调整试错,换而言之,此过程就是一个不断寻找相对最优解的动态过程。