软件设计模型-功能开关
功能开关
本文受到 Pete Hodgson 的文章 Feature Toggles (aka Feature Flags) 启发,原文内容比本文更加丰富。
简单的功能开关
场景一:你刚刚开发完一个新的功能,然后需求告诉你,由于这个功能使用场景的限制,需要添加一个开关,能够让这个功能在某些环境打开或者关闭。
场景二:你实现了一个新版本的算法,但是并不希望立刻在线上启用,希望能进行更多的测试,所以需要添加一个开关,在测试环境打开,在生产环境保持关闭。
这个时候你只需要实现一个简单的功能开关即可。
func doSomeThing() {
useNewAlgorithm := false
// useNewAlgorithm := true // uncomment if you working with new algorithm
if useNewAlgoritem {
doSomeThingNew()
} else {
doSomeThingOld()
}
}
func doSomeThingNew() {
// implement something new
}
func doSomeThingOld() {
// implement something old
}
我们把新的功能写到 doSomeThingNew
函数中,并通过 useNewAlgorithm
变量来控制是否调用新功能。这个方法虽然简单,但是我们无法在运行时动态的去决定是否启用新的功能。为了实现对于新功能的动态控制,我们可以引入一个开关路由器(toggle router)来实现。
开关路由器
type ToggleRouter struct {
featureConfig map[string]struct{}
}
func (tr *ToggleRouter) SetFeature(featureName string, isEnabled bool) {
if isEnabled {
tr.featureConfig[featureName] = struct{}{}
} else {
delete(tr.featureConfig, featureName)
}
}
func (tr *ToggleRouter) IsFeatureEnabled(featureName string) bool {
_, ok := tr.featureConfig[featureName]
return ok
}
func NewToggleRouter(features []string) (tr *ToggleRouter) {
tr = &ToggleRouter{
featureConfig: make(map[string]struct{}),
}
for _, feat := range features {
tr.SetFeature(feat, true)
}
return
}
var toggleRouter = NewToggleRouter([]string{"newAlgo"})
func doSomeThing() {
if toggleRouter.IsFeatureEnabled("newAlgo") {
doSomeThingNew()
} else {
doSomeThingOld()
}
}
通过开关路由器我们可以动态的设置功能的启用与否。ToggleRouter 可以通过配置文件创建,或者通过配置界面,或者 API 接口来进行配置。到现在问题似乎已经解决了,然而随着越来越多的新功能加入,越来越多的功能需要通过开关来配置是否开启,代码中出现了越来越多的开关点,代码也变得越来越难以理解和维护。
让我们看一个新例子,InvoiceEmailler
会根据收据发送一封电子邮件,现在我们希望能加上一个新的功能,在邮件中添加退货链接。而这个退货链接只是在特定的情况下才会触发。下面代码中是根据 开关中是否包含 next-gen-ecomm
来判断的。
var toggle = GetFeatureToggleRouter()
type InvoiceEmailler struct {
invoice Invoice
}
func (emailler *InvoiceEmailler) GenerateInvoiceEmail() Email {
baseEmail := buildEmailForInvoice(emailler.invoice)
if toggle.IsFeatureEnabled("next-gen-ecomm") {
return addOrderCallationContentToEmail(baseEmail)
} else {
return baseEmail
}
}
但是,对于一个功能是否启用的判断逻辑可能是十分复杂的,也许在 A 情况下需要开启,在 B 情况下需要关闭;或者需要对某一部分用户开启这一项功能,对另一部分用户关闭这一功能;开关的判断逻辑可能会频繁的迭代。如果要更新判断逻辑,我们不得不在每个开关点去修改判断逻辑。这是代码中的第一个问题:开关点与开关的判断逻辑耦合在一起。
解耦开关点与开关路由器
幸好“软件中的任何问题都可以通过引入一个间接层来解决”, 通过增加一个开关逻辑判断层我们可以解耦开关点与开关判断逻辑。
type FeatureDecisions struct {
toggle *ToggleRouter
}
func (descisions *FeatureDecisions) IncludeOrderCancellationInEmail() bool {
return descisions.toggle.IsFeatureEnabled("new-gen-ecomm")
}
func NewFeatureDecisions(toggle *ToggleRouter) FeatureDecisions {
return FeatureDecisions{toggle: toggle}
}
var toggle = GetFeatureToggleRouter()
var featureDecisions = NewFeatureDecision(toggle)
type InvoiceEmailler struct {
invoice Invoice
}
func (emailler *InvoiceEmailler) GenerateInvoiceEmail() Email {
baseEmail := buildEmailForInvoice(emailler.invoice)
if featureDecisions.IncludeOrderCancellationInEmail() {
return addOrderCallationContentToEmail(baseEmail)
} else {
return baseEmail
}
}
通过引入 FeatureDecisions
之后,InvoiceEmailler
不再关心添加退货链接的逻辑是什么;而后续对与添加退货链接逻辑的更新也与 InvoiceEmailler
无关。从而实现了开关点与开关判断逻辑的解耦。
判断翻转(Inversion of Decision)
虽然在上一步中,我们通过添加判断逻辑层实现了判断逻辑与开关点的解耦,但是 InvoiceEmailler
依旧与 FeatureDecisions
耦合在一起,在执行 GenerateInvoiceEmail()
需要先创建或者获取 FeatureDecisions
,这处代码“坏味道”带来了两个问题:
- 它不方便对代码进行测试,在测试
GenerateInvoiceEmail()
函数之前,我们必须先设置好调用GetFeatureToggleRouter()
与NewFeatureDecision()
函数的环境,才能确保可以到达待测试逻辑代码块。 - 随着项目功能模块的增多,每个模块都与
FeatureDecisions
模块发生了耦合,使得该模块变成了全局依赖模块。
// invoice_emailler.go
type EmaillerDecisions interface {
IncludeOrderCancellationInEmail() bool
}
type InvoiceEmailler struct {
invoice Invoice
decisions EmaillerDecisions
}
func (emailler *InvoiceEmailler) SetDecisions(decisions *FeatureDecisions) {
emailler.decisions = decisions
}
func (emailler *InvoiceEmailler) GenerateInvoiceEmail() Email {
baseEmail := buildEmailForInvoice(emailler.invoice)
if emailler.decisions.includeOrderCancellationInEmail() {
return addOrderCallationContentToEmail(baseEmail)
} else {
return baseEmail
}
}
// inject decisions module
func NewInvoiceEmailler(decisions EmaillerDecisions, invoice Invoice) *InvoiceEmailler {
return &InvoiceEmailler{
invoice: invoice,
decisions: decisions,
}
}
此处“坏味道”的根本原因是业务模块对 FeatureDecisions
模块的依赖,通过控制反转在 InvoiceEmailler
模块创建时,将 FeatureDecisions
注入到 InvoiceEmailler
中,就可以消除业务模块对与 FeatureDecisions
模块的依赖。
消除条件判断
到现在位置我们代码已经获得了很大优化,那我们能不能在进一步消除 GenerateInvoiceEmail()
函数的条件判断呢? 对于简单的场景下的条件判断语句是没有什么问题的,但是在复杂的业务逻辑中,太多的条件判断并不利于代码的维护。比如如果配置中包含 feature “after-sales-service”, 需要在 email 中添加售后服务的信息。现在我们必须回到 GenerateInvoiceEmail()
,修改判断条件再加上添加售后服务的信息。
通过策略模式,我们可以进一步的消除业务代码中的逻辑判断,提升代码的可扩展性。
type EmaillerDecisions interface {
IncludeOrderCancellationInEmail() bool
IncludeAfterSalesServiceInEmail() bool
}
type AdditionalContentEnhancer interface {
EnhanceContent(Email) Email
}
type InvoiceEmailler struct {
invoice Invoice
enhancer AdditionalContentEnhancer
}
func (emailler *InvoiceEmailler) GenerateInvoiceEmail() Email {
baseEmail := buildEmailForInvoice(emailler.invoice)
return enhancer.EnhanceContent(baseEmail)
}
type OrderCallationContentEnhancer struct{}
func (e OrderCallationContentEnhancer) addOrderCallationContentToEmail(email Email) (newEmail Email) {
// implement addOrderCallationContentToEmail
return
}
func (e OrderCallationContentEnhancer) EnhanceContent(email Email) Email {
return e.addOrderCallationContentToEmail(email)
}
type AfterSalesServiceContentEnhancer struct{}
func (e AfterSalesServiceContentEnhancer) addAfterSalesServiceContentToEmail(email Email) (newEmail Email) {
// implement addAfterSalesServiceContentToEmail
return
}
func (e AfterSalesServiceContentEnhancer) EnhanceContent(email Email) Email {
return e.addAfterSalesServiceContentToEmail(email)
}
type OrderCallationAndAfterSalesServiceContentEnhancer struct {
OrderCallationContentEnhancer
AfterSalesServiceContentEnhancer
}
func (e OrderCallationAndAfterSalesServiceContentEnhancer) EnhanceContent(email Email) Email {
email = e.OrderCallationContentEnhancer.EnhanceContent(email)
email = e.AfterSalesServiceContentEnhancer.EnhanceContent(email)
return email
}
type IdentityContentEnhancer struct{}
func (e IdentityContentEnhancer) EnhanceContent(email Email) Email {
return email
}
// inject decisions module
func NewInvoiceEmailler(decisions EmaillerDecisions, invoice Invoice) *InvoiceEmailler {
emailler := &InvoiceEmailler{
invoice: invoice,
}
if decisions.IncludeOrderCancellationInEmail() && decisions.IncludeAfterSalesServiceInEmail() {
emailler.enhancer = OrderCallationAndAfterSalesServiceContentEnhancer{}
} else if decisions.IncludeOrderCancellationInEmail() {
emailler.enhancer = OrderCallationContentEnhancer{}
} else if decisions.IncludeAfterSalesServiceInEmail() {
emailler.enhancer = AfterSalesServiceContentEnhancer{}
} else {
emailler.enhancer = IdentityContentEnhancer{}
}
return
}
到现在我们就差不多完成了对于代码的优化。