面向对象的三个环节:面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP)。只知道OOA、OOD、OOP只能说有一个宏观了解,我们更重要的还是要知道“如何做”,也就是,如何进行面向对象分析、设计与编程。
本文结合一个真实的开发案例,从基础的需求分析、职责划分、类的定义、交互、组装运行讲起,将最基础的面向对象分析、设计、编程的套路给你讲清楚,为后面学习设计原则、设计模式打好基础。
案例介绍和难点剖析
假设,你正在参与开发一个微服务。微服务通过HTTP协议暴露接口给其他系统调用,说直白点就是,其他系统通过URL来调用微服务的接口。有一天,你的leader找到你说,“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”
分析两点:
- 接口是通过HTTP协议,进行访问。类似访问百度一下。
- 只有认证之后的系统才能调用我们的接口。
以上两点是设计一个这个系统的要求。软件编程就像算法题一样,第一次可能得不到最优解,然后通过分析不足,逐步迭代最终形成一个可执行,可落地的方案。
第一轮基础分析
最简单的解决方案就是,通过用户名加密码来做认证。我们给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用ID、AppID)和一个对应的密码(或者叫秘钥)。这个密钥和ID可以双方提前已经生成的,在两边都有记录。调用者每次访问时候都携带ID和密钥,微服务在接收到请求接口之后,会解析出AppID和密钥跟存储在微服务端的AppID和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。
第二轮分析优化
不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获。未认证系统可以携带这个加密之后的密码以及对应的AppID,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击”。
我们可以借助OAuth的验证思路来解决。调用方将请求接口的URL跟AppID、密码拼接在一起,然后进行加密,生成一个token。调用方在进行接口请求的的时候,将这个token及AppID,随URL一块传递给微服务端。微服务端接收到这些数据之后,根据AppID从数据库中取出对应的密码,并通过同样的token生成算法,生成另外一个token。用这个新生成的token跟调用方传递过来的token对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
第三轮分析优化
不过,这样的设计仍然存在重放攻击的风险,还是不够安全。每个URL拼接上AppID、密码生成的token都是固定的。未认证系统截获URL、token和AppID之后,还是可以通过重放攻击的方式,伪装成认证系统,调用这个URL对应的接口。
为了解决这个问题,我们可以进一步优化token生成算法,在原来的基础上拼接一个时间戳作为随即变成,这样就可以生成动态的token。
微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定token过期,拒绝接口请求。如果没有超过一分钟,则说明token没有过期,就再通过同样的token生成算法,在服务端生成新的token,与调用方传递过来的token比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
优化之后的认证流程如下图所示。
到此基本需求以及满足了,可能随着时间的推移,我们的密码不仅仅存到数据库,可能会在zookeeper、redis等等其他地方。需要选择良好的设计模式进行兼容。
最终确定需求
到此,需求已经足够细化和具体了。现在,我们按照鉴权的流程,对需求再重新描述一下。如果你熟悉UML,也可以用时序图、流程图来描述。不过,用什么描述不是重点,描述清楚才是最重要的。这里我给出的最终需求描述是文字版本和图片版本的。
- 调用方进行接口请求的时候,将URL、AppID、密码、时间戳拼接在一起,通过加密算法生成token,并且将token、AppID、时间戳拼接在URL中,一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后,从请求中拆解出token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间,是否在token失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
- 如果token验证没有过期失效,微服务端再从自己的存储中,取出AppID对应的密码,通过同样的token生成算法,生成另外一个token,与调用方传递过来的token进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
这就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题-解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。
总结,遇到问题或者需求,尽可能的慢慢拆解分析。着手慢慢开始一步步做,逐步迭代后就可以找到一个较优的方案了。
既然需求已经明确,如何进行面向对象设计呢?
面向对象设计?
我们知道,面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。在面向对象设计环节,我们将需求描述转化为具体的类的设计。我们把这一设计环节拆解细化一下,主要包含以下几个部分:
- 划分职责进而识别出有哪些类;
- 定义类及其属性和方法;
- 定义类与类之间的交互关系;
- 将类组装起来并提供执行入口。
划分职责进而识别出有哪些类
根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类。我们来看一下,针对鉴权这个例子,具体该如何来做。
前文中,我们已经给出了详细的需求描述,为了方便你查看,我把它重新贴在了下面。
- 调用方进行接口请求的时候,将URL、AppID、密码、时间戳拼接在一起,通过加密算法生成token,并且将token、AppID、时间戳拼接在URL中,一并发送到微服务端。
- 微服务端在接收到调用方的接口请求之后,从请求中拆解出token、AppID、时间戳。
- 微服务端首先检查传递过来的时间戳跟当前时间,是否在token失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
- 如果token验证没有过期失效,微服务端再从自己的存储中,取出AppID对应的密码,通过同样的token生成算法,生成另外一个token,与调用方传递过来的token进行匹配。如果一致,则鉴权成功,允许接口调用;否则就拒绝接口调用。
首先,我们要做的是逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来。注意,拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法是“单一职责”,后面章节中我们会讲到)。下面是我逐句拆解上述需求描述之后,得到的功能点列表:
- 把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这个操作。
当然初步是进行这样设计,如果发现有不合理的地方可以进行调整。
定义类及其属性和方法
AuthToken类相关的功能点有四个:
- 方法1:把URL、AppID、密码、时间戳拼接为一个字符串;
- 方法2:对字符串通过加密算法加密生成token;
- 方法3:根据时间戳判断token是否过期失效;
- 方法4:验证两个token是否匹配。
- 属性1:String token
- 属性2:时间戳
- 属性3:过期时间间隔。
从上面的类图中,我们可以发现这样三个小细节。
- 第一个细节:并不是所有出现的名词都被定义为类的属性,比如URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数。
- 第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如createTime,expireTimeInterval,它们用在isExpired()函数中,用来判定token是否过期。
- 第三个细节:我们还给AuthToken类添加了一个功能点描述中没有提到的方法getToken()。
Url类相关的功能点有两个:
- 将token、AppID、时间戳拼接到URL中,形成新的URL;
- 解析URL,得到token、AppID、时间戳等信息。
虽然需求描述中,我们都是以URL来代指接口请求,但是,接口请求并不一定是以URL的形式来表达,还有可能是Dubbo、RPC等其他形式。为了让这个类更加通用,命名更加贴切,我们接下来把它命名为ApiRequest。下面是我根据功能点描述设计的ApiRequest类。
CredentialStorage类相关的功能点有一个:
- 从存储中取出AppID和对应的密码。
CredentialStorage类非常简单,类图如下所示。为了做到抽象封装具体的存储方式(上文提到可能是MySQL,Redis和Zookeeper等等),我们将CredentialStorage设计成了接口,基于接口而非具体的实现编程。
定义类与类之间的交互关系
类与类之间都有哪些交互关系呢?UML统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。关系比较多,而且有些还比较相近,比如聚合和组合,接下来我就逐一讲解一下。
泛化(Generalization)可以简单理解为继承关系。具体到Java代码就是下面这样:
public class A { ... }
public class B extends A { ... }
实现(Realization)一般是指接口和实现类之间的关系。具体到Java代码就是下面这样:
public interface A {...}
public class B implements A { ... }
组合(Composition)也是一种包含关系。A类对象包含B类对象,B类对象的生命周期依赖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();
}
}
依赖(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) { ... }
}
如何进行面向对象编程?
面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。有了前面的类图,这部分工作相对来说就比较简单了。
public interface ApiAuthenticator {
void auth(String url);
void auth(ApiRequest apiRequest);
}
/**
* 提供接口供外界调用
*/
public class DefaultApiAuthenticatorImpl implements ApiAuthenticator{
private CredentialStorage credentialStorage;
public DefaultApiAuthenticatorImpl(CredentialStorage credentialStorage) {
this.credentialStorage = credentialStorage;
}
public DefaultApiAuthenticatorImpl() {
}
@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.getBaseUrl();
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.");
}
}
}
/**
* 仅仅定义接口,没有写具体的sql实现
*/
public interface CredentialStorage {
String getPasswordByAppId(String AppId);
}
public class ApiRequest {
private String baseUrl;
private String token;
private String appId;
private long timeStamp;
public ApiRequest(String baseUrl, String token, String appId, long timeStamp) {
this.baseUrl = baseUrl;
this.token = token;
this.appId = appId;
this.timeStamp = timeStamp;
}
// TODO
public static ApiRequest buildFromUrl(String url) {
String[] split = url.split("&");
String baseUrl = split[0];
String appid = split[1].split("=")[1];
String pwd = split[2].split("=")[1];
String timestamp = split[3].split("=")[1];
// 生成token
String token = createToken(baseUrl, appid, pwd, Long.parseLong(timestamp));
return new ApiRequest(baseUrl,token,appid,Long.parseLong(timestamp));
}
/**
* token 生成算法, 这里进行了简写;可以使用hash算法,或者自定义加密算法。
*
* @param baseUrl
* @param appid
* @param pwd
* @param timestamp
* @return
*/
public static String createToken(String baseUrl, String appid, String pwd, long timestamp) {
return "123";
}
public String getBaseUrl() {
return baseUrl;
}
public String getToken() {
return token;
}
public String getAppId() {
return appId;
}
public long getTimestamp() {
return timeStamp;
}
}
import java.util.Date;
public class AuthToken {
// 国企间隔
private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1 * 60 * 1000;
private String token;
private long createTime;
private long expiredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;
public AuthToken(String token, long createTime) {
this.token = token;
this.createTime = createTime;
}
public AuthToken(String token, long createTime, long expiredTimeInterval) {
this.token = token;
this.createTime = createTime;
this.expiredTimeInterval = expiredTimeInterval;
}
/**
* 生成服务端的token
*
* @param baseUrl
* @param appId
* @param password
* @param createTime
* @return
*/
public static AuthToken generate(String baseUrl, String appId, String password, long createTime) {
String token = ApiRequest.createToken(baseUrl, appId, password, createTime);
return new AuthToken(token, new Date().getTime());
}
public String getToken() {
return token;
}
public boolean isExpired() {
long curTime = new Date().getTime();
return (curTime - (createTime + expiredTimeInterval)) >= 0;
}
public boolean match(AuthToken authToken) {
String token = authToken.getToken();
// 1. 根据AppID,url
return this.getToken().equals(token);
}
}
辩证思考与灵活应用
在之前的讲解中,面向对象分析、设计、实现,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么、后做什么,非常清晰、明确,有章可循,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现。
不过,在平时的工作中,大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计,然后就开始写代码了,边写边思考边重构,并不会严格地按照刚刚的流程来执行(不推荐)。而且,说实话,即便我们在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML图,也不可能把每个细节、交互都想得很清楚。在落实到代码的时候,我们还是要反复迭代、重构、打破重写。
毕竟,整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。我们没法严格地按照顺序执行各个步骤。这就类似你去学驾照,驾校教的都是比较正规的流程,先做什么,后做什么,你只要照着做就能顺利倒车入库,但实际上,等你开熟练了,倒车入库很多时候靠的都是经验和感觉。
重点回顾
今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
面向对象分析的产出是详细的需求描述。面向对象设计的产出是类。在面向对象设计这一环节中,我们将需求描述转化为具体的类的设计。这个环节的工作可以拆分为下面四个部分。
1.划分职责进而识别出有哪些类
根据需求描述,我们把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否归为同一个类。
2.定义类及其属性和方法
我们识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选出真正的方法,把功能点中涉及的名词,作为候选属性,然后同样再进行过滤筛选。
3.定义类与类之间的交互关系
UML统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。我们从更加贴近编程的角度,对类与类之间的关系做了调整,保留四个关系:泛化、实现、组合、依赖。
4.将类组装起来并提供执行入口
我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个main()函数,也可能是一组给外部用的API接口。通过这个入口,我们能触发整个代码跑起来。