移动建模平台元数据存储架构演进

源宝导读:明源云天际-移动建模平台是一个快速生成多端移动应用的PaaS平台,元数据是移动应用设计与运行的核心数据结构,本文将从元数据存储这个视角分享我们的技术思考与实践。

一、什么是元数据(Metadata)?

    这个问题要先从移动建模平台的定位说起。移动建模平台是一个高效的应用搭建、管理平台,用户可以通过拖拉拽的方式,自定义快速生成多端移动应用的PaaS平台。

    目前主流的移动应用开发大都是基于H5为主的前端技术,元数据是对移动应用内部结构的一种数据抽象,用于描述应用所使用的组件和配置,是整个移动应用设计阶段和运行阶段的核心数据,也是移动建模平台生成的重要产物之一。本文主要从元数据这个视角去讨论移动建模平台在元数据存储方面的一些实践。

    如果把移动建模平台比作一个汽车生产线的话,那么移动应用就好比这条生产线生产的汽车,元数据就好比汽车的配置,消费者可以基于汽车的原厂配置进行个性化改装,也就有了个性化元数据,改装完成最终验车上牌也就有了运行时元数据。

    设计阶段通过一个Web版的在线设计器,设计器初始化会加载元数据进行页面渲染,元数据数据结构如下:

    设计器加载完成后可以通过设计器进行应用的设计和页面配置,保存设计就会产生新的元数据:

二、元数据存储架构演进过程概览

    移动建模平台元数据存储的演进过程大致可以分为三个阶段:

2.1、单体应用阶段

    这个阶段元数据表和其他业务数据表在同一个数据库中,按照上图的逻辑结构主要分成四张表来存储:

    在项目初期数据量并不大,这种结构也是最容易实现和最容易想到的。但随着业务的发展,各种组件越来越丰富,单个应用的元数据也由最初的普遍几十KB发展到几M,同时伴随着页面增多,页面之间的拷贝、复制、更新等操作也变得越来越缓慢。

    从上图的表结构可以看出,metadata字段是使用字符串来存储json的,并且设计时和运行时元数据存储在同一张表中。很快这种设计方案的弊端就显现出来,主要有几方面问题:

  1. 元数据可能会有几M,对元数据的每次读写操作都需要对元数据进行序列化和反序列化,网络IO和内存消耗大,程序执行时间过长

  2. 即使要修改元数据中很小的一部分内容也必须将元数据全部取出,修改后再序列化为字符串存入数据库。由于元数据的特殊性,缓存方案也无法使用

  3. 页面、文件夹数量比较多时对页面的复制、删除等操作需要涉及到多表事务,事务执行效率低。一个租户升级操作可能需要十几分钟。

  4. 当PHP按照数组方式来处理后导致空对象和空数组转换问题,会导致元数据损坏无法还原,前端页面渲染出错

  5. 由于涉及到多张表的操作,多表查询会让业务逻辑变得极其复杂,程序很难维护

2.2、服务拆分阶段

针对以上出现的一些问题,开始采取一些局部的优化手段,主要有几下几方面:

  1. 采用服务化的方式将原有的元数据操作相关的逻辑从单体应用中剥离出来,有了元数据服务。

  2. 数据表结构增加了一些冗余字段,并针对索引进行了相应调整,提高了查询性能。

  3. 在写入操作比较多的地方将以前的单条insert改为了一次性多条insert插入,优化写入性能。

  4. 对元数据的结构进行优化,精简冗余部分,减小元数据的体积。

  5. PHP操作元数据禁止使用数组方式来处理,统一转为对象。

    这个阶段采用了以上一些优化方法,虽然性能得到一些改善,但是都没有从根本上解决问题,根本问题出在存储层,团队也有讨论过使用NOSQL,比如MongoDB,但是由于元数据和其他模块严重耦合,数据层的拆分难度很大。加之如果改为MongoDB,新的数据模型如何设计,旧的数据如何迁移等问题还没最佳实践。所以这个阶段的一些改进仅限于应用层的拆分,不过对于后续重构提供了参考。

2.3、微服务化阶段

    这个阶段也是移动PaaS2.0阶段,在2.0中元数据相关的能力完全抽离出来成为单独的服务,并且使用golang进行重构,数据库也独立出来,使用MongoDB进行重新建模设计。为什么要选用MongoDB来作为数据库存储,主要基于以下几个方面:

  1. 元数据本来就是json结构,而MongoDB的使用BSON作为数据交换格式,以文档方式组织数据,非常符合元数据的结构特点。

  2. MongoDB4.0之后同样支持事务操作,在一些需要事务的场景下依然能够保证数据的一致性。

  3. 通过性能对比,MongoDB在读写性能上有明显优势。

  4. JSON 格式存储最接近真实对象模型,对开发者友好,方便快速开发迭代。对于测试人员来说,可以直观的看到元数据的数据结构,对测试更加友好。

  5. 能够极大的简化目前的应用层开发,减少大量的多表查询操作。

  6. 可以按需修改元数据文档的某个节点,而不需要读取整个元数据文档。

  7. 高可用复制集满足数据高可靠、服务高可用的需求,运维简单,故障自动切换。

  8. 可扩展分片集群,面对未来海量元数据存储,可以很方便的支持水平扩展。

  9. 强大的aggregation & mapreduce,可以将复杂的查询分解为一个个小的步骤。

    下图是在4核8G的同一台虚拟机上做的一个MySQL和MongoDB的性能对比测试,可以看出随着插入元数据的数量增加,MySQL和MongoDB所花费的时间的差距也越来越大。

    使用MongoDB重新设计后的元数据结构:

{
    "_id": ObjectId("5f3de7507cda70000e433ca2"),
    "workspaceId": "26043287605354496",
    "common": {
        "style": {
            "globalBgColor": "#FFFFFF",
            "primaryColor": "#FF543D",
            "secondaryColor": "#FF6954"
        },
        "body": {
            "header": {
                "hide": false
            }
        }
    },
    "configs": [
        {
            "_id": ObjectId("5f3de7507cda70000e433ca3"),
            "type": "role",
            "name": "游客",
            "alias": "default",
            "isGuest": true,
            "remark": "用户未登录时所使用...",
            "position": 1.59789243277115e+18,
            "viewIds": [
                ObjectId("5f3e45c62ef1d50013b3303e")
            ],
            "metadata": {
                "tabs": {
                    "items": [
                        {
                            "isDefault": true,
                            "text": "123",
                            "activeIcon": "appicon-house",
                            "href": {
                                "name": "bde68663-6f93-2206-0b29-cf910711f71e"
                            },
                            "icon": "appicon-house",
                            "iconClass": "appicon"
                        }
                    ]
                }
            }
        },
        {
            "_id": ObjectId("5f3de7507cda70000e433ca4"),
            "type": "role",
            "name": "已登录用户",
            "alias": "default-login",
            "isGuest": false,
            "remark": "用户登录时所使用...",
            "position": 1.59789243277116e+18,
            "viewIds": [ ],
            "metadata": { }
        },
        {
            "_id": ObjectId("5f470ced59221f0014d2a144"),
            "type": "page",
            "ancestors": [
                ObjectId("5f3de7507cda70000e433ca4")
            ],
            "name": "login",
            "routeName": "ef214890-b3e6-9a24-9dd8-80d12343f76c",
            "routePath": "/ef214890-b3e6-9a24-9dd8-80d12343f76c",
            "remark": "",
            "design": { },
            "metadata": {
                "name": "ef214890-b3e6-9a24-9dd8-80d12343f76c",
                "path": "/ef214890-b3e6-9a24-9dd8-80d12343f76c",
                "body": {
                    "header": {
                        "title": "login",
                        "items": [ ]
                    },
                    "content": {
                        "items": [ ]
                    }
                }
            },
            "position": 1.59849188522867e+18,
            "viewIds": [ ]
        }
    ],
    "createdAt": ISODate("2020-08-20T03:00:32Z"),
    "updatedAt": ISODate("2020-08-20T03:00:32Z")
}

    从新的结构可以看出之前的元数据中的配置变成了一个内嵌数组configs,configs下包含了角色配置、文件夹、页面。三者之间的关系由以前的层次关系被打平后变成了并列关系。那么如何实现他们之前的那种上下级关系呢?仔细看就能发现configs中的每一个对象里都有一个ancestors字段,这个字段用于记录祖先节点,也就是通过这个节点就可以轻松找到当前项有几个上级,只需要增加一个索引字段就可以高效的得到一个树状结构。根据ancestors创建索引:

xxxxxxxxxx
db.metadata.createIndex({
    "configs.ancestors": 1
})

    如图所示,在1.0中,如果想要按照箭头所指的方向移动往往需要配合数据库中的

    这两个字段,更新这两个字段来标注页面的位置。

    在新的数据库当中,由于页面、文件夹、配置是平等关系,所以只需要一个 "position": 1.59849188522867e+18字段来记录就行了,当需要移动上下页面时候只需要取相邻两个元素的position的平均值,最后结果按照position来排序,性能得到很大提升。

    通过前后数据结构的对比,可以很明显发现,在使用MySQL存储时,为了要保证元数据节点之间的关系,往往需要设计多张表,而使用MongoDB后,只要一个集合就能搞定设计时元数据的存储,这样带来的直接好处就是性能提升和应用程序开发的简化。

    元数据服务端使用了golang代替之前的php,其实也是为了方便元数据的操作和提升性能,由于配置、文件夹和页面的差异被抹平,三者被统一抽象为配置,于是就很方便的提供统一的元数据操作API,golang结构体可以完美的将元数据的结构映射到MongoDB的文档模型中,开发者可以清楚的看到数据库中元数据结构和代码中是完全一致的,这对新人理解元数据结构会有很大帮助。

//元数据结构体
type Metadata struct {
  Model       `bson:"-"`
  Id          bson.ObjectId `bson:"_id" json:"id"`
  WorkspaceId string        `bson:"workspaceId" comment:"工作区ID"`
  Common      bson.M        `bson:"common" comment:"公共配置"`
  Configs     []Config      `bson:"configs" comment:"配置信息"`
  IsPublished bool          `bson:"isPublished" comment:"是否发布"`
  CreatedAt   time.Time     `bson:"createdAt"`
  UpdatedAt   time.Time     `bson:"updatedAt"`
}


type Config interface {
  Add(metadataId string, data interface{}) error
  Edit(metadataId, configId string, data interface{}) error
  Delete(metadataId, configId string) error
  GetType() string
}

    解决了存储问题后,需要返回树状结构给前端,这就需要应用端重新组装数据。

type PageListResponse []TreeNode


//统一定义菜单树的数据结构
type TreeNode struct {
  Id        string                 `json:"id"`                  //节点id
  ParentId  string                 `json:"-"`                   //父id
  Type      string                 `json:"type"`                //类型
  Name      string                 `json:"name"`                //节点名字
  RouteName string                 `json:"routeName,omitempty"` //标识
  RoutePath string                 `json:"routePath,omitempty"` //路径
  Leaf      bool                   `json:"leaf,omitempty"`      //叶子节点
  IsGuest   bool                   `json:"isGuest,omitempty"`   //是否是游客配置
  IsLogin   bool                   `json:"isLogin,omitempty"`   //是否是登录页面
  Ancestors []string               `json:"ancestors,omitempty"` //祖先节点
  Remark    string                 `json:"remark,omitempty"`    //备注
  Position  string                 `json:"position"`            //位置
  Design    map[string]interface{} `json:"design,omitempty"`    //组件属性
  Metadata  map[string]interface{} `json:"metadata,omitempty"`  //元数据
  Children  []TreeNode             `json:"children,omitempty"`  //子节点
}


// GenerateTree 自定义的结构体实现 TreeNode 接口后调用此方法生成树结构
// nodes 需要生成树的节点
func GenerateTree(nodes []TreeNode) (trees []TreeNode) {
  trees = []TreeNode{}
  // 定义顶层根和子节点
  var roots, childs []TreeNode
  for _, v := range nodes {
    if len(v.ParentId) <= 0 {
      // 判断顶层根节点
      roots = append(roots, v)
    }
    childs = append(childs, v)
  }
  for _, v := range roots {
    childTree := &v
    // 递归
    recursiveTree(childTree, childs)
    // 递归之后,根据子节确认是否是叶子节点
    childTree.Leaf = (len(childTree.Children) == 0)
    trees = append(trees, *childTree)
  }
  return
}


// recursiveTree 递归生成树结构
// tree 递归的树对象
// nodes 递归的节点
func recursiveTree(tree *TreeNode, nodes []TreeNode) {
  for _, v := range nodes {
    if len(v.ParentId) <= 0 {
      // 如果当前节点是顶层根节点就跳过
      continue
    }
    if tree.Id == v.ParentId {
      childTree := &v
      recursiveTree(childTree, nodes)
      // 递归之后,根据子节确认是否是叶子节点
      childTree.Leaf = (len(childTree.Children) == 0)
      tree.Children = append(tree.Children, *childTree)
    }
  }
}

    这个阶段golang结构体处理json的便利性凸显出来,omitempty可以将空的节点数据忽略掉,这就有效的降低了元数据的体积,降低了网络I/O。

    设计时的元数据存储性能和逻辑复杂的问题解决了,剩下的就是运行时元数据的问题了。元数据在运行时阶段其实是不会变动的,在1.0当中,移动应用在运行时需要动态请求元数据的服务,从元数据服务接口中拉取运行时元数据来渲染页面,显然如果访问量大后元数据服务会成为性能的瓶颈。针对这个问题结合元数据的业务特点,最终运行时元数据就采用静态json文件的方式存储在OSS上,不仅消除了后端服务访问压力问题,同时也提高了运行时元数据加载的稳定性。最终生成的路径其实访问的是一个真实存在的json问题。

xxxxxxxxxx
https://xxxxxx.com/_assets/mobile_three/demo/exp/1.0.12/meta/default.json

三、总结

    好了,以上就是本次分享的移动建模平台元数据存储的演进过程,当然实际演进过程远比本次讲述的要复杂得多,分享的内容也是挑选几个比较重要的场景展开,后续可以分享一些MongoDB设计模式方面的内容,总结一下从开发选型角度大致有以下几点实践经验:

  1. 使用MySQL和MongoDB同时进行数据建模,对比两者之间的优劣,在表关系比较复杂时可能涉及到多表关联查询较多的场景下利用MongoDB内嵌文档、内嵌数组等灵活的文档数据结构往往能设计出结构更清晰、性能更好的存储方案。

  2. 小心MongoDB单个文档16M的存储限制,对于那种可能无限增长的数据不适合直接使用内嵌方式存储,可改为内嵌引用方式。

  3. 尽量不要使用ORM框架来操作MongoDB,往往会误把MongoDB当成MySQL来使用,同时不能很好的使用MongoDB强大的API。

  4. Golang和MongoDB的结合能在提升性能的同时,带来开发上的便利。

  5. MongoDB 4.0以后已经支持多文档事务,扩展了MongoDB的使用场景,越来越多的场景其实是可以使用MongoDB代替MySQL。如果没有特别的必要和限制,采用MongoDB往往会给程序设计带来更大的灵活性,提高数据库开发效率,更好的满足快速迭代开发的需求。

  6. MongoDB不能简单理解为一个json文档存储所有数据,同时要结合具体的业务场景考虑读写操作是否方便来设计文档模型。

------ END ------

作者简介

段同学: 研发工程师,目前负责天际-移动平台产品的研发工作。

也许您还想看

基于 Go 的微服务运行情况监控实践

在明源云客,一个全新的服务会经历什么?

云客后台优化的“前世今生”(一)

云客后台优化的“前世今生”(二)

回归统计在DMP中的实战应用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章