在项目中透明地引入特性开关

之前曾经推荐过崔立强的《使用功能开关更好地实现持续部署》,介绍Feature Toggle的实践。北京办公室的孟宇现在对这个问题有了新的思考,当我们抛却Spring,Feature Toggle又该如何实践呢? 于是,他写了《在项目中透明地引入特性开关》。 在前几期的InfoQ专栏中刊登了一篇名为“使用功能开关更好地实现持续部署”的文章,文中讲解了特性开关与Spring的集成应用。但如果项目没有依赖Spring,又该如何更好地使用特性开关呢?同时,又该如何透明地引入,使得项目不至于完全依赖特性开关呢?

接下来我将结合我们在项目中实际运用特性开关的经验,从另一个角度为大家介绍如何使用特性开关透明地实现功能屏蔽。

问题

我们的团队正在开发一款在线保险产品,该产品下包括若干品牌,每个品牌有不同的目标用户群,但提供的服务基本相同。当第一个品牌正式上线后,我们就面临一个很大挑战——既要修正上线后发现的Bug,又要继续为其它品牌添加新特性,且这些特性暂时不能反映到已上线的品牌中。我们并不愿意为这种多品牌开发的业务创建分支版本,因为它对于版本维护而言,无疑是一场灾难。因而,我们决定选择特性开关来解决这个问题。

若要快速地解决这一问题,我们当然可以选择 if… else… 这种简单的特性开关模型;然而,随之却会引入其它问题:

  • 条件式特型开关会对现有的业务结构产生影响 清晰的业务逻辑,简单的代码结构是保证项目可维护性的基础。如果特性开关的添加使业务逻辑变得复杂而不易理解,那么特性开关就在一定程度上破坏了项目的可维护性。 添加特性开关前的代码如下: inputVehicleDetails(); createQuote(); inputPersonalDetails(); buyInsurance(); 添加特性开关后: inputVehicleDetails(); if (brandA.isActive()) { // 条件式特性开关 createQuote(); } inputPersonalDetails(); buyInsurance(); 可以看到,简单的条件分支虽然实现了特性开关——部分代码只有在满足条件时才会执行,但却破坏了原有清晰的业务结构⋯⋯
  • 当前程序中如果已存在了某些类似特性开关的判断,条件式特性开关会造成逻辑混淆 添加特性开关前: inputVehicleDetails(); createQuote(); if (currentBrand == brandB) { // 原有依赖于品牌的条件判断 inputPersonalDetails(); } buyInsurance(); 添加特性开关后: inputVehicleDetails(); if (brandA.isActive()) { // 特性开关 createQuote(); } if (currentBrand == brandB) { // 原有依赖于品牌的条件判断 inputPersonalDetails(); } buyInsurance();

可以看到,新加入的条件式特性开关 brandA.isActive() 与原有业务判定逻辑 currentBrand == brashA 十分相似,很难区分,在使用过程中更是特别容易混淆。更糟的是,随着项目的不断深入,越来越多的条件式特性开关会被放置在代码中,代码中会充满“坏味道 “,使得混淆的情况进一步恶化!

  • 条件式特性开关并不具有可扩展性条件式特性开关通常只是简单的条件判断,并不具有可扩展性。添加第一个条件判断与添加第十个需要写同样多的代码,并且随着判断逻辑的增多,会令添加代码所用的时间和维护成本持续增加。 例如: if (brandA.isActive()) { // 特性开关 inputVehicleDetails(); } if (brandB.isActive()) { // 特性开关 createQuote(); } if (brandC.isActive()) { // 特性开关 inputPersonalDetails(); } if (brandD.isActive()) { // 特性开关 buyInsurance(); }
  • 当需要移除特性开关时,我们必须删除代码 例如: inputVehicleDetails(); if (brandA.isActive()) { // 移除特性开关时,需要删除此行 createQuote(); } // 移除特性开关时,需要删除此行 inputPersonalDetails(); buyInsurance();

通过上面的分析,我们可以看出简单的条件式特性开关对于我们的项目并不是最好的选择。那么我们所期待的特性开关应该具有那些特点呢?

期待的特点

为了更加完美地解决我们所遇到的问题,我们期待所使用的特性开关具有如下特点:

  1. 不会对现有的业务结构产生影响
  2. 不会与程序中已存在的逻辑判断相混淆
  3. 具有可扩展性
  4. 可以轻易地调整需要屏蔽或开放的功能
  5. 当最终所有品牌都上线后,可以很方便地将特性开关移除
  6. 随意切换,便于测试

所以,如果我们的特性开关如果能像下面代码所示的那样工作就好了。

@Brand("BrandA")          // 将特性开关扩展为对“品牌”进行支持
inputVehicleDetails() { ... }
@Area("Australia")          // 将特性开关扩展为对“地区”进行支持
inputPersonalDetails() { ... } 
……
inputVehicleDetails()       // 只有当前品牌为BrandA时,此方法才会被执行
createQuote()
inputPersonalDetails()     // 只有当前区域为澳洲时,此方法才会被执行
buyInsurance() 

太好了,上面的代码具有了我们期待的全部特性,那么,我们究竟该如何实现呢?

方案

通过上面的问题描述和对期待特点的分析,我们可以看出,特性开关作为一种基础结构不应与业务代码相混淆,它们之间不应存在强耦合的关系。我们既需要保持原有业务逻辑,又要在合适的位置将判断逻辑注入其中,这自然而然让我们想到设计模式中的“代理(Proxy)模式”。

  1. 使用代理模式创建特性开关 “代理模式: 为其他对象提供一种代理,并以控制对这个对象的访问。而对一个对象进行访问控制的一个原因是为了只有在我们确实需要这个对象时才对它进行创建和初始化。它是给某一个对象提供一个替代者(占位者),使之在client对象和subject对象之间编码更有效率。” 在实际应用中,我们可以创建一种名为“保护代理”的对象,即控制对象具有不同的访问权限。

具体实现参见如下代码,ProxyGenerator类使用动态代理创建目标类的代理(Proxy)类。 ProxyGenerator.java public class ProxyGenerator<T> { private Class<T> targetClass; private Object[] constructorArgs; public ProxyGenerator(Class<T> targetClass, Object[] constructorArgs) { this.targetClass = targetClass; this.constructorArgs = constructorArgs; } public T generate(MethodFilter methodFilter, MethodHandler methodHandler) { Class<?>[] argTypes = extractTypes(constructorArgs); ProxyFactory factory = new ProxyFactory(); factory.setSuperclass(targetClass); factory.setFilter(methodFilter); try { return (T) factory.create(argTypes, constructorArgs, methodHandler); } catch (NoSuchMethodException e) { throw new RuntimeException("Can not find constructor"); } catch (InstantiationException e) { throw new RuntimeException("Can not initialize action object"); } catch (IllegalAccessException e) { throw new RuntimeException("Can not call constructor"); } catch (InvocationTargetException e) { throw new RuntimeException("Can not call constructor"); } } private Class<?>[] extractTypes(Object[] constructorArgs) { Constructor<?> constructor = new ConstructorFinder(targetClass, constructorArgs).find(); return constructor.getParameterTypes(); } } 为了能更加清楚地说明如何使用特性开关,我们举一个生活中的小例子: 在日本,由于绝大多数人不喜欢吃番茄酱,所以在日本的麦当劳店中销售的汉堡默认是没有加番茄酱的;但是在世界的其它地方,番茄酱却是汉堡的必备佐料。我们可以定义一个类McDonalds来代表麦当劳,它会将汉堡卖给世界上所有喜爱它的人 ^o^ public class McDonalds { private Country country; public McDonalds(Country country) { this.country = country; } public String makeHamburg() { StringBuilder desc = new StringBuilder(); Material material = area(country).create(Material.class); desc.append(material.bread()); desc.append(material.sauce()); desc.append(material.lettuce()); desc.append(material.cutlet()); desc.append(material.bread()); return desc.toString(); } } 接着,我们再为其定义汉堡中的材料类Material.java,包括:面包、生菜、肉饼和重要的蕃茄酱: class Material { public String bread() { return "Bread|"; } public String lettuce() { return "Lettuce|"; } public String cutlet() { return "Meat|"; } @Location(Others) public String sauce() { return "TomatoSauce|"; } } 细心的你可能已经注意到,McDonalds类中material局部变量的创建是通过 area(country).create(Material.class) 来完成的。通过area()方法我们将国家信息添加到了选材的过程中。当country是日本时,汉堡的组成就会是:Bread|Lettuce|Meat|Bread|;而当country为其它国家时,汉堡就会被加入番茄酱:Bread|TomatoSauce|Lettuce|Meat|Bread| 如果你对用代理模式生成的特性开关还心存疑问,别着急,你会从下面的“应用”环节中找到答案。 除了以上介绍的这种方法,我们还可以通过控制编译器,在编译阶段将判定条件注入到生成的代码中,以实现特性开关。

  1. 使用ASpectJ动态编译创建特性开关 AspectJ是一个面向切面的框架,它扩展了Java语言,定义了AOP语法,所以它有一个专门的编译器用来生成遵守Java字节编码规范的Class文件。 通过AspectJ,我们可以将判定条件在编译时注入到代码中。

如下代码所示,FeatureToggleAspect类通过AspectJ在编译时将切入点(Runner接口的实现类)置入被 @ToggleRunner所标记的方法的前部。当方法被调用时,将首先执行Runner接口的实现类,对是否满足条件作出判断。如果满足,则原逻辑才会 被执行。 FeatureToggleAspect.java @Aspect public class FeatureToggleAspect { @Around("methodProxy(toggleRunner)") public Object beforeExecute(ProceedingJoinPoint joinPoint, ToggleRunner toggleRunner) throws Throwable { ProceedingResult processingResult = execute(joinPoint, toggleRunner); if (processingResult.shouldBeExecuted()) { return joinPoint.proceed(); } return processingResult.getDefaultValue(); } @Pointcut(value = "@annotation(runner)") public void methodProxy(ToggleRunner runner) { } private ProceedingResult execute(ProceedingJoinPoint joinPoint, ToggleRunner toggleRunner) { try { Runner runner = toggleRunner.value().newInstance(); MethodSignature signature = (MethodSignature) joinPoint. getSignature(); return runner.execute(signature, joinPoint.getArgs()); } catch (Exception e) { throw new RuntimeException("Runner should have a default constructor.", e); } } } ProceedingResult.java public class ProceedingResult { private boolean shouldBeExecuted; private Object defaultValue; public ProceedingResult(boolean shouldBeExecuted, Object defaultValue) { this.shouldBeExecuted = shouldBeExecuted; this.defaultValue = defaultValue; } public boolean shouldBeExecuted() { return shouldBeExecuted; } public Object getDefaultValue() { return defaultValue; } } Runner.java public interface Runner { ProceedingResult execute(MethodSignature signature, Object[] args); } ToggleRunner.java @Retention(RUNTIME) @Target({METHOD}) public @interface ToggleRunner { Class<? extends Runner> value(); } 我们仍用上面麦当劳的例子,来看看由AspectJ创建的特性开关是如何工作的。 McDonalds.java public class McDonalds { private Country country; public McDonalds(Country country) { this.country = country; } public String makeHamburg() { StringBuilder desc = new StringBuilder(); Material material = new Material(); desc.append(material.bread()); desc.append(material.sauce(country)); desc.append(material.lettuce()); desc.append(material.cutlet()); desc.append(material.bread()); return desc.toString(); } }

通过上述分析我们可以看出,作为特性开关的实现,以上两种方案都是很好的选择。那么它们之间又有何不同之处呢?下面我们会从多个角度进行比较。

  1. 是否需要使用特殊的方法创建对象:
    • “代理方式”在创建对象时,需要使用类似反射的方式area(country).create(Material.class)
    • “AspectJ编译方式”则没有特殊要求
  2. 是否需要添加特殊标记:
    • “代理方式”不需要在方法上添加额外标记
    • “AspectJ编译方式”需要为Runner添加特殊标记@ToggleRunner(LocationDependingRunner.class) @Location(Others) public String sauce(Country country) { … }
    • 是否会产生对额外参数的依赖:
    • “代理方式”不会依赖额外参数
    • “AspectJ编译方式”由于需要通过参数获取参与条件判断的变量,所以会出现不必要的参数

    public String sauce(Country country) { // country为“不必要”参数 return “TomatoSauce|”; }

通过上面的比较,我们可以看出由“AspectJ编译方式”创建的特性开关由于条件变量的传入,会在一定程度上破坏业务的清晰表达,对代码整洁也会产生一定影响。所以在我们的项目中,最终选择了以“代理模式”创建特性开关。

应用

下面与大家分享一下,在我们的项目中是如何一步步引入特性开关的。

首先,让我们来看看需要加入特性开关的类。

OwnerDetailAction.java

public class OwnerDetailAction {
  private PolicyIdentifier policyIdentifier;
  private OwnerDetailFetchingService service;
  private OwnerDetail ownerDetail;
  ...

  public View onNext() {
      if (policyIdentifier.getCurrentBrand() == Brand.AMMI &&
               policyIdentifier.getCurrentChannel() == Channel.Internet) {
      InsuranceCoverage converage = service.getInsuranceCoverage(ownerDetail.getId());
      ownerDetail.setInsuranceCoverage(converage);
       }
 
       updateFamilyInfo(ownerDetails);
       return View.Continue;
   }
  ...
}

观察上面的代码,我们可以看出,service.getInsuranceCoverage() 与 ownerDetail.setInsuranceCoverage() 是属于业务范畴的操作,而 getCurrentBrand() == Brand.AMMI && getCurrentChannel() == Channel.Internet 则是针对品牌与渠道的判断,属于特性判断的范畴,与业务并没有直接的联系。当这样的逻辑判断与正常的业务逻辑混杂在一起时,严重影响了业务的清晰表达。所以,我们需要先将这团混乱的代码抽取到一个新类中,从而保证主流程的清晰表达。我们将ownerDetail.setInsuranceCoverage()抽取到一个新类中。

InsuranceCoverageUpdater.java

public class InsuranceCoverageUpdater {
  private Brand brand;
  private Channel channel;

  public InsuranceCoverageUpdater(Brand brand, Channel channel) {
      this.brand = brand;
      this.channel = channel;
  }

  public void update(OwnerDetail ownerDetail) {
      if (brand == Brand.AMMI && channel == Channel.Internet) {
      InsuranceCoverage converage = service.getInsuranceCoverage(ownerDetail.getId());
      ownerDetail.setInsuranceConverage(converage);
      }
  }
}

原来OwnerDetailAction类将被修改为:

OwnerDetailAction.java

public class OwnerDetailAction {
  private PolicyIdentifier policyIdentifier;
  private OwnerDetailFetchingService service;
  private OwnerDetail ownerDetail;
  ...

  public View onNext() {
      Brand brand = policyIdentifier.getCurrentBrand();
      Channel channel = policyIdentifier.getCurrentChannel();
      InsuranceCoverage converage = service.getInsuranceCoverage(ownerDetail.getId());
      ownerDetail.setInsuranceCoverage(converage);
       }
 
       updateFamilyInfo(ownerDetails);
       return View.Continue;
   }
  ...

虽然只是简单地做了类的抽取,但是对比之前,现在的代码在业务表达上已经清爽了很多。不过判断依然存在,只是被隐藏到了InsuranceCoverageUpdater类中。

接下来,我们使用特性开关进一步改进逻辑表达。

OwnerDetailAction.java

import static com.corp.domain.BrandToggle.brand;

public class OwnerDetailAction {
  private PolicyIdentifier policyIdentifier;
  private OwnerDetailFetchingService service;
  private OwnerDetail ownerDetail;
  ...

  public View onNext() {
      Brand brand = policyIdentifier.getCurrentBrand();
      Channel channel = policyIdentifier.getCurrentChannel();

      InsuranceCoverageUpdater insuranceConverage = 
                                 brand(brand).channel(channel).create
(InsuranceCoverageUpdater.class);
      insuranceConverage.update(ownerDetail);
      updateFamilyInfo(ownerDetails);
      return View.Continue;
  }
  ...
}

可以看出,原来的 new InsuranceCoverageUpdater(brand, channel) 方法被 brand(brand).channel(channel).create(InsuranceCoverageUpdater.class) 方法所取代。

此处的brand()方法是静态导入的Brand.brand()方法。通过静态导入,使对brand和channel的设定表现为链式结构,进一步增强了代码的可读性。尔后,再通过create()方法创建InsuranceCoverageUpdater类的实例。

InsuranceCoverageUpdater类中的代码也得到进一步精简。

InsuranceCoverageUpdater.java

public class InsuranceCoverageUpdater {

  // 标明只有当brand为AMMI,channel为Internet时update功能才会被执行。
  @BrandAndChannels(AMMI_INTERNET)  
   public void update(OwnerDetail ownerDetail) {
      Scale scale = service.getScale(ownerDetail.getId());
      ownerDetail.setScale(scale);
  }
}

虽然原有的new表达式被create方法调用所取代,但是,InsuranceCoverageUpdater类中恼人的if判断逻辑却被完全移除,没有留下任何特性开关使用的痕迹。复杂的条件判断已被简单的Annotation所取代,整个代码都变得非常清爽。

另外,如果有进一步的开关要求需要——如对AMMI上的Extranet渠道提供支持,只需要简单地在annotation中添加AMMI_EXTRANET即可:

@BrandAndChannels({AMMI_INTERNET, AMMI_EXTRANET})
public void update(OwnerDetail ownerDetail) { ... }

同理,如果未来InsuranceCoverageUpdater.update()功能将对所有品牌开放,只需简单地将@BrandAndChannels标记移除即可。

最后,让我们来揭开 brand().channel().create() 的神秘面纱。

BrandToggle.java

public final class BrandToggle {
  private Brand currentBrand;
  private Channel currentChannel;

  private BrandToggle(Brand brand) {
      this.currentBrand = brand;
  }

  public static BrandToggle brand(Brand brand) {
      return new BrandToggle(brand);
  }

  public BrandToggle channel(Channel channel) {
      this.currentChannel = channel;
      return this;
  }

  public <T> T create(Class<T> targetClass) {
      return create(targetClass, new Object[0]);
  }

  public <T> T create(Class<T> targetClass, Object[] args) {
      return new ProxyGenerator<T>(targetClass, args).generate(new MarkedByBrands(),
              new BrandDependingHandler());
  }

  private static class MarkedByBrands implements MethodFilter {
      @Override
      public boolean isHandled(Method method) {
          return method.getAnnotation(Brands.class) != null;
      }
  }

  private class BrandDependingHandler implements MethodHandler {
      @Override
      public Object invoke(Object targe, Method method, Method methodDelegation, 
                                       Object[] args) throws Throwable {
          BrandAndChannels annotation = method.getAnnotation(BrandAndChannels.class);
          if (brands == null || containsCurrentBrand(annotation.value())) {
              return methodDelegation.invoke(target, args);
          }

          return new DefaultValue(method.getReturnType()).value();
      }

      private boolean containsCurrentBrand(BrandAndChannel[] brandAndChannels) {
          if (BrandAndChannel brandAndChannel : brandAndChannels) {
              if (brandAndChannel.is(currentBrand, currentChannel)) {
                  return true;
              }
          }

          return false;
      }
  }
}

BrandToggle类通过brand(),channel()方法很好地表达了特性开关中的“开关”概念,create()方法则将ProxyGenerator类的实现细节完全隐藏了起来。

至此,我们的系统已经能够通过特性开关实现功能的有效屏蔽。通过这种方式,我们将添加特性开关对原有业务造成的影响降到了最低,不再有恼人的if…else…表达式,只有清爽的业务结构。更重要的是,这种方式易于操作与实现,对于特性开关的使用者来说整个过程几乎是透明的。

Note: 如果您想了解特性开关的更多实现细节,可以在我的Github中找到相应的源代码。

小结

“特性开关”在许多场景中都比“特性分支”具有更好的适用性和更高的效率。但是,就像所有的解决方案一样,特性开关同样也不是银弹,也存在使用的界限。只有我们很好地掌握其原理,合理地应用技术,不断改进,才能使“特性开关”这一利器在我们的项目中发挥更大的作用。

最后,衷心感谢ThoughtWorks公司高级咨询师张逸在本文写作过程中提供的无私帮助与建议。

成文数日后的思考

在本文写成的几日后,曾向一位同事推荐本文中的做法,因为恰好他所在的项目组需要使用特性开关来暂时隐藏一些未完成的功能,并且也希望能通过配置特性开关实现业务分支。对我们的实现,他建议道:“我们为什么不把特性开关做为一种产品或解决方案来发布呢?”

初听起来,这是一个不错的建议。但是我并不完全赞同,理由如下:

  1. 虽然特性开关提供了分支选择的可能,但我们应该明确:特性开关只是用来解决项目中那5%的不同。换句话说,项目中通用的部分应该约等于95%,即主要业务流程是完全相同的,只是在个别步骤上存在些许差异。如果一个项目中通用的部分较少,我建议应该考虑其它业务分支解决方法,而不要使用特性开关,因为这不是特性开关所擅长解决的问题。
  2. 当我们将特性开关作为一个产品或者一揽子的解决方案兜售给他人时,必然面对各种各样的需求,必须满足无数的特殊情况。这会使原本单纯的特性开关变得不再简单,很可能会变成一种重型框架。这不是一个好的方向,至少不是我所希望的。所以我的建议是,保持特性开关的单纯,保持功能实现的最小集,并且使他人可以根据自己的需要轻松扩展。在我看来,本文中讲到的特性开关更适合作为一种方法,一种解决特殊问题的推荐方式。

如果大家能够通过遵循正确的步骤使用特性开关,解决了困扰自己多时的问题,那么特性开关就已经产生了它最大的价值。

个人简介

孟宇,现任ThoughtWorks公司高级咨询师。具有十年商务软件开发经验,精通Java与DotNet开发,多次应邀到客户现场,指导客户团队的开发与改进。也因此,在大型遗留系统的改造方面积累了许多经验。 熟悉的业务领域包括:保险、金融、能源与通讯。

原文发布于微信公众号 - 思特沃克(ThoughtWorks)

原文发表时间:2014-02-14

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏ImportSource

设计模式-搞个接口,留有余地,让你我不再尴尬

设计模式,Design Patterns,Pattern,翻译为“模式”总感觉不够接地气,用今天的话来说可以叫“套路”。设计模式就是写代码的过程中一些常规打法和...

37312
来自专栏BeJavaGod

BeJavaGod - 如何正确使用数据字典进行分类统一操作(一)

先说说什么是数据字典,这个玩意一般不太会解释,举个栗子吧~ 每个系统都会有用户表,性别:男(1)女(0) 另外我们做物流的会涉及到车型:卡车(1),轿车(2),...

3557
来自专栏小樱的经验随笔

CTF---Web入门第十题 Once More

Once More分值:10 来源: iFurySt 难度:易 参与人数:4782人 Get Flag:2123人 答题人数:2166人 解题通过率:98%...

3096
来自专栏坚毅的PHP

HBase client访问ZooKeeper获取root-region-server DeadLock问题(zookeeper.ClientCnxn Unable to get data of zn

2012年11月28日 出现故障," Unable to get data of znode /hbase/root-region-server" 问题比较诡异...

5604
来自专栏Flutter入门到实战

那些年遇到的后台返回的奇葩json数据

然而:错误数据返回null不说,错误信息居然返回一个一个url?就这么一点错误信息,还要我再去请求一次服务器获取这个错误信息吗。。 服务器流量不要钱的吧。。。...

6553
来自专栏海说

深入理解计算机系统(3.1)---走进汇编的世界

  本系列拖了蛮久了,主要是因为LZ写的时候其实刚看到第二章,因此这一段时间快速看了下第三章,并花了点时间沉淀了一下,这才耽误了下来。

973
来自专栏机器学习算法与Python学习

精选26个Python实用技巧,想秀技能先Get这份技术列表!

Python 虽然是脚本语言,但是因为其易学,迅速成为科学家的工具,从而积累了大量的工具库、架构,人工智能涉及大量的数据科学,用 Python 是很自然的事。磨...

1402
来自专栏CDA数据分析师

精选26个Python实用技巧,想秀技能先Get这份技术列表!

【导读】Python 虽然是脚本语言,但是因为其易学,迅速成为科学家的工具,从而积累了大量的工具库、架构,人工智能涉及大量的数据科学,用 Python 是很自然...

1182
来自专栏海说

深入理解计算机系统(3.1)---走进汇编的世界

本文转载地址:http://www.cnblogs.com/zuoxiaolong/p/computer13.html

1033
来自专栏Crossin的编程教室

【我问 Crossin】英语不好能不能学编程?

“我问 Crossin” 栏目会整理一些读者经常会问到的问题,统一作答。有比较具体的编程细节,也会有一些方向的建议,以及学习经验、编程小技巧的分享。 大家有想问...

3419

扫码关注云+社区

领取腾讯云代金券