1.如何对接口鉴权这样一个功能开发做面向对象分析
本章会结合一个真实的案例,从基础的需求分析、职责划分、类的定义、交互、组装运行讲起,将最基础的面向对象分析(00A
)、设计(00D
)、编程(00P
)的套路讲清楚,为后面的设计原则和设计模型打好基础。
1.1 案例介绍和难点剖析
假设,你参与开发一个微服务。微服务通过 HTTP
暴露接口给其他系统调用。有一天,你的领导找到你说,“为了保证接口调用的安全,希望设计实现一个接口调用鉴权功能,只有经过认证的系统才能调用微服务接口,没有认证过的系统会被拒绝。希望由你来开发,争取尽管上线”。
这个时候,你可能会有脑子里一团浆糊,一时间无从下手的感觉? 有这种感觉的原因,个人觉得有以下两点。
1.需求不明确
领导给的需求过于模糊、笼统,离落地到设计、编码还有一定的距离。而人的大脑不擅长思考这种过于抽象的问题。
前面讲过,面向对象分析主要的分析对象是“需求”。因此,面向对象分析可以粗略地看成“需求分析”。实际上,不管是需求分析还是面向对象分析,首先要做的是将笼统的需求细化到足够清晰、可执行。需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做,哪些是未来可能要做的,哪些是不用考虑的。
2.缺少锻炼
相比单纯的 CRUD
开发,鉴权这个开发任务更有难度。鉴权作为一个根具体业务无关的功能,完全可以把它独立开发成一个独立的框架,集成到很多业务系统中。而作为被很多系统复用的通用框架,比如普通的代码,我们对框架的代码质量要求更高。
开发这样的通用框架,对工程师的需求分析能力、设计能力、编码能力,甚至逻辑思维能力的要求,都是比较高的。如果你平时做的都是简单的 CRUD
业务开发,那这方面的锻炼肯定不会很多,所以,一旦遇到这种开发需求,很容易因缺少锻炼,脑子放空,不知道从何入手,完全没有思路。
1.2 对案例进行需求分析
实际上,需求分析的工作很琐碎,没有固定的方法论。系统通过这个例子,给你展示下需求分析时,完整的考虑思路是什么样的。希望你自己体会,举一反三地应用到其他项目的需求分析中。
针对鉴权这个功能的开发,该如何做需求分析?
实际上,这和做算法题类似,先从最简单的法案想起,然后再优化。所以,我把分析的过程分为了循序渐进的四轮。
第一轮基础分析
对于如何鉴权这样的问题,最简单的解决方案是,通过用户名加密码来做认证。我们给每个允许访问服务的调用方,派发一个 APPID
和一个对应的密码。调用方每次请求时都携带自己的 APPID
和密码。微服务在接受到接口调用请求后,会解析出 APPID
和密码,和存储的 APPID
和密码进行对比。如果一致则允许调用请求;否则拒绝调用。
第二轮分析优化
这样的验证方式,每次都要传输明文密码。密码很容易被屏蔽,是不安全的。那如果借助加密算法(比如 SHA
),对密码进行加密后,再传递到微服务端验证,是不是就可以了?
实际上这样也不安全,因为加密之后的密码及 APPID
,照样可以被未认证系统(或黑客)截获,未认证系统可以携带这个加密之后的面以及对应的 APPID
,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击”。
提出问题,再解决问题,是一个非常好的迭代方式。对于刚刚的问题,可以借助 OAuth
的验证思路来解决。 调用方将请求的 URL
跟 APPID
、密码拼接在一起,然后进行加密,生成一个 token
。调用方在接口请求的时候,将这个 token
及 APPID
,跟着 URL
一块传递给服务端。服务端接受到这些数据后,根据 APPID
从数据库中取出对应的密码,并通过同样的 token
生成算法,生成另外一个 token
。用这个新生成的 token
和调用方传递过来的 token
对比。如果一致,则允许接口调用请求;否则拒绝调用。
客户端过程
1.生成token SHA(http://www.test.com/user?id=123&appid=abc&pwd=def)
2.生成新URLhttp://www.test.com/user?id=123&appid=abc&pwd=def&token=xxx
服务端过程
3.解析出 URL、Appid、token
4.从数据库中根据 Appid 取出 pwd
5.使用同样的算法生成服务端 token_s
6. token == token_s,允许访问;token != token_s,拒绝访问。
第三轮分析优化
经过第二轮优化后,仍然存在重放攻击的风险。因为每个 URL
拼接上 Appid
、密码生成的 token
都是固定的。
为解决这个问题,可以进一步优化 token
生成算法,引入一个随机变量,让每次接口请求生成的 token
都不一样。可以选择时间戳作为随机变量。现在使用 URL
、Appid
、密码、时间戳四种进行加密生成 token
。调用方在进行接口请求时,将 token
、Appid
、时间戳,随着 URL
一起传给微服务端。
微服务端在接受到这些数据后,会验证当前时间戳和传递过来的时间戳,是否在一定的时间窗口内(如一分钟)。如果超过时间窗口,则判定 token
过期,拒绝接口请求。如果没有超过时间窗口,则说明 token
没有过期,就在通过同样的 token
生成算法,在服务端生成新的 token
,和调用方的 token
对比。若一致,则允许接口调用请求;否则,拒绝调用。
优化后的认证流程如下
客户端流程:
1.生成token SHA(http://www.test.com/user?id=123&appid=abc&pwd=def&ts=156152345)
2.生成新URLhttp://www.test.com/user?id=123&appid=abc&pwd=def&token=xxx&ts=156152345
服务端流程:
3.解析出 URL、Appid、token
4.验证token是否失效。失效就拒绝访问,否则执行5
5.从数据库中根据 Appid 取出 pwd
6.使用同样的算法生成服务端 token_s
7. token == token_s,允许访问;token != token_s,拒绝访问。
第四轮分析优化
不过,你可能会说,这样还是不够安全呀。未认证系统还是可以在一分钟的 token
失效窗口内,通过截取请求,来调用我们的借口OA。
你说的不错。不过在攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。
实际上,还有一个细节我们还没有考虑到,那就是,如何在微服务端存储每个授权调用方的 Appid
和密码。当然,这个问题并不难。最容易想到的方案就是存储到数据库里,比如 MySQL
。不过,像开发这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。
针对 Appid
和密码的存储,最后可以灵活支持不同的存储方式,比如 Zookeeper、本地配置文件、自研配置中心、MySQL
、Redis
等。我们不一定针对每种存储都去做实现,但起码要留有扩展点,保证系统足够的灵活性和扩展性,能在我们切换存储方式的时候,尽可能少的改动代码。
最终确定需求
- 调用方进行接口请求的时候,将
URL
、Appid
、密码、时间戳拼接在一起,通过加密算法生成token
,并将token
、Appid
、时间戳拼接在URL
中,一并发送到微服务端。 - 微服务端在接收到调用方的请求后,从请求中解析出
token
、Appid
、时间戳。 - 微服务端首先检查传递过来的时间戳是否在
token
失效时间窗口内。若已失效,则接口调用鉴权失败,拒绝接口调用请求 - 如果
token
没有过期失效,微服务再从自己的存储中,取出Appid
对应的密码,通过同样的token
生成算法,生成另一个token
,与调用方的token
进行比对。如果一致,则鉴权成功,允许接口调用;否则就拒绝接口调用。
这就是我们的需求分析的整个过程,从最粗糙、最模型的需求开始,通过“提出问题 - 再解决问题”的方式,循序渐进的方式进行优化,最后得到一个足够清晰、可落地的需求描述。
2.如何利用面向对象设计和编程开发接口鉴权功能?
2.1如何进行面向对象设计(OOD)
面向对象分析的产出是详细的需求描述,面向对象设计的产出是类。在面向对象设计环节,我们将需求描述转化成具体的类的设计。
设计这一环节拆解细化,主要包含以下几个部分:
- 划分职责而识别出有哪些类
- 定义类及其属性和方法
- 定义类之间的交互关系
- 将类组装起来并提供执行入口
划分职责而识别出有哪些类
根据需求描述,把其中涉及的功能点,一个个罗列出来,然后再去看看哪些功能职责相近,操作同样的属性,是否应该归为同一个类。
我们来看下,针对鉴权这个例子,具体如何来做。之前我们依据确定了最终需求,如下:
- 调用方进行接口请求的时候,将
URL
、Appid
、密码、时间戳拼接在一起,通过加密算法生成token
,并将token
、Appid
、时间戳拼接在URL
中,一并发送到微服务端。- 微服务端在接收到调用方的请求后,从请求中解析出
token
、Appid
、时间戳。- 微服务端首先检查传递过来的时间戳是否在
token
失效时间窗口内。若已失效,则接口调用鉴权失败,拒绝接口调用请求- 如果
token
没有过期失效,微服务再从自己的存储中,取出Appid
对应的密码,通过同样的
token
生成算法,生成另一个token
,与调用方的token
进行比对。如果一致,则鉴权成功,允许接口调用;否则就拒绝接口调用。
首先是逐字逐句地阅读上面的需求,拆解成一个个小的功能点,一条条罗列下来。注意,拆解出来的每个功能点要尽可能小。每个功能点只负责一个很小的事情(专业叫法是“单一职责”)。下面是逐句拆解下来后,得到的功能点罗列:
- 将
URL
、Appid
、密码、时间戳拼接为一个字符串 - 对字符串通过加密算法加密得到
token
- 将
token
、Appid
、时间戳拼接在URL
中,形成新的URL
- 解析得到
token
、Appid
、时间戳等信息 - 根据时间戳判断
token
是否过期失效 - 从存储中取出
Appid
对应的密码 - 验证两个
token
是否匹配
从上面的功能列表中,我们发现 1、2、5、7 都是和 token
相关,负责 token
的生成、验证。3、4 都是在处理 URL
,负责 URL
的拼接和解析;6 是操作 Appid
和密码,负责从存储中读取 Appid
和密码。所以,我们可以粗略地得到三个核心类:AuthToken
、URL
、CredentialStorage
。
AuthToken
负责 1、2、5、7 这四个操作。URL
负责 3、4 这两个操作。CredentialStorage
负责 6 这个操作。
当然,这是一个初步的类划分,其他一些不重要的类,我们可能暂时没有办法一下子想全,但这也没关系,面向对象分析、设计、编程本来就是一个循环迭代、不断优化的过程。根据需求,我们先给出一个粗糙版本的设计方案,然后基于这样一个基础,再去迭代优化,会更加容易些,思路也更加清晰一些。
需要强调一点,接口调用鉴权这个需求比较简单,所以需求对应的面向对象设计并不复杂,识别出来的类也不多。如果是面向的更加大型的软件开发、更加复杂的需求,涉及的功能点可能会很多,对应的类也会比较多,像刚刚那样根据需求逐句罗列功能点的方法,最后会得到一个很长的列表,就会优点凌乱、没有规律。
针对这种复杂的需求开发,首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用刚刚的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。
定义类及其属性和方法
通过刚刚的需求分析,识别出了三个核心类:AuthToken
、URL
、CredentialStorage
。现在再来看下,每个类有哪些属性和方法。我们还是从功能点列表中挖掘。
AuthToken 类相关的功能点有四个:
- 将
URL
、Appid
、密码、时间戳拼接为一个字符串 - 对字符串通过加密算法加密得到
token
- 根据时间戳判断
token
是否过期失效 - 验证两个
token
是否匹配
对于方法的识别,一般都是识别需求描述中的动词,作为候选方法,再进一步过滤筛选。类比下方法的识别,可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
借用这个思路,识别出 AuthToken
类的属性和方法
/****** AuthToken ******/
// 属性
private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 60000;
private String token;
private long createTime;
private long expiredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;// 构造函数
public AuthToken(String token, long createTime);
public AuthToken(String token, long createTime, long expiredTimeInterval);// 函数
public static AuthToken create(String baseUrl, long createTime, Map<String, String> params;)
public String getToken();
public boolean isExpired();
public boolean match(AuthToken authToken);
从上面的类中,我们可以返现这样三个小细节:
- 第一个细节: 并不是所有出现的名词都被定义为类的属性,比如
URL
、Appid
、密码、时间戳这几个名词,我们把它作为了方法的参数。 - 第二个细节:我们还需要挖掘出一些没有出现在功能点描述中的属性,比如
createTime
、expiredTimeInterval
,它们用在isExpired()
函数中用来判断token
是否过期。 - 第三个细节:我们还给
AuthToken
类添加了一个功能点描述中没有提到的方法getToken()
。
第一个细节高速我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类中。比如 URL
、Appid
这些信息,从业务模型上来说,不应该属于 AuthToken
,所以不应该放到这个类中。
第二、第三个细节高速我们,在设计类具体有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,应该具有哪些属性和方法。这样一方面保证类定义的完整性,另一方面不仅为当下的需求,还为未来的需求做些准备。
Url 类相关的功能点有两个
- 将
token
、Appid
、时间戳拼接在URL
中,形成新的URL
- 解析得到
token
、Appid
、时间戳等信息
虽然需求描述中,都是以 URL
来代指接口请求,但是,接口请求并不一定是 URL
的形式来表达,还可能是 Dubbo
、RPC
等其他形式。为了让这个类设计的更加通用,命名更加贴切,我们接下来把它命名为 ApiRequest
。下面是根据功能点描述设计的 ApiRequest
。
/****** ApiRequest ******/// 属性
private String baseUrl;
private String token;
private String appId;
private long timestamp;// 构造函数
public ApiRequest(String baseUrl, String token,String appId, long timestamp);// 函数
public static ApiRequest createFromUrl(String url);public String getBaseUrl();
public String getToken();
public String getAppId();
public long getTimestamp();
CredentialStorage 类相关的功能点有一个
- 从存储中取出
Appid
对应的密码
CredentialStorage
类很简单。为了做到抽象封装具体的存储方式,我们将CredentialStorage
设计成了接口,基于接口而非实现编程。
/****** CredentialStorage ******/// 接口函数
String getPasswordByAppId(String appId);
定义类之间的交互关系
类与类之间的关系有哪些? UML
统一建模语言定义了 6 种类之间的关系。分别是:泛化、实现、关联、聚合、组合、依赖。
泛化可以简单理解为继承关系。
public class A {...}
public class B extends A {...}
实现一般是指接口和实现类之间的关系。
public interface A {...}
public class B implements A {...}
聚合 是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 类对象,比如课程与学生的关系。
public class A {private B b;public A(B b) {this.b = b;}
}
组合也是一种包含的关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对的生命周期,B 类对象不可以单独存在,比如鸟与翅膀的关系。
public class A {private B b;public A() {this.b = new B();}
}
关联 是一种比较弱的关系,包含组合和聚合。如果 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();}
}
依赖是一种比关联关系更加弱的关系,包含关联关系。不管 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
中的组合、聚合、关联这三个概念。
相当于重命名关联关系为组合关系,且不在区分组合和聚合这两个概念。
只要 B 类对象,是 A 类的成员变量,那就成 A 类和 B 类具有组合关系。
在看下我们定义的类之间有哪些关系?因为目前只有三个核心类,所以只用到了实现关系,即 CredentialStorage
和 MySqlCredentialStorage
之间是实现关系。接下来讲到组装类的时候,还会用到依赖关系、组合关系,但是泛化关系暂时没有用到。
将类组装起来并提供执行入口
类定义好了,类之间的泛化关系也设计好了,接下来我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是 main()
函数,也可能是一组给外部用的 API
接口。通过这个入口,我们能触发整个代码跑起来。
接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计一个最顶层的 ApiAuthenticator
接口类,暴露一组给外部调用者或者 API
接口,作为触发执行鉴权逻辑的入口。
/****** ApiAuthenticator ******/
// 接口函数
void auth(String url);
void auth(ApiRequest apiRequest);
实现类
/****** DefaultApiAuthenticatorImpl ******/
// 属性
private CredentialStorage credentialStorage;
// 构造函数
public DefaultApiAuthenticatorImpl();
public DefaultApiAuthenticatorImpl(CredentialStorage credentialStorage);
// 函数
void auth(String url);
void auth(ApiRequest apiRequest);
2.2 如何进行面向对象编程(OOP)
面向对象设计完成之后,已经定义了清晰的类、属性、方法、类之间的交互,并将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。有了前面分析,这部分工作相对来说就比较简单了。所以,这里,只给出比较复杂的 ApiAuthenticator
的实现。
对于 AuthToken
、ApiRequest
、CredentialStorage
这三个类,就不给出具体代码实现了。你可以自己试着把整个鉴权框架自己实现一遍。
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;}@Overridevoid auth(String url) {ApiRequest apiRequest = ApiRequest.createFromUrl(url);auth(apiRequest);}@Overridevoid auth(ApiRequest apiRequest) {String appId = apiRequest.getAppId();String token = apiRequest.getToken();long timestamp = apiRequest.getTimestamp();String baseUrl = apiRequest.getBaseUrl();AuthToken clientAuthToken = new AuthToken(token, timestamp);if(clientAuthToken.isExpired()) {throw new RuntimeException("Token is exipred.");}String password = credentialStorage.getPasswordByAppId(appId);AuthToken serverAuthToken = AuthToken.generator(baseUrl, appId, password, timestamp);if(!serverAuthToken.match(clientAuthToken)) {throw new RuntimeException("Token verfication failed.");}}
}
2.3 辩证思考与灵活应用
之前讲解过,面向对象分析、设计、编程,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么,后做什么,都非常清晰、明确。
不过在平时的工作中,大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计后,然后就开始写了,边写边思考重构,并不会严格地按照刚刚的流程来执行。而且,说实话,即使在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML 图,也不可能把每个细节、交互都想的很清楚。在落实到代码的时候,还是要反复迭代、重构、打破重写。
毕竟,整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。我们没法严格地按照顺序执行各个步骤。
2.4 总结回顾
面向对象分析的产出是详细的需求描述。面向对象设计的产出是类。在面向对象设计这一环节,我们将需求描述转化为具体的类的设计。这个环节的工作可以分为四步:
- 划分职责进而识别出有哪些类
根据需求描述,把其中涉及的功能点,一个个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否归为一个类。 - 定义类的属性和方法
识别出功需求中的动词,作为候选方法,再进一步过滤筛选出真正的方法;把功能点中涉及的名词,作为候选属性,然后再同样进行过滤筛选。 - 定义类与类之间的关系
UML
统一建模语言定义了六种类之间的关系。分别是:泛化、实现、关联、组合、聚合、依赖。从贴近编程的角度,我们对类之间的关系做了调整,保留四个关系:泛化、实现、组合、依赖。 - 将类封装起来并提供执行入口
将所有类组装在一起,提供一个执行入口。这个入口可能是main()
函数,也可能是一组给外部调用的API
接口。通过这个接口,我们能触发整个代码跑起来。