在完整的需求开发中,代码的落地实际上是最后一步了,也就是我们所谓的面向对象编程(OOP),实际上拿到需求后最好先进行分析和设计,这部分工作基本都被我省略了或者只是在纸上或者wiki上简单画一画,分析和设计并没有按照规范落到实处。而面向对象分析和设计最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法,类与类之间如何交互等等。它们比其他的分析和设计更加具体、更加落地、更加贴近编码,更能够顺利地过渡到面向对象编程环节。
我们以一个实际的接口鉴权的例子来进行面向对象开发:为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。
面向对象分析
当然接口鉴权的需求并不明确,同样的实际场景下我们接到的大多数需求都不是百分百明确的,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。那么如何做面向对象分析呢?实际上,这跟做算法题类似,先从最简单的方案想起,然后再优化
1. 第一轮基础分析
首先我们能想到最简单的解决方案就是,通过用户名加密码来做认证。
- 给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对应的密码(或者叫秘钥)。
- 调用方每次进行接口请求的时候,都携带自己的 AppID 和密码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的 AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。
不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的,即使对密码加密,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的 AppID,伪装成已认证系统来访问我们的接口。这就是典型的重放攻击(把以前窃听到的数据原封不动地重新发送给接收方)。
2. 第二轮分析优化
借助 OAuth 的验证思路来解决。调用方将请求接口的 URL 、 AppID、密码、时间戳拼接在一起,然后进行加密,生成一个 token,由于引入的时间戳为随机变量,所以每次接口请求生成的 token 都不一样。
虽然这样还不是绝对安全。未认证系统还是可以在这一分钟的 token 失效窗口内,通过截获请求、重放请求,来调用我们的接口!不过,攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了
3. 第三轮分析优化
接口鉴权逻辑就是如上分析,还有一个细节需要考虑就是,如何在微服务端存储每个授权调用方的 AppID 和密码。针对 AppID 和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动【基于接口(抽象)而非实现编程】
4. 最终确定需求
AppID 和密码的存储默认使用数据库,但要留有扩展点,整体的业务流程:
- 调用方进行接口请求的时候,将 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、5、6 都是跟 token 有关,负责 token 的生成、验证;2、3 都是在处理 URL,负责 URL 的拼接、解析;4是操作 AppID 和密码,负责从存储中读取 AppID 和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken 负责实现 1、5、6 这四个操作;Url 负责 2、3 两个操作;CredentialStorage 负责 5 这个操作
2. 定义类及其属性和方法
我们依据功能点的描述分别对各个类进行设计
1 AuthToken类
AuthToken 负责实现 1、5、6这三个操作,我们可以简单设计下:
从上面的类图中,我们可以发现这样三个小细节。
- 第一个细节:并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数。
- 第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime,expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期。
- 第三个细节:我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken(),这是服务端用来获取token的方法
第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。第二和第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。
2 Url类
虽然需求描述中,我们都是以 URL 来代指接口请求,但是,接口请求并不一定是以 URL 的形式来表达,还有可能是 Dubbo、RPC 等其他形式。为了让这个类更加通用,命名更加贴切,我们接下来把它命名为 ApiRequest
拼接URL是客户端要做的事,这里就不体现了
3 CredentialStorage 类
CredentialStorage 类非常简单,类图如下所示。为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程
3. 定义类与类之间的交互关系
UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。关系比较多,而且有些还比较相近,比如聚合和组合
泛化(Generalization)可以简单理解为继承关系。具体到 Java 代码就是下面这样:
public class A { ... } public class B extends A { ... }
实现(Realization)一般是指接口和实现类之间的关系。具体到 Java 代码就是下面这样:
public interface A {...} public class B implements A { ... }
聚合(Aggregation)是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,B类对象是方法的参数,传递进来的,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系。具体到 Java 代码就是下面这样:
public class A { private B b; public A(B b) { this.b = b; } }
组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在,B类对象是A类中创建出来的,比如鸟与翅膀之间的关系。具体到 Java 代码就是下面这样:
public class A { private B b; public A() { this.b = new B(); } }
关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果 B 类对象是 A 类的成员变量,那 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(); } }
依赖(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) { ... } }
我从更加贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖,其中,泛化、实现、依赖的定义不变,组合关系替代 UML 中组合、聚合、关联三个概念,也就相当于重新命名关联关系为组合关系,并且不再区分 UML 中的组合和聚合两个概念。之所以这样重新命名,是为了跟我们前面讲的“多用组合少用继承”设计原则中的“组合”统一含义。只要 B 类对象是 A 类对象的成员变量,那我们就称,A 类跟 B 类是组合关系
再回过头来看几个核心类的关系,这几个关系在组装部分体现
4. 将类组装起来并提供执行入口
接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthenticator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口
共用到了三种关系,实现、组合和依赖
面向对象编程
面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现
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."); } } }
总结一下
面向对象分析英文缩写是 OOA,全称是 Object Oriented Analysis;面向对象设计的英文缩写是 OOD,全称是 Object Oriented Design;面向对象编程的英文缩写是 OOP,全称是 Object Oriented Programming。OOA、OOD、OOP 三个连在一起就是面向对象分析、设计、编程(实现),正好是面向对象软件开发要经历的三个阶段,想想之前为什么我们基本没有面向对象分析和设计,主要原因就是我们进行的都是面向过程编程,我们的视角是自上而下的任务处理流程,而非面向对象的自底向上的业务建模,在这个体系之下进行需求功能开发,当然视角就会局限在顺序流的功能拆解而非面向对象的分析设计了。正确的面向对象分析是细化模糊的需求,正确的面向对象设计是正确设计类及类关系,正确的面向对象编程则是基于面向对象分析和设计之后的代码实践,而非流程式的面向过程编程。