设计模式与 easytrader (一):策略模式

8/17/2018

前言

之前一段时间,easytrader 上用户报了很多 issues,基本都是关于客户端软件无法获取持仓的错误。所以最近就抽了段时间解决下,里面用到了策略模式,就想顺便复习下设计模式。

这里介绍下相关的上下文,easytrader 是一个模拟证券客户端操作的Python类库,基本就是一个定制版的按键精灵,支持银河、华泰等公司以及通用版客户端(支持多家券商),能进行的操作有买卖、获取持仓等。

Old Design

issue 里面的错误是关于获取持仓 get_position 这个接口的,画成类图的话如下所示(省略了一些上下文无关的信息):

这边有问题的是获取持仓这一步。原来客户端的持仓保存在Grid 中,类似网页端的 table,然后 easytrader 通过Ctrl + C 的方式复制内容到剪切板,再解析剪切板的内容获取持仓:

示例代码:

class ClientTrader:    
    def get_position(self):
        # ...
        grid_data = self._get_grid_data()
        # ...
        return position

这个逻辑是所有客户端通用的,因此 YHTraderHTTrader 都是继承的父类 ClientTrader 中的默认方法。

Break

但是前段时间银河和通用客户端的一部分券商通过更新封杀了通过剪切板获取 Grid 数据的方式,导致原有的 get_position 失效。这时在 issue 里有开发者提出可以通过在 Grid 上右键将对应内容保存为 xls 文件再解析保存的文件获取持仓,并给出了示例代码。

Question

这时候的问题就是怎么将通过 xls 获取持仓的策略和原有的通过剪切板获取持仓的策略整合到代码里,修复银河客户端和一部分通用客户端无法获取持仓的 Bug

解决方式

HardCode

ClientTrader 类中实现 get_position_by_copyget_position_by_xls 的方法,并在 get_position 进行 hardcode,然后 YHTrader 通过继承的方式默认使用 xls 方式,示例代码如下:

class ClientTrader:    
    def get_position(self, strategy='copy'):
        # ...
        if strategy == 'copy':
            grid_data = self._get_grid_data_by_copy()
        elif strategy == 'xls':
            grid_data = self._get_grid_data_by_xls()
        else:
            raise NotImplementedError(f'Stratege {strategy} not implenmented')
        # ...
        return position
    
    def _get_grid_data_by_copy(self):
        # ...
    
    def _get_grid_data_by_xls(self):
        # ...
  

class YHTrader(ClientTrader):  
    # 修改银河的方法默认为 xls 策略
    def get_position(self, strategy='xls') 
        # ...

优点

  • 简单粗暴
  • api 的改动较小

缺点

  • 如果获取 position 的方式继续增多,get_position 中会有很多面条式的代码,不好维护。

  • 修改持仓策略的话需要修改ClientTrader 类,而这个类作为父类,修改它影响范围较广,违背了对扩展开放,对修改关闭的开闭原则。

  • 污染ClientTraderget_position 的代码结构,导致阅读代码时有很多无关的代码细节干扰思路

Inheritance (基于继承)

如果我们基于继承实现的话会怎么样,因为通用客户端从原有的仅支持一种策略变为支持两种,此时就需要基于 copy 策略的父类实现使用 Xls 策略的子类,而银河客户端从 Xls 策略子类继承,这里同时需要修改获取 Trader 的工厂方法,提供获取Xls类的方法。示例代码如下:

class ClientTrader:    
    def get_position(self):
        # ...
        grid_data = self._get_grid_data()
        # ...
        return position
    
    def _get_grid_data(self):
        # 默认实现基于 copy 的 grid_data 获取逻辑
    
  
class ClientTraderWithXls(ClientTrader):
    
    def _get_grid_data(self):
        # 实现基于 xls 的 grid_data 获取逻辑

        
# 银河改为从 ClientTraderWithXls 继承
class YHTrader(ClientTraderWithXls):
    pass


class HTTrader(ClientTrader): 
    pass


### api.py 
def get_trader(broker):
    if broker == 'client_trader':
        return ClienTrader()
    if broker == 'client_trader_with_xls': # 新增获取 xls 策略子类的方法
        return ClientTraderWithXls()
    # ...

优点

  • 看起来将 xls 的策略逻辑抽取到了子类中,比较独立
  • 不需要修改原有的 client_trader
  • 不需要修改原有的 api 接口

缺点

  • 如果其他接口需要多种策略的话,子类的策略组合数量会出现爆炸式上升。比如获取余额也需要新增一种策略的话,加上获取持仓的两种策略,则要实现四个子类才能涵盖所有的组合。

那有没有更好的方法呢?自然是有的,那就是设计模式中的策略模式。

Strategy (策略模式)

根据《Design Pattern》,策略模式的目的是:

定义一组封装内部细节并可互换的算法,使得客户端跟它们使用的算法解耦。

而为什么不选择将对应的算法直接嵌入类中或通过继承实现呢?除了我们上面所讲的几点外,书中还列出了直接 hardcode 的带来的一些其他问题:

  1. 将算法细节包含在类内实现,会使得类本身复杂度上升和代码膨胀,导致维护困难,尤其需要支持多个算法的时候。

    • 想象下如果我们需要支持 10 种获取持仓的算法,第一种 HardCode 的实现会导致类基本丧失了可维护性。
  2. 可能需要在不同的时间使用不同的算法,而且有时不需要同时支持多种算法。

    • HardCode 本身允许通过参数调用不同的方法,但是也同时将所有算法的代码包含在内。

    • 类继承的方式虽然只包含对应算法的代码,但是却不允许用户切换算法实现。

  3. 当类本身包含了大量算法实现的时候,修改或者变更已存在的算法都会非常困难。

    • 将算法本身包含在类中暴露了太多细节。而一般来说,程序员会滥用这些暴露的细节使得类本身的行为变得非常脆弱。当修改算法时需要获知很多不必要的上下文细节。

而为了避免这些问题,我们可以定义一些将算法细节封装起来的类,它们具有统一的接口。而基于这样的选择封装的类,我们称它为一个Strategy

这时就让我们按照策略模式的方式修改我们的代码如下 :

### grid_stragies.py
import abc

class IGridStrategy(abc.ABC):
    """获取 grid 策略接口定义"""
    
    @abc.abstractmethod
    def get(self):
        """返回 grid 数据"""
        pass

    
class Copy(IGridStrategy):
    
    def get(self):
        """实现基于 Copy 的逻辑"""
        pass
 

class Xls(IGridStrategy):
    
    def get(self):
        """实现基于 Xls 的逻辑"""
        pass
    
    
### clienttraders.py
import grid_stragies

class ClientTrader:
    # The strategy to use for getting grid data
    grid_strategy = grid_stragies.Copy
    
    def get_position(self):
        # ...
        grid_data = self.grid_strategy(self).get()
        # ...
        return position

        
# 银河改为使用 Xls strategy
class YHTrader(ClientTrader):
    
    grid_strategy = grid_stragies.Xls

    
class HTTrader(ClientTrader): 
    pass


# 如果希望 ClientTrader 使用 Xls 方法,只需要
import grid_strategies

client_trader = ClientTrader()
client_trader.grid_strategy = grid_strategies.Xls

优点

  • 将相关的策略细节都抽取到了另外一个文件中
  • 增加策略时只需要保证接口一致且不需要修改原有的 client_trader 相关类
  • 可以通过修改类的 grid_strategy 属性来动态切换策略,避免多种方法需要多种策略时组合爆炸的问题。

缺点

  • 允许动态切换类导致的性能轻微下降。如果不需要动态切换的话可以通过 mixin 的形式实现。

适用场景

策略模式适合于以下场景:

  • 许多相关类仅仅在行为上有所区别。策略模式提供了一种方式允许配置一个类选择多种行为中的一种。(而不是在一个类中实现所有算法或者继承许多类而仅仅为了覆写不同的算法)
  • 需要使用一种算法的多种变种。策略模式允许你基于类的结构定义多种轻微不同的算法。
  • 算法的内部细节不需要暴露给客户端。使用策略模式避免暴露内部复杂以及特定的数据结构。(提高代码的可读性和可维护性)
  • 一个类定义了很多行为,然后在实现中通过条件语句来切换(见 HardCode 的实现)。此时可以通过将相关条件分支移动到对应的策略类中消除条件语句。

实现细节

strategy 如何访问 context

  1. context 将自身的引用传递给 strategy,适用于 strategy 和 context 关联较为紧密的情况或者参数较多的情况
class Context:
    strategy = ConcreteStrategy1

    def do_something(self):
        self.strategy.do_something(self)
  1. context 将所需数据通过参数传递给 strategy,适用于需要解耦 strategy 和 context 或者参数较少的情况
class Context:
    strategy = ConcreteStrategy1

    def do_something(self):
        self.strategy.do_something(self.arg1, self.arg2)
  1. 由 client 选择 strategy 传递给 context,适用于需要在运行时变更 strategy 的情况
class Context:
    def do_something(self, strategy):
        strategy.do_something(self)
        
# usage
Context().do_something(ConcreteStrategy1)

第一种方法更加灵活,但是暴露了 context 本身过多的信息给 strategy,使得策略和 context 紧耦合。

第二种方法如果需要新的参数,则需要变更 strategy 类的接口,这时候需要 strategy 类本身设计的较为合理。

第三种方法提供了 client 在运行时变更 strategy 的能力。

缺点

虽然策略模式有很多优点,但是不可避免的也会有一些缺陷,正如”没有银弹“所说的,掌握设计模式的关键便是理解它们对应的 tradeoff,如此才可以在正确的时间选择正确的模式。

  1. 如果 client 需要切换 strategy 的话,它必须理解对应的 strategy 的区别。

  2. context 和 strategy 之间的交互带来的额外成本。如果 strategy 设计不合理的话,context 传递的一些参数可能在某些 strategy 中永远不会被用到。

  3. 类以及对象数量的膨胀。这在设计模式中是很难避免的,毕竟设计模式的第二原则就是 Favor object composition over class inheritance.

实际案例

django-rest-framework

rest_framework.views.APIView 就大量使用了策略模式以使得用户在编写对应的 APIView 时可以获得最大的灵活性

class APIView(View):

    # The following policies may be set at either globally, or per-view.
    renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
    parser_classes = api_settings.DEFAULT_PARSER_CLASSES
    authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES
    throttle_classes = api_settings.DEFAULT_THROTTLE_CLASSES
    permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES
    content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS
    metadata_class = api_settings.DEFAULT_METADATA_CLASS
    versioning_class = api_settings.DEFAULT_VERSIONING_CLASS