对于面向对象的设计思想,大部分人还只是停留在理论层面,但更重要的是需要知道“怎么做”,也就是如何使用面向对象进行分析、设计和编程。下面将结合一个真实的开发案例,从基础的需求分析、职责划分、类的定义、交互、组装运行讲起,将最基础的面向对象分析、设计、编程讲清楚。
一、如何进行面向对象分析(OOA)
1.案例介绍
假设你正在开发一个微服务,通过HTTP协议暴露接口给其他系统调用。为了保证接口调用的安全性,我们希望设计一个接口调用鉴权功能,没有认证过的系统调用我们的接口会被拒绝。
2.需求分析
(1)第一轮基础分析 - 用户名密码
很直观的就可以想到,最简单的解决方案就是通过用户名和密码来认证。每个调用方都有一个应用ID和对应的密钥,调用方在请求接口的时候携带,微服务解析并进行对比,一致后再进行接口调用。
(2)第二轮分析优化 - 加密
不过,这样的验证方式,每次都要明文传输密码。所以我们借助算法加密,对密码进行加密处理,但是实际上这也不行,因为加密之后的账号密码同样可以被截获,这就是典型的“重放攻击”。
对于这个问题,我们来借住OAuth的验证思路来解决:
- 调用方将URL、AppID以及AppSecret拼接在一起,然后进行加密,生成一个token;
- 调用方调用接口时将原本的URL、AppID以及第一步生成的token一起传递出去;
- 微服务端收到请求后,根据AppID取出AppSecret,然后用同样的算法生成另外一个token,命名为token_s
- 对比token以及token_s,如果一致就允许接口调用请求
(3)第三轮分析优化 - 引入随机变量时间戳
即使是这样,也会有“重放攻击”的风险,因为URL、AppID以及AppSecret都是固定的,未认证系统截获后仍然可以伪装成认证系统去进行调用。为了解决这个问题,我们进一步优化token生成算法,引入一个随机变量,让每次接口请求生成的token不一样,比如我们使用时间戳作为随机变量。
现在我们将URL、AppID、AppSecret、时间戳四者进行加密来生成token,调用方在接口请求的时候将token、AppID、时间戳一起随着URL传递给服务端。这样的话,微服务由于有了时间戳,可以判定是否在一定的时间窗口内,如果超过指定的时间就认为token过期而拒绝调用。
(4)第四轮分析优化
你可能会说,这样扔拦截不了这个指定时间内的“重放攻击”啊,是的,这个方案仍然有漏洞。但是安全本身就是相对的,我们需要衡量安全性、开发成本、对系统性能的影响等等来做出合理的方案。
我们还有一个细节需要考虑,那就是微服务端如何存储每个客户端的账户密码(即AppID和AppSecret),最容易想到的方案就是存储到MySQL中。不过,像这种非业务功能,最好不要与具体的第三方有过度的耦合。
针对存储方案,我们最好能灵活的支持各种存储方式,不一定每一种都要实现,但都要保留扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。
(5)确定最终需求
- 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
- 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
需求分析的过程实际上是一个不断迭代优化的过程。我们不要试图一下就能给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化,这样一个思考过程能让我们摆脱无从下手的窘境。
二、如何进行面向对象设计(OOD)
第一步,我们使用面向对象分析,并且最终产出了详细的需求。下面我们来进行面向对象设计,最终的产出是:类。
主要包含下面几个部分:
1.划分职责进而识别出有哪些类?
根据详细的的需求分析,将涉及到的功能点罗列出来,然后去看哪些功能点职责相近,操作同样的属性,是否应该属于同一个类。如果是大型的业务系统,那么先进行模块的拆分即可。
针对鉴权这个业务场景,我们可以拆解出来以下功能点:
- 拼接字符串,URL+AppID+AppSecret+Timestamp
- token加密
- 拼接字符串为新的URL
- 解析URL
- 从存储中取出对应的AppID和AppSecret
- 判断token是否过期
- 验证token是否匹配
这其中可以粗略的分为三类,AuthToken,负责token的生成和验证;Url负责URL的拼接和解析;CredentialStorage,负责从存储中取数据。
2.定义类及其属性和方法
AuthToken
接下来具体定义每个类的属性和方法,先看AuthToken:
从上图中可以发现几个细节:
- 并不是所有的名词都被定义为类的属性了,比如URL、AppID、密码、时间戳是定义为方法的参数的;
- 新增的createTime,expireTimeInterval这两个属性是没有出现在功能点描述中的,但是我们需要用它来在isExpired()函数中来判定token是否过期
- 我们给AuthToken这个类中增加了一个功能点中没有描述的方法getToken()
这告诉我们,在业务模型上来说,不属于这个类的属性和方法,不应该被放到这个类里;此外,在设计类具有哪些属性和方法的时候,不止要分析当下,还要分析这个类从业务模型上来讲了理应有哪些属性和方法。
Url
虽然在需求描述中,我们定义这个类为URL,但实际的接口请求不一定是Url的形式,还有可能是Dubbo、RPC等其他形式,为了更加通用,我们命名为ApiRequest。
CredentialStorage
CredentialStorage 类非常简单,类图如下所示。为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程。
3.定义类与类之间的交互关系
UML统一建模语言中定义了六种类之间的关系,它们分别是泛化、实现、关联、聚合、组合、依赖。
- 泛化(Generalization):就是继承关系
- 实现(Realization):一般是接口和实现类的关系
- 聚合(Aggregation):是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象
- 组合(Composition):也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。
- 关联(Association):是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系。
- 依赖(Dependency):是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。
刚刚我们定义的类之间都有哪些关系呢?因为目前只有三个核心的类,所以只用到了实现关系,也即 CredentialStorage 和 MysqlCredentialStorage 之间的关系。
4.将类组装起来并提供执行入口
接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthenticator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示:
三、如何进行面向对象编程(OOP)
面向对象编程就是按照设计好的类,翻译成代码实现就行了,这部分工作比较简单,这里给出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.");
}
}
}
不过,在平时的工作中,大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计,然后就开始写代码了,边写边思考边重构,并不会严格地按照刚刚的流程来执行。而且,说实话,即便我们在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML 图,也不可能把每个细节、交互都想得很清楚。在落实到代码的时候,我们还是要反复迭代、重构、打破重写。毕竟,整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。