一个实例教你实施面向对象开发,构建高内聚、低耦合的高质量代码

105 阅读16分钟

以下文章来源于JAVA日知录 ,作者飘渺Jam

在面向对象开发中,从开始到结束主要有三个过程,分别是面向对象分析(OOA)、面向对象设计(OOD)和面向对象编程(OOP)。

在日常工作中,很多软件开发工程师,尤其是初级软件工程师,他们没有太多的项目经验,或者参与的项目中基本都是基于开发框架编写CRUD代码,导致欠缺代码的分析、设计能力。当他们拿到笼统的开发需求时,往往不知道从何入手。

如何做需求分析?如何做职责划分?需要定义哪些类?每个类应该具有哪些属性和发方法?类之间如何交互?如何将类组装成一个可执行的程序?对于上述问题的解决,他们往往没有清晰的思路,更别提利用成熟的设计原则、设计模式开发出具有高内聚、低耦合、易扩展、易读等特性的高质量代码了。

本文从一个具体的开发案例出发,介绍在实际项目中如何进行面向对象分析、面向对象设计、面向对象编程。

1. 案例说明

假设你正在参与一个微服务项目开发,微服务通过HTTP暴露接口给其他系统调用。某天,你接到了这样一个需求:为了保证接口调用的安全性,需要设计和实现接口调用的鉴权功能,只有经过认证的系统,才能调用微服务的接口。现在你需要基于此需求设计实现方案及完成编码落地。

在继续文章之前,想一想,如果是你,你会怎么实现?

2. 面向对象分析

需求分析的过程并不是一蹴而就的,是一个不断迭代优化的过程。

对于需求分析,我们不要试图立即给出完善的解决方案,而是先给出一个粗糙的基础方案,这样就有了迭代的基础,然后慢慢优化,这种思路能够让我们摆脱无从下手的窘境。我们把整个需求分析过程分为循环渐进的四个步骤,最后形成一个可执行、可落地的需求列表。

2.1 基础分析

简单、常用的鉴权方式是使用用户名和密码进行认证。我们给每个允许访问接口的调用方派发一个AppID和密码。调用方在进行接口请求时,会携带自己的AppID和密码。微服务于在接收到接口调用请求之后,解析出AppID和密码并与存储在微服务端的AppID 和密码进行比对,如果一致则认证成功,允许接口调用请求;否则,拒绝接口调用请求。

2.2 第一轮分析优化

不过,基于用户名和密码的鉴权方式,每次都需要明文传输密码,密码容易被截获。如果我们先借助加密算法(SHA)对密码进行加密,再传递到微服务端验证,那么是不是就安全了呢?实际上,这样也不是安全的,因为AppID和加密之后的密码照样可以被未认证系统截获。未认证系统可以携带这个加密之后的密码和对应的AppID,伪装成已认证系统来访问接口,这就是经典的“重放攻击”。

先提出问题,再解决问题,这是一个非常好的迭代优化方法。对于上面的密码传输安全问题,我们可以借助Oauth验证的思路来解决。调用方将请求的接口的URL与AppID、密码拼接,然后进行加密,生成一个Token。在进行接口请求时,调用方将这个Token和AppID与URL一起传输给微服务端。微服务端接收这些数据之后,根据AppID从数据库取出对应的密码,并通过同样的Token生成算法,生成另一个Token。然后,使用新生成的Token与调用方传递过来的Token进行对比,如果一致,则允许接口调用请求;否则,拒绝接口调用请求。优化后的鉴权过程如下图所示:

图片

2.3 第二轮分析优化

不过,上述设计仍然存在重放攻击风险,还是不够安全。每个 URL 拼接 AppID、密码生成的 Token 都是固定的。未认证系统截获 URL、Token 和 AppID 之后,仍然可以通过重放攻击的方式,伪装成认证系统,调用这个 URL 对应的接口。

为了解决这个问题,我们可以进一步优化 Token 生成算法,即引入一个随机变量,让每次接口请求生成的 Token 都不一样。我们可以选择时间戳作为随机变量。原来的 Token 是通过对URL、AppID 和密码进行加密而生成的,现在,我们通过对 URL、AppID、密码和时间戳进行加密来生成 Token。在进行接口请求时,调用方将 Token、AppID、时间戳与 URL 一起传递给微服务端。

微服务端在收到这些数据之后,会验证当前时间戳与传递过来的时间戳是否在有效时间窗口内(如1分钟)。如果超过有效时间,则判定 Token 过期,拒绝接口请求。如果没有超过有效时间,则说明 Token 没有过期,然后通过同样的 Token 生成算法,在微服务端生成新的Token,并与调用方传递过来的 Token 比对,如果一致,则允许接口调用请求,否则,拒绝接口调用请求。优化之后的鉴权过程如下图所示。

图片

2.4 第三轮分析优化

不过,上述设计还是不够安全,因为未认证系统仍然可以在 Token 时间窗口(如1分钟)失效之前,通过截获请求和重放请求调用我们的接口!

攻与防之间博弈,本来就没有绝对的安全,我们能做的就是尽量提高攻击的成本。上面的方案虽然还有漏洞,但是实现简单,而且不会过多影响接口本身的性能(如响应时间)。权衡安全性、开发成本和对系统性能的影响,我们认为这个折中方案是比较合理的。

实际上,还有一个细节需要考虑,那就是如何在微服务端存储每个授权调用方的 AppID和密码。

当然,这个问题不难解决。我们容易想到的解决方案是将它们存储到数据库中,如MySQL。不过,对于鉴权这样的非业务功能开发,尽量不要与具体的第三方系统过度耦合。对于 AppID 和密码的存储,理想的情况是灵活地支持各种不同的存储方式,如 ZooKeeper、本地配置文件、自研配置中心、MySQL 和 Redis等。我们不需要对每种存储方式都进行代码实现,但起码留有扩展点,保证系统有足够的灵活性和扩展性,能够在切换存储方式时,尽可能减少代码的改动。

3. 面向对象设计

面向对象分析的产出是详细的需求描述,面向对象设计的产出是类。在面向对象设计环节,我们将需求描述转化为具体的类的设计。我们对面向对象设计环节进行拆解,分为以下 4个步骤:

  1. 划分职责进而识别有哪些类;
  2. 定义类及其属性和方法;
  3. 定义类之间的交互关系;
  4. 将类组装起来并提供执行入口。

接下来,我们按照上述 4个细分步骤,介绍如何对鉴权功能进行面向对象设计。

3.1 划分职责进而识别有哪些类

面向对象有关的图书中经常讲到:类是对现实世界中事物的建模。但是,并不是每个需求都能映射到现实世界,也并不是每个类都与现实世界中的事物一一对应。对于一些抽象的概念,我们是无法通过映射现实世界中的事物的方式定义类的。

在实践中,大家可以先把需求描述中的名词罗列出来,作为可能的候选类,再进行筛选;也可以先根据需求描述,把其中涉及的功能点一个个罗列出来,再去检查哪些功能点的职责相近、操作同样的属性,判断可否归为同一个类。针对鉴权功能开发案例,我们来看一下具体如何来做。

在面向对象分析章节已经给出了详细的需求描述,我们重新梳理并罗列。

  1. 调用方进行接口请求时,将 URL、AppID、密码和时间戳进行拼接,通过加密算法生成 Token,并将 Token、AppID 和时间戳拼接在 URL 中,一并发送到微服务端。
  2. 微服务端接收调用方的接口请求后,从请求中拆解出 Token、AppID 和时间戳。
  3. 微服务端先检查传递过来的时间戳与当前时间戳是否在 Token 有效时间窗口内,如果已经超过有效时间,那么接口调用鉴权失败,拒绝接口调用请求。
  4. 如果 Token 验证没有过期失效,那么微服务端再从自己的存储中取出 AppID 对应的密码,通过同样的 Token 生成算法,生成另一个 Token,并与调用方传递过来的 Token 进行匹配。如果二者一致,则鉴权成功,允许接口调用:否则,拒绝接口调用。

接下来,我们将上面的需求描述拆解成“单一职责”的功能点,也就是说,拆解出来的每个功能点的职责要尽可能小。下面是将上述需求描述拆解后的功能点列表。

  1. 把 URL、AppID、密码和时间戳拼接为一个字符串。
  2. 通过加密算法对字符串加密生成 Token。
  3. 将 Token、AppID 和时间戳拼接到 URL 中,形成新的 URL。
  4. 解析 URL,得到 Token、AppID 和时间戳。
  5. 从存储中取出 AppID 和对应的密码。
  6. 根据时间戳判断 Token 是否过期失效。
  7. 验证两个 Token 是否匹配。

从上述功能点列表中,我们发现,1)、2)6)和 7) 都与 Token 有关,即负责 Token 的生成和验证;

3)和 4)是在处理 URL,即负责 URL 的拼接和解析;

5)是操作 AppID 和密码,负责从存储中读取 AppID 和密码。

因此,我们可以大致得到3 个核心类: AuthToken、Url 和 CredentialStorage。其中,AuthToken 类负责实现 1)、2)、6) 和7) 这4 个功能点; Url类负责实现3)和4)两个功能点; CredentialStorage 类负责实现 5) 这个功能点。

当然,这只是类的初步划分,其他一些非核心的类,我们可能暂时没办法罗列全面,但也没有关系,面向对象分析、面向对象设计和面向对象编程本来就是一个循环迭代与不断优化的过程。根据需求,我们先给出一个“粗糙”的设计方案,再在这个基础上进行迭代优化,这样的话,思路会更加清晰。

不过,这里需要强调一点,接口调用鉴权功能开发的需求比较简单,因此,对应的面向对象设计并不复杂,识别出来的类也并不多。但是,如果我们面对的是大型软件的开发,需求会更加复杂,涉及的功能点和对应的类会更多。如果我们像上面那样根据需求逐个罗列功能点,那么会得到一个很长的列表,会显得凌乱和没有规律。针对复杂的需求开发,首先要进行模块划分,将需求简单地划分成若干小的、独立的功能模块,然后再在模块内部,运用上面介绍的方法进行面向对象设计。而模块的划分和识别与类的划分和识别可以使用相同的处理方法。

3.2  定义类及其属性和方法

在上文中,通过分析需求描述,识别出了三个核心类:AuthToken、Url、CredentialStorage。现在,我们看一下每个类中有哪些属性和方法。我们继续对功能点列表进行挖掘。

AuthToken类相关的功能点有以下4个 1)把URL、AppID、密码和时间戳拼接为一个字符串 2)通过加密算法对字符串加密生成Token; 3)根据时间戳判断 Token是否过期失效; 4)验证两个 Token 是否匹配。

对于方法的识别,很多面向对象相关的图书中会这样介绍:先将识别出的需求描述中的动词作为候选的方法,再进一步过滤和筛选。类比方法的识别,我们可以把功能点中涉及的名词作为候选属性,然后进行过滤和筛选。通过上述思路,根据功能点描述,我们可以识别出AuthToken 类的属性和方法:

图片

通过上图,我们发现了下列3个细节。细节一:并不是所有的名词都被定义为类的属性,如 URL、AppID、密码和时间戳,我们把它们作为方法的参数。细节二:我们需要挖掘一些没有出现在功能点描述中的属性,如createTime和epiredTimeInterval,它们用在 isExpired0 函数中,用来判定 Token 是否过期失效细节三: AuthToken类中添加了一个功能点描述里没有提到的方法 getToken。

通过细节一,我们可以知道,从业务模型上来说,不属于这个类的属性和方法不应该放到个类中。对于URL、ApplD,从业务模型上来说,不应该属于 AuhToken 类,因此不应该放到这个类中。

通过细节二和细节三,我们可以知道,类具有哪些属性和方法不能仅依靠当下的需求进行开发,还要分析这个类在业务模型上应该具有哪些属性和方法。这样既可以保证类定义的完整性,又能为未来的需求开发做好了准备。

Url类相关的功能点有两个: 1)将 Token、AppID 和时间戳拼接到URL中,形成新的URL 2)解析URL,得到Token、AppID 和时间戳

虽然在需求描述中,我们都是以 URL 代指接口请求,但是,接口请求并不一定是 URL的形式,还有可能是 RPC 等其他形式。为了让这个类通用,命名更加贴切,我们接下来把它命名为ApiRequest。

图片

CredentialStorage类的功能点只有一个:从存储中取出AppID 和对应的密码。因此CredentialStorage 类非常简单,为了做到封装具体的存储方式,我们将CredentialStorage 设计成接口,基于接口而非具体的实现编程。

图片

3.3 定义类之间的交互关系

类之间存在哪些交互关系呢?UML(统建模语言)中定义了类之间的6种关系:泛化、实现、聚合、组合、关联和依赖。但是在实际开发中我们并不会真正使用这几种关系,而是采取简化合并,只保留泛化、实现、组合这几种,具体细节可以参考我之前的文章。

在我们这个案例中,目前只有三个核心类,只用到了实现关系,即接口CredentialStorage和具体实现MySQLCredentialStorage类之间的关系。

图片

3.4 将类组装起来并提供执行入口

在类与类之间的交互关系设计好之后,接下来,我们将所有的类组装在一起,并提供一个执行入口。这个入口可能是一个main函数,也可能是一组提供给外部调用的API。通过这个入口,我们能够触发代码的执行。

因为接口鉴权并不是一个独立的运行系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计一个顶层的接口类(ApiAuthenticator),并暴露一组提供给外部调用者使用的API。作为触发执行鉴权逻辑的入口。ApiAuthenticator类的详细设计如下所示:

图片

4. 面向对象编程

在面向对象设计完成之后,我们已经定义了类、属性、方法和类之间的交互,并且将有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作就是将这些设思路“翻译”成代码。有了前面的类图,这部分工作就简单了。因此,这里只给出复杂ApiAuthenticator类的代码实现。

public interface ApiAuthenticator {
    void auth(String url);
    void auth (ApiRequest apiRequest);
}

public class DefaultApiAuthenticator implements ApiAuthenticator {
    private CredentialStorage credentialStorage;
    public DefaultApiAuthenticator() {
        this.credentialstorage = new MysqlCredentialstorage();
    }
    
    public DefaultApiAuthenticator(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 verification failed.");
        }
    }
}    

在前面的讲解中,对于面向对象的分析、设计和编程,每个环节的界限划分清楚。而且,面向对象设计和面向对象编程基本上是按照功能点的描述逐句进行的。这样做的好处是,先做什么和后做什么非常清晰与明确,有章可循,即便是没有太多设计经验的初级工程师,也可以参照这个流程按部就班地进行面向对象的分析、设计和编程。

不过,在平时的工作中,大部分程序员往往都是在脑子里或草稿纸上完成面向对象的分析和设计,然后立即开始写代码,一边写,一边优化和重构,并不会严格地按照固定的流程来执行。在写代码之前,即便我们花很多时间进行面向对象的分析和设计,绘制了相当好的类图、UML图,也不可能把每个细节、交互都想清楚。

在落实到代码时,我们还是需要反复迭代重构,甚至推倒重写。毕竟,软件开发本来就是一个不断迭代、修补,以及遇到问题并解决问题的过程,是一个不断重构的过程,我们无法严格地按照顺序执行各个步骤。