设计模式之美-课程笔记6-接口鉴权功能案例分析
实战二:如何对接口鉴权这样一个功能开发做面向对象分析?
- 如何做。分析,设计。
- 如何做需求分析,职责划分,需要定义哪些类,有哪些方法属性,类之间如何交互?如何组装成一个程序?如何结合设计原则、设计模式……
案例介绍和难点剖析
背景
假设要做一个微服务。微服务通过HTTP协议暴露接口给其他系统调用(其他系统通过URL接口调用微服务)。
为了保证接口调用的安全性,设计实现一个接口调用鉴权功能,只有认证过的系统才能调用我们的接口,未经认证的系统会被拒绝。
难点
-
需求不明确:只是一个最终的要求,但是对于细节上的设计要求,以及编码还不是很清晰。
分析需求。将需求细化到很清晰,可执行。需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体需求,哪些是现在要做的,哪些是未来要做的,那些是不用考虑做的。
-
没做过
平常都是仿照现有的业务功能做一些CRUD,对于这种跟具体业务无关的功能,需要整体考虑分析和设计。
需求分析
思考路径。
1. 第一轮基础分析
首先想到的是用户名加密码来做认证。
给调用方分配应用名(AppId)和秘钥,等他们访问的时候带上这个信息,然后和服务端的存储的作对比。
2. 第二轮分析优化
问题在于,秘钥是明文传输的(基于HTTP)。
如果我们将秘钥加密之后传输呢?
也不行,黑客还是可以截取这个内容,并且伪装成已认证系统来访问接口(这个叫重放攻击)。
那我们借助OAuth验证思路解决。将请求ULR拼接appid和秘钥一起加密成为一个token,appid和token一起在调用的时候发过来,然后服务端用同样方法去加密,然后对比token。
3. 第三轮分析优化
还不行啊,还是可以被重放攻击。token是固定的。
可以在生成token的时候引入随机变量,让token随机。我们可以选择时间戳作为随机变量。原来的 token 是对 URL、AppID、密码三者进行加密生成的,现在我们将 URL、AppID、密码、时间戳四者进行加密来生成 token。调用方在进行接口请求的时候,将 token、AppID、时间戳,随 URL 一并传递给微服务端。
微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
4. 第四轮分析优化
和我想的一样,这还是不行啊。。。。
作者说了,没有绝对的安全,我们只能是尽可能提高攻击成本。这个方案提高安全性的同时不会过于影响接口的性能。
另一个问题是服务端在哪存appid和密码?业务数据库并不是一个好的选择,这种非业务性的功能不应该和系统过度耦合。
其实有很多选择配置和存储。针对 AppID 和密码的存储,灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。
5. 最终确定需求
- 调用接口的时候,将URL、AppID、密码和时间戳拼接,通过加密算法生成token,并将token、AppID、时间戳拼接在URL中一起发送到服务端。
- 微服务在接收到调用方的请求后,从URL中拆解出token、AppID、时间戳。
- 微服务端检查时间戳是否在设置的失效窗口期内。超过时间则算鉴权失败,拒绝调用。
- 如果没有过期,则从存储中取出AppID和密码,用同样的算法生成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我们可以得到
- 不是所有的名词都被定义为类,有些就成了属性。从业务上来说不属于这个类的属性和方法不应该放到类中。
- 在方法设计过程中还需要设计一些其他的属性和方法,例如
createTime
,expireTimeInterval
,他们勇于isExpired()
函数中用来判断token
是否过期。我们还给AuthToken类添加了getToken()
方法。在设计方法和属性的时候,不能单单依赖当下的需求,要从业务模型上分析应该具有哪些属性和方法。 既保证类的完整性,也为未来做准备。
-
URL类的功能点有两个
-
将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
-
解析 URL,得到 token、AppID、时间戳等信息。
为了让这个类更加通用,命名更加贴切,我们接下来把它命名为 ApiRequest。
-
-
CredentialStorage类的功能点只有一个,是从存储中取出AppID和对应密码。为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程。
3. 定义类和类之间的交互关系
-
UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖.
-
泛化Generalization,简单理解就是继承关系。
public class A { ... } public class B extends A { ... }
-
实现Realization一般指接口和实现类之间的关系。
public interface A {...} public class B implements A { ... }
-
聚合Aggregation是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系。
public class A { private B b; public A(B b) { this.b = b; } }
-
**组合(Composition)**也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。
public class A { private B b; public A() { this.b = new B(); } }
-
关联(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(); } }
-
**依赖(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) { ... } }
-
笔者还是只保留了四个关系:泛化、实现、组合、依赖。泛化、实现、依赖的定义不变,组合关系替代 UML 中组合、聚合、关联三个概念。这样比较贴合前面多用组合少用继承的理念。
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."); } } }
-
To be added - AuthToken、ApiRequest、CredentialStorage
参照这个同学的内容,写的比较完整。
https://github.com/murreIsCoding/auth/tree/master/src/main/java/com/murre/auth
辩证思考
- 不需要所有的需求完全按照这个在日常工作,灵活运用。
Util的故事
看到了下面一起学习的人留言,然后回想起自己上周刚做的需求。也是忍俊不禁。因为自己也是刚刚做了类似的事情,一个新的上游feature上线,我们team需要对传过来的Objects做一些validation即可。我最开始是在相关的包里找到了一个xxxValidator,本着学习前辈的原则,我直接在这个validator里面加了一个只针对我当前这个需求的对象的方法,在save之前调用这个validator去检查对象的属性。
后面被组里的engineer review 代码的时候,推荐在另一个地方实现,因为我们组里的大佬已经对代码进行了一些重构前一阵子,其中有一点是,我们的Object在中途的处理过程中,反复存取,对于性能有很大的影响,应该集中在某一处去做逻辑处理。所以我就讲xxxValidator该到了对应的xxxService中,并且拆分到了相应的位置,确实看起来更加融为一体了。
看完这个案例分析,又结合了自己的实践,想到的是自己之前的实现也是不折不扣的面向过程,而且也没考虑到性能问题,颇有感触。
写了一阵子的代码,脑子里很多时候是在学习别人的写法,对于为什么这么实现却很多有时候不理解,也不能结合整体去思考,也没有一些指导原则和思路。这个课程真的是解决了我的燃眉之急,是我一直以来在脑子里想要梳理的一些东西。