10k

设计模式之美-课程笔记6-接口鉴权功能案例分析

实战二:如何对接口鉴权这样一个功能开发做面向对象分析?

  1. 如何做。分析,设计。
  2. 如何做需求分析,职责划分,需要定义哪些类,有哪些方法属性,类之间如何交互?如何组装成一个程序?如何结合设计原则、设计模式……

案例介绍和难点剖析

背景

假设要做一个微服务。微服务通过HTTP协议暴露接口给其他系统调用(其他系统通过URL接口调用微服务)。

为了保证接口调用的安全性,设计实现一个接口调用鉴权功能,只有认证过的系统才能调用我们的接口,未经认证的系统会被拒绝。

难点

  1. 需求不明确:只是一个最终的要求,但是对于细节上的设计要求,以及编码还不是很清晰。

    分析需求。将需求细化到很清晰,可执行。需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体需求,哪些是现在要做的,哪些是未来要做的,那些是不用考虑做的。

  2. 没做过

    平常都是仿照现有的业务功能做一些CRUD,对于这种跟具体业务无关的功能,需要整体考虑分析和设计。

需求分析

思考路径。

1. 第一轮基础分析

首先想到的是用户名加密码来做认证。

给调用方分配应用名(AppId)和秘钥,等他们访问的时候带上这个信息,然后和服务端的存储的作对比。

2. 第二轮分析优化

问题在于,秘钥是明文传输的(基于HTTP)。

如果我们将秘钥加密之后传输呢?

也不行,黑客还是可以截取这个内容,并且伪装成已认证系统来访问接口(这个叫重放攻击)。

那我们借助OAuth验证思路解决。将请求ULR拼接appid和秘钥一起加密成为一个token,appid和token一起在调用的时候发过来,然后服务端用同样方法去加密,然后对比token。

img

3. 第三轮分析优化

还不行啊,还是可以被重放攻击。token是固定的。

可以在生成token的时候引入随机变量,让token随机。我们可以选择时间戳作为随机变量。原来的 token 是对 URL、AppID、密码三者进行加密生成的,现在我们将 URL、AppID、密码、时间戳四者进行加密来生成 token。调用方在进行接口请求的时候,将 token、AppID、时间戳,随 URL 一并传递给微服务端。

微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。

img

4. 第四轮分析优化

和我想的一样,这还是不行啊。。。。

作者说了,没有绝对的安全,我们只能是尽可能提高攻击成本。这个方案提高安全性的同时不会过于影响接口的性能。

另一个问题是服务端在哪存appid和密码?业务数据库并不是一个好的选择,这种非业务性的功能不应该和系统过度耦合。

其实有很多选择配置和存储。针对 AppID 和密码的存储,灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。

5. 最终确定需求

  • 调用接口的时候,将URL、AppID、密码和时间戳拼接,通过加密算法生成token,并将token、AppID、时间戳拼接在URL中一起发送到服务端。
  • 微服务在接收到调用方的请求后,从URL中拆解出token、AppID、时间戳。
  • 微服务端检查时间戳是否在设置的失效窗口期内。超过时间则算鉴权失败,拒绝调用。
  • 如果没有过期,则从存储中取出AppID和密码,用同样的算法生成token,与传过来的匹配,一致则鉴权成功否则拒绝调用。

就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题 - 解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。

针对框架、类库、组件等非业务系统的开发,其中一个比较大的难点就是,需求一般都比较抽象、模糊,需要你自己去挖掘,做合理取舍、权衡、假设,把抽象的问题具象化,最终产生清晰的、可落地的需求定义。

如何进行面向对象设计

  1. 面向对象分析是产出详细的需求描述,面向对象设计产出的就是
    • 划分职责进而识别出有哪些类;
    • 定义类及其属性和方法;
    • 定义类和类之间的交互关系;
    • 将类组装并提供执行入口。

1. 划分职责进而识别出有哪些类

  1. 类是现实世界中一个事物的建模(不绝对,有的就不是事物)。
  2. 或者另一个方法,把需求中的名词作为候选类。(新手友好)
  3. 或者根据需求,将其中的功能点一个个罗列出来,找到职责相似的,操作同样属性的,看他们是否可以归为一个类。
    1. 拆解的功能点一定要小(单一职责)。
      1. 把 URL、AppID、密码、时间戳拼接为一个字符串;(这个是客户端做的事情)
      2. 对字符串通过加密算法加密生成 token;
      3. 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
      4. 解析 URL,得到 token、AppID、时间戳等信息;
      5. 从存储中取出 AppID 和对应的密码;
      6. 根据时间戳判断 token 是否过期失效;
      7. 验证两个 token 是否匹配;
    2. 从上面的功能列表中,我们发现,1、2、6、7 都是跟 token 有关,负责 token 的生成、验证;3、4 都是在处理 URL,负责 URL 的拼接、解析;5 是操作 AppID 和密码,负责从存储中读取 AppID 和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken 负责实现 1、2、6、7 这四个操作;Url 负责 3、4 两个操作;CredentialStorage 负责 5 这个操作。
  4. 针对更加复杂的需求开发,线划分模块,再去模块内部进行功能点拆解。

2. 定义类及其属性和方法

  1. 对于方法,一般建议识别出需求描述中的动词作为候选方法,再进一步筛选过滤。

  2. 对于AuthToken我们可以得到

    img

    • 不是所有的名词都被定义为类,有些就成了属性。从业务上来说不属于这个类的属性和方法不应该放到类中。
    • 在方法设计过程中还需要设计一些其他的属性和方法,例如createTimeexpireTimeInterval,他们勇于isExpired()函数中用来判断token是否过期。我们还给AuthToken类添加了getToken()方法。在设计方法和属性的时候,不能单单依赖当下的需求,要从业务模型上分析应该具有哪些属性和方法。 既保证类的完整性,也为未来做准备。
  3. URL类的功能点有两个

    • 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;

    • 解析 URL,得到 token、AppID、时间戳等信息。

    为了让这个类更加通用,命名更加贴切,我们接下来把它命名为 ApiRequest

    img

  4. CredentialStorage类的功能点只有一个,是从存储中取出AppID和对应密码。为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程。

    img

3. 定义类和类之间的交互关系

  1. UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖.

  2. 泛化Generalization,简单理解就是继承关系。

    public class A { ... }
    public class B extends A { ... }
  1. 实现Realization一般指接口和实现类之间的关系。
    public interface A {...}
    public class B implements A { ... }
  1. 聚合Aggregation是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系。
    public class A {
      private B b;
      public A(B b) {
        this.b = b;
      }
    }
  1. 组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。
    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
  1. 关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系。(聚合和组合都可以称之为关联)
    public class A {
      private B b;
      public A(B b) {
        this.b = b;
      }
    }
    或者
    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
  1. 依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。
    public class A {
      private B b;
      public A(B b) {
        this.b = b;
      }
    }
    或者
    public class A {
      private B b;
      public A() {
        this.b = new B();
      }
    }
    或者
    public class A {
      public void func(B b) { ... }
    }
  1. 笔者还是只保留了四个关系:泛化、实现、组合、依赖。泛化、实现、依赖的定义不变,组合关系替代 UML 中组合、聚合、关联三个概念。这样比较贴合前面多用组合少用继承的理念。

4. 将类组装并提供执行入口

  1. 接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthenticator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。

    img

如何面向对象编程

  1. ApiAuthenticator
    public interface ApiAuthenticator {
      void auth(String url);
      void auth(ApiRequest apiRequest);
    }
    
    public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
      private CredentialStorage credentialStorage;
      
      public DefaultApiAuthenticatorImpl() {
        this.credentialStorage = new MysqlCredentialStorage();
      }
      
      public DefaultApiAuthenticatorImpl(CredentialStorage credentialStorage) {
        this.credentialStorage = credentialStorage;
      }
    
      @Override
      public void auth(String url) {
        ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
        auth(apiRequest);
      }
    
      @Override
      public void auth(ApiRequest apiRequest) {
        String appId = apiRequest.getAppId();
        String token = apiRequest.getToken();
        long timestamp = apiRequest.getTimestamp();
        String originalUrl = apiRequest.getOriginalUrl();
    
        AuthToken clientAuthToken = new AuthToken(token, timestamp);
        if (clientAuthToken.isExpired()) {
          throw new RuntimeException("Token is expired.");
        }
    
        String password = credentialStorage.getPasswordByAppId(appId);
        AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
        if (!serverAuthToken.match(clientAuthToken)) {
          throw new RuntimeException("Token verfication failed.");
        }
      }
    }
  1. To be added - AuthToken、ApiRequest、CredentialStorage

    参照这个同学的内容,写的比较完整。

    https://github.com/murreIsCoding/auth/tree/master/src/main/java/com/murre/auth

辩证思考

  1. 不需要所有的需求完全按照这个在日常工作,灵活运用。

Util的故事

image-20230604100318410

看到了下面一起学习的人留言,然后回想起自己上周刚做的需求。也是忍俊不禁。因为自己也是刚刚做了类似的事情,一个新的上游feature上线,我们team需要对传过来的Objects做一些validation即可。我最开始是在相关的包里找到了一个xxxValidator,本着学习前辈的原则,我直接在这个validator里面加了一个只针对我当前这个需求的对象的方法,在save之前调用这个validator去检查对象的属性。

后面被组里的engineer review 代码的时候,推荐在另一个地方实现,因为我们组里的大佬已经对代码进行了一些重构前一阵子,其中有一点是,我们的Object在中途的处理过程中,反复存取,对于性能有很大的影响,应该集中在某一处去做逻辑处理。所以我就讲xxxValidator该到了对应的xxxService中,并且拆分到了相应的位置,确实看起来更加融为一体了。

看完这个案例分析,又结合了自己的实践,想到的是自己之前的实现也是不折不扣的面向过程,而且也没考虑到性能问题,颇有感触。

写了一阵子的代码,脑子里很多时候是在学习别人的写法,对于为什么这么实现却很多有时候不理解,也不能结合整体去思考,也没有一些指导原则和思路。这个课程真的是解决了我的燃眉之急,是我一直以来在脑子里想要梳理的一些东西。

Thoughts? Leave a comment