65.9K
CodeProject 正在变化。 阅读更多。
Home

功能标志(又称功能切换)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2020年4月17日

CPOL

12分钟阅读

viewsIcon

17744

downloadIcon

116

部署和功能发布的分离:如何在配置时启用和禁用应用程序(服务)中的功能

Sample Image - maximum width is 600 pixels

使用特性标志驱动您的产品发布策略和持续交付

引言

本文介绍了特性标志(也称为特性开关)的概念;之后,提出了一个可能的设计,并分析了其优缺点;然后,提供了两种可能的实现方式;一种是Java(11),另一种是C#(7)。

最后但同样重要的是,文章进行了总结和结论,并给出了一些(我相信)有用的建议。

简而言之,特性标志允许通过配置修改系统行为,而无需更改代码。因此,在您的生产应用程序环境中,可以在不(重新、回滚)部署二进制文件的情况下,为特定的(用户)群体打开或关闭功能(“特性”)。这个概念是一种“关注点分离”或“单一职责”模式。毕竟,发布二进制文件(部署)和发布功能是相互分离的。总的来说,不将两项职责合并到一项操作(或组件)中是一个好主意。通过应用特性标志,可以部署带有新特性的新版本,而无需将新特性本身发布给所有人。即使这样,也可以为某些群体或个人撤销特性,而无需回滚二进制版本(先前版本的部署)。

参阅相关文章

  1. Roland Roos, Aspect-Oriented Programming and More Patterns in Micro-Services
  2. Martin Fowler, feature-toggles
  3. Stack Flow What-is-a-feature-flag
  4. 其他可能的C#实现示例

本文是如何构建的?

  • Java和C#的最终用法示例
  • 特性标志/开关的概念
  • 高层设计和原理(设计的优缺点)
  • Java实现细节
  • C#实现细节
  • 总结和结论,以及更多

阅读本文之前

在您开始阅读本文之前,这里有一个通用建议;如果您不熟悉面向切面编程,建议您先阅读我关于此主题的文章或其中的链接,因为本文的设计和实现严重依赖于面向切面编程。请参见[1.] Aspect-Oriented Programming and More Patterns in Micro-Services

使用Java中的代码(11+,Spring boot 2.2+,MVC thymeleaf,Aspectj)

//
// Configuration application.yml example
//
featureFlags:
	feature1:
		enabled : true
		included :
			user1
		
// Java code usage example
// 
// The @before @FeatureFlag aspect does a lookup of Feature flag "feature1" 
// with key being the value of user parameter,
// it is injected in the model and modelandView method parameters,
// available in the GUI (HTML)
// to be used for building up the view:
// For user = "user1"
// modelandView["feature1"] = true

@FeatureFlag(features = {"feature1"}, paramNameForKeyOfFeature = "user")
public void annotatedFeatureOnController(
	String user, 
	Model model, 
	ModelAndView modelandView) {
	
	if (user.compereTo("user1") == 0) {
		assert.isTrue(modelandView["feature1"]);
	}
}
...
//A call to the controller (pseudo code)
Controller.annotatedFeatureOnController("user1", viewModel);

使用C#中的代码(7+,.NET MVC Core 2.1+,Autofac,Caste Core Dynamic Proxy)

//
// Configuration featureFlagConfig.json example
//
"featureFlags": [
	{
		"name": "feature1",
		"enabled": true,
		"included": [
			"user1"
		]
	}
]
		
// C# code usage example
// 
// The [FeatureFlag] dynamic proxy interceptor does a lookup of Feature flag "feature1" 
// with key being the value of user parameter,
// it is injected in the ViewData model,
// available in the GUI (HTML)
// to be used for building up the view:
// For user = "user1"
// ViewData["feature1"] = true

[FeatureFlag("user", new String[] { "feature1" })]
public ActionResult AnnotatedFeatureOnController([FeatureFlagKey] String user)
{
	if (user.compereTo("user1") == 0) 
	{
		assert.isTrue( ViewData["feature1"]);
	}
}
...
//A call to the controller (pseudo code)
HomeController.AnnotatedFeatureOnController("user1");
or
http://locahost:.../Home/AnnotatedFeatureOnController?user="user1"

概念:基本原理

什么是特性标志?

总的来说,已经有很多关于这个主题的好文章了。请参见[2.] Martin Fowler, feature-toggles

注意:特性开关和特性标志是同义词。

在编程领域,“标志”通常指一个布尔值1/0、true/false、开启/关闭。因此,该特性要么是开启的,要么是关闭的。

在编程领域,“开关”通常指标志更动态的切换,0<->1,true<->false,开启<->关闭。因此,它指的是将特性从开启切换到关闭,反之亦然。

为什么使用特性标志?

在采用“真正”敏捷方法的现代微服务导向的应用程序环境中,我们希望尽早部署和发布功能(特性)。最好是在自动构建并成功通过构建流水线自动测试之后立即发布。然而,一旦部署,我们不希望这个特性始终发布并对“所有人”可用。能够一次性将功能部署到生产环境,跳过复杂的验收期,这难道不令人高兴吗?能够在生产环境中进行验收测试,与旧的实现并存,而无需让新特性可用,这难道不令人高兴吗?然后,如果被证明不会与其他功能产生冲突,就可以“发布”它供早期采用者使用?最后,如果被证明对早期采用者稳定且可用,就可以逐步发布给所有人?或者,如果被证明具有破坏性,就可以立即禁用(撤销)它,而无需重新部署或进行复杂的回滚?
我敢打赌您会说:是的,请,但不用了。“不可能”。
我向您保证:这是可能的,毫无疑问,我没开玩笑。
但这也有成本和另一个负担:特性管理。

在本文中,我们将描述一种可能的影响较小、相对简单的设计以及在Java和C#中的一种可能实现。在总结结论中,我们还将讨论应用此模式的一些重要策略,以防止代码中出现大量的布尔if-then-else流程。

高层设计

Sample Image - maximum width is 600 pixels

查看此模型,我们可以看到以下内容

  • 我们在模型中没有使用“用户”一词。它被称为“键”(Key),它是“用户”的抽象。这允许除了用户之外的其他类型的键来开启特性。
  • 它使用特性方面(Feature aspects)将特性开关提供给您的代码。
  • 一个特性可以被“启用”(对所有人可用)、“禁用”(对无人可用)或“过滤”(对特定用户可用)。
  • “键组”(KeyGroup)是“键”的组;
  • 一个特性可以有一个包含和/或排除的键(又称用户)和/或键组(又称用户组)的列表。
  • 特性方面是通过二进制库提供的,使用服务外观(service facade)而不是客户端-代理-服务外观(client-proxy-service facade)。因此,我们选择不远程管理特性标志。我们只需为每个服务/组件提供一个配置文件,其中包含该服务/组件所需的特性标志。如果您需要这种集中式的特性标志功能,您需要将其替换为远程特性服务和客户端概念,可能还会加入一些缓存和刷新机制。这种每个服务类型的本地配置文件实现方式不太适合集群负载均衡服务,例如Kubernetes负载均衡器下的容器。确实,集中化和远程化形式会更适用。
  • 没有提供用于编辑和切换的GUI。注意:在现代(微)服务导向的景观中,分散的配置文件不利于维护或可追溯性。为了解决这个问题,有一个非常实用且可行的模式(请参阅Spring Boot中的Config服务器)。只需创建独立的Config Git存储库,其中包含所有配置文件,并将配置视为“源代码”。因此,在您的Git Config中签入/签出,具有完整的版本控制和可追溯性。所有服务都可以从那里拉取其配置。

所以,正如承诺的那样,一个非常KISS(Keep It Stupid and Simple)的简单直接的实现,但可以轻松扩展以满足其他需求。

一般实现细节

我们在两种实现(Java/C#)中都有单元测试来测试代码。还有一个小的演示应用程序MVC-ViewModel服务,带有一个标签库(Java中是Thymeleaf,C#中是Tag Helpers)。这表明特性是如何“流经”控制链的:配置文件->模型->服务->控制器->视图->HTML。并且,像任何现代设置一样,我们使用IOC和容器:Java Spring Boot AppContext配合@Autowiring,C# Autofac附加到默认的Microsoft MVC容器。我稍后会写一篇文章关于Java Spring Boot和C# Autofac的IOC的“如何”和“为什么”,所以请耐心等待,如果您还不熟悉这方面的内容,请跳过。单元测试和模拟框架(Mockito, Fakeiteasy)也是如此。一篇关于其“如何”和“为什么”的文章也即将发布。

我曾承诺在Java和C#中提供一个KISS的简单解决方案。这里是它的概述。

模型实现

关于FeatureFlags模型

  • 4个简单的结构类(FeatureFlagsFeatureKeyGroupKey
  • 1个简单的数据文件(Java中是.yml,C#中是.json
  • 1个简单的服务,它持有FeatureFlags模型,并在该结构中按Feature.name进行Feature查找。
  • 一些初始化代码,用于将配置数据解析到类模型数据结构中(Java使用Spring @ConfigurationProperties,C#使用ConfigurationBuilder,并稍作设置)。

关于@FeatureFlag("feature1")注解

  • 两个注解,@FeatureFlag@FeatureFlagKey
  • 一个方面:FeatureFlagAspect,它通过服务查找模型中的Feature属性,并在Method参数中找到Key值,然后查找元组{}。
  • 1个简单的服务,它在该结构中进行查找:Feature FeatureFlagServiceImpl.getFeature(String forKey, String forFeatureName){..}

Java实现细节(Java 11, Spring Boot, Spring AOP, Lombok, Thymeleaf MVC)

Sample Image - maximum width is 600 pixels

用于保存特性配置的数据结构

请记住,这是模型

Sample Image - maximum width is 600 pixels

首先,根据此模型在Spring Boot yaml结构中实现配置。

featureFlags:
    feature1:
         enabled : true
         included :
            group1,
            user3
         excluded :
            user4

keyGroups:
    feature1 : 
        keys : 
            user1,
            user2

其次,定义一组与该yaml结构匹配的类。

@Configuration
@ConfigurationProperties
public class FeatureFlags 
{
    private static final Logger log = LoggerFactory.getLogger(FeatureFlags.class);
    //
    //really does not matter how you call your pop. As long as the method name matched
    //
    private final Map<string, feature=""> featureFlags = new HashMap<>();

    //
    //it does matter how you call this method. get<rootnameinyaml> -> getFeatureFlags
    //
    public Map<string, feature=""> getFeatureFlags() 
    {
        log.debug("getFeatureFlags");
        return featureFlags1;
    }
    private final Map<string, keygroup=""> keygroups = new HashMap<>();

	//
    //it does matter how you call this method. get<rootnameinyaml> -> getKeyGroups
	//
    public Map<string, keygroup=""> getKeyGroups() 
    {
        log.debug("getKeyGroups");
        return keygroups;
    }
    
    @Data
    public static class Feature {
       private String enabled;
       private List<string> included;
       private List<string> excluded;
    }
    
    @Data
    public static class KeyGroup 
    {
       private List<string> keys;
    }
}

注意1:yaml中的“根”键{featureFlags, keyGroups}必须与{getFeatureFlags(), getKeyGroups()}的getter匹配。

注意2:如果您只有要映射的字符串列表,请使用此构造,包含行分隔的、逗号分隔的列表(注意:Lombok使用@Data生成公共getter/setter)。

List<String> included;

included :
    group1,
    user3 

注意3:不要忘记类结构上方的@ConfigurationProperties

注意4:只需将@autowire FeatureFlags作为类中的属性,它将把yaml文件解析到类结构中。

@Service
@ConfigurationProperties
public class FeatureFlagServiceImpl implements FeatureFlagService
{
    private static final Logger log = LoggerFactory.getLogger(FeatureFlagServiceImpl.class);

    @Autowired
	//
	// Because class FeatureFlags has @ConfigurationProperties, 
    // the yaml is mapped to this structure automagically
	//
    private FeatureFlags  featureConfig; 
...
}

这样就完成了数据结构解析的设置。

@FeatureFlag 注解

我将不详细介绍面向切面编程,只提供代码和一些说明。有关更多面向切面编程的细节,请参见[1.]。为此,我们仅使用Spring AOP的LTW(Load Time Weaving)。

首先,定义@FeatureFlag注解。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FeatureFlag {
    //
    //The list of available features
    //
    String[] features() default {""};
    //
    // The nameof the parameter in the annotated method 
    // that is the key-value to lookup the feature from the model
    //
    String paramNameForKeyOfFeature() default "";
}

注意1:我没有实现@FeatureFlagKey,而是使用FeatureFlag.paramNameForKeyOfFeature作为keyName在方法中查找。

因此,定义@Before @Aspect

@Aspect
@Component
public class FeatureFlagAspect 
{
    /**
     * Logger to log any info
     */
    private static final Logger log = LoggerFactory.getLogger(FeatureAspect.class);
    
    /**
     * Service delivering the actual Feature for a key/feature name combi
     */
    @Autowired
    private FeatureFlagService  featureConfigService; 
    
     /**
     *  Injects available feature flags into Model and/or ModelAndView of API. 
     * @see unitests for examples 
     *
     *
     * @param joinPoint  place of method annotated
     * @param featureFlag the object with fields of the FeatureFlagKey annotation
     */
    @Before("@annotation(featureFlag)")
    public void featureFlag(
            final JoinPoint joinPoint,
            FeatureFlag featureFlag) 
                throws Throwable 
    {
        String features = Arrays.asList(featureFlag.features())
                .stream()
                .collect(Collectors.joining(","));

        log.info("for features {} of param {}", features, 
                  featureFlag.paramNameForKeyOfFeature());
        Method method = MethodSignature.class.cast(joinPoint.getSignature()).getMethod();
        log.debug("on {}", method.getName());
        Object[] args = joinPoint.getArgs();
        MethodSignature methodSignature = 
            (MethodSignature) joinPoint.getStaticPart().getSignature();
        String[] paramNames = methodSignature.getParameterNames();

        Class[] paramTypes= methodSignature.getParameterTypes();
        String curKey = null;
        Model modelParam = null;
        ModelAndView modelAndViewParam = null;
        for (int argIndex = 0; argIndex < args.length; argIndex++)
        {
            String curParamName = paramNames[argIndex];

            Object curValue = args[argIndex];

            if (curParamName.equals(featureFlag.paramNameForKeyOfFeature()))
            {
                curKey = curValue.toString();
            }
            log.debug("arg {} value {}", curParamName, curValue);

            if (curValue instanceof Model)
            {
                modelParam = (Model)curValue;
            }
            else if (curValue instanceof ModelAndView)
            {
                modelAndViewParam = (ModelAndView)curValue;
            }
        }
        if (curKey != null)
        {
            for(String featureName : featureFlag.features())
            {
                nl.ricta.featureflag.FeatureFlags.Feature curFeature = 
                    featureConfigService.getFeature(curKey.toString(), featureName);
                if (curFeature != null )
                {
                    if (modelParam != null)
                    {
                        modelParam.addAttribute(featureName, curFeature);
                    }
                     if (modelAndViewParam != null)
                    {
                        modelAndViewParam.addObject(featureName, curFeature);
                    }
                }
            }
        }
    }

注意2:在FeatureFlagService.getFeature()中定义了逻辑,它由切面调用,该逻辑在FeatureFlags模型中查找Feature及其属性,并进行查找。

@Service
@ConfigurationProperties
public class FeatureFlagServiceImpl implements FeatureFlagService
{
    private static final Logger log = LoggerFactory.getLogger(FeatureFlagServiceImpl.class);

    private FeatureFlags  featureConfig;  

    @Autowired
    public FeatureFlagServiceImpl(FeatureFlags  featureConfig)
    {
        log.debug("FeatureFlagServiceImpl : #features = {}", 
                   featureConfig.getFeatureFlags().size());
        this.featureConfig = featureConfig;
    }
    
    public Map<string, feature=""> getFeatureFlags() 
    {
        return this.featureConfig.getFeatureFlags();
    }
    
	public Feature getFeature(String forKey, String forFeatureName)
    {
        require(featureConfig != null);
        require(forKey != null);
        require(forFeatureName != null);
        log.debug("getFeature, forkey={}, forFeatureName={}", forKey, forFeatureName);
        Feature lookupFeature = featureConfig.getFeatureFlags().get(forFeatureName);
        if (lookupFeature == null)
        {
            log.debug("forkey={} has no feature {}",forKey, forFeatureName);
            return null;
        }
        if (lookupFeature.getEnabled()=="true")
        {
            log.debug("forkey={} enabled feature for everyone {}",forKey, forFeatureName);
            return lookupFeature;
        }
        else if (lookupFeature.getEnabled()=="false")
        {
            log.debug("forkey={} enable feature for none {}",forKey, forFeatureName);
            return null;
        }
        //else: filtered
        List<string> includedInFeaureList = lookupFeature.getIncluded();
        boolean direct = includedInFeaureList.contains(forKey);
        if (direct)
        {
            log.debug("forkey={} directly has feature {}",forKey, forFeatureName);
            return lookupFeature;
        }
        log.debug("looking up all included keys in keygroups, 
                   to see if key={} is included there...");
        
        for (String included : includedInFeaureList)
        {
            log.debug("lookup group for {}", included);
            KeyGroup lookupGroup = featureConfig.getKeyGroups().get(included);
            if (lookupGroup == null)
            {
                log.debug("forkey={} has no feature {}",forKey, forFeatureName);
            }
            else
            {
                boolean inGroup = lookupGroup.getKeys().contains(forKey);
                if (inGroup)
                {
                    log.debug("forkey={} has feature {}",forKey, forFeatureName);
                    return lookupFeature;
                }
            }
        }
        log.debug("forkey={} has no feature {}",forKey, forFeatureName);
        return null;
    }
}

现在,我们准备好了MVC和Controller部分。

@FeatureFlag(features = "{feature1}", paramNameForKeyOfFeature = "user")
@GetMapping(value = "/features")
public String featuresPage(Model model, @RequestParam(value = "user") String user) {
	model.addAttribute("featureEntries", featureFlagService.getFeatureFlags().entrySet());
	return "featureflags/features";
}

以及视图html Features.cshtml(使用Thymeleaf)。

...
<div class="main">
<div>
	<h2>Features</h2>
	<div class='rTable' >
		<div class="rTableRow">
			<div class="rTableHead"><strong>Name</strong></div>
			<div class="rTableHead"><span style="font-weight: bold;">Type</span></div>
			<div class="rTableHead"><span style="font-weight: bold;">Included</span></div>
			<div class="rTableHead"><span style="font-weight: bold;">Excluded</span></div>
		</div> 
		<div class='rTableRow' th:each="featureEntry : ${featureEntries}">
			<div class='rTableCell'><span th:text="${featureEntry.getKey()}"></span></div>
			<div class='rTableCell'><span th:text="${featureEntry.getValue().getEnabled()}">
            </span></div>
			<div class='rTableCell'><span th:text="${featureEntry.getValue().getIncluded()}">
            </span></div>
			<div class='rTableCell'><span th:text="${featureEntry.getValue().getExcluded()}">
            </span></div>
		</div>
		<div class="rTableRow">
			<div class="rTableFoot"><strong>Name</strong></div>
			<div class="rTableFoot"><span style="font-weight: bold;">Type</span></div>
			<div class="rTableHead"><span style="font-weight: bold;">Included</span></div>
			<div class="rTableFoot"><span style="font-weight: bold;">Excluded</span></div>
		</div> 
	</div>
</div>
</div>
<div>
	<h2>Features</h2>
	<div class='rTable' >
		<div class="rTableRow">
			<div class="rTableHead"><strong>Name</strong></div>
			<div class="rTableHead"><span style="font-weight: bold;">Type</span></div>
			<div class="rTableHead"><span style="font-weight: bold;">Included</span></div>
			<div class="rTableHead"><span style="font-weight: bold;">Excluded</span></div>
		</div> 
		<div class='rTableRow' th:each="featureEntry : ${featureEntries}">
			<div class='rTableCell'><span th:text="${featureEntry.getKey()}"></span></div>
			<div class='rTableCell'><span th:text="${featureEntry.getValue().getEnabled()}">
            </span></div>
			<div class='rTableCell'><span th:text="${featureEntry.getValue().getIncluded()}">
            </span></div>
			<div class='rTableCell'><span th:text="${featureEntry.getValue().getExcluded()}">
            </span></div>
		</div>
		<div class="rTableRow">
			<div class="rTableFoot"><strong>Name</strong></div>
			<div class="rTableFoot"><span style="font-weight: bold;">Type</span></div>
			<div class="rTableHead"><span style="font-weight: bold;">Included</span></div>
			<div class="rTableFoot"><span style="font-weight: bold;">Excluded</span></div>
		</div> 
	</div>
</div>
</div>
...

C# DotNet 实现细节(C# 7, .NET Core 2.1 MVC, Razor, AutoFac, Castle Core, AutoFac Dynamic Proxy)

Sample Image - maximum width is 600 pixels

用于保存特性配置的数据结构

请记住,这是模型

Sample Image - maximum width is 600 pixels

首先,根据此模型,以json格式(featureFlagConfig.json)实现配置。

{
	"features": [
		{
			"name": "feature1",
			"type": "enabled",
			"included": [
				"group1",
				"user3"
			],
			"excluded": [
				"user4"
			]
		}
	],
	"keyGroups": [
		{
			"Name": "feature1",
			"keys": [
				"user1",
				"user2"
			]
		}
	]
}

接下来,定义类结构模型来保存Feature配置数据。

public enum FeatureType
{
	enabled, 
	disabled,
	filtered
}

public class Feature
{
	public string name { get; set; }
	public FeatureType type { get; set; }
	public List<string> included { get; set; }
	public List<string> excluded { get; set; }
}
public class KeyGroup
{
	public string name { get; set; }
	public List<string> keys { get; set; }
}
public class FeatureFlags
{
	public Dictionary<string, feature=""> features { get; set; }
	public Dictionary<string, keygroup=""> keyGroups { get; set; }
}

接下来,为了将此配置json加载到模型中,我使用了.NET的ConfigurationBuilder

ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddJsonFile
       ("featureFlagConfig.json", optional: true, reloadOnChange: true);

IConfigurationRoot config = configurationBuilder.Build();

var sectionFeatureFlags = config.GetSection("features");
List<Feature> features = sectionFeatureFlags.Get<List<Feature>>();

var sectionKeyGroups = config.GetSection("keyGroups");
List<KeyGroup> keyGroups = sectionKeyGroups.Get<List<KeyGroup>>();

FeatureFlags featureFlags = new FeatureFlags()
{
	features = features.ToDictionary(f => f.name, f => f),
	keyGroups = keyGroups.ToDictionary(kg => kg.name, kg => kg),
};

我们还需要一个服务,它定义了特性标志模型上的逻辑。

public interface FeatureFlagService
{
	FeatureFlags FeatureConfig { get; set; }
	void LoadFeatureFlags(String fileName);
	Feature getFeature(String forKey, String forFeatureName);
	List<Feature> getFeatures(String forKey, List<String> forFeatureNames);
}
public class FeatureFlagServiceImpl : FeatureFlagService
{
	public FeatureFlags FeatureConfig { get; set; }

	/// <summary>
	/// 
	/// </summary>
	/// <param name="fileName"></param>
	public virtual void LoadFeatureFlags(String fileName)
	{
		ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
		configurationBuilder.AddJsonFile(fileName, optional: true, reloadOnChange: true);

		IConfigurationRoot config = configurationBuilder.Build();

		var sectionFeatureFlags = config.GetSection("features");
		List<Feature> features = sectionFeatureFlags.Get<List<Feature>>();

		var sectionKeyGroups = config.GetSection("keyGroups");
		List<KeyGroup> keyGroups = sectionKeyGroups.Get<List<KeyGroup>>();

		this.FeatureConfig = new FeatureFlags()
		{
			features = features.ToDictionary(f => f.name, f => f),
			keyGroups = keyGroups.ToDictionary(kg => kg.name, kg => kg),
		};
	}

	/// <summary>
	/// 
	/// </summary>
	/// <param name="forKey"></param>
	/// <param name="forFeatureNames"></param>
	/// <returns></returns>
	public virtual List<Feature> getFeatures(String forKey, List<String> forFeatureNames)
	{
		var features = new List<Feature>();
		foreach(var featureName in forFeatureNames)
		{
			Feature f = getFeature(forKey, featureName);
			if (f != null)
			{
				features.Add(f);
			}
		}
		return features;
	}
	/// <summary>
	/// 
	/// 
	/// </summary>
	/// <param name="forKey"></param>
	/// <param name="forFeatureName"></param>
	/// <returns></returns>
	public virtual Feature getFeature(String forKey, String forFeatureName)
	{
		Debug.WriteLine($"getFeature, forkey={forKey}, forFeatureName={forFeatureName}");

		FeatureConfig.features.TryGetValue(forFeatureName, out Feature lookupFeature);
		if (lookupFeature == null)
		{
			Debug.WriteLine($"forkey={forKey} has no feature {forFeatureName}");
			return null;
		}
		if (lookupFeature.type == FeatureType.enabled)
		{
			Debug.WriteLine($"forkey={forKey} enabled feature for everyone {forFeatureName}");
			return lookupFeature;
		}
		else if (lookupFeature.type == FeatureType.disabled)
		{
			Debug.WriteLine($"forkey={forKey} enable feature for none {forFeatureName}");
			return null;
		}
		//else: filtered
		List<String> includedInFeaureList = lookupFeature.included;
		Boolean direct = includedInFeaureList.Contains(forKey);
		if (direct)
		{
			Debug.WriteLine($"forkey={forKey} directly has feature {forFeatureName}");
			return lookupFeature;
		}
		Debug.WriteLine($"looking up all included keys in keygroups, 
                        to see if key={forKey} is included there...");

		foreach (String included in includedInFeaureList)
		{
			Debug.WriteLine($"lookup group for {included}");
		   
			FeatureConfig.keyGroups.TryGetValue(included, out KeyGroup lookupGroup);
			if (lookupGroup == null)
			{
				Debug.WriteLine($"forkey={forKey} has no feature {forFeatureName}");
			}
			else
			{
				Boolean inGroup = lookupGroup.keys.Contains(forKey);
				if (inGroup)
				{
					Debug.WriteLine($"forkey={forKey} has feature {forFeatureName}");
					return lookupFeature;
				}
			}
		}
		Debug.WriteLine($"forkey={forKey} has no feature {forFeatureName}");
		return null;
	}
}

这样就完成了C#中数据结构、特性标志逻辑和解析的设置。正如承诺的,代码大约50行,非常简单。

C# [FeatureFlag] 注解

我们开始(再次参见[1.] Aspect-Oriented Programming and More Patterns in Micro-Services)定义FeatureFlag属性。

 [AttributeUsage(
   AttributeTargets.Method,
   AllowMultiple = true)]
public class FeatureFlagAttribute : System.Attribute
{
	public FeatureFlagAttribute(String keyName, string[] features)
	{
		Features = features.ToList();
		KeyName = keyName;
	}
	public List<string> Features { get; set; } = new List<string>();
	public String KeyName { get; set; } = "";
}

然后,我们定义了该注解上的切面(拦截器)。

public class FeatureFlagIntersceptor : Castle.DynamicProxy.IInterceptor
{
	public FeatureFlagService featureFlagService { get; set; }

	public void Intercept(Castle.DynamicProxy.IInvocation invocation)
	{
		Debug.Print($"1. @Before Method called {invocation.Method.Name}");

		var methodAttributes = invocation.Method.GetCustomAttributes(false);
		FeatureFlagAttribute theFeatureFlag = (FeatureFlagAttribute)methodAttributes.Where
		(a => a.GetType() == typeof(FeatureFlagAttribute)).SingleOrDefault();
	
		if (theFeatureFlag != null)
		{
			var paramNameForKeyOfFeature = theFeatureFlag.ParamNameForKeyOfFeature;
			ParameterInfo[] paramsOfMethod = invocation.Method.GetParameters();
			//
			// Iterate params, to get param position index
			//
			int iParam;
			for (iParam = 0; iParam < paramsOfMethod.Count(); iParam++)
			{
				ParameterInfo p = paramsOfMethod[iParam];
				if (p.Name.CompareTo(paramNameForKeyOfFeature) == 0)
				{
					break;
				}
			}
			//
			// Now, as we know the index, get the key value from the actual method call
			//
			string value = (string)invocation.Arguments[iParam];
			List<feature> features = featureFlagService.getFeatures
                                     (value, theFeatureFlag.Features);
			Debug.Print($"2. FeatureFlagAttribute on method found with name = 
                       {theFeatureFlag.Features}\n");
			foreach(Feature f in features)
			{
				Debug.Print($"3. Feature {f.name} exists and type = {f.type}");
			}
		}
		//
		// This is the actual "implementation method code block".
		// @Before code goes above this call
		//
		invocation.Proceed();
		//
		// Any @After method code goes below here
		//
		Debug.Print($"5. @After method: {invocation.Method.Name}\n");
	}
}

而且,由于我们需要在REST控制器中使用FeatureFlag来将特性属性提供给Razor ViewModel和HTML GUI,因此我们还需要REST控制器的面向切面解决方案,其中属性和切面在一个ActionFilterAttribute类中结合。

public class FeatureFlagActionAtrribute : 
    Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute
    {
		public List<string> Features { get; set; } = new List<string>();
        public String KeyName { get; set; } = "";
		public override void OnActionExecuting
                (Microsoft.AspNetCore.Mvc.Filters.ActionExecutingContext context)
		{
			ControllerActionDescriptor actionDescriptor = 
                   (ControllerActionDescriptor)context.ActionDescriptor;
            Debug.Print($"2. @Before Method called 
               {actionDescriptor.ControllerName}Controller.{actionDescriptor.ActionName}");
            var controllerName = actionDescriptor.ControllerName;
            var actionName = actionDescriptor.ActionName;
            IDictionary<object, object=""> properties = actionDescriptor.Properties;
            ParameterInfo[] paramsOfMethod = actionDescriptor.MethodInfo.GetParameters();
            var fullName = actionDescriptor.DisplayName;

            var paramNameForKeyOfFeature = ParamNameForKeyOfFeature;

            var arguments = context.ActionArguments;
            string value = (string)arguments[paramNameForKeyOfFeature];
            
            using (ILifetimeScope scope = BootStrapper.Container.BeginLifetimeScope())
            {
                var featureFlagService = scope.Resolve<featureflagservice>();
                List<feature> features = featureFlagService.getFeatures(value, Features);
                Debug.Print($"2. 
                    FeatureFlagAttribute on method found with name = {Features}\n");
                var ctrler = (Controller)context.Controller;
                foreach (Feature f in features)
                {
                    Debug.Print($"3. Feature {f.name} exists and type = {f.type}");
                    ctrler.ViewData[f.name] = f;
                }
                ctrler.ViewData["features"] = 
                      featureFlagService.FeatureConfig.features.Values;
            }
            
            base.OnActionExecuting(context);
        }
		public override void OnActionExecuted
               (Microsoft.AspNetCore.Mvc.Filters.ActionExecutedContext context)
        {...}
	}

现在,让我们将其全部连接起来,在一个真实的控制器中,就像我们在文章开头所做的那样。

[FeatureFlagActionAtrribute("user", new String[] { "feature1" })]
public IActionResult DoSomethingWithFilterAction(String user)
{
	Debug.Assert(ViewData["Features"] != null);
	Debug.Assert(ViewData["feature1"] != null);
	return View("Features");
}

以及视图html Features.cshtml(使用Razor)。

...
<table>
	<tr>
		<th>enabled for user</th>
		<th>type</th>
		<th>included</th>
		<th>excluded</th>
	</tr>
	@foreach (var feature in ViewData["Features"] 
              as IEnumerable<nl.ricta.featureflag.Feature>)
	{
		<tr>
			<td>@feature.name <input type="checkbox" checked=@ViewData[feature.name]></td>
			<td>@feature.type</td>
			<td>@(string.Join(",", @feature.included));</td>
			<td>@(string.Join(",", @feature.excluded));</td>
		</tr>
	}
</table>
...

注意1:如果FeatureFlagActionAtrribute中某个用户的特性名为“checked”(即已启用,或已过滤且用户值在包含列表中),则视图中的特性复选框被“选中”。

注意2:我使用autofac作为IOC容器。我将另写一篇文章介绍IOC,并及时将其添加到引用的文章中。我在Startup中启用了autofac模块注册和通过(单例)FeatureFlagService加载特性标志。

public void ConfigureServices(IServiceCollection services)
{
	var builder = new ContainerBuilder();
	builder.RegisterModule(new FeatureFlagModule());
	BootStrapper.BuildContainer(builder);
	using (var scope = BootStrapper.Container.BeginLifetimeScope())
	{
		FeatureFlagService featureFlagService = scope.Resolve<featureflagservice>();
		featureFlagService.LoadFeatureFlags("featureFlagConfig.json");
	}
	...
}

结论和关注点

我们在本文中学到了什么?

  • 特性标志允许更早地发布功能。
  • 特性标志将部署与发布功能分离开来。
  • 定义和实现模型相对容易,采用一个简单的可重用二进制库中的特性标志模型,使用简单的文件格式(.json.yaml)。
  • 定义和实现逻辑相对容易,使用可重用二进制库中的简单特性标志逻辑服务。
  • 在应用程序中使用面向切面的特性标志,基本上只需要一两行代码和一些配置数据,并且我们添加了一个FeatureFlag(在所有库的底层工作都就绪后)。
  • 结果是简单的ViewModel特性标志,我们可以在MVC HTML标签库(Razor、Thymeleaf等)的HTML视图中对其进行响应。

如前所述,在集群、负载均衡的环境中,上述解决方案不是最佳实现(或者说,根本不好且不可行)。一个远程的中心化特性标志服务将是更好的选择。上述模型需要添加/更改什么?基本上,您需要调整一个实现部分:从远程服务加载模型,而不是通过文件。这大约需要一天的工作。然后将模型加载移动到一个集中的特性标志微服务。好吧,再花一两天的工作。

特性标志策略:建议与不建议

我还承诺提供一种应用特性标志的模式或策略,以便特性标志的管理开销和代码混乱不会成为过重的负担。

建议

实际上,您应该应用这三种模式/策略。

  1. 仅当出现以下情况时,才将特性置于特性标志之后:
    1. 当特性开启时可能会出现一些技术稳定性问题。
    2. 当特性开启时可能会出现一些业务和/或可用性问题。
    3. 您想(永久)允许或禁止一部分用户使用某个特性。
  2. 尽快从代码中移除特性标志(通常是在所有人都可以访问且特性被证明稳定可用时)。
  3. 从代码中移除被证明对所有人都不太好用的特性。

换句话说:只有当有非常非常充分的理由时,才将特性置于标志之后,并且只有在那时;一旦理由消失,就尽快将其移除。

不建议

以及一种您不应该应用的策略。

将所有内容都置于标志之后,而没有充分的理由,并且让它永远留在那里。

“宁可安全也不要冒险”的标志习惯是使用特性标志时经常犯的一个错误,这通常源于几种“滥用”的原因:

  • 没有人对是否需要标志的决定负责,导致“宁可安全也不要冒险”的标志:开发人员默认将所有新功能都置于标志之后:“你永远不知道……”
  • “质量借口标志”:所有特性和代码的质量都很差,以至于一切都置于标志之后,这样如果被证明太差就可以随时禁用。
  • “将糟糕的不可用功能隐藏在标志后面”:已被证明不可用的特性不会从代码中移除,而是永久地对所有人禁用。

如果您采用这种非常糟糕的策略,特性标志的管理开销和标志代码将成为一个巨大的负担,并且是痛苦和折磨的根源,导致问题、错误以及可测试性和可用性的下降,以至于很快就会变得不可行。

结论

特性标志可以简单实现,并且当正确使用时,可以实现(更快、更平稳的)特性发布。

请这样做!

但是,如果特性标志被滥用于其非预期目的,可能会导致混乱、严重的痛苦和悲伤。它将导致代码质量下降、可测试性下降、可用性下降和极差的质量。

请不要这样做?

历史

  • 2020年4月16日:初始版本
© . All rights reserved.