【Java设计模式 面向对象设计思想】七 面向对象分析、面向对象设计和面向对象编程

简介: 【Java设计模式 面向对象设计思想】七 面向对象分析、面向对象设计和面向对象编程

在完整的需求开发中,代码的落地实际上是最后一步了,也就是我们所谓的面向对象编程(OOP),实际上拿到需求后最好先进行分析和设计,这部分工作基本都被我省略了或者只是在纸上或者wiki上简单画一画,分析和设计并没有按照规范落到实处。而面向对象分析和设计最终的产出是类的设计,包括程序被拆解为哪些类每个类有哪些属性方法类与类之间如何交互等等。它们比其他的分析和设计更加具体、更加落地、更加贴近编码,更能够顺利地过渡到面向对象编程环节。

我们以一个实际的接口鉴权的例子来进行面向对象开发:为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝

面向对象分析

当然接口鉴权的需求并不明确,同样的实际场景下我们接到的大多数需求都不是百分百明确的,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。那么如何做面向对象分析呢?实际上,这跟做算法题类似,先从最简单的方案想起,然后再优化

1. 第一轮基础分析

首先我们能想到最简单的解决方案就是,通过用户名加密码来做认证。

  1. 给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对应的密码(或者叫秘钥)。
  2. 调用方每次进行接口请求的时候,都携带自己的 AppID 和密码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的 AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。

不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的,即使对密码加密,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的 AppID,伪装成已认证系统来访问我们的接口。这就是典型的重放攻击(把以前窃听到的数据原封不动地重新发送给接收方)。

2. 第二轮分析优化

借助 OAuth 的验证思路来解决。调用方将请求接口的 URL 、 AppID、密码、时间戳拼接在一起,然后进行加密,生成一个 token,由于引入的时间戳为随机变量,所以每次接口请求生成的 token 都不一样。

虽然这样还不是绝对安全。未认证系统还是可以在这一分钟的 token 失效窗口内,通过截获请求、重放请求,来调用我们的接口!不过,攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了

3. 第三轮分析优化

接口鉴权逻辑就是如上分析,还有一个细节需要考虑就是,如何在微服务端存储每个授权调用方的 AppID 和密码。针对 AppID 和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如 ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动基于接口(抽象)而非实现编程

4. 最终确定需求

AppID 和密码的存储默认使用数据库,但要留有扩展点,整体的业务流程:

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

这就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题 - 解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述

面向对象设计

面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是。在面向对象设计环节,我们将需求描述转化为具体的类的设计

1. 划分职责进而识别出有哪些类

根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类,我们将上述的分析得出的详细需求描述进行拆解:

  1. 把 URL、AppID、密码、时间戳拼接为一个字符串,对字符串通过加密算法加密生成 token
  2. 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL
  3. 解析 URL,得到 token、AppID、时间戳等信息
  4. 从存储中取出 AppID 和对应的密码
  5. 根据时间戳判断 token 是否过期失效
  6. 验证两个 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 三个连在一起就是面向对象分析、设计、编程(实现),正好是面向对象软件开发要经历的三个阶段,想想之前为什么我们基本没有面向对象分析和设计,主要原因就是我们进行的都是面向过程编程,我们的视角是自上而下的任务处理流程,而非面向对象的自底向上的业务建模,在这个体系之下进行需求功能开发,当然视角就会局限在顺序流的功能拆解而非面向对象的分析设计了。正确的面向对象分析是细化模糊的需求,正确的面向对象设计是正确设计类及类关系,正确的面向对象编程则是基于面向对象分析和设计之后的代码实践,而非流程式的面向过程编程。

相关文章
|
16天前
|
Java 调度
Java中常见锁的分类及概念分析
Java中常见锁的分类及概念分析
16 0
|
16天前
|
Java
Java中ReentrantLock中tryLock()方法加锁分析
Java中ReentrantLock中tryLock()方法加锁分析
13 0
|
2天前
|
安全 Java
Java基础&面向对象&继承&抽象类
Java基础&面向对象&继承&抽象类
|
2天前
|
Java
【Java基础】详解面向对象特性(诸如继承、重载、重写等等)
【Java基础】详解面向对象特性(诸如继承、重载、重写等等)
8 0
|
3天前
|
设计模式 算法 Java
[设计模式Java实现附plantuml源码~行为型]定义算法的框架——模板方法模式
[设计模式Java实现附plantuml源码~行为型]定义算法的框架——模板方法模式
|
3天前
|
设计模式 JavaScript Java
[设计模式Java实现附plantuml源码~行为型] 对象状态及其转换——状态模式
[设计模式Java实现附plantuml源码~行为型] 对象状态及其转换——状态模式
|
3天前
|
设计模式 存储 JavaScript
[设计模式Java实现附plantuml源码~创建型] 多态工厂的实现——工厂方法模式
[设计模式Java实现附plantuml源码~创建型] 多态工厂的实现——工厂方法模式
|
3天前
|
设计模式 Java Go
[设计模式Java实现附plantuml源码~创建型] 集中式工厂的实现~简单工厂模式
[设计模式Java实现附plantuml源码~创建型] 集中式工厂的实现~简单工厂模式
|
3天前
|
设计模式 存储 前端开发
Java从入门到精通:2.2.1学习Java Web开发,了解Servlet和JSP技术,掌握MVC设计模式
Java从入门到精通:2.2.1学习Java Web开发,了解Servlet和JSP技术,掌握MVC设计模式
|
8天前
|
安全 Java 机器人
《Java 简易速速上手小册》第2章:面向对象的 Java(2024 最新版)
《Java 简易速速上手小册》第2章:面向对象的 Java(2024 最新版)
19 0