BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

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

| 作者 孟宇 关注 0 他的粉丝 发布于 2013年9月6日. 估计阅读时间: 47 分钟 | 如何结合区块链技术,帮助企业降本增效?让我们深度了解几个成功的案例。

在前几期的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|

    如果你对用代理模式生成的特性开关还心存疑问,别着急,你会从下面的“应用”环节中找到答案。

    除了以上介绍的这种方法,我们还可以通过控制编译器,在编译阶段将判定条件注入到生成的代码中,以实现特性开关。

  2. 使用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开发,多次应邀到客户现场,指导客户团队的开发与改进。也因此,在大型遗留系统的改造方面积累了许多经验。 熟悉的业务领域包括:保险、金融、能源与通讯。


感谢张逸对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

挺感兴趣,有些部分能否再详细些? by Zhou Hippoom

1.不会对现有的业务结构产生影响
2.不会与程序中已存在的逻辑判断相混淆
3.具有可扩展性

以上三点感觉说的比较清楚,下面几点我水平有限,能否再解释一下?

4.可以轻易地调整需要屏蔽或开放的功能

这里似乎是需要通过修改源码的?当然,品牌上线应该有一个发布过程,而且现在修改时不需要去动if-else,改起来应该容易不少

5.当最终所有品牌都上线后,可以很方便地将特性开关移除

是指通过修改Proxy,去除判断来实现吗?

6.随意切换,便于测试

这里能举几个场景说明下就好了

感觉只是善用了某些设计模式 by w j

在不修改源代码或没有源代码的情况下,要达到类似目的的话,需要原有设计和实现已经完全符合相应要求,这个难度是很高的;其难度主要来自于原有设计可以很灵活(“随意切换”)地获取到相应的实现对象实例,也即 既可以获得代理后的新对象,也可以获取到旧有的对象。

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

2 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT