设计模式学习笔记 - 项目实战一:设计实现一个支持各种算法的限流框架(实现)

概述

上篇文章,我们介绍了如何通过合理的设计,来实现框架的功能性需求的同时,满足易用、易扩展、灵活、低延迟、高容错等非功能性需求。在设计的过程中,我们也借鉴了之前讲过的一些开源项目的设计思想。比如 Spring 的低侵入松耦合、约定优于配置等设计思想,还借鉴了 Mybatis 通过 Mybatis-Spring 类库将框架的易用性做到极致等设计思路。

本章,我们讲解这样一个问题,针对限流框架的开发,如何做高质量的代码。说具体点就是,如何利用之前讲过的设计思想、原则、模式、编码规范、重构技巧等,写出易用、易扩展、易维护、灵活、简洁、可复用、易测试的代码。


V1 版本功能需求

前面提到,优秀的代码是重构出来的,复杂的代码也是慢慢堆砌出来的。小不快跑、逐步迭代是我比较推崇的开发模式。所以,针对限流框架,我们也不用已下载就做的大而全。况且,受章节篇幅所限,也不可能将一个大而全的代码阐述清楚。所以,我们可以先实现一个包含核心功能、基本功能的 V1 版本。

针对上两篇文章给出的需求和设计,我们重新梳理一下。看看有哪些功能要放到 V1 版本中实现。

在 V1 版本中:

  • 对于接口类型,我们只支持 HTTP 接口(也就是 URL)的限流,暂时不支持 RPC 等其他类型的接口限流。
  • 对于限流规则,我们只支持本地文件配置,配置文件格式只支持 YAML。
  • 对于限流算法,我们只支持固定时间窗口算法。
  • 对于限流模式,我们只支持单机限流。

尽管功能 "裁剪" 之后,V1 版本实现起来简单多了,但在编程开发的同时,我们还要考虑代码的扩展性,预留好扩展点。这样,在接下来的新版本开发中,我们才能够轻松地扩展新的限流算法、限流模式、限流规则和数据源。

最小原型代码

上篇文章讲到,项目实战中的实现等于面向对象设计加实现。而面向对象设计与实现一般可以分为四个步骤:划分职责识别类、定义属性和方法、定义类之间的交互关系、组装类并提供执行入口 。在《实践:如何进行面向对象分析、设计与编码》中,我们还带你使用这个方法,设计和实现了一个接口鉴权框架。

不过,前面也讲过,在平时的工作中,大部分程序员都是边写代码边做设计,边思考重构,并不会严格的按照步骤,先做完类的设计再去写代码。而且,如果一下子把类设计得很好、和合理,也是比较难的。所以,我的习惯是,先完全不考虑设计和代码质量,先把功能写完,先把基本的流程走通,哪怕所有的代码都写在一个类中也无所谓。然后,我们在对这个 **MVP 代码(最小原型代码)**做优化重构,比如,将代码中比较独立的代码抽离出来,定义成独立的类或函数。

我们先按照 MVP 代码的思路,把代码实现出来。它的目录结构如下所示。代码非常简单,只包含 5 个类,接下来,我们针对每个类一一讲解下。

java 复制代码
com.ratelimiter
  --RateLimiter
com.ratelimiter.rule
  --ApiLimit
  --RuleConfig
  --RateLimitRule
com.ratelimiter.alg
  --RateLimitAlg

先看下 RateLimiter 类

java 复制代码
public class RateLimiter {
    private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
    // 为每个api在内存中存储限流计数器
    private Map<String, RateLimitAlg> counters = new ConcurrentHashMap<>();
    private RateLimitRule rule;

    public RateLimiter() {
        // 根据限流规则配置文件ratelimiter-rule.yaml中的内容读取到RuleConfig中
        InputStream in = null;
        RuleConfig ruleConfig = null;
        try {
            in = this.getClass().getResourceAsStream("/ratelimiter-rule.yaml");
            if (in != null) {
                Yaml yaml = new Yaml();
                ruleConfig = yaml.loadAs(in, RuleConfig.class);
            }
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    log.error("close file error:", e);
                }
            }
        }

        // 将限流规则构建成支持快速查找的数据结构RateLimitRule
        this.rule = new RateLimitRule(ruleConfig);
    }

    public boolean limit(String appId, String url) {
        ApiLimit apiLimit = rule.getLimit(appId, url);
        if (apiLimit == null) {
            return true;
        }

        // 获取api对应在内存中的限流计数器(rateLimitCounter)
        String counterKey = appId + ":" + apiLimit.getApi();
        RateLimitAlg rateLimitCounter = counters.get(counterKey);
        if (rateLimitCounter == null) {
            RateLimitAlg newRateLimitAlg = new RateLimitAlg(apiLimit.getLimit());
            rateLimitCounter = counters.putIfAbsent(counterKey, newRateLimitAlg);
            if (rateLimitCounter == null) {
                rateLimitCounter = newRateLimitAlg;
            }
        }

        // 判断是否限流
        return rateLimitCounter.tryAcquire();
    }
}

RateLimiter 类用来串联整个限流流程。它先读取限流规则读取我呢间,映射为内存中的 Java 对象(RuleConfig),然后再将这个中间结构构建成一个支持快速查询的数据结构(RateLimitRule)。此外,这个类还提供用户直接使用的最顶层接口(limit() 接口)。

再来看下 RuleConfig 和 ApiLimit 两个类

java 复制代码
public class RuleConfig {
    private List<AppRuleConfig> configs;

    public List<AppRuleConfig> getConfigs() {
        return configs;
    }

    public void setConfigs(List<AppRuleConfig> configs) {
        this.configs = configs;
    }

    public static class AppRuleConfig {
        private String appId;
        private List<ApiLimit> limits;

        public AppRuleConfig() {
        }

        public AppRuleConfig(String appId, List<ApiLimit> limits) {
            this.appId = appId;
            this.limits = limits;
        }

        public String getAppId() {
            return appId;
        }

        public void setAppId(String appId) {
            this.appId = appId;
        }

        public List<ApiLimit> getLimits() {
            return limits;
        }

        public void setLimits(List<ApiLimit> limits) {
            this.limits = limits;
        }
    }
}

public class ApiLimit {
    private static final int DEFAULT_TIME_UNIT = 1; // 1 second
    private String api;
    private int limit;
    private int unit = DEFAULT_TIME_UNIT;

    public ApiLimit() {
    }

    public ApiLimit(String api, int limit, int unit) {
        this.api = api;
        this.limit = limit;
        this.unit = unit;
    }

    public String getApi() {
        return api;
    }

    public void setApi(String api) {
        this.api = api;
    }

    public int getLimit() {
        return limit;
    }

    public void setLimit(int limit) {
        this.limit = limit;
    }

    public int getUnit() {
        return unit;
    }

    public void setUnit(int unit) {
        this.unit = unit;
    }
}

从代码中,可以看出,RuleConfig 类嵌套了另外两个类 AppRuleConfigApiLimit。这三个类跟配置文件的三层嵌套结构完全对应。我把对应关系标注在了下面的示例中。

java 复制代码
configs:                 <!--对应RuleConfig-->
- appId: app-1           <!--对应AppRuleConfig-->
  limits:
    - api: /v1/user      <!--对应ApiLimit-->
      limit: 100
      unit: 60
    - api: /v1/order
      limit: 50
- appId: app-2
  limits:
    - api: /v1/user
      limit: 50
    - api: /v1/order
      limit: 50

再看下 RateLimitRule 这个类

你可能会好奇,有了 RuleConfig 来存储限流规则,为什么还要 RateLimitRule 类呢?这是因为,限流过程中会频繁的查询接口对应地限流规则,为了尽可能提提高查询速度,我们需要将限流规则组织成一种支持按照 URL 快速查询的数据结构。考虑到 URL 的复杂度比较高,且需要按照前缀来匹配,我们这里选择使用 Trie 树这种数据结构。下面我举了个例子来解释下,如下图所示。左边的限流规则对应到 Trie 树,就是图片中的样子。


RateLimitRule 的实现代码比较多,这里只给出了定义。如果你感兴趣的话,可以自己实现下。

java 复制代码
public class RateLimitRule {
    public RateLimitRule(RuleConfig ruleConfig) {
        // ...
    }

    public ApiLimit getLimit(String appId, String url) {
        ApiLimit apiLimit = null;
        // ...
        return apiLimit;
    }
}

最后,看下 RateLimitAlg 这个类

这个类是限流算法实现类。它实现了最简单的固定时间窗口限流算法。每个接口都要在内存中对应一个 RateLimitAlg 类的代码如下所示。对于代码的逻辑,你可以看上篇文章对固定时间窗口限流算法的讲解。

java 复制代码
public class RateLimitAlg {
    /* timeout for {@code Lock.tryLock()} */
    private static final long TRY_LOCK_TIMEOUT = 200L; // 200 ms
    private Stopwatch stopwatch;
    private AtomicInteger currentCount = new AtomicInteger(0);
    private final int limit;
    private Lock lock = new ReentrantLock();

    public RateLimitAlg(int limit) {
        this(limit, Stopwatch.createStarted());
    }

    @VisibleForTesting
    public RateLimitAlg(int limit, Stopwatch stopwatch) {
        this.limit = limit;
        this.stopwatch = stopwatch;
    }


    public boolean tryAcquire() {
        int updatedCount = currentCount.incrementAndGet();
        if (updatedCount <= limit) {
            return true;
        }
        try {
            if (lock.tryLock(TRY_LOCK_TIMEOUT, TimeUnit.MICROSECONDS)) {
                if (stopwatch.elapsed(TimeUnit.MICROSECONDS) > TimeUnit.SECONDS.toMillis(1)) {
                    // 过了固定时间窗口,要重置计数器和时间统计
                    currentCount.set(0);
                    stopwatch.reset();
                }
                updatedCount = currentCount.incrementAndGet();
                return updatedCount <= limit;
            } else {
                throw new InternalErrorException("tryAcquire() wati lock too long: " + TRY_LOCK_TIMEOUT + " ms");
            }
        } catch (InterruptedException e) {
            throw new InternalErrorException("tryAcquire() is interrupted by timeout.", e);
        }
    }
}

Review 最小原型代码

刚刚给出的 MVP 代码,虽然代码量不多,但已经实现了 V1 版本中规划的功能。不过,从代码质量角度来看你,它还有很多值得优化的地方。现在,我们站在一个 Code Reviewer 的角度,来分析下这段代码的设计和实现。

接个 SOLID、DRY、KISS、LOD、基于接口而非实现编程、高内聚松耦合等经典的设计思想和原则,以及编码规范,我们从代码质量评判标准的角度重点剖析一下,这段代码在可读性扩展性 等方面的表现,比如复用性、可测试性等。

首先,我们来看下代码的可读性

影响代码可读性的因素有很多。我们重点关注:

  • 目录设计(package 包)是否合理
  • 模块划分是否清晰
  • 代码结构是否符合高内聚低耦合
  • 以及是否符合统一的编码规范这几点。

因为涉及的代码不多,目录结构前面也给出了,总体来说比较简单,所以目录设计、包的划分没有问题

按照上篇文章的模块划分

  • RuleConfigApiLimitRateLimitRule 属于 "限流规则模块",负责限流规则的构建和查询。
  • RateLimitAlg 属于 "限流算法" 模块,提供了基于内存的单机固定时间窗口限流算法。
  • RateLimiter 属于 "集成使用" 模块,作为最顶层类,组装其他类,提供执行入口(也就是调用入口)。 不过,RateLimiter 作为执行入口,我们希望它只是负责组装工作,而不应该包含具体的业务逻辑,所以,RateLimiter 类中,从配置文件中读取限流规则这块逻辑,应该拆分出来,设计成独立的类

如果,我们把类与类之间的依赖关系图画出来,你会发现,它们之间的依赖关系很简单,每个类职责也比较单一,所以类的设计满足单一职责原则、LOD 迪米特法则、高内聚松耦合的要求

从编码规范上来讲,没有超级大的类、函数、代码块类、函数、变量的命名基本达意,也符合最小惊奇原则 。虽然,有些命名不能一眼就看出是干啥的,有些命名命名采用了缩写,比如 RateLimitAlg,但我们起码能猜个八九不离十,很容易理解和记忆。

总结一下,在最小原型代码中,目录设计、代码结构、模块划分、类的设计还算合理清理,基本符合编码规范,代码的可读性不错!

其次,再来看下代码的扩展性

实际上,这段代码最大的问题就是它的扩展性,也是我们最关注的,比较后续还有更多版本的迭代开发。编写可扩展代码,关键是建立扩展意识。这就像下象棋,我们要往前想几步,为以后做准备。在写代码时,我们要时刻思考,这段代码如果要扩展新的功能,那是否可以在尽量少改动代码的情况下完成,还是需要大动干戈,推到重写。

具体到 MVP 代码,不易扩展的最大原因是,没有遵循基于接口而非实现的编程思想 ,没有接口抽象意识。比如,RateLimitAlg 类只是实现了固定时间窗口限流算法,没有提炼出更加抽象的算法接口。如果我们要替换其他限流算法,就要改动比较多的代码。其他类的设计也同样有问题,比如 RateLimitRule

此外,RateLimiter 类中,配置文件的名称、路径,是硬编码在代码中的。尽管我们说约定优于配置,但也要兼顾灵活性,能够让用户在需要的时候,自定义配置文件名称、路径。而且,配置我呢间的格式目前也只支持 Yaml,之后扩展其他格式,需要对这部分代码做很大的改动。

重构最小原型代码

根据刚刚对 MVP 代码的剖析,我们发现它的可读性没有太大问题,问题主要在于可扩展性。主要的修改有两个,一个是将 RateLimiter 中的规则配置文件的读取解析逻辑拆出来,设计成独立的类,另一个是参照基于接口而非实现编程思想,对于 RateLimitAlgRateLimitRule 类提炼抽象接口。

按照这个修改思路,我们对代码进行重构。重构之后的目录结构如下所示。我们对每个类稍微都做了说明,你可以对比着重构前的目录来看。

yaml 复制代码
// 重构前
com.ratelimiter
  --RateLimiter
com.ratelimiter.rule
  --ApiLimit
  --RuleConfig
  --RateLimitRule
com.ratelimiter.alg
  --RateLimitAlg

// 重构后
com.ratelimiter
  --RateLimiter(有所修改)
com.ratelimiter.rule
  --ApiLimit(不变)
  --RuleConfig(不变)
  --RateLimitRule(抽象接口)
  --TrieRateLimitRule(实现类,就是重构前的RateLimitRule)
com.ratelimiter.rule.parse
  --RuleConfigParser(抽象接口)
  --YamlRuleConfigParser(Yaml格式配置文件解析类)
  --JsonRuleConfigParser(Json格式配置文件解析类)
com.ratelimiter.rule.datasource
  --RuleConfigSource(抽象接口)
  --FileRuleConfigSource(基于本地文件的配置类)
com.ratelimiter.alg
  --RateLimitAlg(抽象接口)
  --FixedTimeWinRateLimitAlg(实现类,就是重构前的RateLimitAlg)

其中,RateLimiter 类重构之后的代码如下所示。代码的改动集中在构造函数中,通过调用 RuleConfigSource 来实现了限流规则配置文件的加载。

java 复制代码
public class RateLimiter {
    private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
    // 为每个api在内存中存储限流计数器
    private Map<String, RateLimitAlg> counters = new ConcurrentHashMap<>();
    private RateLimitRule rule;

    public RateLimiter() {
        // 主要改动在这里:调用RuleConfigSource类来实现配置加载
        RuleConfigSource configSource = new FileRuleConfigSource();
        RuleConfig ruleConfig = configSource.load();
        this.rule = new TrieRateLimitRule(ruleConfig);
    }

    public boolean limit(String appId, String url) {
        ApiLimit apiLimit = rule.getLimit(appId, url);
        if (apiLimit == null) {
            return true;
        }
        String counterKey = appId + ":" + apiLimit.getApi();
        RateLimitAlg rateLimitCounter = counters.get(counterKey);
        if (rateLimitCounter == null) {
            // 这里也有一点点改动
            RateLimitAlg newRateLimitAlg = new FixedTimeWinRateLimitAlg(apiLimit.getLimit());
            rateLimitCounter = counters.putIfAbsent(counterKey, newRateLimitAlg);
            if (rateLimitCounter == null) {
                rateLimitCounter = newRateLimitAlg;
            }
        }
        return rateLimitCounter.tryAcquire();
    }
}

再来看下,从 RateLimiter 中拆分出来的限流规则加载的逻辑,现在是如何设计的。这部分涉及的类主要是下面几个。其中,各个 ParserRuleConfigSource 类的设计都有点类似策略模式,如果要添加新的格式的解析,只需要实现对应的 Parser 类,并添加到 FileRuleConfigSource 类的 PARSER_MAP 中就可以了。

java 复制代码
com.ratelimiter.rule.parse
  --RuleConfigParser(抽象接口)
  --YamlRuleConfigParser(Yaml格式配置文件解析类)
  --JsonRuleConfigParser(Json格式配置文件解析类)
com.ratelimiter.rule.datasource
  --RuleConfigSource(抽象接口)
  --FileRuleConfigSource(基于本地文件的配置类)




public interface RuleConfigParser {
    RuleConfig parse(String configText);
    RuleConfig parse(InputStream in);
}

public class YamlRuleConfigParser implements RuleConfigParser {
    @Override
    public RuleConfig parse(String configText) {
        try {
            return new Yaml().loadAs(configText, RuleConfig.class);
        } catch (Exception e) {
            throw new InternalErrorException("Parse yaml text[" + configText + "] config error", e);
        }
    }

    @Override
    public RuleConfig parse(InputStream in) {
        try {
            return new Yaml().loadAs(in, RuleConfig.class);
        } catch (Exception e) {
            throw new InternalErrorException("Parse yaml file config error", e);
        }
    }
}

public class JsonRuleConfigParser implements RuleConfigParser {
    @Override
    public RuleConfig parse(String configText) {
        try {
            return JSON.parseObject(configText, RuleConfig.class);
        } catch (Exception e) {
            throw new InternalErrorException("Parse json text[" + configText + "] config error", e);
        }
    }

    @Override
    public RuleConfig parse(InputStream in) {
        try {
            return JSON.parseObject(in, RuleConfig.class);
        } catch (IOException e) {
            throw new InternalErrorException("Parse json file config error", e);
        }
    }
}

public interface RuleConfigSource {
    RuleConfig load();
}

public class FileRuleConfigSource implements RuleConfigSource {
    private static final Logger logger = LoggerFactory.getLogger(FileRuleConfigSource.class);

    private static final String API_LIMIT_CONFIG_NAME = "ratelimiter-rule";
    private static final String YAML_EXTENSION = "yaml";
    private static final String YML_EXTENSION = "yml";
    private static final String JSON_EXTENSION = "json";

    private static final Map<String, RuleConfigParser> PARSER_MAP = new HashMap<>();

    static {
        PARSER_MAP.put(YAML_EXTENSION, new YamlRuleConfigParser());
        PARSER_MAP.put(YML_EXTENSION, new YamlRuleConfigParser());
        PARSER_MAP.put(JSON_EXTENSION, new JsonRuleConfigParser());
    }

    @Override
    public RuleConfig load() {
        for (String extension : PARSER_MAP.keySet()) {
            InputStream in = null;
            try {
                in = this.getClass().getResourceAsStream("/" + getFileNameByExt(extension));
                if (in != null) {
                    RuleConfigParser configParser = PARSER_MAP.get(extension);
                    return configParser.parse(in);
                }
            } finally {
                if (in != null) {
                    try {
                        in.close();
                    } catch (IOException e) {
                        logger.error("close file error:", e);
                    }
                }
            }
        }
        return null;
    }

    private String getFileNameByExt(String extension) {
        return API_LIMIT_CONFIG_NAME + "." + extension;
    }
}

此外,抽象的接口相关代码如下所示。

java 复制代码
public interface RateLimitRule {
    ApiLimit getLimit(String appId, String url);
}

public class TrieRateLimitRule implements RateLimitRule {
    public TrieRateLimitRule(RuleConfig ruleConfig) {
        // 由你来实现...
    }

    @Override
    public ApiLimit getLimit(String appId, String url) {
        ApiLimit apiLimit = null;
        // 由你来实现...
        return apiLimit;
    }
}

public interface RateLimitAlg {
    boolean tryAcquire();
}

public class FixedTimeWinRateLimitAlg implements RateLimitAlg {
    /* timeout for {@code Lock.tryLock()} */
    private static final long TRY_LOCK_TIMEOUT = 200L; // 200 ms
    private Stopwatch stopwatch;
    private AtomicInteger currentCount = new AtomicInteger(0);
    private final int limit;
    private Lock lock = new ReentrantLock();

    public FixedTimeWinRateLimitAlg(int limit) {
        this(limit, Stopwatch.createStarted());
    }

    @VisibleForTesting
    public FixedTimeWinRateLimitAlg(int limit, Stopwatch stopwatch) {
        this.limit = limit;
        this.stopwatch = stopwatch;
    }

    @Override
    public boolean tryAcquire() {
        int updatedCount = currentCount.incrementAndGet();
        if (updatedCount <= limit) {
            return true;
        }
        try {
            if (lock.tryLock(TRY_LOCK_TIMEOUT, TimeUnit.MICROSECONDS)) {
                if (stopwatch.elapsed(TimeUnit.MICROSECONDS) > TimeUnit.SECONDS.toMillis(1)) {
                    // 过了固定时间窗口,要重置计数器和时间统计
                    currentCount.set(0);
                    stopwatch.reset();
                }
                updatedCount = currentCount.incrementAndGet();
                return updatedCount <= limit;
            } else {
                throw new InternalErrorException("tryAcquire() wait lock too long: " + TRY_LOCK_TIMEOUT + " ms");
            }
        } catch (InterruptedException e) {
            throw new InternalErrorException("tryAcquire() is interrupted by timeout.", e);
        }
    }
}

总结

优秀的代码都是重构出来的,复杂的代码是慢慢堆砌出来的。小步快跑、逐步迭代是我个人比较推崇的开发模式。追求完美主义会让我们迟迟无法下手。所以,为了克服这个问题,一方面,我们可以规划多个小版本来开发;另一方面,在编程实现的过程中,我们可以先实现 MVP 代码,并以此来重构优化

如果对 MVP 代码优化重构?

我们站在 Code Reviewer 的角度结合 SOLID、DRY、KISS、LOD、基于接口而非实现编程、高内聚松耦合等经典的设计思想和原则,以及编码规范,从代码质量评判标准的角度,来剖析代码在可读性、扩展性、可维护性、灵活、简洁、复用性、可测试性等方面的表现,并且针对性地去优化不足