10、实战二(下)

1、如何进行面向对象设计

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

1.1、划分职责进而识别出有哪些类

如何划分职责进而识别出有哪些类

  • 类是现实世界中事物的一个建模
    但是并不是每个需求都能映射到现实世界,也并不是每个类都与现实世界中的事物一一对应
    对于一些抽象的概念,我们是无法通过映射现实世界中的事物的方式来定义类的
  • 把需求描述中的名词罗列出来,作为可能的候选类,然后再进行筛选
    对于没有经验的初学者来说,这个方法比较简单、明确,可以直接照着做
  • 根据需求描述,把其中涉及的功能点,一个一个罗列出来
    然后再去看哪些功能点职责相近,操作同样的属性,可否应该归为同一个类,我们来看一下,针对鉴权这个例子,具体该如何来做

在上一节课中,我们已经给出了详细的需求描述,为了方便你查看,我把它重新贴在了下面

  • 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起
    通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端
  • 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳
  • 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内
    如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求
  • 如果 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 和密码

所以我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage

  • AuthToken 负责实现 1、2、6、7 这四个操作
  • Url 负责 3、4 两个操作
  • CredentialStorage 负责 5 这个操作

当然这是一个初步的类的划分,其他一些不重要的、边边角角的类,我们可能暂时没法一下子想全
但这也没关系,面向对象分析、设计、编程本来就是一个循环迭代、不断优化的过程
根据需求,我们先给出一个粗糙版本的设计方案,然后基于这样一个基础,再去迭代优化,会更加容易一些,思路也会更加清晰一些

不过我还要再强调一点,接口调用鉴权这个开发需求比较简单,所以需求对应的面向对象设计并不复杂,识别出来的类也并不多
但如果我们面对的是更加大型的软件开发、更加复杂的需求开发,涉及的功能点可能会很多,对应的类也会比较多
像刚刚那样根据需求逐句罗列功能点的方法,最后会得到一个长长的列表,就会有点凌乱、没有规律
针对这种复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块
然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计,而模块的划分和识别,跟类的划分和识别,是类似的套路

1.2、定义类及其属性和方法

刚刚我们通过分析需求描述,识别出了三个核心的类,它们分别是 AuthToken、Url 和 CredentialStorage
现在我们来看下,每个类都有哪些属性和方法,我们还是从功能点列表中挖掘

AuthToken(身份验证令牌)类相关的功能点有四个

  • 把 URL、AppID、密码、时间戳拼接为一个字符串
  • 对字符串通过加密算法加密生成 token
  • 根据时间戳判断 token 是否过期失效
  • 验证两个 token 是否匹配

对于方法的识别,很多面向对象相关的书籍,一般都是这么讲的,识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选
类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选

我们可以借用这个思路,根据功能点描述,识别出来 AuthToken 类的属性和方法,如下所示(expireTimeInterval 过期时间间隔)
image

从上面的类图中,我们可以发现这样三个小细节

  • 并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数
  • 我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime,expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期
  • 我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken()

第一个细节告诉我们:从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里
比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken,所以我们不应该放到这个类中

第二、第三个细节告诉我们:在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法
这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备

Url 类相关的功能点有两个

  • 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL
  • 解析 URL,得到 token、AppID、时间戳等信息

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

CredentialStorage(凭据存储)类相关的功能点有一个:从存储中取出 AppID 和对应的密码

CredentialStorage 类非常简单,类图如下所示
为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程

image

1.3、定义类与类之间的交互关系

类与类之间都哪些交互关系呢,UML 统一建模语言中定义了六种类之间的关系,它们分别是:继承、实现 | 关联、依赖 | 聚合、组合

  • 继承:extends
  • 实现:implements
  • 依赖:使用关系,一个类使用另一个类作为参数使用或返回值
  • 关联:成员变量
  • 聚合:成员变量,生命周期不同,has a 的关系(课程与学生)
  • 组合:成员变量,生命周期相同,contains a 的关系(鸟与翅膀)

image

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

类定义好了,类之间必要的交互关系也设计好了,接下来我们要将所有的类组装在一起,提供一个执行入口
这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口,通过这个入口,我们能触发整个代码跑起来

接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件
所以我们封装所有的实现细节,设计了一个最顶层的 ApiAuthencator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口
image

2、如何进行面向对象编程

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

对于 AuthToken、ApiRequest、CredentialStorage 这三个类,在这里我就不给出具体的代码实现了
给你留一个课后作业,你可以试着把整个鉴权框架自己去实现一遍

public interface ApiAuthenticator {

    void auth(String url);

    void auth(ApiRequest apiRequest);
}
public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {

    private CredentialStorage credentialStorage; // 凭据存储

    public ApiAuthencator() {
        credentialStorage = new MysqlCredentialStorage();
    }

    public ApiAuthencator(CredentialStorage credentialStorage) {
        this.credentialStorage = credentialStorage;
    }

    @Override
    public void auth(String url) {
        // 解析 URL,得到 baseUrl、appId、token、timestamp
        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.");
        }
    }
}

3、辩证思考与灵活应用

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

不过在平时的工作中,大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计
然后就开始写代码了,边写边思考边重构,并不会严格地按照刚刚的流程来执行
说实话,即便我们在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML 图,也不可能把每个细节、交互都想得很清楚
在落实到代码的时候,我们还是要反复迭代、重构、打破重写

毕竟整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程,我们没法严格地按照顺序执行各个步骤
这就类似你去学驾照,驾校教的都是比较正规的流程,先做什么,后做什么,你只要照着做就能顺利倒车入库
但实际上等你开熟练了,倒车入库很多时候靠的都是经验和感觉

posted @ 2023-06-24 19:46  lidongdongdong~  阅读(4)  评论(0编辑  收藏  举报