极客时间-设计模式之美笔记(4) 面向对象-实战(二)
如何对接口鉴权这样一个功能开发做面向对象分析?
面向对象分析主要的分析对象是“需求”,因此,面向对象分析可以粗略地看成“需求分析”。
1. 第一轮基础分析
用户名和密码的方式
2. 第二轮分析优化
调用方将请求接口的 URL 跟 AppID、密码拼接在一起,然后进行加密,生成一个 token。
调用方在进行接口请求的的时候,将这个 token 及 AppID,随 URL 一块传递给微服务端。微服务端接收到这些数据之后,根据 AppID 从数据库中取出对应的密码,并通过同样的 token 生成算法,生成另外一个 token。用这个新生成的 token 跟调用方传递过来的 token 对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
3. 第三轮分析优化
我们可以进一步优化 token 生成算法,引入一个随机变量,让每次接口请求生成的 token 都不一样。我们可以选择时间戳作为随机变量。
调用方在进行接口请求的时候,将 token、AppID、时间戳,随 URL 一并传递给微服务端。微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
4. 第四轮分析优化
针对 AppID 和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。
5. 最终确定需求
- 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
- 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
如何利用面向对象设计和编程开发接口鉴权功能?
如何进行面向对象设计?
- 划分职责进而识别出有哪些类;
- 定义类及其属性和方法;
- 定义类与类之间的交互关系;
- 将类组装起来并提供执行入口。
1. 划分职责进而识别出有哪些类
根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类。让我们拆解上文的最终需求,注意:拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(单一职责)。
- 把 URL、AppID、密码、时间戳拼接为一个字符串;
- 对字符串通过加密算法加密生成 token;
- 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
- 解析 URL,得到 token、AppID、时间戳等信息;
- 从存储中取出 AppID 和对应的密码;
- 根据时间戳判断 token 是否过期失效;
- 验证两个 token 是否匹配;
我们发现,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 这个操作。
这个需求相对简单,针对复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。
2. 定义类及其属性和方法
AuthToken 类相关的功能点有四个:
- 把 URL、AppID、密码、时间戳拼接为一个字符串;
- 对字符串通过加密算法加密生成 token;
- 根据时间戳判断 token 是否过期失效;
- 验证两个 token 是否匹配。
识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。我们可以借用这个思路,根据功能点描述,识别出来 AuthToken 类的属性和方法,如下所示:
从上面的类图中,我们可以发现这样三个小细节。
第一个细节:并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数。
第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime,expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期。
第三个细节:我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken()。
第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken,所以我们不应该放到这个类中。
第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。
Url 类相关的功能点有两个:
- 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
- 解析 URL,得到 token、AppID、时间戳等信息。
接口请求并不一定是以 URL 的形式来表达,还有可能是 Dubbo、RPC 等其他形式。为了让这个类更加通用,命名更加贴切,我们接下来把它命名为 ApiRequest。下面是我根据功能点描述设计的 ApiRequest 类。
CredentialStorage 类相关的功能点有一个:
- 从存储中取出 AppID 和对应的密码。
3. 定义类与类之间的交互关系
泛化、实现、组合、依赖 这四种关系掌握即可
泛化(Generalization)可以简单理解为继承关系。具体到 Java 代码就是下面这样:
public class A { ... } public class B extends A { ... }
实现(Realization)一般是指接口和实现类之间的关系。具体到 Java 代码就是下面这样:
public interface A {...} public class B implements A { ... }
依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。具体到 Java 代码就是下面这样:
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) { ... } }
组合(Association)具体到代码层面,只要 B 类对象是 A 类对象的成员变量,那我们就称,A 类跟 B 类是组合关系。具体到 Java 代码就是下面这样:
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(); } }
4. 将类组装起来并提供执行入口
接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthenticator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示:
如何进行面向对象编程?
面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。有了前面的类图,这部分工作相对来说就比较简单了。所以,这里我只给出比较复杂的 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."); } } }