爱奇艺会员交易团队在系统扩展性方面的探索

注:

本文首发于“爱奇艺技术产品团队”公众号。作为本文的作者,我将此文在自己的简书上再次发表,希望更多人阅读,并欢迎提出问题和意见。

本文介绍的设计并非最佳,而是考虑到历史问题后的折中方案,欢迎大家提出自己的设计。

一、前言

互联网是一个强调速度的行业,各公司都希望自己的开发人员能尽可能快地完成需求开发。但随着系统和业务复杂度的增加,单靠程序员加班也越来越难以满足产品提出的业务需求,而且单纯强调速度也会留下越来越多的技术债。那如何设计开发业务系统才能满足速度和质量的双重要求呢?

解决这一问题的一个重点是系统的架构设计是否具有良好的扩展性,是否能够使后来的业务开发能够轻松而又高质量地完成。接下来我将从理论、实践等几个方面,介绍我们会员团队是如何解决这一问题的。

二、微内核模式

业务系统同其它软件系统其实并无本质的区别,所以在如何打造高扩展性的业务系统这个问题上,可以从优秀的同行身上得到借鉴。其中一个很重要的借鉴是微内核模式。

很多开源项目,如 Linux、Spring Framework 等,都有一个小而精的内核。比如 Linux 的 Kernel、Spring Framework 的 ApplicationContext。这些软件系统(或框架)在这个核心基础之上,通过组件(插件)的形式实现更加丰富的功能。这样的设计满足了高内聚低耦合、开闭等设计原则,这就是微内核模式的重要意义。

那微内核模式是什么呢?这个问题并没有完整统一的答案。对于一个业务系统的微内核,我理解它应当定义出基本业务流程,仅包含基本的业务功能实现(最好基本功能也由插件的形式实现,而业务流程最好也是可定制的)。并且,为业务流程上的每一步定义出组件定义。不同场景通过提供不同的组件接口实现来完成。

所以,归纳来说,业务系统的微内核应当做如下的事情:

  1. 流程定义
  2. 组件定义
  3. 组件调用

这样,系统即可以实现基本的功能,又可以通过组件的灵活实现和组合满足不同业务场景下的需求。

举个例子

举例来说,在电商交易的结算页服务中,需要记载各种信息,包括购物车、收货人等等信息。这些操作可以抽象为如下的接口:

interface ReceiverLoader {
    // 根据 userId 加载收货人信息
    Receiver load(Long userId);
}

interface InventoryChecker {
    // 根据商品 SKU 查询库存数据
    InventorySummary check(List<String> skus);
}

interface DeliveryLoader {
    // 返回用户和商品类型(实体、虚拟等)所对应的可用的递送方式
    DeliverySummary load(Long userId, CommodityType commodityType);
}

interface PayTypeLoader {
    // 根据 userId 返回改用所能使用的支付方式
    List<PayType> load(Long userId);
}

对于不同的电商业务,上述功能会有不同的业务需求:

例如,实现收货人信息加载功能时,如果是对应实物购买,需要返回收货人真实地址;如果是虚拟物品,则不需要真实地址。实现库存查询功能时,如果是自营的实体物品,需要查询自己的库存系统;如果是商旅产品,则需要查询对应的航空公司、酒店等;如果是一些虚拟商品,则可能不需要查询库存,因为虚拟物品可能是无上限的。

通过为上述接口提供不同的实现类,再组合搭配,就可以灵活实现不同的场景。

三、组件的组合

上一节并没有介绍微内核如何将不同的组件组合在一起,如何调用,以实现不同场景下的需求。本节就来介绍这方面的内容。

简单来说,组件的组合方式分为两类:

  1. 静态组合
  2. 动态组合

静态组合

首先介绍静态组合方式。

所谓静态组合,就是软件在编译部署之后,组件之间的关系不再发生变化的一种组合方式。

静态组合的优点是实现起来相对简单,配置也比较简单。缺点是只能用于一些变化不难么复杂的业务场景。当业务场景变的复杂多变,需要根据参数的不同灵活调用不同组件时,静态配置就无法满足需求了。

对静态组合的一个简单解释可以通过下面这段 Spring XML 配置来呈现:

<!-- 普通商品结算服务 -->
<bean id="checkoutService" class="com.company.team.CheckoutServiceImpl">
    <property name="receiverLoader" value="defaultReceiverLoader" />
    <property name="inventoryChecker" value="defaultInventoryChecker" />
    <property name="payTypeLoader" value="defaultPayTypeLoader" />
    <!-- more properties -->
</bean>

<!-- 虚拟商品结算服务 -->
<bean id="virtualCheckoutService" class="com.company.team.CheckoutServiceImpl">
    <property name="receiverLoader" value="virtualReceiverLoader" />
    <property name="inventoryChecker" value="virtualInventoryChecker" />
    <property name="payTypeLoader" value="defaultPayTypeLoader" />
    <!-- more properties -->
</bean>

上面例子中,两个 bean 是用来实现结算服务 (CheckoutService) 的,这两个 bean 使用了同一个实现类 CheckoutServiceImpl。这个类可以看作是结算服务的内核。它通过与不同的组件(ReceiverLoaderInventoryCheckerPayTypeLoader 等)组合,实现不同场景下的需求。

这样的设计方式反映出了一个面向对象设计原则 —— 组合优于继承

但静态组合因为在编译部署之后不再变化,无法适应更加复杂的业务场景,这是静态组合的不足。

动态组合

上面讲到了静态组合的不足,所以我们需要另一种更加灵活的组合方式。这种组合方式我们可以称为动态组合。

如何实现呢?

显然,在直接使用条件语句调度组件是不合适的,原因在于它不满足开闭原则,无法满足我们对扩展性的要求。

要实现易于扩展的动态组合设计,可以使用一些设计模式:例如表驱动模式、职责链模式等等。具体如何使用这些设计模式实现动态组合这里就不介绍。接下来看看这么实现之后,开发人员如何去实现新的组件以实现新的业务场景:

class NewComponent implements Component {
    @Autowired
    private ComponentRegistry registry;
    
    public boolean canProcess(Request request) {
        return request.getParam1().equals("abc") && request.getParam2().equals("def");
    }
    
    @PostConstruct
    public void init() {
        register();
    }
    
    public void register() {
        registry.register(Component.class, this);
    }    
}

上述形式的组件定义再结合职责链模式,就能够实现组件的动态组合和调度。但是,这样的形式并不是很理想。原因在于:

  1. 组件的调度规则配置既不直观也不方便
  2. 组件划分的粒度无法得到统一

这样的问题,放在比较复杂的系统里,就会导致系统难以理解和维护的问题。

那如何实现一个直观方便,易于维护,能够统一组件划分粒度的动态组合方式呢?接下来讲结合实践给予介绍。

四、实践

接下来介绍一下爱奇艺会员交易团队在构建易于扩展的业务系统方面的实践。

先简单介绍一下会员交易系统的业务。会员交易系统是整个会员系统中重要组成部分,承载了绝大部分的 VIP 会员收入,功能包括:

  1. VIP 商品的展示和售卖
  2. 影片商品的售卖
  3. VIP 和影片订单的查询
  4. 自动续费的开通、执行和管理
  5. 相关后台业务

同很多实体和商旅类的交易服务相比,会员交易在库存、发货、物流等方面的需求被简化了,但另一些需求却变得复杂了,例如:

  • 需要支持更加丰富多样的支付方式和客户端平台
  • 需要支持更加丰富多样的优惠促销活动
  • 需要支持多种形式的自动续费
  • 需要支持各级别会员的升级购买

这些复杂的需求再加上紧张的业务排期,使得代码越来越复杂和难以维护扩展。静态代码检查发现,代码的复杂度和代码行数之比接近1比3,代码重复率达到了20%,而实际情况则更为严重

这些问题导致了新需求实现缓慢、技术优化长期难以推进等问题。

为了解决这些问题,我们重新设计了订单支付服务

首先我们重新设计了订单支付服务的架构。将订单支付的核心流程交由核心模块实现,各个场景下的特殊逻辑由支付处理类或配置文件的方式定制实现:

image

然后梳理核心流程

image

首先,在第4步,订单支付服务会根据支付处理器上的注解配置或支付处理配置文件的定义,选择出合适的支付处理类。具体的配置方式如下:

注解形式的配置

@PayRequestMapping(
    payTypes = {ALIPAY, ALIPAY_V3},
    platformTags = {PlatformTag.H5}
)
@Component
class AliPayH5OrderPayHandler implements OrderCustomizer, GatewayPayRequestCustomizer {
  @Override
  public void customize(Order order, PayContext payContext) {
    ...
  }
    
  @Override
  public void customize(GatewayPayRequest gatewayPayRequest, PayContext payContext) {
    ...
  }    
}

文件形式的配置

{
  "handlers": [
    {
      "name": "WeChatOrderContract",
      "mapping": {
        "payTypes": [
          379
        ],
        "platformTags": [
          "h5"
        ]
      },
      "gatewayPayRequest": {
        "extendParameters": {
          "isFirstSign": "yes"
        }
      },
      "resultType": "DIRECT_AND_JSON"
    }
  ]
}

注解形式的配置常用于需要代码实现动态逻辑的场景,而对于比较简单的场景,仅需通过配置文件即可完成适配。

在找到合适的支付处理类之后,会根据其代码实现(注解形式)或配置文件的内容(配置形式)进行自定义操作。在上面流程图的第6、9步都会进行相应的自定义操作。

在进行上述改进之后,大部分简单的订单支付服务的业务实现就只需增加新的配置即可。而比较复杂的场景,因为扩展组件的明确定义和声明式的组件路由,因此开发效率也得到的极大的提升,代码复杂度大大降低。

五、Navi 项目

由来

在重新设计订单支付服务之后,我们发现其它的一些服务也需要类似的设计:即通过声明式的规则定义,动态地选择合适的组件。

那如何让其它项目也能轻松的拥有这样的能力呢?于是便有了设计开发一个通用框架实现这样功能的想法。这就是 Navi 项目的由来。

项目地址:https://github.com/yanglifan/navi

Navi 项目能做什么呢?简单来说,Navi 能够通过简单、灵活、丰富的声明式配置,实现动态的组件选择功能。

接下来我将举几个栗子。

简单示例

interface OrderCreateHandler { // 1
    void handle(Order order, OrderCreateRequest request);
}

@EqualMatcher(property = "clientType", value = "android") // 2
@VersionMatcher(range = "[1.0.0,2.0.0)") // 2
@Component
class AndroidV1Handler implments OrderCreateHandler { // 2
    void handle(Order order, OrderCreateRequest request) {
    }
}

@EqualMatcher(property = "clientType", value = "android") // 2
@VersionMatcher(range = "[2.0.0,3.0.0)") // 2
@Component
class AndroidV2Handler implments OrderCreateHandler { // 2
    void handle(Order order, OrderCreateRequest request) {
    }
}

public class OrderService {
    public OrderCreateResult createOrder(OrderCreateRequest req) {
        // 略        
        OrderCreateHandler handler = selector.select(req, OrderCreateHandler.class); // 3
        if (handler != null) {
            handler.handle(order, req);
        }        
        // 略
    }
}

上面这段示例中,OrderCreateHandler 是一个业务组件的接口定义。它有两个实现类:AndroidV1HandlerAndroidV2Handler,分别用来实现安卓客户端 1.0 和 2.0 下不同的需求。即当 1.0 版本的 Android APP 调用时,选择 AndroidV1Handler,2.0 版本的 Android APP 调用时,选择 AndroidV2Handler

在 Navi 中,组件选择的过程通过执行如下语句实现:

 OrderCreateHandler handler = selector.select(req, OrderCreateHandler.class);

selector 的类型为 Selector,意为组件选择器,简称选择器。在 Spring 应用中推荐使用其实现类 SpringBasedSelector

组件选择参数:select 方法的第一个入参 req 是组件选择参数,组件选择器会根据其中的数据与组件上定义的匹配规则进行比较,从而选择出满足条件的组件。

匹配器注解

要想组件按照期望被选择出来,就需要用到匹配器(Matcher)注解。以上面的代码为例进行说明

  • @EqualMatcher(property = "clientType", value = "android")
    这个注解的意思是组件选择参数(后续说明)中的 clientType 的值等于 android。此时,这条匹配条件就是命中,否则就会拒绝。
  • @VersionMatcher(range = "[2.0.0,3.0.0)")
    这个注解的意思是组件选择参数中的 version 字段的值表示一个版本(格式必须为 major.minor.patch 格式,每一段必须为数字类型)。而这个版本符合 range 所定义的范围之内。方括号表示闭区间,小括号表示开区间。这个与数学中的定义相同。

Navi 自建的匹配器除了上面两个外,目前还有:

  • ContainMatcher 组件选择参数对应字段的值包含期望的值(字符串 contains 操作)
  • IntersectMatcher 组件选择参数对应字段的值(集合类型或用分割符分割的字符串类型)与期望的值有交集

注:匹配器之间的关系都是「与」

组合匹配器

当需要在组件上同时使用多个匹配器的时候,可能会导致在同一个业务功能上,组件选择的维度不统一,从而对功能的理解、维护造成困难。为了解决这个问题,达到简化和统一匹配器使用的目的,Navi 引入了自定义匹配器的功能。

下面是一个例子:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@IntersectMatcher(property = "platform.tags")
@VersionMatcher(property = "appVersion")
@CompositeMatcherType
public @interface StoreViewHandler {
    String ALL_PLATFORMS = "*";

    @AliasFor(annotationFor = EqualMatcher.class, attributeFor = "value")
    String[] platform() default ALL_PLATFORMS;

    @AliasFor(annotationFor = VersionMatcher.class, attributeFor = "range")
    String clientVersionRange() default "";
}

@StoreViewHandler(platform = "h5", clientVersionRange = "[1.0.0,2.0.0]")
class Component {    
}

通过自定义一个注解,并在其上配置 Navi 内置的匹配器注解,你就能得到一个新的匹配器注解。

注:匹配器注解不能无限制叠加,只能有一层的复合关系。

后续计划

目前 Navi 只发布了两个版本,后续还有更多功能:

  1. 函数式的配置
    注解方式的配置简单易用,但存在不能覆盖的场景,所以计划提供函数式的配置方式
  2. 透明化的组件选择
    不再通过 Selector 显式选择组件,而是为组件生成代理,通过代理选择实际的组件
  3. 组件多选
    目前选择器只会返回单个组件,后续会支持选择多个组件,根据优先级依次调用的功能

六、总结

相比高可用、高并发等话题,扩展性是相对冷门的。但系统的扩展性与技术人员的日常工作关系更为紧密,对企业业务发展速度也有显著的影响。

但如何设计易于扩展的系统并不像设计高可用、高并发系统那样有着很多可供使用的技术、可供参考的设计模式。因为没有哪两家公司的业务是完全相同的,而业务上的差异就会带来系统设计上的差异。所以,从这个角度讲,是否能设计出易于扩展的系统更加能反映程序员的技术水平和设计能力。

任何公司的发展都是由小变大的,在公司发展初期,并不需要过分考虑系统扩展性的问题。但在公司不断发展,业务不断复杂的过程中,其背后的业务系统的可扩展性的重要性就会不断提高。当发展到一定规模时,新提出的业务需求如果还是需要重复建设的话,那其所需的成本将会变得难以接受。

所以,有不少公司在推进业务平台的建设,通过提供完善的、可灵活定制的业务能力,以支持新业务的快速上线。而这些业务平台的建设,其实就反映出对系统扩展性不断提高的要求。

本篇文章介绍了一些系统扩展性方面相关的理论与实践。但实际情况其实远比文中介绍的更加复杂,所以在系统扩展性方面,还有更多值得思考和讨论的内容。

我的技术公众号“编走编想”
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,566评论 25 707
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,472评论 2 59
  • Spring Boot 参考指南 介绍 转载自:https://www.gitbook.com/book/qbgb...
    毛宇鹏阅读 46,358评论 6 343
  • 回想那个曾经牵个小手都能脸红的年代,一句话就是一辈子的年代,我送你一个小礼物便代表我爱你的年代,在池塘边悄悄翻看情...
    恋爱迹阅读 799评论 0 0
  • 最近被林丹出轨事件刷屏了,各种文章众说纷纭。其实这类事件在我们身边随处可见,只是因为林丹头戴光环而被过分关注。而我...
    Teresa999阅读 150评论 0 0