开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Service Mesh 介绍

发表于 2021-11-05

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

Service Mesh翻译为“服务网格”,作为服务间通信的基础设施层。轻量级高性能网络代理,提供安全的、快速的、可靠地服务间通讯,与实际应用部署一起,但对应用透明。应用作为服务的发起方,只需要用最简单的方式将请求发送给本地的服务网格代理,然后网格代理会进行后续的操作,如服务发现,负载均衡,最后将请求转发给目标服务。

Service Mesh目的是解决系统架构微服务化后的服务间通信和治理问题。 服务网格由Sidecar节点组成,这个模式的精髓在于实现了数据面(业务逻辑)和控制面的解耦。具体到微服务架构中,即给每一个微服务实例同步部署一个Sidecar。

Service Mesh部署网络结构图

图 3.1.1:Service Mesh部署网络结构图

在Service Mesh部署网络结构图中,绿色方块为应用服务,蓝色方块为 Sidecar,应用服务之间通过Sidecar进行通信,整个服务通信形成图中的蓝色网络连线,图中所有蓝色部分就形成了Service Mesh。其具备如下主要特点:

  • 应用程序间通讯的中间层
  • 轻量级网络代理
  • 应用程序无感知
  • 解耦应用程序的重试/超时、监控、追踪和服务发现

Service Mesh 的出现解决了传统微服务框架中的痛点,使得开发人员专注于业务本身,同时,将服务通信及相关管控功能从业务中分离到基础设施层。

1、Service Mesh 功能

Service Mesh 作为微服务架构中负责网络通信的基础设施层,具备网络处理的大部分功能。下面列举了一些主要的功能:

  • 动态路由。 可通过路由规则来动态路由到所请求的服务,便于不同环境、不同版本等的动态路由调整。
  • 故障注入。 通过引入故障来模拟网络传输中的问题(如延迟)来验证系统的健壮性,方便完成系统的各类故障测试。
  • 熔断。 通过服务降级来终止潜在的关联性错误。
  • 安全。 在Service Mesh上实现安全机制(如TLS),并且很容易在基础设施层完成安全机制更新。
  • 多语言支持。 作为独立运行且对业务透明的 Sidecar 代理,Service Mesh 很轻松地支持多语言的异构系统。
  • 多协议支持。 同多语言一样,也支持多协议。
  • 指标和分布式链路追踪。

概括起来,Service Mesh 主要体现在以下 4 个方面:

  • 可见性: 运行时指标遥测、分布式跟踪。
  • 可管理性: 服务发现、负载均衡、运行时动态路由等。
  • 健壮性: 超时、重试、熔断等弹性能力。
  • 安全性: 服务间访问控制、TLS 加密通信。

2、Service Mesh 解决的问题

从上述Service Mesh的定义看:

  • 基础设施层是Service Mesh的定位,致力于解决微服务基础设施标准化、配置化、服务化和产品化的问题。
  • 服务间通信是Service Mesh技术层面对的问题,对微服务屏蔽通信的复杂度,解决微服务的通信治理问题。
  • 请求的可靠传递是Service Mesh的目标。
  • 轻量级网络代理是Service Mesh的部署方式。
  • 对应用程序透明是Service Mesh的亮点和特色,实现对业务无侵入。

综合上述,Service Mesh主要解决用户如下 3 个维度的痛点需求:

  • 完善的微服务基础设施

通过将微服务通信下沉到基础设施层,屏蔽了微服务处理各种通信问题的复杂度,形成微服务之间的抽象协议层。开发者无需关心通信层的具体实现,也无需关注RPC通信(包含服务发现、负载均衡、流量调度、流量降级、监控统计等)的一切细节,真正像本地调用一样使用微服务,通信相关的一起工作直接交给Service Mesh。

  • 语言无关的通信和链路治理

功能上,Service Mesh并没有提供任何新的特性和能力,Service Mesh提供的所有通信和服务治理能力在Service Mesh之前的技术中均能找到,比如Spring Cloud就实现了完善的微服务RPC通信和服务治理支持。

Service Mesh改变的是通信和服务治理能力提供的方式,通过将这些能力实现从各语言业务实现中解耦,下沉到基础设施层面,以一种更加通用和标准化的方式提供,屏蔽不同语言、不同平台的差异性,有利于通信和服务治理能力的迭代和创新,使得业务实现更加方便。

Service Mesh避免了多语言服务治理上的重复建设,通过Service Mesh语言无关的通信和服务治理能力,助力于多语言技术栈的效率提升。

  • 通信和服务治理的标准化
+ **微服务治理层面**,`Service Mesh`是标准化、体系化、无侵入的分布式治理平台。
+ **标准化方面**,`Sidecar`成为所有微服务流量通信的约束标准,同时`Service Mesh`的数据平台和控制平面也通过标准协议进行交互。
+ **体系化方面**,从全局考虑,提供多维度立体的微服务可观测能力(`Metric`、`Trace`、`Logging`),并提供体系化的服务治理能力,如限流、熔断、安全、灰度等。通过标准化,带来一致的服务治理体验,减少多业务之间由于服务治理标准不一致带来的沟通和转换成本,提升全局服务治理的效率。

3、Service Mesh 原理

Service Mesh 的核心是数据平面 Sidecar 与控制平面 Control Plane,如下图:

Service Mesh架构图

图 3.1.2:Service Mesh架构图

  • 数据平面: Sidecar,与服务部署在一起的轻量级网络代理,用于实现服务框架的各项功能(如,服务发现、负载均衡、限流熔断等),让服务回归业务本质。

数据平台可以认为是将 Spring Cloud、Dubbo 等相关的微服务框架中通信和服务治理能力独立出来的一个语言无法的进程,并且更注重通用性和扩展性。在 Service Mesh 中,不再将数据平面代理视为一个个独立的组件,而是将这些代理连接在一起形成一个全局的分布式网格。

在传统的微服务架构中,各种服务框架的功能(如,服务发现、负载均衡、限流熔断等)代码逻辑或多或少的都需要耦合到服务实例的代码中,给服务实例增加了很多无关业务的代码,同时带来了一定的复杂度。

有了 Sidecar之后,服务节点只做业务逻辑自身的功能,服务之间的调用只需交给 Sidecar,由Sidecar 完成注册服务、服务发现、请求路由、熔断限流、日志统计等业务无关功能。

在这种新的微服务架构中,所有的 Sidecar 组成在一起,就形成了服务网格。那么这个大型的服务网格并不是完全自治的,它还需要一个统一的控制节点 Control Plane。

  • 控制平面: 是用来从全局的角度上控制 Sidecar,相当于 Service Mesh 架构的大脑,控制着 Sidecar 来实现服务治理的各项功能。比如,它负责所有 Sidecar 的注册,存储统一的路由表,帮助各个 Sidecar 进行负载均衡和请求调度;它收集所有 Sidecar的监控信息和日志数据。

\

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

批量生成独一无二的NFT猫猫图,这项目王多鱼会投吗?

发表于 2021-11-05

一起用代码吸猫!本文正在参与【喵星人征文活动】。

批量生成独一无二的NFT猫猫图,这项目王多鱼会投吗?

零、前言

我们本项目是基于Benyamin Ahmed小朋友写的 nft-image-generator。之前在新闻看到这个小朋友暑假期间靠卖NFT鲸鱼“表情包” weird-whales-images一个暑假赚了250万元。另外他还将这个NFT图片生成器开源了,今天我们就基于他的这个开源工具来生成猫猫表情包。

一、设置合成部件的权重

1
2
3
4
5
py复制代码cat = ["01","02","03","04","05","06","07"]
cat_weights = [10, 40, 15, 5, 10, 10, 10]
​
title = ["01","02","03","04"]
title_weights = [20, 40, 15, 15]

我们这里分别有7张和4张猫咪图片。我们要将这7张猫咪与4张猫咪排列组合,生成独一无二的图片。

P.S. 这里权重之和要是100.

二、每一张图片对应的文件名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
py复制代码cat_files = {
   "01": "cat1",
   "02": "cat2",
   "03": "cat3",
   "04": "cat4",
   "05": "cat5",
   "06": "cat6",
   "07": "cat7"
}
​
title_files = {
   "01": "title01",
   "02": "title02",
   "03": "title03",
   "04": "title04"
}
​

我们是将文件夹中的文件名放入字典中。

image-20211105212957978

image-20211105213009005

三、随即生成基于权重的图片组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
py复制代码TOTAL_IMAGES = 10 # Number of random unique images we want to generate
​
all_images = []
​
# A recursive function to generate unique image combinations
def create_new_image():
   
   new_image = {} #
​
   # For each trait category, select a random trait based on the weightings
​
   new_image ["Cat"] = random.choices(cat, cat_weights)[0]
   new_image ["Title"] = random.choices(title, title_weights)[0]
   
   if new_image in all_images:
       return create_new_image()
   else:
       return new_image
   
   
# Generate the unique combinations based on trait weightings
for i in range(TOTAL_IMAGES):
   
   new_trait_image = create_new_image()
   
   all_images.append(new_trait_image)

TOTAL_IMAGES这个变量是我们要生成的最大图片数量。

create_new_image是利用递归,对于每个图片类别都根据权重随机选择一张图片。

四、判断所有的图片是否独特

1
2
3
4
5
py复制代码def all_images_unique(all_images):
   seen = list()
   return not any(i in seen or seen.append(i) for i in all_images)
​
print("Are all images unique?", all_images_unique(all_images))

这里我们遍历所有图片来判断是否有重复图片。

五、为每一张图片添加Token Id

1
2
3
4
5
6
py复制代码# Add token Id to each image
i = 0
for item in all_images:
   item["tokenId"] = i
   i = i + 1
print(all_images)

这之后all_images中内容是:

1
py复制代码[{'Cat': '05', 'Title': '01', 'tokenId': 0}, {'Cat': '02', 'Title': '04', 'tokenId': 1}, {'Cat': '01', 'Title': '03', 'tokenId': 2}, {'Cat': '01', 'Title': '02', 'tokenId': 3}, {'Cat': '02', 'Title': '03', 'tokenId': 4}, {'Cat': '07', 'Title': '02', 'tokenId': 5}, {'Cat': '02', 'Title': '02', 'tokenId': 6}, {'Cat': '07', 'Title': '01', 'tokenId': 7}, {'Cat': '03', 'Title': '02', 'tokenId': 8}, {'Cat': '05', 'Title': '02', 'tokenId': 9}]

包含tokenId和两种猫的图片。

六、获取图片使用的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
py复制代码# Get Trait Counts
​
   
title_count = {}
for item in title:
   title_count[item] = 0
​
cat_count = {}
for item in cat:
   cat_count[item] = 0
​
for image in all_images:
   cat_count[image["Cat"]] += 1
   title_count[image["Title"]] += 1
   
print(cat_count)
print(title_count)

使用字典来存储各个图片被使用的次数。

七、拼凑图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
py复制代码#### Generate Images    
for item in all_images:
​
   im1 = Image.open(f'./trait-layers/cat/{cat_files[item["Cat"]]}.png').convert('RGBA')
   im2 = Image.open(f'./trait-layers/title/{title_files[item["Title"]]}.png').convert('RGBA')
   
   im1 = im1.resize((400, 400))
   im2 = im2.resize((400, 400))
   #Create each composite
​
   com = Image.alpha_composite(im1, im2)
   
   #Convert to RGB
   rgb_im = com.convert('RGB')
   file_name = str(item["tokenId"]) + ".png"
   rgb_im.save("./images/" + file_name)

这里的思路很简单,将图片转化为同一尺寸,然后利用Image.alpha_composite将其叠加,然后在根据tokenId来保存图片。

八、来欣赏一下我生成的独一无二的图片吧~

image-20211105214705663

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Mysql 温故知新系列「表记录」「批量增」「批量删」

发表于 2021-11-05

「这是我参与11月更文挑战的第 5 天,活动详情查看:2021最后一次更文挑战」

表记录管理

先准备好测试表

1
2
3
4
5
sql复制代码create table test(
id int PRIMARY key auto_increment,
uname VARCHAR(20),
sex TINYINT default 1
)

插入

指定字段单条插入

1
sql复制代码insert into test(uname) values('b');

image.png

我们可以选择对我们关注的字段做赋值操作,以及对必填非空字段做赋值操,表内其余未指定的字段,统一使用默认值。 默认值没有声明时,值为 null

不指定字段单条插入

在上一种插入方法的基础上,我们可以省略 sql 语句中插入的字段

1
sql复制代码insert into test values(default, 'a', default)

image.png

如图,我们没有显示的指定插入的数据中,哪个值对应哪个列,mysql 就会根据表当前的列顺序来依次解析,所以我们需要按顺序为全部字段赋予一个值进行占位。如果字段允许,我们可以设置为 null,或者是用 default 表示我们希望使用列定义时的默认值

批量插入

与单条插入类似,可以选择指定字段、亦或不指定然后按顺序全字段数据录入

mysql 有一个特殊的插入操作,即支持一次性插入多条记录,格式如下

1
sql复制代码insert into table (c1, c2, ..) values (a1, b1, ..), (a2, b2, ...), ...

image.png

需要插入的多条记录之间,使用 , 隔开即可

删除

常规删除

我们日常使用 id 作为删除的依据

1
sql复制代码DELETE from test where id=1

image.png

批量删除

在我们的应用中,有时候需要支持一次性删除多条记录,如果遍历 id 的集合然后执行单条的删除 sql,1000 次操作性能会有明显的延迟

推荐的优化方案就是,修改删除的条件为 in

1
2
3
4
5
sql复制代码DELETE
FROM
test
WHERE
id IN (2, 3, 4);

image.png

或者,根据我们实际的业务,我们可以修改删除语句中 where 后面跟随的条件,这里可以理解为,mysql 会为我们删除满足条件的全部记录

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Python Jenkins模块 前言 1 什么是Jenk

发表于 2021-11-05

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

前言

在如今快速发展的时代中,为了支撑公司产品快速迭代的需求,行业中近期流行起来Devops。

  • 什么是Devops?

Devops 通常定义是组织团队之间通过工具实现自动化的协作和沟通完成软件周期管理,其目的是更快、更好地进行软件开发、测试、发布等一系列流程管理。

+ Devops 主要包含三个部分:开发、测试和运维。
+ Devops 平台搭建主要流程如图所示:![image.png](https://gitee.com/songjianzaina/juejin_p17/raw/master/img/643cb71722eaeb33329e61ad28f30e77d16fac66c8c92a729e03ef1e3ed22c49)

在Devops流程中,我们知道了作为持续集成的工具Jenkins发挥着至关重要的作用。

对于强大的Python来说,Python也支持Jenkins工具操作的第三方库,接下来我们来学习它吧,let’s go~

  1. 什么是Jenkins?

我们在学习Python Jenkins模块之前,先来认识一下持续集成的工具-Jenkins吧

Jenkins 是作为Devops流程中进行软件持续集成、发布等重要环节。并且其自身提供超过1000个插件来支持构建、部署、自动化,可以满足多元化的项目需求。

image.png

  • Jenkins工具特点

+ ### 持续集成、持续交付


Jenkins是一个可扩展的自动化工具,可以用作CI服务器、或者项目交付中心
+ ### 简易安装


Jenkins 支持多系统独立运行
+ ### 配置简单


Jenkins凭借着简单的网页设置和配置,可以快速上手
+ ### 大量插件


Jenkins 提供超过1000个插件,集成了持续集成和持续交付工具链中所有的工具
+ ### 易扩展性


Jekins 用于插件架构可以对其进行扩展
+ ### 分布式结构


Jenkins 可以支持在多台服务器上分配工作,帮助快速跨多个平台推动构建、测试和部署

PS: Jekins官网上提供详细的用户操作手册

  1. Jenkins 模块概述

Python Jenkins 模块可以允许用户通过Jenkins REST端点管理Jenkins服务器。

  • python-jenkins 模块特点

+ 支持自动连接Jenkins服务器
+ 控制Jenkins进行工作
+ 对Jenkins进行创建、配置和删除视图
+ 为Jenkins进行添加插件
  • python-jenkins 模块获取

Jekins模块是Python第三方库,需要通过pip进行下载

1
python复制代码pip install python-jenkins
  • python-jenkins 模块使用

我们在代码中,直接使用import导入第三方库

1
python复制代码import Jenkins

PS:python-Jenkins 模块官方操作手册可见详细操作

  1. Jenkins 相关方法

Jenkins 模块提供的类方法

类方法 作用
jenkins.Jenkins(url,username,password) 登陆jenkins服务器

Jenkins 模块实例对象方法

方法 作用
jen.build_job(job_name) 构建任务
jen.get_job_info(job_name) 获取任务名
jen.get_whoami() 获取账号名字
jen.get_version() 获取版本号
jen.pprint() 查看全局信息
jen.delete_job(job_name) 删除指定的任务
jen.create_job(job_name) 创建指定的任务
jen.copy_job(job_name) 复制指定的任务
jen.rename_job(job_name) 重命名指定的任务

Jenkins 模块实例化提供的属性

属性 作用
jen.baseurl jenkins服务地址
jen.username jenkins 用户名字
jen.password Jenkins 用户密码
  1. 小试牛刀

  • 登陆 Jenkins 流程步骤

1. 导入jenkins模块
2. 创建Jenkins实例对象
3. 实例对象需要传入url,用户名,密码
1
2
3
4
5
6
python复制代码import Jenkins

Jen = jenkins.Jenkins("http://localhost:8080",
username="uesrname",password="password")
print("userinfo:",Jen.get_whoami())
print("version:",Jen.get_version())

image.png

总结

本期,我们对Python对Jenkins系统进行操作的第三模块Jenkins库相关方法进行学习,在学习的过程中也对Devops流程有一定的了解。

在团队工作中,每个人都各司其职,怎么能做到高效且可靠的交付,团队协作与沟通的方式在不断升级改善,更好更快地去适应快速迭代的时代。

以上是本期内容,欢迎大佬们评论点赞,下期见~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

从一个有趣的SQL优化案例,自顶向下了解数据库优化机制 问题

发表于 2021-11-05

原文:zhuanlan.zhihu.com/p/409323151

问题描述

同事A来问我这个假DBA一条SQL的性能问题:

  • A:两条SQL语句只有limit不一样,而limit 1的执行比limit 10的慢N倍
  • 我:是不是缓存问题,先执行limit 10再执行limit 1试试
  • A:……,执行了,limit 还是很慢

两条SQL生产环境执行情况

limit 10

1
sql复制代码select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 10;

Execution Time: 1.307 ms

limit 1

1
sql复制代码select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 1;

Execution Time: 144.098 ms

分析

执行计划

既然不是缓存问题,那我们先看看执行计划有什么不一样的

limit 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql复制代码# explain analyze verbose select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 1;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.43..416.25 rows=1 width=73) (actual time=135.213..135.214 rows=1 loops=1)
Output: xxx
-> Index Scan Backward using user_gift_pkey on yay.user_gift (cost=0.43..368000.44 rows=885 width=73) (actual time=135.212..135.212 rows=1 loops=1)
Output: xxx
Filter: ((user_gift.user_id = 11695667) AND (user_gift.user_type = 'default'::user_type))
Rows Removed by Filter: 330192
Planning Time: 0.102 ms
Execution Time: 135.235 ms
(8 rows)

Time: 135.691 ms

limit 10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sql复制代码# explain analyze verbose select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 10;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=868.20..868.22 rows=10 width=73) (actual time=1.543..1.545 rows=10 loops=1)
Output: xxx
-> Sort (cost=868.20..870.41 rows=885 width=73) (actual time=1.543..1.543 rows=10 loops=1)
Output: xxx
Sort Key: user_gift.id DESC
Sort Method: top-N heapsort Memory: 27kB
-> Index Scan using idx_user_type on yay.user_gift (cost=0.56..849.07 rows=885 width=73) (actual time=0.020..1.366 rows=775 loops=1)
Output: xxx
Index Cond: (user_gift.user_id = 11695667)
Filter: (user_gift.user_type = 'default'::user_type)
Planning Time: 0.079 ms
Execution Time: 1.564 ms
(12 rows)

Time: 1.871 ms

可以看到,两个SQL执行计划不一样:

  • limit 1语句 :使用主键进行倒序扫描, Index Scan Backward using user_gift_pkey on yay.user_gift
  • limit 10语句 :使用(user_id, user_type)复合索引直接查找用户数据,Index Scan using idx_user_type on yay.user_gift

为什么执行计划不一样?

total cost

其实postgreSQL的执行计划并没有“问题”,因为limit 1的total cost
Limit (cost=0.43..416.25 rows=1 width=73) 是416,run cost是416-0.43=415.57。而limit 10的total cost
Limit (cost=868.20..868.22 rows=10 width=73)是868.22。

如果使用Index Scan Backward using user_gift_pkey的方式估算,那么limit 1成本是415, limit 2是415*2=830, limit 3 是 1245,大于868,所以当limit 3的时候会使用Index Scan using idx_user_type扫索引的计划。

验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sql复制代码# explain  select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 2;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.43..831.95 rows=2 width=73)
-> Index Scan Backward using user_gift_pkey on user_gift (cost=0.43..367528.67 rows=884 width=73)
Filter: ((user_id = 11695667) AND (user_type = 'default'::user_type))
(3 rows)

Time: 0.341 ms
# explain select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 3;
QUERY PLAN
----------------------------------------------------------------------------------------------------------
Limit (cost=866.19..866.20 rows=3 width=73)
-> Sort (cost=866.19..868.40 rows=884 width=73)
Sort Key: id DESC
-> Index Scan using idx_user_type on user_gift (cost=0.56..854.76 rows=884 width=73)
Index Cond: (user_id = 11695667)
Filter: (user_type = 'default'::user_type)
(6 rows)

Time: 0.352 ms

结果显示:

  • 当limit 2时,执行计划是Index Scan Backward using user_gift_pkey
  • 当limit 3时,就改变计划了,Index Scan using idx_user_type on user_gift

实际执行时间

limit 1时成本估算的是416.25,比limit 10的868.22还是要快的。

但是实际limit 1执行cost是135.691 ms,而limit 10执行cost是1.871 ms,比limit 10慢了70倍!!!

我们重新执行下explain,加上buffers选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sql复制代码# explain (analyze, buffers, verbose)  select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 1;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.43..416.29 rows=1 width=73) (actual time=451.542..451.544 rows=1 loops=1)
Output: xxx
Buffers: shared hit=214402 read=5280 dirtied=2302
I/O Timings: read=205.027
-> Index Scan Backward using user_gift_pkey on yay.user_gift (cost=0.43..368032.94 rows=885 width=73) (actual time=451.540..451.540 rows=1 loops=1)
Output: xxx
Filter: ((user_gift.user_id = 11695667) AND (user_gift.user_type = 'default'::user_type))
Rows Removed by Filter: 333462
Buffers: shared hit=214402 read=5280 dirtied=2302
I/O Timings: read=205.027
Planning Time: 1.106 ms
Execution Time: 451.594 ms
(12 rows)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ini复制代码# explain (analyze, buffers, verbose)  select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id desc limit 3;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=860.51..860.52 rows=3 width=73) (actual time=14.633..14.634 rows=3 loops=1)
Output: xxx
Buffers: shared hit=467 read=321
I/O Timings: read=10.112
-> Sort (cost=860.51..862.72 rows=885 width=73) (actual time=14.632..14.632 rows=3 loops=1)
Output: xxx
Sort Key: user_gift.id DESC
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=467 read=321
I/O Timings: read=10.112
-> Index Scan using idx_user_type on yay.user_gift (cost=0.56..849.07 rows=885 width=73) (actual time=0.192..14.424 rows=775 loops=1)
Output: xxx
Index Cond: (user_gift.user_id = 11695667)
Filter: (user_gift.user_type = 'default'::user_type)
Buffers: shared hit=464 read=321
I/O Timings: read=10.112
Planning Time: 0.111 ms
Execution Time: 14.658 ms
(18 rows)

可以看出:

  • limit 1时的IO成本I/O Timings: read=205.027,Rows Removed by Filter: 333462显示过滤了333462行记录
  • limit 3时IO成本I/O Timings: read=10.112,

从上面输出Buffers: shared hit=214402 read=5280 dirtied=2302可以看出limit 1的计划从磁盘读取了5280个blocks(pages)才找到符合where条件的记录。

为什么要读取这么多数据呢?我们来看看统计信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
arduino复制代码schemaname             | yay
tablename | user_gift
attname | id
inherited | f
null_frac | 0
avg_width | 8
n_distinct | -1
most_common_vals |
most_common_freqs |
histogram_bounds | {93,9817,19893,28177,.......}
correlation | 0.788011
most_common_elems |
most_common_elem_freqs |
elem_count_histogram |

schemaname | yay
tablename | user_gift
attname | user_id
inherited | f
null_frac | 0
avg_width | 4
n_distinct | -0.175761
most_common_vals | {11576819,10299480,14020501,.......,11695667,......}
most_common_freqs | {0.000353333,0.000326667,0.000246667,......,9.33333e-05,......}
histogram_bounds | {3,10002181,10005599,10009672,......,11693300,11698290,......}
correlation | 0.53375
most_common_elems |
most_common_elem_freqs |
elem_count_histogram |

schemaname | yay
tablename | user_gift
attname | user_type
inherited | f
null_frac | 0
avg_width | 4
n_distinct | 3
most_common_vals | {default, invalid, deleted}
most_common_freqs | {0.997923,0.00194,0.000136667}
histogram_bounds |
correlation | 0.99763
most_common_elems |
most_common_elem_freqs |
elem_count_histogram |

从统计信息里可以看出:

  • user_id字段的most_common_vals中有11695667(user_id)的值,则可以直接通过其对应的most_common_freqs来得到其selectivity是9.33333e-05;
  • user_type字段为default对应的selectivity是0.997923。
  • 所以where user_id=11695667 and user_type='default'的selectivity是0.0000933333*0.997923 = 0.0000931394467359。

那么可以估算出满足where条件的用户数是0.0000931394467359 * 9499740(总用户数) = 884.8,和执行计划(cost=0.43..367528.67 rows=884 width=73)的884行一样。

而优化器的估算是基于数据分布均匀这个假设的:

  • 从user_gift_pkey(主键id)扫描的话:只要扫描9499740/884=10746行就能找到满足条件的记录,且无须进行排序(order by id desc)
  • 从idx_user_type索引扫描的话:虽然能很快找到此用户的数据,但是需要给884行进行排序,扫描+排序的cost比从主键扫描要高。

那么数据分布真的均匀吗?继续查看数据的实际分布:

  • 表最大的page=128709
1
2
3
4
5
sql复制代码# select max(ctid) from user_gift;
max
-------------
(128709,29)
(1 row)
  • user id=11695667的最大page=124329
1
2
3
4
5
scss复制代码# select max(ctid), min(ctid) from user_gift where user_id=11695667;
max | min
-------------+-----------
(124329,22) | (3951,64)
(1 row)
  • 表本身的pages和tuples数量
1
2
3
4
5
sql复制代码# SELECT relpages, reltuples FROM pg_class WHERE relname = 'user_gift';
relpages | reltuples
----------+-------------
128875 | 9.49974e+06
(1 row)

每个page存储的记录数:9.49974e+06 tuples / 128875 pages = 73.713 tuples/page。

计算:表(main table)的B+tree的最大page是128709,而实际用户11695667的最大page是124329,128709 - 124329 = 4380,需要扫描4380个page才能找到符合where条件的记录所在的page,所以过滤的rows是4380 pages * 73.713 tuples/page ≈ 322862。

实际limit 1时扫描了5280个pages(包含了主键索引的pages),过滤了333462万行记录,和估算的基本一样:

1
2
3
ini复制代码Rows Removed by Filter: 333462
Buffers: shared hit=214402 read=5280 dirtied=2302
I/O Timings: read=205.027

所以,此用户数据分布倾斜了:

  • 优化器假设数据分布均匀,只需要扫描10746个记录
  • 而实际需要扫描322862个记录

那么扫描5280个pages要多久?

需要读取的数据量:5280pages * 8KB/page = 41.2MB的数据。

1
2
3
4
bash复制代码[root]$ fio -name iops -rw=read -bs=8k -runtime=10 -iodepth=1 -filename /dev/sdb -ioengine libaio -direct=1
...
Run status group 0 (all jobs):
READ: bw=193MiB/s (202MB/s), 193MiB/s-193MiB/s (202MB/s-202MB/s), io=1928MiB (2022MB), run=10001-10001msec

从fio结果可以看出,此数据库机器磁盘的顺序读取速度约为 200MB/s,那么扫描40MB数据需要约200ms,
与实际需要的时间205ms基本相等。

到这里问题基本定位清楚了:

postgreSQL的优化器认为数据分布是均匀的,只需要倒序扫描很快就找到符合条件的记录,而实际上此用户的数据分布在表的前端,就导致了实际执行start-up time如此慢了。

从内核视角来分析

我们从postgreSQL内核的角度来继续分析几个问题:

  • 优化器如何估算cost
  • 优化器如何统计actual time

表的信息

  • 表结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sql复制代码# \d user_gift;
Table "yay.user_gift"
Column | Type | Collation | Nullable | Default
--------------+--------------------------+-----------+----------+------------------------------------------------
id | bigint | | not null | nextval('user_gift_id_seq'::regclass)
user_id | integer | | not null |
ug_name | character varying(100) | | not null |
expired_time | timestamp with time zone | | | now()
created_time | timestamp with time zone | | not null | now()
updated_time | timestamp with time zone | | not null | now()
user_type | user_type | | not null | 'default'::user_type
Indexes:
"user_gift_pkey" PRIMARY KEY, btree (id)
"idx_user_type" btree (user_id, ug_name)
"user_gift_ug_name_idx" btree (ug_name)
  • 主键索引
1
2
3
4
5
sql复制代码# SELECT relpages, reltuples FROM pg_class WHERE relname = 'user_gift_pkey';
relpages | reltuples
----------+-------------
40035 | 9.49974e+06
(1 row)
  • user_id 索引
1
2
3
4
5
sql复制代码# SELECT relpages, reltuples FROM pg_class WHERE relname = 'idx_user_type';
relpages | reltuples
----------+-------------
113572 | 9.49974e+06
(1 row)
  • 表本身的pages是128875
1
2
3
4
5
sql复制代码# SELECT relpages, reltuples FROM pg_class WHERE relname = 'user_gift';
relpages | reltuples
----------+-------------
128875 | 9.49974e+06
(1 row)
  • user id=11695667的数据775行
1
2
3
4
5
6
7
8
9
10
11
sql复制代码=# select count(1) from user_gift where user_id=11695667;
count
-------
775
(1 row)

=# select count(1) from user_gift where user_id=11695667 and user_type = 'default' ;
count
-------
775
(1 row)
  • 树高度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql复制代码# 主键高度
# select * from bt_metap('user_gift_pkey');
magic | version | root | level | fastroot | fastlevel | oldest_xact | last_cleanup_num_tuples
--------+---------+------+-------+----------+-----------+-------------+-------------------------
340322 | 3 | 412 | 2 | 412 | 2 | 0 | 9.31928e+06
(1 row)


// idx_user_type 高度
# select * from bt_metap('idx_user_type');
magic | version | root | level | fastroot | fastlevel | oldest_xact | last_cleanup_num_tuples
--------+---------+-------+-------+----------+-----------+-------------+-------------------------
340322 | 3 | 15094 | 3 | 15094 | 3 | 0 | 9.49974e+06
(1 row)

估算cost

start-up cost

postgreSQL对于每种索引的成本估算是不一样的,我们看看B+tree的start-up成本是如何估算的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
C复制代码// selfuncs.c
void
btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
Cost *indexStartupCost, Cost *indexTotalCost,
Selectivity *indexSelectivity, double *indexCorrelation,
double *indexPages)
{
......

descentCost = ceil(log(index->tuples) / log(2.0)) * cpu_operator_cost;
costs.indexStartupCost += descentCost;

......
// This cost is somewhat arbitrarily set at 50x cpu_operator_cost per page touched
descentCost = (index->tree_height + 1) * 50.0 * cpu_operator_cost;
costs.indexStartupCost += descentCost;

......
}

其实start-up cost估算很简单,只考虑从B+tree的root page遍历到leaf page,且将这个page读入第一个tuple(记录)的cost。

start-up估算公式如下:

{ceil(log⁡2(Nindex,tuple))+(Heightindex+1)×50} ×cpu_operator_cost\left { ceil({\log_2 (N_{index,tuple})}) + (Height_{index} + 1) \times 50 \right }\ \times cpu\_operator\_cost{ceil(log2​(Nindex,tuple​))+(Heightindex​+1)×50} ×cpu_operator_cost

  • N(index,tuple) :索引tuples(记录)数量
  • Height(index) : 索引B+tree的高度
  • cpu_operator_cost : 默认值0.0025

使用user_gift_pkey计划的start-up cost

从上面表信息中可以看出:

  • N(index,tuple) :9.49974e+06,
  • Height(index) : 2

所以

{ceil(log⁡2(9499740))+(2+1)×50} ×cpu_operator_cost=173×0.0025=0.435\left { ceil({\log_2 (9499740)}) + (2 + 1) \times 50 \right }\ \times cpu\_operator\_cost = 173 \times 0.0025 = 0.435{ceil(log2​(9499740))+(2+1)×50} ×cpu_operator_cost=173×0.0025=0.435
和postgreSQL估算的start-up cost=0.43 一样。

使用idx_user_type计划的start-up cost

  • N(index,tuple) :9.49974e+06,
  • Height(index) : 3

{ceil(log⁡2(9499740))+(3+1)×50} ×cpu_operator_cost=223×0.0025=0.5575\left { ceil({\log_2 (9499740)}) + (3 + 1) \times 50 \right }\ \times cpu\_operator\_cost = 223 \times 0.0025 = 0.5575{ceil(log2​(9499740))+(3+1)×50} ×cpu_operator_cost=223×0.0025=0.5575
和postgreSQL估算的start-up cost=0.56 一样。

run cost

run cost的估算是比较复杂的,判断的条件非常多,无法用一个固定的公式计算出来,所以这里就不做计算,有兴趣的可以看postgreSQL源码src/backend/optimizer/path/costsize.c的cost_index函数。

actual start-up time vs estimated start-up cost

刚刚的分析中有一个疑问被忽略了:estimated start-up cost是开始执行计划到从表中读到的第一个tuple的cost(cost is an arbitrary unit);而actual start-up time则是开始执行计划到从表中读取到第一个符合where条件的tuple的时间。这是为什么呢?

SQL处理流程:postgreSQL将SQL转化成AST,然后进行优化,再将AST转成执行器(executor)来实现具体的操作。不进行优化的执行器是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bash复制代码  ┌──────────────┐
│ projection │
└──────┬───────┘
│
│
┌──────▼──────┐
│ limit │
└──────┬──────┘
│
│
┌──────▼──────┐
│ selection │
└──────┬──────┘
│
│
┌──────▼──────┐
│ index scan │
└─────────────┘

简化的执行流程如下:

  • index scan executor:扫描到一个tuple,就返回给selection executor
  • selection executor:对tuple进行过滤,如果符合条件则返回给limit executor,如果不符合则继续调用index scan executor
  • limit executor:当达到limit限制则将数据返回给projection executor
  • projection executor:过滤掉非select列的数据

那么如果进行优化,一般会将selection executor和projection executor合并到index scan executor中执行,以减少数据在executor之间的传递。

1
2
3
4
5
6
7
8
9
10
11
bash复制代码┌─────────────┐
│ limit │
└──────┬──────┘
│
│
┌──────▼──────┐
│ index scan │
│ │
│ + selection │
│ + projection│
└─────────────┘

优化后的执行流程:

  • index scan executor:扫描到tuple,然后进行selection过滤,如果符合条件就进行projection再返回给limit,如果不符合条件,则继续扫描
  • limit executor:当达到limit限制则将数据返回

而通过下面代码可以看出,postgreSQL对于执行时间的统计是基于executor的,

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码// src/backend/executor/execProcnode.c
static TupleTableSlot *
ExecProcNodeInstr(PlanState *node)
{
TupleTableSlot *result;
InstrStartNode(node->instrument);
result = node->ExecProcNodeReal(node);

// 统计执行指标
InstrStopNode(node->instrument, TupIsNull(result) ? 0.0 : 1.0);
return result;
}

所以actual time的start-up是从启动executor直到扫描到符合where语句的第一条结果为止。

再看看实际的函数调用栈,user_id=xxx的过滤已经下沉到index scan executor里面了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ruby复制代码--->    int4eq(FunctionCallInfo fcinfo) (/home/ken/cpp/postgres/src/backend/utils/adt/int.c:379)
ExecInterpExpr(ExprState * state, ExprContext * econtext, _Bool * isnull) (/home/ken/cpp/postgres/src/backend/executor/execExprInterp.c:704)
ExecInterpExprStillValid(ExprState * state, ExprContext * econtext, _Bool * isNull) (/home/ken/cpp/postgres/src/backend/executor/execExprInterp.c:1807)
ExecEvalExprSwitchContext(ExprState * state, ExprContext * econtext, _Bool * isNull) (/home/ken/cpp/postgres/src/include/executor/executor.h:322)
---> ExecQual(ExprState * state, ExprContext * econtext) (/home/ken/cpp/postgres/src/include/executor/executor.h:391)
ExecScan(ScanState * node, ExecScanAccessMtd accessMtd, ExecScanRecheckMtd recheckMtd) (/home/ken/cpp/postgres/src/backend/executor/execScan.c:227)
---> ExecIndexScan(PlanState * pstate) (/home/ken/cpp/postgres/src/backend/executor/nodeIndexscan.c:537)
ExecProcNodeInstr(PlanState * node) (/home/ken/cpp/postgres/src/backend/executor/execProcnode.c:466)
ExecProcNodeFirst(PlanState * node) (/home/ken/cpp/postgres/src/backend/executor/execProcnode.c:450)
ExecProcNode(PlanState * node) (/home/ken/cpp/postgres/src/include/executor/executor.h:248)
---> ExecLimit(PlanState * pstate) (/home/ken/cpp/postgres/src/backend/executor/nodeLimit.c:96)
ExecProcNodeInstr(PlanState * node) (/home/ken/cpp/postgres/src/backend/executor/execProcnode.c:466)
ExecProcNodeFirst(PlanState * node) (/home/ken/cpp/postgres/src/backend/executor/execProcnode.c:450)
ExecProcNode(PlanState * node) (/home/ken/cpp/postgres/src/include/executor/executor.h:248)
ExecutePlan(EState * estate, PlanState * planstate, _Bool use_parallel_mode, CmdType operation, _Bool sendTuples, uint64 numberTuples, ScanDirection direction, DestReceiver * dest, _Bool execute_once) (/home/ken/cpp/postgres/src/backend/executor/execMain.c:1632)
standard_ExecutorRun(QueryDesc * queryDesc, ScanDirection direction, uint64 count, _Bool execute_once) (/home/ken/cpp/postgres/src/backend/executor/execMain.c:350)
ExecutorRun(QueryDesc * queryDesc, ScanDirection direction, uint64 count, _Bool execute_once) (/home/ken/cpp/postgres/src/backend/executor/execMain.c:294)
ExplainOnePlan(PlannedStmt * plannedstmt, IntoClause * into, ExplainState * es, const char * queryString, ParamListInfo params, QueryEnvironment * queryEnv, const instr_time * planduration, const BufferUsage * bufusage) (/home/ken/cpp/postgres/src/backend/commands/explain.c:571)
ExplainOneQuery(Query * query, int cursorOptions, IntoClause * into, ExplainState * es, const char * queryString, ParamListInfo params, QueryEnvironment * queryEnv) (/home/ken/cpp/postgres/src/backend/commands/explain.c:404)
ExplainQuery(ParseState * pstate, ExplainStmt * stmt, ParamListInfo params, DestReceiver * dest) (/home/ken/cpp/postgres/src/backend/commands/explain.c:275)

下面代码是scan的实现,其中的ExecQual(qual, econtext)是对tuple进行过滤,因为selection已经合并到scan中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ini复制代码TupleTableSlot *
ExecScan(ScanState *node, ExecScanAccessMtd accessMtd, ExecScanRecheckMtd recheckMtd)
{
......
for (;;)
{
TupleTableSlot *slot;
slot = ExecScanFetch(node, accessMtd, recheckMtd);
......
econtext->ecxt_scantuple = slot;

// Note : selection判断
if (qual == NULL || ExecQual(qual, econtext))
{
if (projInfo)
{
return ExecProject(projInfo);
}
else
{
return slot;
}
}
else
InstrCountFiltered1(node, 1);
}
}

解决方案

禁用走主键扫描

既然计划走的是user_gift_pkey倒序扫描,那么我们可以手动避免优化器使用这个索引。

1
sql复制代码# explain analyze verbose select xxx from user_gift where user_id=11695667 and user_type = 'default' order by id+0 desc limit 1;

将order by id改成order by id+0,由于id+0是个表达式所以优化器就就不会使用user_gift_pkey这个索引了。

这个方案不适合所有场景,如果数据分布均匀的话则某些情况下使用user_gift_pkey扫描更加合理。

增加(user_id, id)索引

1
csharp复制代码create index idx_user_id on user_gift(user_id, id);

通过增加where条件列和排序键的复合索引,来避免走主键扫描。

写在最后

从排除缓存因素,分析查询计划,定位数据分布倾斜,到调试内核源码来进一步确定原因,最终成功解决性能问题。通过这个有趣的SQL优化经历,相信能给大家带来收获。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【设计模式】通过简单案例学习组合模式

发表于 2021-11-05

「这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战」

组合模式

  组合模式(Composite Pattern),又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次。
  可以通过一个或者多个简单的对象,经过组合之后,生成一个新的对象,原来的对象是这个对象的元素。这种类型的设计模式属于结构型模式,它创建了对象组的树形结构。

  组合模式也可以看成是对某些对象的迭代调用的方式。

  组合模式优点:

  • 1、高层模块调用简单。
  • 2、节点自由增加。

案例分析

  下面通过一个大家都熟悉的示例,进行组合模式的初步理解。这个示例来源于往年的软考下午试题。

  如下图预构造一个文件目录树,就是我们常用的文件夹和文件的关系类似的。文件夹里面可以包含文件和文件夹,二层文件夹下又可以包含文件和文件夹。其中一层文件夹是二层文件夹和文件的父类;而三层文件夹或者文件又是二层文件夹的子类。类似这样的树状结构, 采用组合设计模式来设计。
图片.png

分析

  性和动作,不管文件和文件夹,都有名称、大小、格式,都可以对文件和文件夹进行操作,包含修改、删除、移动等操作方式。那么可以将这些公共的操作和属性值提取出来一个抽象类。然后用一个子属性来代替下层数据信息。

开发

  可以从图中看到,针对公共属性和方法进行提取之后,可以创建一个AbstractFile的抽象类。其中包含:文件夹、文件的名称;添加文件或者文件夹的方法;删除文件或者文件夹的方法;移动文件或者文件夹的方法;获取文件子文件列表的方法。
AbstractFile的抽象类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
csharp复制代码/**
* @ClassName AbstractFile
* @Description:
* @Author 公众号:Java全栈架构师
* @Version V1.0
**/
public abstract class AbstractFile {
protected String fileName;

/**
* 输出文件、文件夹名称
* */
public void printFileName(){
System.out.println(fileName);
}

/**
* 添加文件或者文件夹
* */
public abstract boolean addChild(AbstractFile abstractFile);

/**
* 删除文件或者文件夹
* */
public abstract boolean delChild(AbstractFile abstractFile);

/**
* 移动文件或者文件夹
* */
public abstract boolean removeChild(AbstractFile abstractFile);

/**
* 获取文件子文件列表
* */
public abstract List<AbstractFile> getChildAbstractFileList();
}

  现在编写文件的具体实现类,因为文件没有子文件或文件夹,所以我们子啊获取文件子文件列表的时候,默认就返回null即可。这一点需要注意,和我们业务中的点一样,在编写代码实现的时候,对业务的基本流程和属性是需要一定了解的。其中在方法返回值之前,可以加入实际的业务逻辑,如果成功就返回true;如果处理失败,就返回false。系统会默认的返回false;

  文件File的类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
typescript复制代码/**
* @ClassName File
* @Description:
* @Author 公众号:Java全栈架构师
* @Version V1.0
**/
public class File extends AbstractFile {

public File(String name){
this.fileName = name;
}
@Override
public boolean addChild(AbstractFile abstractFile) {
return false;
}

@Override
public boolean delChild(AbstractFile abstractFile) {
return false;
}

@Override
public boolean removeChild(AbstractFile abstractFile) {
return false;
}

@Override
public List<AbstractFile> getChildAbstractFileList() {
return null;
}
}

  在创建文件夹的下的子文件或文件的集合的时候,需要注意去获取文件夹下的子文件和子文件夹下的文件和文件夹,需要层层迭代下去,本文作为示例,直接使用new 去创建了。这点需要注意。

  Folder文件夹的类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
typescript复制代码/**
* @ClassName Folder
* @Description:
* @Author 公众号:Java全栈架构师
* @Version V1.0
**/
public class Folder extends AbstractFile {
private List<AbstractFile> list;

public Folder(String name) {
this.fileName = name;
this.list = new ArrayList<AbstractFile>();
}

@Override
public boolean addChild(AbstractFile abstractFile) {
this.list.add(abstractFile);
return false;
}

@Override
public boolean delChild(AbstractFile abstractFile) {
return false;
}

@Override
public boolean removeChild(AbstractFile abstractFile) {
return false;
}

@Override
public List<AbstractFile> getChildAbstractFileList() {
return list;
}
}

运行

  以上基础模块已经搭建完成了,包含文件夹Folder和文件File两个。他们都有共同的方法。那么现在开始变写一下 遍历目录下的文件和文件夹名称的方法,输出文件名和文件夹名称。

  首先初始化模拟一个文件夹及文件的目录格式:其中根目录是:D:\,下面有几个文件夹分别为:“Linux”和“Win”两个,这两个文件夹下还有下层子集文件和文件夹。编写一个输出文件和文件夹名称的方法printFileTree,由于整个目录树都是基于文件夹Folder和文件File两个构建的,因此在输出过程中需要迭代使用printFileTree,具体方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
ini复制代码/**
* @ClassName Folder
* @Description:
* @Author 公众号:Java全栈架构师
* @Version V1.0
**/
public class MainApp {
public static void main(String[] args) {
AbstractFile rootFolder =new Folder("D:\\");
AbstractFile linuxFolder =new Folder("Linux");
AbstractFile winFolder =new Folder("Win");
AbstractFile jdkFolder =new Folder("jdk");
AbstractFile testFile =new File("test.java");
AbstractFile linuxTestFile =new File("Linux_test.java");
rootFolder.addChild(linuxFolder);
rootFolder.addChild(winFolder);
winFolder.addChild(jdkFolder);
jdkFolder.addChild(testFile);
linuxFolder.addChild(linuxTestFile);
printFileTree(rootFolder);
}

public static void printFileTree(AbstractFile abstractFile) {
abstractFile.printFileName();
List<AbstractFile> abstractFileList = abstractFile.getChildAbstractFileList();
if (abstractFileList == null) {
return;
} else {
for (AbstractFile abstractFiles : abstractFileList) {
printFileTree(abstractFiles);
}
}
}
}

  运行之后输出结果如下,可以看到文件目录的层级信息。

1
2
3
4
5
6
makefile复制代码D:\
Linux
Linux_掘金.java
Win
jdk
test_掘金.java

图片.png

结语

  好了,一个简单的组合模式就介绍完成了,学习设计模式的时候,尽量结合熟悉的案例进行分析,这样的话就会有事半功倍的奇效,也更容易理解。大家可以尝试着在项目中使用设计模式的方式进行开发。

  感谢您的阅读,希望您喜欢,如对您有帮助,欢迎点赞收藏。如有不足之处,欢迎评论指正。下次见。

  作者介绍:【小阿杰】一个爱鼓捣的程序猿,JAVA开发者和爱好者。公众号【Java全栈架构师】维护者,欢迎关注阅读交流。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

python中使用闭包及修改外部函数的局部变量 函数中定义函

发表于 2021-11-05

函数中定义函数

在python中,函数可以被嵌套定义,也就是说,函数中可以定义函数。该函数还可以将其内部定义的函数作为返回值返回。

1
2
3
4
5
6
7
8
9
10
11
12
python复制代码def func():
print("这是外部的函数")
def func1():
print("这是里面的函数")
return func1

func()
print("*" * 50)

# 由于func()的返回值就是函数
# 因此我们可以通过func()()调用里面的函数
func()()

闭包

闭包的定义:一般来说,我们可以认为,如果一个函数可以读取其他函数中的局部变量,那么它们就构成了闭包。

注意:闭包的定义不是特别清晰,但大体上的意思是这样的。

我们知道,普通的函数是可以使用全局变量的

1
2
3
4
5
6
7
8
python复制代码a = 100

# 此时,a=100是一个全局变量
# 但是在该函数中,是可以使用全局变量的
def func():
print("a: ", a)

func()

类似的,函数中定义的函数,也是可以使用外部函数的变量的。因此,满足了函数读取了其他函数局部变量的这一条件,他们因此构成了闭包。

1
2
3
4
5
6
7
8
9
python复制代码def func():
a = 100
# 此时的a为函数func的局部变量
# 局部变量a可以被func函数中定义的内部函数所读取
def func1():
print(a)
return func1

func()()

在闭包的使用中,我们可以先给外部的函数赋予不同的局部变量,然后再调用其中内部的函数时,就可以读取到这些不同的局部变量了。

1
2
3
4
5
6
7
8
9
10
11
12
python复制代码def func(x, y):
def func1(a, b):
return a * x + b * y
return func1

f = func(3, 5)
print(f(1, 2))
print(f(2, 3))

f2 = func(4, 6)
print(f2(1, 2))
print(f2(2, 3))

外部变量的使用
在普通函数中,虽然可以直接使用全局变量,但是不可以直接修改全局变量。从变量的作用域来说,一旦你尝试修改全局变量,那么就会尝试创建并使用一个同名的局部变量。因此,如果你需要在普通函数中修改全局变量,需要使用global

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
python复制代码a = 100

def func1():
# 直接使用全局变量,可以
print("func1:", a)

def func2():
# 直接修改全局变量,不可以
# 该代码会创建同名的局部变量
a = 200
print("func2:", a)

def func3():
global a
a = 300
print("func3:", a)

func1()

# 执行完func2,全局的a的值不会被改变
func2()
print(a)

# 执行func3,全局的a的值发生了改变
func3()
print(a)

同样的,如果你希望通过定义在内部的函数去修改其外部函数的变量,那么必须使用nonlocal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码def func1():
a = 100
def func1_inner():
a = 200
func1_inner()
print("func1:", a) # 此时a仍然为100,不会被修改

def func2():
a = 100
def func2_inner():
nonlocal a
a = 200
func2_inner()
print("func2:", a) # 此时a已经被修改为了200

func1()
func2()

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Gson流式反序列化JsonReader

发表于 2021-11-05

「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战」。

前面介绍了一片Gson的流式序列化,接下来我们看一下流式的反序列化,主要借助JsonReader来实现

1. 关键类与方法

流式反序列化,关键实现类为 JsonReader,每次在使用前后需要通过 beginObject/endObject来声明一个序列化的过程

1
2
3
4
java复制代码JsonReader reader = new JsonReader(new StringReader(json));
reader.beginObject(); // throws IOException
// ....
reader.endObject();

2. 实例演示

接下来通过一个实例来演示流式的反序列化过程

json串 "{\"user\": \"一灰灰blog\", \"age\": 18}"

反序列化过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码@Data
public static class User {
private String user;
private int age;
}

@Test
public void testReader() throws IOException {
String str = "{\"user\": \"一灰灰blog\", \"age\": 18}";

User user = new User();
JsonReader reader = new JsonReader(new StringReader(str));
reader.beginObject();
while(reader.hasNext()) {
String key = reader.nextName();
if ("user".equalsIgnoreCase(key)) {
user.setUser(reader.nextString());
} else if ("age".equalsIgnoreCase(key)) {
user.setAge(reader.nextInt());
}
}
reader.endObject();
System.out.println(user);
}

从上面的反序列化case,可以看出对于一层json串而言,比较简单,那么如果json串中的value是一个对象,或者数组,那改怎么处理呢?

如我们在User类中,新增一个列表对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码@Data
public static class User {
private String user;
private int age;
private List<String> skills;
}

@Test
public void testReader() throws IOException {
String str = "{\"user\": \"一灰灰blog\", \"age\": 18, \"skills\": [\"java\", \"python\"]}";

User user = new User();
JsonReader reader = new JsonReader(new StringReader(str));
reader.beginObject();
while(reader.hasNext()) {
String key = reader.nextName();
if ("user".equalsIgnoreCase(key)) {
user.setUser(reader.nextString());
} else if ("age".equalsIgnoreCase(key)) {
user.setAge(reader.nextInt());
} else if ("skills".equalsIgnoreCase(key)) {
// 注意这个实现,支持了嵌套的操作
reader.beginArray();
List<String> skills = new ArrayList<>();
while (reader.hasNext()) {
skills.add(reader.nextString());
}
user.setSkills(skills);
reader.endArray();
}
}
reader.endObject();
System.out.println(user);
}

一灰灰的联系方式

尽信书则不如无书,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

  • 个人站点:blog.hhui.top
  • 微博地址: 小灰灰Blog
  • QQ: 一灰灰/3302797840
  • 微信公众号:一灰灰blog

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一个 DDD 小白的取经之路 前言 为什么要使用 DDD D

发表于 2021-11-05

前言

工作中我们经常听周围的同事谈起 DDD 这个东西,但是大部分时候,我们对于 DDD 是什么,为什么要使用 DDD ,以及如何使用 DDD 还是存在不少的疑惑。本文从一个 DDD 小白的角度,介绍一下我理解的 DDD 是什么,并且通过一个典型的案例简单实践一下 DDD。

为什么要使用 DDD

如果我们的业务非常简单,普通的 CRUD 就能满足大部分的业务需求,那我们完全不需要 DDD。然而,随着需求的不断迭代,系统的不断演化,我们面临的问题越来越复杂,业务逻辑也越来越复杂,由此产生的一个问题是,模块之间过度耦合,当修改一个功能时,往往只是回溯该功能的修改点就需要很长时间,更别提修改带来的不可预知的影响面。

下图是一个模块过度耦合的示意图:

订单服务中包含了订单接口、评价接口、支付接口和保险接口,多个不同的业务模块耦合在一起,牵一发而动全身。此外,订单表也是一个大表,包含了非常多的字段。当我们维护代码时,有可能只是想修改一下评价相关的功能,却影响到了订单相关的功能。虽然我们可以通过测试保证功能的正确性,但是,当我们在订单领域下有大量的需求并行开发时,我们很有可能就会顾此失彼,从而降低开发的效率。

为了解决模块过度耦合的问题,我们可以采用重构的方式,在保证代码行为不变的前提下,改善局部不协调的设计,提高代码的可读性、可维护性和可测试性。业务初期,功能比较简单,我们往往采用过程式开发的方式快速实现业务需求。当业务发展到一定阶段时,系统逐渐变得复杂,传统的过程式开发已经不能满足业务快速迭代的需求了,此时,我们会对系统做一定的重构,通过一些重构手法或设计模式将原有的代码组织成可读性、可维护、可测试性更强的代码,方便我们继续在原有的系统上进行快速迭代。

然而,仅仅通过重构的方式只能赋予系统一个技术上的含义,很难给它一个业务上的含义。这会带来什么问题呢?在回答这个问题之前,想一想我们平时是怎么通过代码解决业务问题的。在拿到一个业务问题之后,我们首先会将问题映射为脑海中的一个概念模型,然后在模型中解决问题,最后再将解决方案转化为代码。假设我们现在是一个团队的新同学,并没有参与到团队之前对系统的建设或者重构中来,这就导致在开发新需求时,我们并不能很自然地将一个业务问题映射为一个概念模型,相反,我们需要深入到系统的设计模型之中,梳理出与我们本次需求相关的修改点,然后再着手进行开发,这样一来就提高了系统的理解成本。此外,当我们在和业务同学聊需求的时候,也会由于双方在概念模型理解上的差异造成需求沟通上的困难。

使用 DDD 可以很好地解决概念模型到设计模型的同步和演化,同时将反映了概念模型的设计模型转换为实际的代码。

DDD 是什么

DDD(Domain-Driven Design),中文叫领域驱动设计,是一套应对复杂软件系统分析和设计的面向对象的建模方法论。

2003 年,Eric Evans 发表了一篇著作《Domain-driven Design: Tackling Complexity in the Heart of Software》,正式定义了领域的概念,开始了 DDD 的时代。2013 年,Vaughn Vernon 出版了《Implementing Domain-Driven Design》 进一步定义了 DDD 的领域方向,并且给出了很多落地指导,让 DDD 离人们又近了一步。

DDD 中包含很多概念,比如领域、限界上下文、聚合、实体、值对象等。我们之所以觉得 DDD 很难学,就是因为这些概念太抽象了,导致 DDD 在实践中很难落地。说实话,我也觉得 DDD 中的一些概念很抽象,但是,就像我们想学好编程就必须学好数据结构和算法一样,数据结构和算法也很抽象,但是,它作为计算机学科的基础知识,对于我们理解程序是如何组织和运行的很有帮助,而且其中的很多的思想也会被我们潜移默化地应用到日常的开发当中。类似地,当我们在实践 DDD 的时候,虽然不会刻意地去套领域、限界上下文这些概念,但是学习这些概念有助于我们更好地理解 DDD 的思想,从而在实践中更好地发挥 DDD 的优势,享受 DDD 给我们日常开发带来的便利。

我这里简单罗列一下我认为 DDD 中的一些比较重要的概念,关于 DDD 的更多知识大家可以阅读 Eric Evans 的 《Domain-driven Design: Tackling Complexity in the Heart of Software》或其他资料。

  • 领域(Domain) :领域就是范围,可以类比为微服务中的一个服务。领域的核心思想是将问题逐级细分来降低业务和系统的复杂度,是 DDD 的核心概念。领域又可以细分为子域、核心域、通用域、支撑域等。
  • 限界上下文(Bounded Context) :即定义上下文的边界,可以类比为微服务中服务职责划分的边界。领域存在于边界之内。对于同一个概念,不同上下文会有不同的理解,比如商品,在销售阶段叫商品,在运输阶段就叫货品。
  • 聚合(Aggregate) :聚合类似于包的概念,每个包里包含一类实体或者行为,它有助于分散系统的复杂性。
  • 实体(Entity) :《Patterns, Principles, and Practices of Domain-Driven Design》(领域驱动设计模式、原理与实践)一书中对实体的定义为:实体是具有唯一标识的身份和连续性的领域概念。从上面的定义可以看出,实体也是一种特殊的领域。这里我们需要注意两点:唯一标识的身份、连续性,两者缺一不可。你可以想象,文章可以是实体,作者也可以是实体,因为它们有 id 作为唯一标识。
  • 值对象(Value Object) :为了更好地展示领域之间的关系制定的一个对象,本质上也是一种实体,但相对实体而言,它没有身份和状态,它存在的目的就是为了表示一个值。比如 money,让它具有 id 显然是不合理的,你也不可能通过 id 查询一个 money。

下图是一种常见的 DDD 设计图:

上面我们介绍了 DDD 中的一些概念 ,下面我们通过一个典型的案例,来简单介绍一下如何实践 DDD,这里主要用到了值对象的思想 。

案例分析

假设现在我们接到了产品经理的一个需求如下:

我们的应用准备在全国范围内通过业务员做推广,现在需要在应用内实现一个用户注册系统,同时希望在用户注册后能够通过用户电话号码(假设目前只有座机)的区号对业务员发放奖金。

根据上述需求,一个简单的用户注册系统的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
java复制代码public class User {
  Long userId;
  String name;
  String phone;
  String address;
  Long repId;
}
​
public class RegistrationServiceImpl implements RegistrationService {
 
@Autowired
  private SalesRepRepository salesRepRepo;
 
@Autowired
  private UserRepository userRepo;
​
  public User register(String name, String phone, String address) throws ValidationException {
    // 校验逻辑
    if (name == null || name.length() == 0) {
      throw new ValidationException("name");
   }
    if (phone == null || !isValidPhoneNumber(phone)) {
      throw new ValidationException("phone");
   }
    // 此处省略 address 的校验逻辑
​
    // 取电话号码里的区号,然后通过区号找到区域内的 SalesRep
    String areaCode = null;
    String[] areas = new String[]{"0571", "021", "010"};
    for (int i = 0; i < phone.length(); i++) {
      String prefix = phone.substring(0, i);
      if (Arrays.asList(areas).contains(prefix)) {
        areaCode = prefix;
        break;
     }
   }
    SalesRep rep = salesRepRepo.findRep(areaCode);
​
    // 创建用户,落库并最后返回
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) {
      user.repId = rep.repId;
   }
​
    return userRepo.save(user);
 }
​
  private boolean isValidPhoneNumber(String phone) {
    String pattern = "^0[1-9]{2,3}-?\d{8}$";
    return phone.matches(pattern);
 }
}

相信我们日常开发中绝大部分的代码应该都和这段代码类似,乍一看好像没什么问题,但是,当我们再深入一步,从接口的清晰度、数据校验和错误处理的方式、以及业务逻辑的清晰度这三个维度去分析一下,这段代码可能并不像我们想象地那么好用。

1. 接口的清晰度

对于 Java 中的一个方法来说,所有的参数名在编译时都会丢失,留下的仅仅是一个参数类型的列表。所以我们重新看一下以上代码的接口定义,其实在运行时仅仅是:

1
java复制代码User register(String, String, String);

因此下面这段代码是一段编译器完全不会报错的代码,很难通过肉眼发现其中隐藏的 bug(正确的调用方法是第二个入参是电话号码,第三个入参是地址):

1
java复制代码service.register("zhaoqiang05", "浙江省杭州市", "0571-12345678");

当然,上面的代码在运行时还是会报错,但是,这种 bug 是在运行时被发现的,而不是在编译时,即使通过 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。那么,有没有办法让方法入参一目了然,避免入参错误导致出现 bug?

2. 数据校验和错误处理的的方式

这是代码中的数据校验部分:

1
2
3
java复制代码if (phone == null || !isValidPhoneNumber(phone)) {
  throw new ValidationException("phone");
}

类似的代码在我们的日常编码中也会经常出现,一般来说,这种代码需要出现在方法的最前端,确保能够 fail-fast(快速失败)。但是假设你有多个类似的接口和类似的入参,那么在每个方法里都要加入类似的校验逻辑,造成代码的重复。更严重的是,如果未来我们要扩展用户电话号码包含手机时,很可能需要加入以下代码:

1
2
3
java复制代码if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
  throw new ValidationException("phone");
}

如果你有很多个地方用到了 phone 这个入参,但是有个地方忘记修改了,会产生 bug 。这是一个 DRY(Don’t Repeat Yourself) 原则被违背时经常会发生的问题。

如果现在有个新的需求,需要把入参错误的原因返回,那么这段代码就会变得更加复杂:

1
2
3
4
5
java复制代码if (phone == null) {
  throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
  throw new ValidationException("phone格式错误");
}

能够想像得到,代码里充斥着大量类似的代码时,维护成本要有多高。

此外,这个业务方法会抛 ValidationException,所以需要外部去捕获这个异常,造成业务逻辑异常和数据校验异常被混在了一起,这样是否是合理的呢?

对于数据校验问题,传统的 Java 架构有一些方法可以解决一部分问题,比如 Validator 或编写一个静态工具类 ValidationUtils,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// 使用 Validator
public User registerWithBeanValidation(
 @NotNull @NotBlank String name,
 @NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\d{8}$") String phone,
 @NotNull String address) {
...
}
​
// 使用 ValidationUtils
public User registerWithUtils(String name, String phone, String address) {
  ValidationUtils.validateName(name);
  ValidationUtils.validatePhone(phone);
  ValidationUtils.validateAddress(address);
 ...
}

但这几个方法同样有问题:

  • Validator:
    • 通常只能解决简单的逻辑校验,复杂的校验逻辑一样要写代码实现定制的校验器
    • 在添加了新校验逻辑时,同样会出现在某些地方忘记添加注解,导致 DRY 原则被违背
  • ValidationUtils 类:
    • 当大量的校验逻辑集中在一个类里之后,违背了单一性原则,导致代码混乱和难以维护
    • 业务逻辑异常和数据校验异常还是会混杂在一起

那么,有没有一种方法能够一劳永逸的解决所有的校验问题,降低后续的维护成本呢?

3. 业务逻辑的清晰度

再看获取 SalesRep 的这段代码:

1
2
3
4
5
6
7
8
9
10
java复制代码String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
  String prefix = phone.substring(0, i);
  if (Arrays.asList(areas).contains(prefix)) {
    areaCode = prefix;
    break;
 }
}
SalesRep rep = salesRepRepo.findRep(areaCode);

这段代码做的一件事情就是:首先从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,最后从新的数据中再抽取部分数据用作他用。我们一般称这种代码为胶水代码,其本质是由于外部服务的入参并不符合我们原始的入参导致的。比如,如果 SalesRepRepository 包含一个 findRepByPhone 方法,那么上面大部分的代码都不需要了。

解决这种胶水代码的一个常见的做法是,利用重构中抽取函数的方式将这段代码抽取出来,变成独立的一个或多个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码private static String findAreaCode(String phone) {
  for (int i = 0; i < phone.length(); i++) {
    String prefix = phone.substring(0, i);
    if (isAreaCode(prefix)) {
      return prefix;
   }
 }
  return null;
}

private static boolean isAreaCode(String prefix) {
  String[] areas = new String[]{"0571", "021"};
  return Arrays.asList(areas).contains(prefix);
}

然后原始代码变为:

1
2
java复制代码String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

为了复用以上获取 areaCode 的方法,我们可能会选择抽离出一个静态工具类 PhoneUtils 。但是,这里仍然要考虑,静态工具类是否是最好的实现方式?当你的项目中充斥着大量的静态工具类,业务代码散落在多个类中时,你是否还能找到核心的业务逻辑呢?

解决方案

现在,让我们重新看一下这个需求,并且标注其中可能重要的概念:

我们的应用准备在全国范围内通过业务员做推广,现在需要在应用内实现一个用户注册系统,同时希望在用户注册后能够通过用户电话号码(假设目前只有座机)的区号对业务员发放奖金。

分析上述需求后我们发现,业务员、用户带有唯一身份标识 ID,属于实体(Entity),而注册系统属于应用服务(Application Service),但是电话号码这个概念却完全被隐藏到了代码之中。我们可以问一下自己,取电话号码的区号的逻辑是否属于用户?是否属于注册服务?如果都不是很贴切,那就说明这个逻辑是一个独立的概念。实际上,电话号码的区号是包含业务逻辑的,我们可以通过写一个值对象(Value Object) 将电话号码的概念显性化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
java复制代码public class PhoneNumber {

  private final String number;
  
  public String getNumber() {
    return number;
 }

  public PhoneNumber(String number) {
    if (number == null) {
      throw new ValidationException("number不能为空");
   } else if (isValid(number)) {
      throw new ValidationException("number格式错误");
   }
    
    this.number = number;
 }

  public String getAreaCode() {
    for (int i = 0; i < number.length(); i++) {
      String prefix = number.substring(0, i);
      if (isAreaCode(prefix)) {
        return prefix;
     }
   }
    
    return null;
 }

  private static boolean isAreaCode(String prefix) {
    String[] areas = new String[]{"0571", "021", "010"};
    return Arrays.asList(areas).contains(prefix);
 }

  public static boolean isValid(String number) {
    String pattern = "^0?[1-9]{2,3}-?\d{8}$";
    return number.matches(pattern);
 }

}

这里面有几个很重要的元素:

  • 通过 private final String number 确保 PhoneNumber 是一个不可变的值对象。
  • 校验逻辑都放在了构造函数里面,确保只要 PhoneNumber 的对象被创建出来后,一定是校验通过的。
  • 之前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。

这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号码这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

我们看一下全面使用了值对象之后的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class User {
  UserId userId;
  Name name;
  PhoneNumber phone;
  Address address;
  RepId repId;
}
​
// 注意这里 register 方法的三个入参均改为了值对象
public User register(
 @NotNull Name name,
 @NotNull PhoneNumber phone,
 @NotNull Address address
) {
  // 找到区域内的 SalesRep
  SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
​
  // 创建用户,落库并最后返回
  User user = new User();
  user.name = name;
  user.phone = phone;
  user.address = address;
  if (rep != null) {
    user.repId = rep.repId;
 }
​
  return userRepo.saveUser(user);
}

我们可以看到,在使用了值对象之后,所有的数据校验逻辑和非核心的业务逻辑都消失了,剩下都是核心的业务逻辑。我们重新用上面的三个维度来评估一下现在的代码:

1. 接口的清晰度

重构后的方法签名变得很清晰:

1
java复制代码public User register(Name, PhoneNumber, Address)

而之前容易出现的 bug,如果按照现在的写法是:

1
java复制代码service.register(new Name("zhaoqiang05"), new Address("浙江省杭州市"), new PhoneNumber("0571-12345678"));

可以看到无论是入参的类型还是值都一目了然。

2. 数据校验和错误处理的方式

1
2
3
4
5
java复制代码public User register(
 @NotNull Name name,
 @NotNull PhoneNumber phone,
 @NotNull Address address
) // No throws

如代码所示,重构后的方法中完全没有了任何数据校验的逻辑,也不会抛 ValidationException 。原因是,因为值对象的特性,只要是能够带到入参里的一定是正确的。我们把数据校验的工作前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。

此外,使用值对象的另外一个好处就是,代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber 的校验逻辑,只需要在一个文件里修改即可,所有使用到 PhoneNumber 的地方都会生效。

3. 业务代码的清晰度

1
2
3
java复制代码SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 findAreaCode 被改为了 PhoneNumber 类的一个计算属性 getAreaCode ,让代码清晰度大大提升。而且胶水代码通常都是不可复用的,但是使用了值对象之后,变成了可复用、可扩展的代码。我们能够看到,在移除了数据校验代码、胶水代码之后,剩下的都是核心的业务逻辑。

一些讨论

1. 值对象和 DTO(Data Transfer Object)的区别

在日常开发中经常会碰到的另外一个数据结构是 DTO,比如方法的入参和出参。值对象和 DTO 的主要区别如下:

值对象DTO功能表示特定业务领域的概念数据传输和数据的关联数据之间具有高相关性数据之间不一定有关联,只是一堆数据放在一起行为丰富的行为和业务逻辑无行为

2. 什么情况下应该使用值对象

常见的使用值对象的场景包括:

  • 有格式限制的 String:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等
  • 有限制的 Integer:比如OrderId(> 0),Percentage(0-100%),Quantity(>= 0)等
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map<String, List<Integer>> 等,尽量把 Map 的所有操作包装掉,仅暴露必要行为

总结

本文先介绍了一些 DDD 相关的背景和概念,然后通过一个案例分析了如何在代码中实践 DDD 中值对象的思想以及带来的好处,最后讨论了值对象和 DTO 的一些区别和值对象的适用场景。

值对象只是 DDD 思想的冰山一角,本文作为 DDD 小白也还只是在 DDD 的大门前徘徊,不过确实能感受到 DDD 带给业务开发的便利。希望未来可以继续深入学习下 DDD,并有机会参与 DDD 相关的实践和落地,早日踏进 DDD 的大门。

参考

  1. Domain-driven Design: Tackling Complexity in the Heart of Software
  2. Implementing Domain-Driven Design
  3. 阿里技术专家详解 DDD 系列- Domain Primitive
  4. 领域驱动设计在互联网业务开发中的实践
  5. 领域驱动设计—概念篇
  6. DDD 模式从天书到实践

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

如何利用重构提高代码质量 一、一个关于 ID 生成器的案例

发表于 2021-11-05

一、一个关于 ID 生成器的案例

某日某公司某部门周会上:

老板:”当前我们系统出错一般是通过看日志来排查问题,但是现在我们系统每天的日志量太大了,而且日志文件中不同请求的日志会交织在一起,没办法直接看到某个请求的所有日志,这个大家有什么好办法吗?”

小王:”老板,我觉得我们可以借鉴微服务中调用链追踪的思路,给每个请求分配一个唯一的 ID,保存在请求的上下文中,比如保存在 ThreadLocal 中。每次打印日志的时候,我们从请求上下文中取出 ID,跟日志一块输出,这样,同一个请求的所有日志都包含同样的 ID,我们就可以通过 ID 来搜索同一个请求的所有日志了。”

老板:”嗯,这个方案可以,不过这个 ID 应该如何生成呢?”

小王:”我们需要先定义这个 ID 的格式,比如将 ID 分成三部分,第一部分是本机名的最后一个字段,第二部分是当前时间戳,第三部分是 8 位的随机字符串,包含大小写字母和数字,然后借助一个 ID 生成器生成这个 ID。”

老板:”通过这种方式生成的 ID 不会重复吗?”

小王:”通过这种方式生成的 ID 确实有重复的可能,但是事实上重复的概率非常低,对于我们这个日志追踪的需求来说,极小概率的 ID 重复是完全可以接受的。”

老板:”好的,那就由你来负责这个 ID 生成器的开发吧。”

小王:”收到。”

周会结束后,回到工位的小王开始着手于这个 ID 生成器的开发。对于稍微有点开发经验的小王来说,实现这样一个 ID 生成器并不是件难事。很快,小王就将代码写了出来,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

public static String generate() {
String id = "";
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\.");
if (tokens.length > 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8) {
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii >= 65 && randomAscii <= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii >= 97 && randomAscii <= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s", hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}

return id;
}
}

写好代码后,小王兴冲冲地把代码发给老板,请求老板做一下 code review,老板看了看小王的代码,皱了皱眉头,把代码打回给了小王,告诉他代码不够规范,让他重构一下。小王挠了挠头,不知道该如何重构。假设我们现在是小王的同事,我们应该如何帮助小王去重构这样一份代码呢?

在进行重构之前,我们先了解一些重构相关的知识。

二、为什么要重构

首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在演进,代码在不停地堆砌,如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进,当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,想要再去重构,已经没有人能做到了。

其次,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。

最后,重构可以帮助我们学习经典的设计思想、设计原则、设计模式和编程规范。重构实际上就是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力。

三、重构是什么

软件设计大师 Martin Fowler 是这样定义重构的:

重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。

简单来说,重构就是,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。

根据重构的规模,我们可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次重构(以下简称为“小型重构”):

  • 大型重构:大型重构指的是对顶层代码设计的重构,包括系统、模块、代码结构、类与类之间的关系等的重构。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大。
  • 小型重构:小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。

四、什么时候重构

是代码烂到一定程度之后才去重构吗?当然不是。因为当代码真的烂到出现“开发效率低,招了很多人,天天加班,产出却不多,线上 bug 频发,工程师抱怨不断”的时候,基本上重构也无法解决问题了。所以,寄希望于在代码烂到一定程度之后,集中重构解决所有问题是不现实的。

有一种重构策略是持续重构,也是我个人比较赞同的重构策略。在平时工作中,我们可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,我们也可以顺手把不符合编码规范、不好的设计重构一下。如果我们能把持续重构作为日常开发工作的一部分,培养持续重构的意识,使之成为一种开发习惯,那么对项目、对自己都是很有好处的。

五、如何重构

按照重构的规模,重构可以笼统地分为大型重构和小型重构。对于这两种不同规模的重构,我们要区别对待。

5.1 大型重构

对于大型重构来说,因为涉及的模块、代码会比较多,我们要提前做好完善的重构计划,有条不紊地分阶段来进行,每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,我们都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。

对于大型重构来说,最有效的一个手段就是“解耦”。那么,如何进行解耦呢?

5.1.1 封装与抽象

封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

举个例子,Unix 提供的 open() 函数用来打开一个文件,我们用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。我们通过将其封装成一个抽象的 open() 函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。除此之外,因为 open() 函数基于抽象而非具体的实现来定义,所以我们在改动 open() 函数的底层实现的时候,并不需要改动依赖它的上层代码,保证了代码的高内聚、低耦合。

5.1.2 中间层

引入中间层能简化模块或类之间的依赖关系。

image.png

上面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存存储、Redis 存储、DB 存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图中可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰。

系统架构设计中的防腐层,以及设计模式中的门面模式都体现了中间层的设计思想。

5.1.3 模块化

模块化是构建复杂系统常用的手段。对于一个大型、复杂的系统来说,没有人能掌控所有的细节。将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,我们也能协调各个模块,让整个系统有效运转。

聚焦到代码层面,合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。在开发代码的时候要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。

像 SOA、微服务、lib 库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。

5.1.4 一些设计思想和原则

理论指导实践,在重构的过程中,掌握一些通用的设计思想和设计原则可以更有效地帮助我们进行重构,下面列举了几种常用的设计思想和设计原则:

  • 单一职责原则:模块或类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。
  • 基于接口而非实现编程:通过接口这样一个中间层,隔离变化和实现,这样做的好处是,在有依赖关系的两个模块之间,一个模块的改动不会影响到另一个模块。
  • 依赖注入:跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。
  • 多用组合少用继承:继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活。
  • 迪米特法则:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。

5.2 小型重构

如果说大型重构需要构建完善的重构计划、设计复杂的技术方案和花费大量的时间和精力才能完成,小型重构通过编码规范基本上就能够很好地改善代码的质量。

编码规范的内容比较多,公司也有自己的编码规范,每个人的代码风格也不一样,这里我总结下我觉得比较好用的几条编码规范,大家也可以在评论区讨论一下哪种编码规范比较好。

5.2.1 命名

之所以把命名放到编码规范的第一条来说,是因为我觉得,命名太重要了。大到项目名、模块名、包名、对外暴露的接口名,小到类名、函数名、变量名、参数名,只要是做开发,我们就逃不过“起名字”这一关。命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。

那么,具体应该如何命名呢?

1、长命名 or 短命名?

长命名可以包含更多的信息,更能准确直观地表达作者的意图,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长,在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码的可读性。

相反,短命名占用的空间更小,但是往往不能准确地表达作者的意图,同时,命名中出现的各种缩写也会给阅读你代码的人造成很大的理解成本。

我认为,对于作用域比较小的变量,我们可以使用相对短的命名,比如一些函数内的临时变量。相反,对于类名这种作用域比较大的,我更推荐用长的命名方式。

2、利用上下文简化命名

举个例子:

1
2
3
4
5
6
java复制代码public class User {
private String userName;
private String userPassword;
private String userAvatarUrl;
//...
}

在上述代码中,userName 等属性名前的 user 就没有必要加,因为在 User 这个类的上下文中,name 指的就是 userName,表意足够明确。

同理,对于函数参数的命名也可以使用函数名上下文进行简化。

3、接口和抽象类的命名

对于接口的命名,一般有两种比较常见的方式,一种是加前缀 “I”,表示一个 Interface,比如 IUserService,对应的实现类命名为 UserService。另一种是不加前缀,比如 UserService,对应的实现类加后缀 “Impl”,比如 UserServiceImpl。

对于抽象类的命名,也有两种方式,一种是带上前缀 “Abstract”,比如 AbstractConfiguration;另一种是不带前缀 “Abstract”。

我认为,具体哪种命名方式不重要,只要能够在团队和项目中统一就行。

5.2.2 注释

有一种说法是,好的命名完全可以替代注释,如果需要注释,那说明命名不够好,需要在命名上下功夫,而不是添加注释。我认为这种说法有点极端,理由有三:

  • 我们很难保证自己的命名是规范的命名,自己的代码是规范的代码。
  • 注释比代码承载的信息更多:函数和变量如果命名得好,确实可以不用再在注释中解释它是做什么的。但是,对于类来说,包含的信息比较多,一个简单的命名就不够全面详尽了。
  • 注释起到总结性作用、文档的作用:在注释中,关于具体的代码实现思路,我们可以写一些总结性的说明、特殊情况的说明。这样能够让阅读代码的人通过注释就能大概了解代码的实现思路,阅读起来就会更加容易。

此外,一些总结性注释能让代码结构更清晰,对于逻辑比较复杂的代码或者比较长的函数,如果不好提炼、不好拆分成小的函数调用,那我们可以借助总结性的注释来让代码结构更清晰、更有条理,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public boolean isValidPasword(String password) {
// 检查密码是否为空
if (StringUtils.isBlank(password)) {
return false;
}

// 检查密码的长度是否在 4-64 之间
int length = password.length();
if (length < 4 || length > 64) {
return false;
}

// 检查密码是否只包含小写字母、数字和小数点
for (int i = 0; i < length; i++) {
char c = password.charAt(i);
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.')) {
return false;
}
}

return true;
}

5.2.3 把代码分割成更小的单元块

大部分人阅读代码的习惯都是,先看整体再看细节。所以,我们要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码// 重构前的代码
public void invest(long userId, long financialProductId) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return;
}
//...
}

// 重构后的代码
public void invest(long userId, long financialProductId) {
if (isLastDayOfMonth(new Date())) {
return;
}
//...
}

public boolean isLastDayOfMonth(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
return true;
}

return false;
}

重构前,在 invest() 函数中,最开始的那段关于时间处理的代码,是不是很难看懂?重构之后,我们将这部分逻辑抽象成一个函数,并且命名为 isLastDayOfMonth,从名字就能清晰地了解它的功能,判断今天是不是当月的最后一天。

5.2.4 函数设计要职责单一

将单一职责原则应用到函数上的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public boolean checkUserIfExisting(String telephone, String username, String email)  { 
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}

if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}

if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}

return false;
}

// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);

5.2.5 移除过深的嵌套层次

代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁。可以通过调整执行顺序和使用continue、break、return 等关键字,提前退出嵌套,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
List<String> matchedStrings = new ArrayList<>();
if (strList != null && substr != null) {
for (String str : strList) {
if (str != null) {
if (str.contains(substr)) {
matchedStrings.add(str);
}
}
}
}
return matchedStrings;
}

// 重构后的代码
public List<String> matchStrings(List<String> strList,String substr) {
if (strList == null || substr == null) {
return Collections.emptyList();
}

List<String> matchedStrings = new ArrayList<>();
for (String str : strList) {
if (str == null) {
continue;
}

if (str.contains(substr)) {
matchedStrings.add(str);
}
}

return matchedStrings;
}

5.2.6 善用解释性变量

善用解释性变量来解释复杂表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
// ...
} else {
// ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
// ...
} else {
// ...
}

六、重构 ID 生成器代码

介绍完了重构的一些知识,下面让我们把理论应用到实践中,回过头来看一下,对于文章开头提到的那份 ID 生成器代码,我们如何利用重构把它从一份“能用”的代码变成一份“好用”的代码。

首先,我们来重新审视一下这份 ID 生成器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

public static String generate() {
String id = "";
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\.");
if (tokens.length > 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8) {
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii >= 65 && randomAscii <= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii >= 97 && randomAscii <= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s", hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}

return id;
}
}

通过观察这份代码,我们大致可以找出以下几处代码中存在的问题:

  1. IdGenerator 设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。
  2. 代码的可读性不好。特别是随机字符串生成的那部分代码,一方面,代码完全没有注释,生成算法比较难读懂,另一方面,代码里有很多魔法数,严重影响代码的可读性。
  3. 获取 hostName 这部分代码并未处理 hostName 为空的情况。
  4. 尽管代码中针对获取不到本机名的情况做了异常处理,但是代码中对异常的处理是在 IdGenerator 内部将其吐掉,然后打印一条错误日志,并没有继续往上抛出。
  5. 每次生成 ID 都需要获取本机名,获取主机名会比较耗时,这部分可以考虑优化一下。
  6. randomAscii 的范围是 0-122,但可用数字仅包含三段子区间(0-9,a-z,A-Z),极端情况下会随机生成很多三段区间之外的无效数字,需要循环很多次才能生成随机字符串,所以随机字符串的生成算法也可以优化一下。
  7. 在 generate() 函数的 while 循环里面,三个 if 语句内部的代码非常相似,而且实现稍微有点过于复杂了,实际上可以进一步简化,将这三个 if 合并在一起。

没想到一份只有 30 几行的代码竟然被我们 review 出了 7 个问题,小王的内心一定是崩溃的……

没关系,下面我们一步一步对代码进行重构。

6.1 提高代码可读性

首先,我们要解决最明显、最急需改进的代码可读性问题。具体有下面几点:

  • hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候;
  • 将获取 hostName 的代码抽离出来,定义为 getLastfieldOfHostName() 函数;
  • 删除代码中的魔法数,比如,57、90、97、122;
  • 将随机数生成的代码抽离出来,定义为 generateRandomAlphameric() 函数;
  • generate() 函数中的三个 if 逻辑重复了,且实现过于复杂,我们要对其进行简化;
  • 对 IdGenerator 类重命名,并且抽象出对应的接口。

这里我们重点讨论下最后一个修改。实际上,对于 ID 生成器的代码,有下面三种类的命名方式:

image.png

我们来逐一分析一下三种命名方式。

第一种命名方式,将接口命名为 IdGenerator,实现类命名为 LogTraceIdGenerator,这可能是我们最先想到的命名方式了。在命名的时候,我们要考虑到的是,以后两个类会如何使用、会如何扩展。从使用和扩展的角度来分析,这样的命名就不合理了。理由有二:

  • 首先,如果我们扩展新的日志 ID 生成算法,也就是要创建另一个新的实现类,因为原来的实现类已经叫 LogTraceIdGenerator 了,命名过于通用,那新的实现类就不好取名了,无法取一个跟 LogTraceIdGenerator 平行的名字了。
  • 其次,你可能会说,假设我们没有日志 ID 的扩展需求,但要扩展其他业务的 ID 生成算法,比如针对用户的 ID 生成器(UserldGenerator)、订单的 ID 生成器(OrderIdGenerator),第一种命名方式是不是就是合理的呢?答案也是否定的。基于接口而非实现编程,主要的目的是为了方便后续灵活地替换实现类。而 LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator 三个类从命名上来看,涉及的是完全不同的业务,不存在互相替换的场景。也就是说,我们不可能在有关日志的代码中,进行这种替换。所以,让这三个类实现同一个接口,实际上是没有意义的。

第二种命名方式是不是就合理了呢?答案也是否定的。其中,LogTraceIdGenerator 接口的命名是合理的,但是 HostNameMillisIdGenerator 实现类暴露了太多实现细节,只要代码稍微有所改动,就可能需要改动命名,才能匹配实现。

第三种命名方式是我比较推荐的。在目前的 ID 生成器代码实现中,我们生成的 ID 是一个随机 ID,不是递增有序的,所以,命名成 RandomIdGenerator 是比较合理的,即便内部生成算法有所改动,只要生成的还是随机的 ID,就不需要改动命名。如果我们需要扩展新的 ID 生成算法,比如要实现一个递增有序的 ID 生成算法,那我们可以命名为 SequenceIdGenerator。

实际上,更好的一种命名方式是,我们抽象出两个接口,一个是 IdGenerator,一个是 LogTraceIdGenerator,LogTraceIdGenerator 继承 IdGenerator。实现类实现接口 LogTraceIdGenerator,命名为 RandomIdGenerator、SequenceIdGenerator 等。这样,实现类可以复用到多个业务模块中,比如前面提到的用户、订单。

根据上面的优化策略,我们对代码进行第一轮的重构,重构之后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
java复制代码public class RandomIdGenerator implements LogTraceIdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

@Override
public String generate() {
String substrOfHostName = getLastfieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}

private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}

private String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}

private String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}

6.2 重构异常处理代码

接着,我们讨论下代码在异常情况下的处理方式,比如,在本机名获取失败的时候,ID 生成器的 generate() 函数应该返回什么呢?是异常?空字符?还是 null 值?又或者是其他特殊值呢?

在讨论具体的异常处理方式之前,让我们先讨论一下一般函数出错都有哪些处理方式?常见的函数出错返回数据类型有 4 种,它们分别是:错误码、null 值、空对象、异常对象。

1、错误码

C 语言中没有异常这样的语法机制,因此,返回错误码便是最常用的出错处理方式。而在 Java、Python 等比较新的编程语言中,大部分情况下,我们都用异常来处理函数出错的情况,极少会用到错误码。由于我们日常工作中使用的主要语言是 Java,因此这里不对错误码做过多的介绍,感兴趣的小伙伴可以自行查阅资料。

2、null

在多数编程语言中,我们用 null 来表示“不存在”这种语义。不过,网上很多人不建议函数返回 null 值,认为这是一种不好的设计思路,主要的理由有以下两个:

  • 如果某个函数有可能返回 null 值,我们在使用它的时候,忘记了做 null 值判断,就有可能会抛出空指针异常(Null Pointer Exception,NPE)。
  • 如果我们定义了很多返回值可能为 null 的函数,那代码中就会充斥着大量的 null 值判断逻辑,一方面写起来比较繁琐,另一方面它们跟正常的业务逻辑耦合在一起,会影响代码的可读性。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class UserService {
private UserRepo userRepo; // 依赖注入

public User getUser(String telephone) {
// ...

// 如果用户不存在,则返回 nullreturn null;
}
}

// 使用函数 getUser()
User user = userService.getUser("130xxxx0605");
if (user != null) { // 做 null 值判断,否则有可能会报 NPE
String email = user.getEmail();
if (email != null) { // 做 null 值判断,否则有可能会报 NPE
String escapedEmail = email.replaceAll("@", "#");
}
}

那我们是否可以用异常来替代 null 值,在查找用户不存在的时候,让函数抛出 UserNotFoundException 异常呢?

我认为,尽管返回 null 值有诸多弊端,但对于以 get、find、query 等单词开头的查找函数来说,数据不存在,并非一种异常情况,这是一种正常行为。所以,返回代表不存在语义的 null 值比返回异常更加合理。

不过,刚刚说的这个也不是绝对的,还是要看项目中的其他类似的查找函数都是如何定义的,只要整个项目遵从统一的约定即可。

3、空对象

当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代 null 值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 null 值判断。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码// 使用空集合替代 null
public class UserService {
private UserRepo userRepo;

public List<User> getUsers(String telephonePrefix) {
// 没有查找到数据
return Collections.emptyList();
}
}
// getUsers 使用示例
List<User> users = userService.getUsers("1300605");
for (User user : users) { // 这里不需要做 null 值判断
// ...
}

4、异常对象

尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式就是抛出异常。异常可以携带更多的错误信息,比如函数调用栈信息。除此之外,异常可以将正常逻辑和异常逻辑的处理分离开来,这样代码的可读性就会更好。

Java 中的异常分为两大类:

  • 运行时异常(非受检异常)
  • 编译时异常(受检异常)

关于两种异常的说明这里不做过多的介绍,网上资料有很多。这里我们重点讨论下如何处理函数抛出的异常。

一般处理函数抛出的异常有 3 种方法:

  1. 直接吞掉
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public void func1() throws Exception1 {
// ...
}

public void func2() {
//...
 try {
func1();
} catch(Exception1 e) {
log.warn("...", e);
}
//...
}
  1. re-throw
1
2
3
4
5
6
7
8
9
10
java复制代码public void func1() throws Exception1 {
// ...
}


public void func2() throws Exception1 {
//...
func1();
//...
}
  1. 包装成新的异常 re-throw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public void func1() throws Exception1 {
// ...
}


public void func2() throws Exception2 {
//...
 try {
func1();
} catch(Exception1 e) {
throw new Exception2("...", e);
}
//...
}

当我们面对函数抛出异常的情况时,应该选择上面的哪种处理方式呢?结合网上的几种说法我总结了下面 3 个参考原则:

  • 如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,我们完全可以在 func2() 内将 func1() 抛出的异常吞掉。
  • 如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,我们可以选择直接将 func1 抛出的异常 re-throw。
  • 如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的新异常,然后 re-throw。

好了,介绍了这么多关于异常处理的方法,现在,我们来对之前代码中的异常处理部分进行重构。先看下获取主机名的这部分代码 :

generate():

1
2
3
4
5
6
7
java复制代码public String generate() {
String substrOfHostName = getLastFieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s", substrOfHostName, currentTimeMillis, randomString);
return id;
}

getLastFieldOfHostName():

1
2
3
4
5
6
7
8
9
10
java复制代码private String getLastFieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}

在 generate() 函数中,假设主机名获取失败,如果 substrOfHostName 返回 null,那 generate() 函数会返回类似“null-16723733647-83Ab3uK6”这样的数据;如果 substrOfHostName 返回空字符串,那 generate() 函数会返回类似“-16723733647-83Ab3uK6”这样的数据。这两种数据都不是我们期望返回给用户的数据,因此当主机名获取失败时,我们需要抛一个异常。

进到 getLastFieldOfHostName() 函数中看一下,在当前函数的实现中,如果主机名获取失败函数内部会捕获 UnknownHostException 异常,打印一条错误日志,同时返回 null 值。这里相当于是把 UnknownHostException 异常吞掉了。由于这里主机名获取失败会影响到后续逻辑的执行,因此这是一种异常行为,我们应该向上抛出异常而不是返回 null。

至于是直接将 UnknownHostException 抛出,还是重新封装成新的异常抛出,要看函数跟异常是否有业务相关性。getLastFieldOfHostName() 函数用来获取主机名的最后一个字段,UnknownHostException 异常表示主机名获取失败,两者算是业务相关,所以可以直接将 UnknownHostException 抛出,不需要重新包裹成新的异常。

重构后的 getLastFieldOfHostName() 函数代码如下所示:

1
2
3
4
5
6
java复制代码private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}

getLastFieldOfHostName() 函数修改之后,generate() 函数也要做相应的修改。这里我们选择捕获 getLastFieldOfHostName() 抛出的 UnknownHostException 异常,并重新包裹成新的异常 IdGenerationFailureException 往上抛出。之所以这么做原因有三:

  • 调用者在使用 generate() 函数的时候,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。也就说是,这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节。
  • 从代码封装的角度来讲,我们不希望将 UnknownHostException 这个比较底层的异常,暴露给更上层的代码,也就是调用 generate() 函数的代码。而且,调用者拿到这个异常的时候,并不能理解这个异常到底代表了什么,也不知道该如何处理。
  • UnknownHostException 异常跟 generate() 函数,在业务概念上没有相关性。

重构后的 generate() 函数代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s", substrOfHostName, currentTimeMillis, randomString);
return id;
}

对于 getLastSubstrSplittedByDot() 和 generateRandomAlphameric() 函数,我们也需要对非法入参进行检查,当入参不合法时抛出异常。

至此,我们就完成了对 ID 生成器代码的重构,重构后的代码如下所示,你可以拿来和之前的代码对比一下,看相对于之前的代码,现在的代码是否在可读性、可维护、健壮性上有所提升:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
java复制代码/**
* ID 生成器,用来生成随机的 ID。
*
* <p>
* 通过这个类生成的 ID 不是绝对唯一的,但是重复的概率非常小。
*/
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

/**
* 生成随机的 ID。
*
* @return 一个随机的 ID
*/
@Override
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("...", e);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}

/**
* 获得本地主机名的最后一个字段。主机名字段之间通过 '.' 进行分隔。
*
* @return 主机名的最后一个字段。当主机名获取失败时返回空。
*/
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) {
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}

/**
* 获得 {@hostname} 的最后一个字段,字段之间通过 '.' 进行分隔。
*
* @param hostName 不能为空
* @return {@hostname} 的最后一个字段。当 {@hostname} 为空时返回空字符串。
*/
private String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw new IllegalArgumentException("...");
}

String[] tokens = hostName.split("\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}

/**
* 生成随机的字符串,该字符串只包括数字、大写字母和小写字母。
*
* @param length 不能小于 0
* @return 随机的字符串。当 {@length} 为 0 时返回空字符串。
*/
private String generateRandomAlphameric(int length) {
if (length <= 0) {
throw new IllegalArgumentException("...");
}

char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}

七、总结

至此,本文对重构的介绍就告一段落了。我想说的是,在平时的工作中,由于业务迭代比较快,需要频繁地产出,很多人往往忽视了代码规范和代码质量,无休止地堆砌“烂”代码,等到系统 bug 频出,代码难以维护,开发效率降低的时候,我们想重构,却又无从下手。其实,我们在平时写代码的时候就应该培养一种持续重构的意识,再简单的代码,看上去再完美的代码,只要我们下功夫去推敲,总有可以优化的空间,就看你愿不愿把事情做到极致。作为一名程序员,起码对代码要有追求啊,不然跟咸鱼有啥区别呢?

八、参考

  1. 《重构:改善既有代码的设计》
  2. 《设计模式之美》
  3. 《代码整洁之道》
  4. 代码重构
  5. 重构介绍与原则

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…411412413…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%