之前曾经推荐过崔立强的《使用功能开关更好地实现持续部署》,介绍Feature Toggle的实践。北京办公室的孟宇现在对这个问题有了新的思考,当我们抛却Spring,Feature Toggle又该如何实践呢? 于是,他写了《在项目中透明地引入特性开关》。 在前几期的InfoQ专栏中刊登了一篇名为“使用功能开关更好地实现持续部署”的文章,文中讲解了特性开关与Spring的集成应用。但如果项目没有依赖Spring,又该如何更好地使用特性开关呢?同时,又该如何透明地引入,使得项目不至于完全依赖特性开关呢?
接下来我将结合我们在项目中实际运用特性开关的经验,从另一个角度为大家介绍如何使用特性开关透明地实现功能屏蔽。
问题
我们的团队正在开发一款在线保险产品,该产品下包括若干品牌,每个品牌有不同的目标用户群,但提供的服务基本相同。当第一个品牌正式上线后,我们就面临一个很大挑战——既要修正上线后发现的Bug,又要继续为其它品牌添加新特性,且这些特性暂时不能反映到已上线的品牌中。我们并不愿意为这种多品牌开发的业务创建分支版本,因为它对于版本维护而言,无疑是一场灾难。因而,我们决定选择特性开关来解决这个问题。
若要快速地解决这一问题,我们当然可以选择 if… else… 这种简单的特性开关模型;然而,随之却会引入其它问题:
可以看到,新加入的条件式特性开关 brandA.isActive() 与原有业务判定逻辑 currentBrand == brashA 十分相似,很难区分,在使用过程中更是特别容易混淆。更糟的是,随着项目的不断深入,越来越多的条件式特性开关会被放置在代码中,代码中会充满“坏味道 “,使得混淆的情况进一步恶化!
通过上面的分析,我们可以看出简单的条件式特性开关对于我们的项目并不是最好的选择。那么我们所期待的特性开关应该具有那些特点呢?
为了更加完美地解决我们所遇到的问题,我们期待所使用的特性开关具有如下特点:
所以,如果我们的特性开关如果能像下面代码所示的那样工作就好了。
@Brand("BrandA") // 将特性开关扩展为对“品牌”进行支持
inputVehicleDetails() { ... }
@Area("Australia") // 将特性开关扩展为对“地区”进行支持
inputPersonalDetails() { ... }
……
inputVehicleDetails() // 只有当前品牌为BrandA时,此方法才会被执行
createQuote()
inputPersonalDetails() // 只有当前区域为澳洲时,此方法才会被执行
buyInsurance()
太好了,上面的代码具有了我们期待的全部特性,那么,我们究竟该如何实现呢?
通过上面的问题描述和对期待特点的分析,我们可以看出,特性开关作为一种基础结构不应与业务代码相混淆,它们之间不应存在强耦合的关系。我们既需要保持原有业务逻辑,又要在合适的位置将判断逻辑注入其中,这自然而然让我们想到设计模式中的“代理(Proxy)模式”。
具体实现参见如下代码,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| 如果你对用代理模式生成的特性开关还心存疑问,别着急,你会从下面的“应用”环节中找到答案。 除了以上介绍的这种方法,我们还可以通过控制编译器,在编译阶段将判定条件注入到生成的代码中,以实现特性开关。
如下代码所示,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(); } }
通过上述分析我们可以看出,作为特性开关的实现,以上两种方案都是很好的选择。那么它们之间又有何不同之处呢?下面我们会从多个角度进行比较。
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公司高级咨询师张逸在本文写作过程中提供的无私帮助与建议。
在本文写成的几日后,曾向一位同事推荐本文中的做法,因为恰好他所在的项目组需要使用特性开关来暂时隐藏一些未完成的功能,并且也希望能通过配置特性开关实现业务分支。对我们的实现,他建议道:“我们为什么不把特性开关做为一种产品或解决方案来发布呢?”
初听起来,这是一个不错的建议。但是我并不完全赞同,理由如下:
如果大家能够通过遵循正确的步骤使用特性开关,解决了困扰自己多时的问题,那么特性开关就已经产生了它最大的价值。
孟宇,现任ThoughtWorks公司高级咨询师。具有十年商务软件开发经验,精通Java与DotNet开发,多次应邀到客户现场,指导客户团队的开发与改进。也因此,在大型遗留系统的改造方面积累了许多经验。 熟悉的业务领域包括:保险、金融、能源与通讯。