Salesforce架构体系梳理

发布于:2023-03-29 ⋅ 阅读:(2042) ⋅ 点赞:(2)

架构模式

img

底层机制的关键架构组件

img

在Force.com中,暴露给开发和应用程序用户的一切东西都内在地呈现为元数据。表格、报告、工作流、用户访问权限、租户特定的自定义和业务逻辑,甚至是基础数据表和索引的定义,所有这些都只是作为元数据在Force.com的通用数据字典(UDD-The Universal Data Dictionary)中被抽象和构建。例如,当一个开发人员建立一个新的自定义应用时,定义客户表,放置一个表单,或写一些程序代码,Force.com并不会在数据库中创建一个“实际”的表或编译任何代码。相反,Force.com只是简单地存储元数据,该平台的引擎可使用这些元数据在运行时生成“虚拟”的应用程序组件。当有人想修改或定制一些有关应用程序,所需要做的只是对相应元数据简单的无阻塞更新。

由于元数据是影响Force.com应用的关键成分,该平台的运行时引擎必须优化元数据的访问,否则,频繁访问元数据将会阻碍平台的可扩展性。考虑到这个潜在的瓶颈,Force.com使用元数据缓存,以将最近最多使用的元数据保持在内存中,从而避免性能受到磁盘I / O和代码重新编译的影响,并提高应用程序的响应时间。

Force.com将少数大型数据库虚表中的所有应用数据作为堆存储。然后考虑相应的元数据,该平台的引擎在运行时物化虚拟表中的数据。为了优化系统大表的数据存取,Force.com的引擎上依赖一组专门的数据透视表,来维护各种用途的非规范化的数据,如索引,唯一性,关系,等等。

Force.com的数据处理引擎通过透明地进行批量数据修改操作,精简了大型数据的加载开销和在线事务处理应用。该引擎内置故障恢复机制,自动在分析出导致错误的因素后重试批量保存操作。

为了进一步提升应用程序的响应时间,该平台采用外部搜索服务优化全文索引和搜索。应用程序更新数据时,搜索服务的后台进程近实时地异步更新租户和用户的索引。应用程序引擎和搜索服务之间的职责分离使平台应用可以有效地处理事务,而没有文本索引的更新开销,同时也为用户迅速提供准确的搜索结果。

由于Force.com的运行时应用程序生成器,针对特定的用户请求动态地建立应用程序,所以引擎在很大程度上依赖其“多租户感知”的查询优化器尽可能有效地执行内部操作。查询优化器首先考虑哪些用户正在执行给定的应用功能,然后利用维护在UDD中的相关租户的特定元数据以及内部的系统透视表,建立并执行数据访问操作,以优化数据库查询。

数据模型

img

1 对象元数据表(The Objects Metadata Table)

Metadata表的作用是存储用户定制的对象和对象所包含的字段的结构信息,不保存具体的数据,主要有两大类:

  • Object Metadata表:这个表主要存储对象的信息,其中主要字段包括对象的ID(ObjID),拥有这个对象的租户的ID(OrgID)和这个对象的名字(ObjName)。
  • Field Metadata表:这个表主要存储对象附带字段的信息,其中主要字段包括字段的ID(FieldID),拥有这个字段的租户的ID(OrgID),这个字段的名字(FieldName),这个字段的数据类型(datatype)和一个布尔字段(IsIndexed)来定义这个字段是否需要被检索。

2 字段元数据表(The Fields Metadata Table)

字段元数据表存储组织自定义对象的自定义字段(又名列或属性),包括一个字段的唯一标识符(FieldID),拥有该对象的组织(OrgID),包含该字段的对象(ObjID),字段名称(FieldName),该字段的数据类型,表示如果该字段是否需要索引的Boolean值(IsIndexed)和在对象中相对于其他字段的位置(FieldNum)。

img

3 数据表(The Data Table)

数据表存储可供访问的应用程序数据,并通过对象和字段的元数据定义映射到自定义对象和字段。每一行包括一个标识字段如全局唯一标识符(GUID)、拥有该行的组织(OrgID)以及对象标识符(ObjID)。数据表中的每一行也有一个名称字段存储相应对象实例的“自然名”。例如,一个Account对象可以使用“Account Name”,一个Case对象可能会使用“Case Number”等等。这Value0 ...Value500列存储应用数据,这些列将对象和字段分别映射到在对象和字段表中声明过的对象和字段;所有“flex”列使用可变长度字符串数据类型,从而能够存储任何类型的应用程序数据(strings,numbers,dates等)。

自定义字段可以使用任何一种标准结构化数据类型,如text,number,date,和date/time以及特殊用途的结构化数据类型,如选择列表(枚举字段),自动编号(自动递增,系统生成的序列数),公式(只读的派生值),主从关系(外键),复选框(布尔),电子邮件,网址等。自定义字段也可能是必须的(不为空),或者包含自定义的验证规则(例如,一个字段的值必须比其他字段更大),两者都是通过该平台的应用服务器来实现的。

当一个组织声明或修改自定义应用程序的对象,Force.com管理对象元数据表中定义对象的一行。同样,对于每个自定义字段,Force.com管理字段表中的一行,包括将这个字段映射到数据表中的一个存储相应字段数据的flex列的元数据。因为Force.com将对象和字段定义作为元数据而不是实际的数据库结构来管理,所以平台可以容忍多租户应用模式的维护活动,而不阻碍其他租户和用户的并发活动。

同一个对象不会有两个字段映射到数据表中同一个的flex列(槽)存储;然而,一个flex列可以管理多个字段的信息,但每个字段来自于不同的对象。

img

(一个flex列可以存储来自不同对象的属性的数据,它们拥有不同的数据类型)

如图所示的数据表的简化表示,flex列拥有一种通用数据类型(变长字符串类型),允许Force.com在多个字段间共享一个flex列,使用各种结构化的数据类型(strings,numbers,dates等)。

Force.com使用统一的格式存储所有flex列数据,而且当应用程序从flex列读取和写入数据时,必要时候使用底层数据库系统的数据类型转换函数(例如,TO_NUMBER,TO_DATE,TO_CHAR)。

4 Clobs表(The Clobs Table)

Force.com支持将字段作为字符大对象(CLOBs)声明,以允许存储多达32,000个字符的长文本字段。对于每一个在数据表含有CLOB字段的行,Force.com会在一个称为Clobs的透视表中存储CLOB超行,系统可以必要时通过它与数据表中相应的行进行连接运算。

注:Force.com还将CLOBs以索引形式存储在数据库外部,以方便快速的文本搜索。

5 索引透视表(The Indexes Pivot Table)

传统的数据库系统依赖索引迅速定位数据库表中的字段满足特定匹配条件的行。但是,为数据表的flex列创建本地索引是不切合实际的,因为Force.com可能用一个flex列来存储很多字段的数据,且它们的数据结构类型各不相同。所以,Force.com通过同步复制有标记的字段来管理数据表的索引,这些标记字段数据被索引到一个称为Indexes的数据透视表中相应的列,如图7简化ER图的描述。

该Indexes表包含强类型的索引列,如StringValue,NumValue和DateValue,Force.com借此查找相应数据类型的字段数据。例如,Force.com将复制一个数据表中flex列的字符串值到Indexes表的stringValue的字段,将日期值复制到DateValue字段,等等。Indexes表的底层索引是标准的非唯一数据库索引。当包含一个查询参数内部系统查询引用了自定义对象中的一个结构化字段,该平台的查询优化器就使用索引表,以帮助优化相关的数据访问操作。

img

(Force.com使用数据透视表来索引存储在flex列的数据)

注 :Force.com可以处理多语言搜索,因为该平台的应用服务器使用大小写折叠算法将字符串值转换成一个普遍的、大小写不敏感的格式。Indexes表的StringValue列以这种格式存储字符串。在运行时,查询优化器自动生成数据访问操作,从而使得对于字面上给定的搜索请求,优化后的SQL语句可以做些相应大小写折叠后的StringValue的过滤。

6 数据和元数据的分区(Partitioning of Metadata, Data, and Index Data)

所有Force.com数据,元数据和数据透视表结构(包括基础数据库索引)均由OrgID(租户)使用本机数据库分区机制进行物理分区。 数据分区是数据库系统提供的一种成熟技术,可将大型逻辑数据结构物理划分为更小,更易管理的部分。 分区还可以帮助提高大型数据库系统(如多租户环境)的性能,可伸缩性和可用性。 根据定义,每个Force.com查询都会针对特定租户的信息,因此查询优化器只需考虑访问包含租户数据的数据分区,而不是整个表或索引。 这种常见的优化有时被称为““partition pruning”。

7 唯一域的数据透视表(The UniqueFields Pivot Table)

Force.com允许组织指出何时一个对象中的字段必须包含唯一值(区分大小写或不区分大小写)。考虑到数据表值列上共享使用自定义的字段数据,为表创建唯一的数据库索引是不切实际的,这与前面部分讨论过的非唯一索引问题类似。

为了支持自定义字段的唯一性,Force.com使用称为UniqueFields的数据透视表来实现,这表与Indexes数据透视表非常相似,只不过UniqueFields数据透视表的底层数据库索引强制唯一性。当一个应用程序试图在需要唯一性的字段上插入重复值,或者管理员试图在包含重复值的已有字段上强制唯一性时,Force.com中转一个适当的错误消息给应用程序。

8 关系数据透视表(The Relationships Pivot Table)

Force.com提供了组织声明其应用对象间关系的“关系”数据类型(参照完整性)。当一个组织将对象的字段声明为关系类型时,平台将该字段映射到数据表中的一个值字段,然后使用该字段来存储相关对象ObjID。

为了优化连接操作,Force.com维护一个称为关系(Relationships)的数据透视表,如图8所示。

img

图8:关系表可帮助优化对象连接。

关系索引表(The Relationships Index table)有两个底层数据库的唯一联合索引,即OrgID +GUID,和OrgID + ObjID + RelationID + TargetObjID,从而保证必要时对象双向遍历的高效性。

9 FallbackIndex表(The FallbackIndex Table)

在极少数情况下,平台的外部搜索引擎可能超载或不可用,或可能无法及时响应搜索请求。用户提交一个查询请求后,该平台的用户服务器进入后备的二级搜索机制,返回合理的搜索结果,而不是返回一个令人失望的错误。

后备搜索是数据库查询的直接实现,它参照目标应用对象的名称字段进行搜索。为了不必执行潜在的昂贵的Union查询,实现优化搜索全局对象(跨对象搜索),Force.com维护一个称为FallbackIndex的数据透视表来记录所有对象的名称。当事务修改对象时,FallbackIndex同步更新,后备搜索总可以访问到最新的数据库信息。

10 NameDenorm表(The NameDenorm Table)

该表是一个精简的数据表,存储ObjID和数据表中每个对象实例的名称。当应用需要给出一个父子关系的对象实例的超链接列表时,Force.com使用NameDenorm表来执行一个相对简单的查询,检索每个引用对象的名称以得到超链接的列表。

11 历史跟踪表(History Tracking Table)

Force.com容易实现对任何字段的历史跟踪。当组织审计特定的字段,系统将使用一个用于审计跟踪的内部透视表异步记录该字段的变化信息(旧值,新值,改变的日期等)。

好了,今天这篇是后面章节所有内容的铺垫,了解了这个数据模型,会方便大家理解Salesforce的“令人发指”的灵活性。

UDD模型

SF的每一条记录都有自己的编号,一个Account,一个业务机会等等都会有自己的UniqueID。怎么查看呢?

当你打开一条记录,在浏览器的地址栏都可以看到这条记录的ID,这个ID在开发人员后台是可以直接用来检索数据的。

img

这个18位的编号就是这个名叫“Canson”的客户的ID,它是怎么构成的呢?

img

1)前三位是key prefix (关键前缀),对应代表不同的对象;

2)后面二位是Instance的编号,代表着所属的Instance,现在一共有75+个Instance,所以占两位,未来有可能会场超过2位,所以紧跟着的第6和7位是reserved,预留Instance以后数量增加用的;

3) 中间那串才是正经的这个记录的Unique ID;

4)最后三位,Case Checksum,用来标记case的一些补充信息信息的;(有时候没有这三位,ID就是16位数字;)

应用服务器

img

应用服务器主要包括五大核心模块:

  • Metadata Cache:用于存放那些最近用到的和比较常用的Metadata来加速应用的生成。
  • 大规模数据处理引擎:主要用来加速处理大量的数据读写和在线事务。
  • 多租户感知的查询优化引擎:这个引擎将通过维护多租户的信息来帮助Oracle自带的基于成本的查询优化器更好地适应多租户环境。
  • 运行时应用生成器:这个生成器主要根据用户的请求来动态生成应用,并且利用上面提到的查询优化引擎来提升效率。
  • 全文检索引擎:在数据库对数据进行更新的同时,这个引擎会异步更新这个数据的相关索引。

共享数据库

img

图1. Force.com的架构(图源自参[1])

整个共享数据库主要有三种类型的数据库表:

  • Metadata表:主要存放用户定制的对象和对象所包含的字段的结构信息,也被称为"UDD"。
  • 数据表:主要存储那些用户定制的对象和对象所包含的字段的数据。
  • Pivot表:用来维护那些用于检索(indexing),唯一性和关系等denormalized (去规范化)数据以优化系统的效率。

还有,在物理层面,数据库里面所有表格,包括底下的索引,都根据每个租户不同的租户ID(OrgID)来使用Oracle的Hash分区技术进行分区。通过Hash分区这种久经考验的技术能够将大规模的数据平均地分割成多个更小的和更容易管理的分块,从而帮助大数据库系统能够在多租户的环境下提升速度,伸缩性和可用性等。

虚拟应用

Force.com会有一套引擎来通过分析数据库中的Metadata来动态生成一个虚拟应用实例和这个应用所需的模块(Virtual Application Componets),比如公共UI(Common Application Screen),定制UI(Tenant-Specific Screen)和其他对象等。

多租户处理

在很大程度上,Force.com的表现和扩展非常好,因为salesforce.com根据两个重要原则构建了服务。

  • 尽可能提高效率。
  • 帮助开发者尽可能高效地完成所有工作。
多租户查询处理

为了提供足够的统计信息来确定多租户系统中的最佳查询执行计划,Force.com为每个虚拟多租户对象维护一组完整的优化器统计信息(租户,组和用户级别)。统计数据反映了特定查询可能访问的行数,仔细考虑了整个租户特定的对象统计信息(例如,整个租户拥有的总行数)以及更精确的统计数据(例如,数字特定特权组或最终用户可能访问的行)。

Force.com还维护其他类型的统计数据,这些统计数据证明对特定查询有帮助例如,该服务维护所有自定义索引的统计信息,以揭示相应字段中非空和唯一值的总数以及揭示每个列表值的基数的选项列表字段的直方图。

当现有的统计数据不存在或不被认为有帮助时,Force.com的优化器会使用一些不同的策略来帮助构建合理的最佳查询。在其他情况下,优化器将在运行时动态生成缺少的统计信息。

与优化器统计结合使用时,Force.com的优化器还依赖与内部安全相关的表(组,会员,GroupBlowout和CustomShare)维护有关系统用户安全域的信息,包括给定用户的组成员身份和自定义访问权限对象和行。这样的信息在确定每个用户的查询过滤器的选择性方面是非常有价值的。

img

多租户搜索处理

基于Web的应用程序用户期望具有交互式搜索功能,能够扫描应用程序数据库的全部或选定范围,返回最新的排名结果,并在亚秒级响应时间内完成。为了为应用程序提供强大的搜索功能,Force.com使用与其事务引擎分开的搜索引擎。下图描述了两个引擎之间的关系。搜索引擎从事务引擎接收数据,并用它创建搜索索引。事务引擎将搜索请求转发给搜索引擎,搜索引擎返回事务引擎用于查找满足搜索请求的行的结果。

img

多租户隔离和保护

为了防止共享的多租户系统资源遭到恶意或无意的垄断,Force.com拥有大量与Force.com代码执行相关的管理员和资源限制。例如,Force.com密切监视代码脚本的执行情况,并限制它可以使用多少CPU时间,可以使用多少内存,可以执行多少个查询和DML语句,可以执行多少个数学计算,以及如何执行许多出站Web服务调用它可以做,等等。 Force.com的优化器认为执行代价过高的个别查询会向调用方抛出运行时异常。尽管这些限制听起来有些限制,但它们对于保护所有相关应用程序的共享数据库系统的整体可扩展性和性能是必要的。从长远来看,这些措施有助于促进开发人员之间更好的编码技术,并为使用该平台的每个人创造更好的体验。例如,最初尝试编码循环的开发人员由于资源限制而无法有效更新每行一千行的行,将会收到运行时异常,然后开始使用Force.com的高效批量处理API调用。

为了进一步避免由编写不佳的应用程序引起的潜在系统问题,部署新的生产应用程序是一个严格管理的过程。在组织可以将新应用程序从开发转换为生产状态之前,salesforce.com需要进行单元测试,以验证应用程序的Force.com代码例程的功能。提交的单元测试必须覆盖不低于应用程序源代码的75%。 Salesforce.com在Force.com的沙箱开发环境中执行提交的单元测试,以确定应用程序代码是否会对整个多租户人群的性能和可扩展性产生不利影响。单个单元测试的结果表示基本信息,例如执行的总行数,以及有关代码未被测试执行的特定信息。

多租户批量操作

事务密集型应用程序产生的开销较小,并且在批量组合和执行重复操作时性能会更好。例如,对比应用程序可能加载许多新行的两种方式。一种效率低下的方法是使用一个带有循环的例程,该循环插入单独的行,为每个行插入创建一个API调用。更有效的方法是创建一个行数组,并让这个例程通过一个API调用来插入所有这些行。

使用Force.com进行高效的批量处理对开发人员来说很简单,因为它已经被加入到API调用中。在内部,Force.com还批量处理与显式批量操作相关的所有内部步骤。

Force.com的批量处理引擎会自动计算在此过程中遇到的孤立故障。批量操作在部分保存模式下启动时,引擎会识别已知的启动状态,然后尝试执行流程中的每个步骤(批量验证现场数据,批量火灾预触发器,批量保存记录等)。如果引擎在任何步骤中检测到错误,引擎会回滚违规操作和所有副作用,删除引发故障的行并继续尝试批量处理剩余行的子集。这个过程遍历每个后续阶段,直到引擎可以提交行的一个子集而没有任何错误。应用程序可以检查返回对象以确定哪些行失败以及引发了哪些异常。

注意:根据你的判断,批量操作可以使用全空模式。此外,批量操作期间触发器的执行受制于限制工作量的内部管理员。

多租户模式修改

某些类型的对象定义修改需要的不仅仅是简单的UDD元数据更新。在这种情况下,Force.com使用有效的机制来帮助减少整体性能对云数据库服务的影响。

例如,考虑当您将列的数据类型从选取列表修改为文本时发生的后果。 Force.com首先为列的数据分配一个新插槽,批量复制与当前值关联的选取列表标签,然后更新列的元数据以使其指向新插槽。发生所有这些情况时,访问数据是正常的,应用程序继续运行,没有任何明显的影响。

作为另一个例子,请考虑在将汇总摘要字段添加到对象时会发生什么情况。在这种情况下,Force.com使用高效批量操作在后台异步计算初始摘要。在进行背景计算时,查看新字段的用户会收到Force.com当前正在计算字段值的指示。

APEX

APEX的语言是为Force.com度身定做的一门语法上类似Java的强类型面向对象语言,主要可以通过APEX在Force.com上创建Web Service,编辑复杂的商业逻辑和整合多个Force.com的模块等。APEX主要以两种方式执行:其一是以单独脚本的形式,按照用户的需要执行。其二是以触发器的形式,当一个特定的数据处理事件发生的之前或者之后,与这个事件绑定的APEX代码将会被执行。而且所有APEX代码将会以Metadata的形式存储在Metadata表内。当一段APEX代码被调用的时候,APEX的翻译器(runtime interpreter)将会从Metadata Cache读取编译之后的APEX代码,而且能够同时被多个租户共享以提升效率。

那么为什么要在Force.com引入APEX这门新的语言,而不是像Google App Engine那样支持已经有一定市场占有率的语言,比如Java和Pyhon。Salesforce的首席架构师在谈到这点时,他提出了一个非常重要的原因,那就是安全,首先,Salesforce会APEX语言度身设计一组管理工具,通过这个工具能够非常方便地监控APEX脚本的执行,并且能知道这个脚本在执行过程所耗费的CPU时间,内存容量和SQL语句的数量等数据来判断是否需要中断这个APEX脚本,以避免影响到属于其他租户的应用,如果中断的话,系统会抛出一个runtime exception给上层的调用者。其次,基于APEX语言的代码能够对其内嵌的SOQL(Sforce Object Query Language)和SOSL(Sforce Object Search Language)进行验证来避免实际运行时出现错误。还有,在安全方面除了APEX自带的功能之外,Salesforce还要求每个上传到Force.com的APEX脚本,都需要自带能覆盖其75%代码的测试用例,这种做法不仅显著地提升APEX代码的质量从而确保平台整体运行的稳定,而且在Force.com自己更新的时候,能使用这些用例来确保新的更新不会影响现有的基于Force.com的应用。

设计理念

根据 Craig Weissman 的演讲和几份官方的白皮书,在Force.com的设计方面Salesforce团队主要有下面这五大考量:

  • 数据驱动:由于 Salesforce 主要面向企业用户,导致其上面运行的应用,无论是 CRM ,还是报表工具,都是以数据的CRUD(增删改查)为核心,所以 Force.com 需要由数据来驱动,而且也需要为此做一定程度的优化。
  • 规模经济:由于需要在低价格和灵活付费的基础上提供可定制化应用,所以需要让尽可能多用户共享同一套系统,来大幅减低基础设施和管理等资源的投入,并实现规模经济的效益。
  • 安全为先:由于在一套物理设备上将承载数以万计客户的企业级应用,那么如果出现严重的程序错误或者数据方面遗失或者错乱,将会发生非常严重的后果,所以安全问题是一个 Salesforce绝不能轻视的问题。
  • 定制方便:虽然各个企业都会存在一部分比较通用的流程,但是每个企业都可能存在一部分私有或者独特的流程,所以Force.com需要提供方便的定制功能来帮助用户将更快捷地将企业的业务迁移到其上。
  • 功能丰富:虽然用户能在 Force.com 上进行开发和定制,但是如果 Force.com 能提供更多的功能模块或者能让用户购买和整合第三方的应用将非常有效地帮助用户开发应用。
    虽然这些设计理念说起来很容易,但是实现起来是非常艰难的。可贵地是,Salesforce 团队在开发 Force.com 的过程中基本实现了这些设计理念。

总结

关于本系列的总结,也主要包括五个方面:

  • Trade-Off 是难免的:为了满足设计目标,有时不得不做Trade-Off 。由于 Salesforce 所需要承载的多租户应用的规模之大,定制化需求之高都是前所未见的,所以Salesforce并没有采用常见的ddl变更模型,而是从长计议,采用了更灵活但技术要求更高的 Metadata 方式。 还有为了避免在数据库中执行成本非常高并会 Locking 整个数据库的 DDL(数据库定义语句)操作,所以在 Force.com 运行的时候是无法创建和修改数据库表,而这样将会提升实现的难度。
  • 优化很重要,虽然 Force.com 的多租户架构就像 Java 一样,采用了很多动态生成的机制。很显然,如果像早期的Java那样缺乏优化的话,那么 Force.com 整体的性能将会非常糟糕,从而无法实现其设计要求。但幸运的是,Salesforce 团队不仅做了优化,而且凭借着其很多核心成员来自于 Oracle 的背景,在数据库端做了很多高水平的优化,比如添加了很多貌似累赘的 Pivot 表来加快部分常用数据的读取。
  • 人才很重要:经过本系列的介绍,可以看出 Force.com 的整个架构并不全是在现有的框架和库的基础上构建的,而是为了设计目标开发了很多比较底层和比较复杂的模块,而且这些模块是只有那些顶级的程序员才能编写出来的,所以说如果没有硅谷那个庞大的优秀程序员池,Salesforce 就很难走到今天。
  • 软件是一个进化的工程:刚开始的时候 Salesforce 架构是普普通通的 B/S 架构,但是随着用户不断地提出定制化的要求,Salesforce 也不得不在架构中引入多租户的概念,之后,由于用户需要更灵活的,可伸缩的和功能更强大的平台,导致 Salesforce 不断地对其架构进行重构,到最后,终于整出了 Force.com 这一优秀的 PaaS 平台。
  • 有用的创新才珍贵:Salesforce 不仅在 Force.com 引入很多创新,而且都非常有效。在这些创新当中,最有用的除了 Metadata 驱动这种多租户架构实现机制之外,还有一个名为"回收站(Recycle Bin)"的概念,这个回收站主要存储30天来那些从数据表里面删除的数据,如果用户在30天内发现数据是误删,可以对数据进行恢复,这样既减低数据误删的可能性,而且能回收部分物理资源,比如硬盘空间等。

若有收获,就点个赞吧

本文含有隐藏内容,请 开通VIP 后查看