Linux 安装 quarkpan v0.4.0

Command-line client for Quark Drive based on libquarkpan

Readme4 VersionsDependenciesDependentsSecurity

quarkpan

quarkpan 是基于 libquarkpan 的夸克网盘命令行工具。

它是面向直接使用终端的场景,负责把底层库能力包装成可恢复、可观察、可取消的命令行体验。

功能概览

当前命令行支持:

  • auth 管理持久化 Cookie
  • list 列出目录内容
  • folder create 创建目录
  • delete 删除一个或多个文件或目录项
  • rename 重命名文件或目录项
  • download 下载文件
  • download-dir 下载整个目录
  • upload 上传文件
  • upload-dir 上传整个目录

同时支持:

  • 仅在交互式 TTY 中显示进度条
  • --color auto|always|never
  • Ctrl+C 取消
  • 中断后保留 .quark.task 任务文件
  • 下载中断后的显式重试与续传
  • 上传分片失败后的显式重试
  • 下次通过 -c, --continue 恢复传输

TLS Features

quarkpan 会把底层 libquarkpan 的 TLS backend feature 透传出来,命名与 reqwest 0.13 对齐,默认使用 default-tls。

示例:

1
2
cargo install quarkpan
cargo install quarkpan --no-default-features --features native-tls

安装

使用 Cargo

1
cargo install quarkpan

从源码运行

1
cargo run --bin quarkpan -- --help

CLI 需要夸克登录后的 Cookie,支持三种方式:

  • --cookie 'k1=v1; k2=v2'
  • --cookie-file ./cookie.txt
  • 环境变量 QUARK_COOKIE
  • auth set-cookie 持久化到系统配置目录

Cookie 内容应为完整的 key=value; key2=value2 格式。 auth set-cookie 需要显式指定来源:

  • --cookie
  • --from-stdin
  • --from-nano
  • --from-vi

使用示例

列出根目录

1
quarkpan --cookie 'k1=v1; k2=v2' list
1
2
quarkpan auth set-cookie --from-stdin
quarkpan auth show-source

也可以显式传入:

1
quarkpan auth set-cookie --cookie 'k1=v1; k2=v2'

也支持通过标准输入:

1
printf 'k1=v1; k2=v2\n' | quarkpan auth set-cookie --from-stdin

如果直接在终端运行:

1
quarkpan auth set-cookie --from-stdin

CLI 会先提示:

1
paste cookie, then press Enter:

也支持通过编辑器:

1
2
quarkpan auth set-cookie --from-nano
quarkpan auth set-cookie --from-vi

逐页查看更多

1
quarkpan --cookie 'k1=v1; k2=v2' list --pdir-fid 0 --more

创建目录

1
quarkpan --cookie 'k1=v1; k2=v2' folder create --pdir-fid 0 --file-name 我的文档

重命名文件或目录项

1
quarkpan --cookie 'k1=v1; k2=v2' rename --fid <fid> --file-name 新名字

删除一个或多个文件或目录项

1
quarkpan --cookie 'k1=v1; k2=v2' delete --fid <fid1> --fid <fid2>

下载文件

1
quarkpan --cookie 'k1=v1; k2=v2' download --fid <fid> --output ./file.bin

恢复下载

1
quarkpan --cookie 'k1=v1; k2=v2' download --fid <fid> --output ./file.bin -c

下载目录

1
quarkpan download-dir --pdir-fid <pdir_fid> --output ./backup

恢复或合并目录下载

1
2
quarkpan download-dir --pdir-fid <pdir_fid> --output ./backup -c
quarkpan download-dir --pdir-fid <pdir_fid> --output ./backup -c -o

上传文件

1
quarkpan --cookie 'k1=v1; k2=v2' upload --file ./file.bin --pdir-fid 0

恢复上传

1
quarkpan --cookie 'k1=v1; k2=v2' upload --file ./file.bin --pdir-fid 0 -c

上传目录

1
quarkpan upload-dir --dir ./photos --pdir-fid 0

恢复或合并目录上传

1
2
quarkpan upload-dir --dir ./photos --pdir-fid 0 -c
quarkpan upload-dir --dir ./photos --pdir-fid 0 -c -o

任务文件说明

下载和上传在中断、报错或收到 Ctrl+C 后,会保留:

1
${filename}.quark.task

目录任务使用与目录同级同名的任务文件,例如:

1
photos.quark.task

用途:

  • 下载时记录远端文件身份信息和目标输出路径
  • 上传时记录预检得到的上传会话信息和已完成的分片状态

成功完成后,任务文件会自动删除。

Ctrl+C 时:

  • 当前传输会尽快停止
  • 已生成的任务文件会保留
  • 不会把取消误当作普通失败继续重试
  • 后续可使用 -c 恢复

进度条显示规则:

  • 只在交互式终端显示
  • 定时任务、管道和重定向默认不显示
  • 下载和上传都会显示当前文件名

当前限制

  • 目录和文件操作当前以 ID 为主
  • 下载当前只支持按文件 ID
  • CLI 当前不负责复杂的路径递归解析
  • 上传恢复依赖本地文件内容未变化,因此继续上传前会重新校验 size/md5/sha1
  • 云端覆盖当前通过删除旧文件后再上传实现
  • CLI 已移除 --json 输出模式

License

1
GPL-3.0-only
l

Intellij IDEA 2020.3 激活

  1. 先下载一下agent agent img

链接: https://cloud.189.cn/t/NjArIbzuUF7z (访问码:cm0f)

  1. 拖入Intellij 中,然后重启

  2. 重启完成后,输入激活码

1
BISACXYELK-eyJsaWNlbnNlSWQiOiJCSVNBQ1hZRUxLIiwibGljZW5zZWVOYW1lIjoiQ2hpbmFOQiIsImFzc2lnbmVlTmFtZSI6IiIsImFzc2lnbmVlRW1haWwiOiIiLCJsaWNlbnNlUmVzdHJpY3Rpb24iOiIiLCJjaGVja0NvbmN1cnJlbnRVc2UiOmZhbHNlLCJwcm9kdWN0cyI6W3siY29kZSI6IklJIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJBQyIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiRFBOIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlJTQyIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjp0cnVlfSx7ImNvZGUiOiJQUyIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUlNGIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IkdPIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJETSIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjp0cnVlfSx7ImNvZGUiOiJDTCIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUlMwIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlJDIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlJEIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJQQyIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUlNWIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlJTVSIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiUk0iLCJwYWlkVXBUbyI6IjIwOTktMTItMzEiLCJleHRlbmRlZCI6ZmFsc2V9LHsiY29kZSI6IldTIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOmZhbHNlfSx7ImNvZGUiOiJEQiIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjpmYWxzZX0seyJjb2RlIjoiREMiLCJwYWlkVXBUbyI6IjIwOTktMTItMzEiLCJleHRlbmRlZCI6dHJ1ZX0seyJjb2RlIjoiUERCIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlBXUyIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjp0cnVlfSx7ImNvZGUiOiJQR08iLCJwYWlkVXBUbyI6IjIwOTktMTItMzEiLCJleHRlbmRlZCI6dHJ1ZX0seyJjb2RlIjoiUFBTIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlBQQyIsInBhaWRVcFRvIjoiMjA5OS0xMi0zMSIsImV4dGVuZGVkIjp0cnVlfSx7ImNvZGUiOiJQUkIiLCJwYWlkVXBUbyI6IjIwOTktMTItMzEiLCJleHRlbmRlZCI6dHJ1ZX0seyJjb2RlIjoiUFNXIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IkRQIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9LHsiY29kZSI6IlJTIiwicGFpZFVwVG8iOiIyMDk5LTEyLTMxIiwiZXh0ZW5kZWQiOnRydWV9XSwibWV0YWRhdGEiOiIwMTIwMjAwNzI4RVBKQTAwODAwNiIsImhhc2giOiIxNTAyMTM1NC8wOi0xMjUxMTE0NzE3IiwiZ3JhY2VQZXJpb2REYXlzIjowLCJhdXRvUHJvbG9uZ2F0ZWQiOmZhbHNlLCJpc0F1dG9Qcm9sb25nYXRlZCI6ZmFsc2V9-H7NUmWcLyUNV1ctnlzc4P79j15qL56G0jeIYWPk/HViNdMg1MqPM7BR+aHR28yyuxK7Odb2bFDS8CeHNUtv7nT+4fUs85JJiqc3wc1psRpZq5R77apXLOmvmossWpbAw8T1hOGV9IPUm1f2O1+kLBxrOkdqPpv9+JanbdL7bvchAid2v4/dyQMBYJme/feZ0Dy2l7Jjpwno1TeblEAu0KZmarEo15or5RUNwtaGBL5+396TLhnw1qL904/uPnGftjxWYluLjabO/uRu/+5td8UA/39a1nvGU2nORNLk2IdRGIheiwIiuirAZrII9+OxB+p52i3TIv7ugtkw0E3Jpkw==-MIIDlzCCAn+gAwIBAgIBCTANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDEw1KZXRQcm9maWxlIENBMCAXDTE4MTEwMTEyMjk0NloYDzIwOTkwODA5MDIyNjA3WjBoMQswCQYDVQQGEwJDWjEOMAwGA1UECBMFTnVzbGUxDzANBgNVBAcTBlByYWd1ZTEZMBcGA1UEChMQSmV0QnJhaW5zIHMuci5vLjEdMBsGA1UEAxMUcHJvZDN5LWZyb20tMjAxODExMDEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdXyaNhhRySH1a8d7c8SlLLFdNcQP8M3gNnq7gudcpHC651qxRrN7Qks8gdXlIkA4u3/lp9ylp95GiIIDo4ydYje8vlTWDq02bkyWW/G7gZ3hkbBhRUK/WnNyr2vwWoOgwx5CfTRMjKkPkfD/+jffkfNfdGmGcg9yfnqPP9/AizKzWTsXSeS+0jZ8Nw5tiYFW+lpceqlzwzKdTHug7Vs0QomUPccRtZB/TBBEuiC7YzrvLg4Amu0I48ETAcch/ztt00nx/oj/fu1DTnz4Iz4ilrNY+WVIEfDz/n3mz+PKI9kM+ZeB0jAuyLsiC7skGpIVGX/2HqmZTtJKBZCoveAiVAgMBAAGjgZkwgZYwSAYDVR0jBEEwP4AUo562SGdCEjZBvW3gubSgUouX8bOhHKQaMBgxFjAUBgNVBAMMDUpldFByb2ZpbGUgQ0GCCQDSbLGDsoN54TAJBgNVHRMEAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAsGA1UdDwQEAwIFoDAdBgNVHQ4EFgQUYSkb2hkZx8swY0GRjtKAeIwaBNwwDQYJKoZIhvcNAQELBQADggEBAJZOakWgjfY359glviVffBQFxFS6C+4WjYDYzvzjWHUQoGBFKTHG4xUmTVW7y5GnPSvIlkaj49SzbD9KuiTc77GHyFCTwYMz+qITgbDg3/ao/x/be4DD/k/byWqW4Rb8OSYCshX/fNI4Xu+hxazh179taHX4NaH92ReLVyXNYsooq7mE5YhR9Qsiy35ORviQLrgFrMCGCxT9DWlFBuiPWIOqN544sL9OzFMz+bjqjCoAE/xfIJjI7H7SqGFNrx/8/IuF0hvZbO3bLIz+BOR1L2O+qT728wK6womnp2LLANTPbwu7nf39rpP182WW+xw2z9MKYwwMDwGR1iTYnD4/Sjw=
  1. 注意
  2. 拖入若未生效,将agent文件,解压成BetterIntelliJ-1.16.jar,放入自己电脑目录path。
  3. 编辑idea.vmoptions文件,最后一行添加一下代码。idea.vmopthons文件目录:
    window:<IDE安装目录>\bin\idea64.exe.vmoptions
    macOS:~/Library/Application Support/JetBrains/IntelliJIdea<版本号>/idea.vmoptions
    1
    -javaagent:/path/to/BetterIntelliJ-1.16.jar
  4. 编辑完之后保存,重启idea,再输入激活码激活。
l

26种设计模式

2. 创建型模式

接下来我们来看看设计原则的最佳实践

设计模式的类型

根据设计模式的参考书 Design Patterns - Elements of Reusable Object-Oriented Software(中文译名:设计模式 - 可复用的面向对象软件元素) 中所提到的,总共有 23 种设计模式。这些模式可以分为三大类:创建型模式(Creational Patterns)、结构型模式(Structural Patterns)、行为型模式(Behavioral Patterns)。当然,我们还会讨论另一类设计模式:J2EE 设计模式。

序号 模式 & 描述 包括
1 创建型模式 这些设计模式提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建哪些对象时更加灵活。 工厂模式(Factory Pattern)抽象工厂模式(Abstract Factory Pattern)单例模式(Singleton Pattern)建造者模式(Builder Pattern)原型模式(Prototype Pattern)
2 结构型模式 这些设计模式关注类和对象的组合。继承的概念被用来组合接口和定义组合对象获得新功能的方式。 适配器模式(Adapter Pattern)桥接模式(Bridge Pattern)过滤器模式(Filter、Criteria Pattern)组合模式(Composite Pattern)装饰器模式(Decorator Pattern)外观模式(Facade Pattern)享元模式(Flyweight Pattern)代理模式(Proxy Pattern)
3 行为型模式 这些设计模式特别关注对象之间的通信。 责任链模式(Chain of Responsibility Pattern)命令模式(Command Pattern)解释器模式(Interpreter Pattern)迭代器模式(Iterator Pattern)中介者模式(Mediator Pattern)备忘录模式(Memento Pattern)观察者模式(Observer Pattern)状态模式(State Pattern)空对象模式(Null Object Pattern)策略模式(Strategy Pattern)模板模式(Template Pattern)访问者模式(Visitor Pattern)
4 J2EE 模式 这些设计模式特别关注表示层。这些模式是由 Sun Java Center 鉴定的。 MVC 模式(MVC Pattern)业务代表模式(Business Delegate Pattern)组合实体模式(Composite Entity Pattern)数据访问对象模式(Data Access Object Pattern)前端控制器模式(Front Controller Pattern)拦截过滤器模式(Intercepting Filter Pattern)服务定位器模式(Service Locator Pattern)传输对象模式(Transfer Object Pattern)

2.1 工厂设计模式

工厂设计模式属于创建型模式,创建型模式的核心是隐藏细节,并创建实例,我们看工厂模式中的模式都是能够向外提供类的实例对象的

其中工厂设计模式分为三种:

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工厂模式

在讲工厂设计模式之前,我们先来看看一个合理的运用设计模式设计出来的软件架构应该是怎么样的,这也是面向接口编程的设计方式

image-20221003164956251

我们在面向接口编程中,模块和模块之间不能直接调用具体实现类,而是调用模块提供的接口,我们仔细想想在平时的编码中,有直接service层调用dao层的xxxDaoImpl的吗?显然没有,如果直接调用了也差不多该换下一份工作了,一般都是注入接口,让容器注入一个实现类

工厂模式的核心也是隐藏内部实现,对外暴露接口实现具体逻辑,我们来看看工厂模式中的几个重要概念:

  • 产品:就是具体的产品,例如下面代码中的Hamburger
  • 抽象产品:产品的抽象,例如Food
  • 产品簇:这个概念在抽象工厂中在场景中再解释
  • 产品等级:同上

我们来看一个简单的例子,来理解上面的几个概念,另外在学习设计模式的时候一定要清醒的认识到,为什么要有设计模式,为了应对变化,以不变应万变,这里还有两个概念,就是我们习惯于把功能提供者称为作者,把我们这些API调用工厂师叫做用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 作者做的抽象产品
interface Food{
void eat();
}
// 作者做的具体产品
class Hamburger implements Food{

@Override
public void eat() {
System.out.println("吃汉堡包...");
}
}

// 用户的业务逻辑
public class AppTest {
public static void main(String[] args) {
Food food = new hamburger();
food.eat();
}
}

本来没有什么问题的,但是我一开始就提过,我们要应对这个不断变化的世界,现在变化来了,由于某些原因,作者把原来提供的类名改了,例如把hamburger改为hamburger2,这时候怎么办,是不是我们也要跟着改!!!

跟我说,什么叫耦合?一个改了另一个也要改,这就叫耦合;什么叫解耦?一个改了另一个不需要改,这就叫解耦!

有人说怎么可能,作者好好地没事会去该类名,闲得慌??你别说,笔者在项目中还经常遇到一些作者瞎改类名又不提示用户的,hutool包是我们常用的工具包,但是有一些类名在包升级的时候直接修改,而且完全没有做兼容处理,有一次团队项目打包失败了,最后定位问题发现,这个作者之前类名单词拼写错误了,然后升级版本的时候直接改了类名,导致我们升级版本导入一个不存在的类,导致项目出现问题,记得当时我们都说这个作者是个坑爹玩意,一看就是设计模式没学好

image-20221003171512629

读者可以看看这个作者违反了什么原则?违反了开闭原则,正确的做法应该是给错误的类加过时的标记,重新写一个

我们来总结一下:

  • 这样的设计非常的脆弱,为什么呢?只要作者修改了具体产品的类名,那么客户端就要一起修改,这样的代码就是耦合的
  • 我们希望的效果是,无论服务端客户端如何修改,客户端代码都应该无感知,而不用去进行修改

那么我们如何进行改进呢?针对服务端代码一旦修改,客户端代码也要修改的问题,我们直接使用简单工厂设计模式

2.1.1 简单工厂模式

设计模式的类型

这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。它的本质就是通过传入不同的参数达到多态的目的

优点:

  1. 一个调用者想创建一个对象,只要知道其名称就可以了。
  2. 扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。
  3. 屏蔽产品的具体实现,调用者只关心产品的接口。

缺点:

  1. 用户需要去记忆具体产品和常量之间的映射关系,例如:FoodNumberEnum.HAMBURGER -> hamburger
  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
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
// 作者做的抽象产品
interface Food {
void eat();
}

// 作者做的具体产品
class hamburger implements Food {

@Override
public void eat() {
System.out.println("吃汉堡包...");
}
}

class hamburger2 implements Food {

@Override
public void eat() {
System.out.println("这是作者修改后的汉堡包...");
}
}

class FoodFactory {
public enum FoodNumberEnum {
HAMBURGER(1001, "汉堡包"),
HAMBURGER2(1002, "修改后的汉堡包"),
;
private final Integer foodNumber;
private final String describe;

FoodNumberEnum(Integer foodNumber, String describe) {
this.foodNumber = foodNumber;
this.describe = describe;
}

public Integer getFoodNumber() {
return foodNumber;
}

public String getDescribe() {
return describe;
}

public static FoodNumberEnum getByType(int type) {
for (FoodNumberEnum constants : values()) {
if (constants.getFoodNumber() == type) {
return constants;
}
}
return null;
}
}

public static Food getFood(FoodNumberEnum foodNumberEnum) {
Food food = null;
switch (FoodNumberEnum.getByType(foodNumberEnum.getFoodNumber())) {
case HAMBURGER :
food = new hamburger();
break;
case HAMBURGER2 :
food = new hamburger2();
break;
default:
break;
}
return food;
}
}

// 用户的业务逻辑
public class AppTest {
public static void main(String[] args) {
Food food = FoodFactory.getFood(FoodFactory.FoodNumberEnum.HAMBURGER);
food.eat(); // 输出吃汉堡包...
}
}

我们来看这样做的好处是什么:

  • 之前是我们直接创建对象,依赖于作者的代码;现在是依赖于工厂,由工厂创建对象
  • 如果作者对原来的实现类做出了修改,也必须修改工厂里面的代码,注意,这里是作者进行修改,而不是用户修改,这样就做到了依赖倒置,完成了解耦,这样用户代码不用做出任何修改
  • 之前由于作者修改代码导致用户也要修改其实还违背了迪米特法则,因为我们被迫去了解了作者的实现,其实我们是不关心如何实现的,我们只需要一个接口实现我们想要的功能即可!!!

可能有又有杠精要问了,要是作者把枚举也改了怎么办?这不是还是要改客户端代吗,我我我???直接好家伙

请杠精看看Spring中的工厂模式是怎么做的,后面笔者也会分析源码,我们在Spring中,不是一直写这样的代码吗???

1
2
3
4
5
@Component
public class XXX {
@Autowired(required = false)
private XXXBean xxxBean;
}

请问,这样做不管实现类怎么修改,只要注入IOC容器,我难道不能直接注入接口中吗?这就叫解耦,面向接口编程

我们总结一下简单工厂的优点:

  • 把具体产品的类名,从客户端代码中解构出来了,服务端如果修改了服务端类名,客户端也不知道
  • 这便符合了面向接口编程的思想,这里注意,这里的接口并不特指interface,而是指下层给上层暴露的东西,可以是一个方法、一个接口、甚至是一个工厂,并且这些接口必须稳定,绝对不能改变

那么缺点呢?好像也没啥缺点感觉,又解耦了,又隐藏细节了,这里又不得不提一直提到的变字了,学习设计模式,我们要将变字贯穿整个学习过程

  • 客户端不得不死记硬背那些枚举类和产品之间的映射关系,比如FoodNumberEnum.HAMBURGER -> hamburger
  • 如果有成千上万个产品,那么简单工厂就会变得十分的臃肿,造成映射关系爆炸
  • 最重要的是如果变化来了,如果客户端需要拓展产品,首先我们不能改源代码(违反开闭原则),我们只能搞一个实现类实现自己的逻辑,但是工厂中又没有映射关系让我们创建这个实例,我们又得去修改工厂的源码,又违背了开闭原则,同时最重要的是,你这是去改别人提供的jar包呀我的天,你觉得你能看到被人的源代码吗?你能修改吗?????显然不能,那怎么解决呢?这就引出第二个工厂模式:工厂方法设计模式
  • 有的同学可能会觉得自己拓展的类,自己new一个不就好了,还改什么源代码?讲的有道理,但是如果你也是作者呢?你写的拓展是要给别人使用的呢?难道让读者去new一个你的实现类,这不违背迪米特法则了吗?用户只想要一个具体的实现,你现在要让用户去找你的实现类,这合理吗?这不合理,我们直接看工厂方法设计模式是怎么解决的

最后来画一下类图,我们学设计模式,一定要能熟练画出类图

image-20221003182724048

总结:

image-20221003210806839

2.1.2 工厂方法模式

我们回顾一下上面简单工厂产生的问题,就是简单工厂只能提供作者提供好的产品,我们无法对产品进行拓展

工厂方法

定义一个用于创建产品的接口,由子类决定生产什么产品

  • 坏处: 加一个类,需要加一个工厂,类的个数成倍增加,增加维护成本,增加系统抽象性和理解难度
  • 好处: 符合开闭原则

总结:简单工厂模式 + 开闭原则 = 工厂方法

我们来改造一下上面有问题的代码,我们现在不将工厂写死,而是面向抽象编程,将工厂定义为一个接口,在作者的代码中提供一些基本的实现,例如创建Hamburger、RichNoodle的实现

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
// 作者做的抽象产品
interface Food {
void eat();
}

// 作者做的具体产品
class Hamburger implements Food {

@Override
public void eat() {
System.out.println("吃汉堡包...");
}
}

class RichNoodle implements Food {

@Override
public void eat() {
System.out.println("过桥米线");
}
}
/** 定义工厂的接口 **/
interface FoodFactory {
Food getFood();
}

class HamburgerFactory implements FoodFactory {

@Override
public Food getFood() {
return new Hamburger();
}
}

class RichNoodleFactory implements FoodFactory {

@Override
public Food getFood() {
return new RichNoodle();
}
}

public class App {
public static void main(String[] args) {
// 拿到产生产品的工厂
FoodFactory foodFactory = new HamburgerFactory();
// 创建对应的产品
Food food = foodFactory.getFood();
food.eat();
}
}

我们会发现我们将工厂作为接口暴露之后就有一个好处,如果我们想要新增加一个产品,我们不需要去修改原来的产品,而是通过继承Food创建产品类,再通过暴露的工厂接口创建工厂,再来实例化我们需要的产品,具体实现为:

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
/** 新的产品 **/
class PorkFeetRice implements Food{

@Override
public void eat() {
System.out.println("吃猪角饭...");
}
}
/** 生产猪角饭的工厂 **/
class PorkFeetRiceFactory implements FoodFactory{

@Override
public Food getFood() {
return new PorkFeetRice();
}
}

public class App {
public static void main(String[] args) {
// 拿到产生产品的工厂
FoodFactory foodFactory = new PorkFeetRiceFactory();
Food food = foodFactory.getFood();
food.eat();
}
}

我们可以看到,我们通过提供的工厂接口,并没有修改之前的工厂逻辑,又进行了拓展,并且新的产品和工厂实现类都是我们自己创建的,符合开闭原则

我们来总结一下工厂方法模式的优点:

  • 仍然具有简单工厂的优点,服务端修改了生产产品的逻辑时,用户端无感知
  • 因为产品和工厂都是拓展出来的,所以不需要修改原来的代码,只需要创建一个新的产品和工厂即可

但是我们也有发现这样好像怪怪的,感觉就是暴露了个接口而已,读者可能会有以下的疑问:

  • 虽然说好像不管是简单工厂也好,工厂方法也罢,虽然都做到了和具体实现解耦,用户不用关注实现是否发生了改变,但是,反观我们现在的代码,好像还在依赖于具体的工厂,说的就是上面的PorkFeetRiceFactory,如果我们每生产一种产品,都要去知道这种产品对应的工厂,这又违反了迪米特法则,并且如果如果作者如果把工厂名字写错了,又会出现上面的问题
  • 感觉折腾了一圈,又回到了原点,之前是依赖于具体的实现,现在是依赖具体的工厂,还是耦合的关系

我们来解释一下上面的两个问题:

  • 首先,既然作者已经对外暴露了接口,那么作者有义务保存接口的稳定,不能出现改接口名的行为(或弃用)
  • 其次工厂模式还可以隐藏一个实例创建的过程,学过spring等框架的同学就会知道,在框架中一个实例的创建并不只是简简单单new那么简单,可能还会牵涉到容器生命周期以及一些解析bean的操作,显然会复杂很多,所以工厂模式还帮我们隐藏了细节、封装了代码,至于具体要使用那个工厂,是我们应该去了解的,毕竟每个工厂提供产品都会不同
  • 很多问题现在简单的业务下并不是问题,但是请不要忘记变这个字,我们的代码为什么要分一层又一层,就是为了能够在之后改变后的业务场景依旧能够使用,如果读者还有问题,请复习复习Spring IOC容器的设计,如果没有工厂,IOC容器如果做到解析那么多注解,如何完成依赖注入,如果完成各种Spring预留的拓展接口,如果完成Bean的声明周期

总结一下缺点:

  • 每一个层级的产品的产品都需要对应一个产品,不仅增加了编码的负担,还可能产生类爆炸的现象

    image-20221003210348599

我们现在来画一下工厂方法模式的UML类图:

image-20221003205650960

最后总结一下:

image-20221003210656801

2.1.3 抽象工厂

抽象工厂模式

抽象工厂模式(Abstract Factory Pattern)是围绕一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

在抽象工厂模式中,接口是负责创建一个相关对象的工厂,不需要显式指定它们的类。每个生成的工厂都能按照工厂模式提供对象

优点:解决了工厂方法模式中类爆炸的问题,同时拥有其可拓展性的优点

缺点:产品族扩展非常困难,要增加或删除一个系列的某一产品,既要在抽象的工厂里加代码,又要在具体的里面产品加代码,违背开闭原则

我们来后顾一下前面的两种工厂模式的缺点

  • 简单工厂:耦合与具体的工厂实现,没有良好的拓展性
  • 工厂方法:会产生类爆炸的问题,每一个产品类都需要对应的工厂,代码臃肿

我们现在要通过工厂方法设计量类产品的生产,分别是食物Food、饮料Drink,其中食物有三种,饮料有两种,我们会发现食物需要定义一个接口,三个具体产品类,一个抽象工厂类,三个具体工厂类;饮料需要定义一个接口,两个具体产品类,一个抽象工厂类,两个具体工厂类,一共14个类,我就想要产生五种类,好家伙,按照工厂方法的写法,直接写出14个类了

image-20221003232555160

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/******************  抽象产品   *********************/
// 食物抽象类
interface Food {
void eat();
}
// 饮料抽象类
interface Drink{
void drink();
}
/****************** 具体产品 *********************/
// 食物的具体产品
class Hamburger implements Food {

@Override
public void eat() {
System.out.println("吃汉堡包...");
}
}

class RichNoodle implements Food {

@Override
public void eat() {
System.out.println("过桥米线");
}
}

class Cola implements Drink{
@Override
public void drink() {
System.out.println("喝可口可乐...");
}
}

class IcePeak implements Drink{
@Override
public void drink() {
System.out.println("喝冰峰...");
}
}

/****************** 抽象工厂类 *********************/
interface FoodFactory {
Food getFood();
}

interface DrinkFactory{
Drink getDrink();
}

/****************** 实例工厂类 *********************/

class HamburgerFactory implements FoodFactory {

@Override
public Food getFood() {
return new Hamburger();
}
}

class RichNoodleFactory implements FoodFactory {

@Override
public Food getFood() {
return new RichNoodle();
}
}

class ColaFactory implements DrinkFactory{
@Override
public Drink getDrink() {
return new Cola();
}
}

class IcePeakFactory implements DrinkFactory{
@Override
public Drink getDrink() {
return new IcePeak();
}
}

/*************** 新拓展的产品和工厂 **********************/
class PorkFeetRice implements Food{

@Override
public void eat() {
System.out.println("吃猪角饭...");
}
}

/** 生产猪角饭的工厂 **/
class PorkFeetRiceFactory implements FoodFactory{

@Override
public Food getFood() {
return new PorkFeetRice();
}
}

public class AbstractApp {
public static void main(String[] args) {
// 拿到产生产品的工厂
FoodFactory foodFactory = new PorkFeetRiceFactory();
Food food = foodFactory.getFood();
food.eat();
}
}

上面的代码就是工厂设计模式,其实工厂设计模式到抽象工厂只有一步之遥,我们会发现上面导致类爆炸的原因就在于太多工厂类的接口了,那我们就将工厂类的接口再进行抽象,例如食物和饮料的工厂接口统一进行抽象,再将各自的实例工厂也进行合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/******************  抽象工厂类   *********************/
interface AbstractFactory {
Food getFood();
Drink getDrink();
}

/****************** 实例工厂类 *********************/

class KFCFactory implements AbstractFactory {
@Override
public Food getFood() {
return new Hamburger();
}
@Override
public Drink getDrink() {
return new Cola();
}
}

显然这样通过抽象 + 组合的方式,这样就可以减少一些类的产生

image-20221003234528532

这样做的优点是就是在拥有工厂方法模式的优点下可以有效减少类的产生

缺点是好像在一个工厂类绑定死了具体的产品,例如KFCFactory -> Hamburger + Cola,我们为什么在这个工厂生成这两种产品

现在我们补充一下上面没有提到的两个概念:

  • 产品簇:多个有内在联系或有逻辑关系的产品,例如上面的KFC套餐就固定为Hamburger + Cola,这里就组成一个产品簇

  • 产品等级:其实要弄清楚产品的等级我们可以看下面的图,产品等级就是指由一个接口或者父类泛化的子类,例如各种饮料、各种电冰箱,而产品簇就是指由一个工厂生产的产品,看下图中美的生产的产品,这些就组成一个产品簇

    image-20221004000743736

我们可以看到如果要生产上面的25个具体产品,一共需要多少个抽象产品,多少个抽象工厂?都是五个;如果是工厂方法则需要25个类,可以看到通过不断组合的方式,可以大大减少抽象产品类和抽象工厂类的创建

👀👀👀 现在让我们想一想这样的设计有什么问题没有?我们好好想一想

  • 新增产品簇:产品如果现在新增了一个工厂京东,那么我们只需要写一个京东的抽象工厂类,在写五个产品类分别继承上图中的抽象产品类,再加到自己的工厂类中,符合开闭原则

  • 新增产品等级:如果我们需要新加一个手机的产品等级呢?想想我们该如何添加,首先写一个抽象产品类,然后写??等等,好像发现写不下去了叭,怎么好像又需要在其他的工厂里面添加自己的产品类??又违反了开闭原则

    image-20221004001900063

🎯总结抽象工厂的缺点:

  • 当产品等级发生改变时(增加、减少),都会引起之前工厂代码的修改,违背开闭原则

所以设计模式有优点的同时也一定都有自己的局限性,我们要看场景来使用具体的工厂模式,笔者在下一节总结中进行分析

抽象工厂总结:

image-20221004150459347

2.1.4 工厂模式总结

工厂方法 优点 缺点
简单工厂 隐藏了类创建的细节,只需要类的标识就能创建一个类(多态) 产生很多映射关系;当添加产品时违反开闭原则
工厂方法 符合开闭原则(简单工厂模式 + 开闭原则 = 工厂方法) 加一个类,需要加一个工厂,类的个数成倍增加,增加维护成本,增加系统抽象性和理解难度
抽象工厂 解决了工厂方法模式中类爆炸的问题,同时拥有其可拓展性的优点 产品等级扩展非常困难,会违反开闭原则
工厂方法 适用场景
简单工厂 产品不扩充,简单工厂最好
工厂方法 产品经常要拓展(产品簇与产品等级)
抽象工厂 当产品等级固定,且产品簇需要拓展时

看了这么多好像这些工厂模式都不是我们心中最想要的结果,我们要的是既想马儿跑,又不想马儿吃草,即又不想多写代码,又想拥有良好的拓展性,那么这样的方法有吗?当然有。Spring就帮我们解决了,想想Spring是如何帮我们解决这个问题的,这将在下一节中揭晓谜底

2.1.5 Spring中的工厂模式

上面分析了这么久,还是不能满足我们好吃懒做既想马儿跑,又不想马儿吃草的需求,我们的目标是,既不能写过多的代码,就像工厂方法中类爆炸一样,又想要极致的拓展性,不能像抽象工厂一样不能添加产品等级

接下来让我们看看Spring中是如何解决的叭,可能很多同学平时框架用的爽,却根本不知道框架导致帮我们解决了什么问题,也不知道怎么解决的,这就导致一看源码就表示看不懂,很多面试题看了很多遍又会忘记,其实归根结底还是不理解,不会用,文章一开始我就讲过,最厉害的武功应该是忘记招式,无招胜有招!

直接揭晓答案叭,既想马儿跑,又不想马儿吃草的方案是:动态工厂 + 反射

当然并不是Spring中没有用其他的工厂,当然也用到了,像静态工厂(简单工厂)也运用到了,并且更多的不是一种设计模式使用,而是多种,例如:策略设计模式 + 工厂模式 + 模板方法模式

首先从BeanFactory这个顶层工厂接口说起,BeanFactory定义的是IOC容器最基本的规范

可以说BeanFactory 是 Spring 的心脏。它就是 Spring IoC 容器的真面目。Spring 使用 BeanFactory 来实例化、配置和管理 Bean。

相信被面试过BeanFactory面试题的同学对下面的张图一定很熟悉

image-20221004014858663

从上面我们可以看到BeanFactory有三个实现接口,分别是ListableBeanFactory、HierarchicalBeanFactory和AutowireCapableBeanFactory,并且井井有序,继承结构设计的非常优秀,其中:

  1. BeanFactory作为一个主接口不继承任何接口,暂且称为一级接口

  2. 有3个子接口继承了它,进行功能上的增强。这3个子接口称为二级接口

  3. ConfigurableBeanFactory可以被称为三级接口,对二级接口HierarchicalBeanFactory进行了再次增强,它还继承了另一个外来的接口SingletonBeanRegistry

  4. ConfigurableListableBeanFactory是一个更强大的接口,继承了上述的所有接口,无所不包,称为四级接口

    (这4级接口是BeanFactory的基本接口体系。继续,下面是继承关系的2个抽象类和2个实现类:)

  5. AbstractBeanFactory作为一个抽象类,实现了三级接口ConfigurableBeanFactory大部分功能

  6. AbstractAutowireCapableBeanFactory同样是抽象类,继承自AbstractBeanFactory,并额外实现了二级接口AutowireCapableBeanFactory

  7. DefaultListableBeanFactory继承自AbstractAutowireCapableBeanFactory,实现了最强大的四级接口ConfigurableListableBeanFactory,并实现了一个外来接口BeanDefinitionRegistry,它并非抽象类

  8. 最后是最强大的XmlBeanFactory,继承自DefaultListableBeanFactory,重写了一些功能,使自己更强大,但是这个类现在以及被标记为过期类,Spring官方建议使用:BeanFactory懒加载 或者 ApplicationContext中的逻辑来替换它

看看我们上面写的是什么玩意,跟玩具一样,就抽象了一层,不仅想解耦还想高拓展,实际的开发场景往往是十分复杂的,学习设计模式一定要把变字牢牢的记在心里。Spring中复杂的继承和抽象结构,就是为了满足尽可能多的应用场景

名字 作用
(一级接口)BeanFactory 定义的是IOC容器最基本的规范,核心方法getBean()
(二级接口)ListableBeanFactory 实现对Bean实例的枚举,以及对默些公共特征Bean的管理(同一产品等级)
(二级接口)HierarchicalBeanFactory 在BeanFactory定义的功能上增加了对父容器的定义,表示Bean继承关系
(二级接口)AutowireCapableBeanFactory Bean的创建注入并提供对Bean的初始化前后拓展性处理
(三级接口)ConfigurableBeanFactory 提供配置Factory的各种方式
(四级接口)ConfigurableListableBeanFactory 修改Bean定义信息和分析Bean的功能,实现了预实例化单例Bean以及冻结当前工厂配置的功能
(抽象类)AbstractBeanFactory Bean的创建和信息描述抽象方法,由继承者实现
(抽象类)AbstractAutowireCapableBeanFactory 实现Bean的创建并解决依赖注入问题,实现createBean()方法
(实现类)DefaultListableBeanFactory 对Bean容器完全成熟的默认实现,可对外使用

我们先来看(一级接口)BeanFactory,可以看到BeanFactory的核心方法就一个getBean(),还有一些获取Bean属性的方法

image-20221004020426123

接下来就是(二级接口)HierarchicalBeanFactory,它的方法更少,核心就是getParentBeanFactory(),我们知道不同的Bean有不同的工厂加载,这个方法就是获取这个Bean工厂的父工厂的,主要是为了解决IOC容器循环依赖的问题,其中在IOC容器中定义一个Bean是否一样,不仅要判断Bean是否一样,还需要判断创建其的工厂是否一样,这里就不展开讲了

image-20221004021050504

(二级接口)ListableBeanFactory

(二级接口)AutowireCapableBeanFactory:

(三级接口)ConfigurableBeanFactory主要是提供配置Factory的各种方法,主要的方法有:

  • setConversionService():设置转换器
  • addBeanPostProcess():添加Bean的后置处理器
  • destoryBean():销毁Bean

(四级接口)ConfigurableListableBeanFactory:修改Bean定义信息和分析Bean的功能,实现了预实例化单例Bean以及冻结当前工厂配置的功能

(抽象类)AbstractBeanFactory,Bean的创建和信息描述抽象方法,定义createBean()方法,核心方法有:

  • getBeanDefinition():BeanDefinition我们熟,Bean的定义,这个方法就是获取Bean的信息
  • createBean():创建Bean,由继承者来实现

(抽象类)AbstractAutowireCapableBeanFactory:实现Bean的创建并解决依赖注入问题,实现createBean()方法

好重点来了兄弟们,看来这么多层的抽象,终于到了创建Bean的地方,让我们到源码里面看看Spring是如何优雅的创建产品的

image-20221004023115134

看来这么多,可能读者还是没明白在Spring中是如果动态创建产品的,其实过程很简单,我们想想在平时的编码中会干什么

首先我们会将项目分层,分为controller、service、dao等层次,我们使用SpringBoot的时候只需要加一个注解SpringBootApplication,就能自动扫描当前包及其子包下面被标记了@Component注解的类,然后加载到Spring的容器中,我们想想,我们需要管实现类的类名是什么吗?需要管实现内的代码改没改吗?都不需要,Spring会帮我们将其注册到IOC容器中,并且依靠强大的DI来进行注入,每次启动项目都会动态的去扫描,并且依靠各种工厂去创建产品

1
2
3
4
5
@Component
public class XXXService {
@Autowired(required = false)
private XXXDao xxxdao;
}

我们来思考到底比之前的工厂模式好在哪里

  • 首先,用户添加@Component后,这个Bean的定义就被注册到IOC容器了,相当于简单工程中做映射;Bean工厂在创建这个实例时不需要知道具体类型,因为是靠反射创建的实例,无论什么类型都可以创建,这样就消除了工厂类对接口实现类的依赖,当我们想要拓展产品时,只需要写实现类并将其交给IOC容器即可

    image-20221004113108410

总结:总而言之,言而总之,Spring是如何解决工厂模式问题的?

  • 通过动态工厂 + 反射,通过添加@Component注解动态获取Bean的定义,解决简单工厂中不能动态添加产品的问题
  • 通过反射解决抽象工厂中无法拓展产品等级的问题;并且解决工厂方法中类爆炸的问题
  • 通过强大的依赖注入,解决接口与实现类之间耦合的问题,并且可以进行自动装配,条件装配,真正面向接口编程

2.1.6 工作中工厂方法使用

没有实际业务场景,一切都是无源之水、无本之木,是空洞的,现在我们举一个笔者实际开发中配合IOC容器使用的工厂模式的例子

现在有一个场景,就是需要做登录,但是我们不确定现在移动端的同学做几端,可能会有手机号登录、账号密码登录、qq登录、微信登录、PC端登录、网页端登录。这些登录的具体实现肯定是不一样的,而且我们并不知道到底要做几种策略,并且之后一定会有所拓展

所以我们一般会用IOC容器 + 工厂模式 + 策略模式 + 模板方法模式来完成这些功能

首先我们要定义一个策略接口,用来动态获取Bean(产品)的定义,该接口继承InitializingBean,我们后面动态添加的策略只需要实现该接口,通过回调我们的注册方法,就能将自己添加到我们自己的工厂中

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 策略接口
*
* @author Eureka
* @since 2022/9/25 11:59
*/
public interface LoginHandle extends InitializingBean {
/**
* 具体的登录逻辑
*/
void login(Map<String,String> params);
}

设计我们自己的登录工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LoginFactory {
private static Map<String, LoginHandle> loginStrategyFactoryMap = new ConcurrentHashMap<>();

public static LoginHandle getLoginStrategy(String loginSign) {
return loginStrategyFactoryMap.get(loginSign);
}

public static void register(String loginSign, LoginHandle loginHandle) {
if (StringUtils.isEmpty(loginSign) || Objects.isNull(loginHandle)) {
throw new RuntimeException("登录策略注册失败,参数错误");
}
// 将策略注册到工厂中
loginStrategyFactoryMap.put(loginSign, loginHandle);
}
}

例如我现在是手机号登录,我们就写一个具体的实现类,并实现我们的策略接口

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
/**
* 使用电话号码登录具体策略
*
* @author Eureka
* @since 2022/10/4 12:02
*/
@Slf4j
@Component
public class PhoneNumberLoginStrategy implements LoginHandle {

private static final String PHONE_NUMBER_LOGIN_STRATEGY = "PHONE_NUMBER_LOGIN_STRATEGY";

@Override
public void login(Map<String, String> params) {
// 放具体的登录策略
if (log.isDebugEnabled()) {
log.debug("用户通过手机号码登录,参数为:{}", params);
}
}

@Override
public void afterPropertiesSet() throws Exception {
// 将自己注册到我们自己定义的工厂中
LoginFactory.register(PHONE_NUMBER_LOGIN_STRATEGY, this);
}
}

我们来思考一下上面的策略模式 + 工厂模式解决了之前工厂模式的什么问题

  • 首先,新增策略(产品),只需要实现LoginHandle策略接口,通过将自己的映射关系注册到工厂中,解决了简单工厂中无法拓展的问题
  • 其次,每个策略上用@Component标记,表示这个Bean的实例交给Spring完成,通过反射解决抽象工厂无法拓展产品等级的问题

我们现在想要调用不同的执行策略,只需要让前端小哥哥在用户登录的时候传不同的标识,例如LoginSign = PHONE_NUMBER_LOGIN_STRATEGY,我们只需要

1
2
3
4
5
6
7
8
9
10
11
@Controller
public class LoginController {

@PostMapping("/user/login")
public ResponseEntity<Void> login(@RequestBody Map<String, String> params) {
// 调用对应的登录逻辑
LoginHandle loginHandle = LoginFactory.getLoginStrategy(params.get("LoginSign"));
loginHandle.login(params);
return ResponseEntity.ok().build();
}
}

这样如果下次需要新增加一种登录,我只需要写好具体的策略并实现策略接口,再和前端小哥沟通好标识就行了,上面这个Controller中的代码完全不用修改,这样既然解耦了,也能做到极致的拓展,并且完成不用写多余的代码,真正做到又想马儿跑,又不给马儿吃草

但是这样就ok了吗???笔者一再强调,开发讲究的就是一个变字,并且细心的小伙伴可能已经发现现在的类图好像看起来还是有点不太合理,现在的类图长这样

image-20221004123856620

好像我们现在的实现类现在耦合接口LoginHandle了,有的同学会有疑问,什么叫耦合接口???不就应该面向接口编程吗?难道接口会变?对,接口就是会变,如果现在我们辛辛苦苦写了十种策略,马上要下班了,现在前端小哥说,还有一个下线的功能别忘记写了

好,现在BBQ了,我们得去该接口LoginHandle,新添加logout的接口,并且在十种策略里面都添加下线的实现,违反了开闭原则,并且我们假设,有的策略是不需要登出功能,而且又导致有些策略类不得不实现一个空方法,有的同学会问,难道不能在重新写一套工程和策略吗?当然不好,注册登录登出,本来就应该在一个体系里面

总结一下上面的问题:

  • 依赖于LoginHandle接口,并且实现类必须要实现里面的所有接口,如果实现类有不想实现的,也必须实现

这里我们再引入板方法模式,也就是在接口与实现类之前再套一层,看多了源码的同学肯定知道,那有接口下面直接就是实现的,一般都要再套一层抽象层进行解耦

我们现在再来捋一捋思路,首先我们现在接口里面新增加一个方法

1
2
3
4
5
6
7
8
9
10
11
public interface LoginHandle extends InitializingBean {
/**
* Bean实例化后回调该方法,将自己注册到自定义的工厂中
*/
void login(Map<String, String> params);

/**
* 登出功能
*/
void logout();
}

我们再创建一层抽象层AbstractLoginHandle,称为模板方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 模板方法
*
* @author Eureka
* @since 2022/10/4 12:52
*/
public class AbstractLoginHandle implements LoginHandle {
@Override
public void login(Map<String, String> params) {
throw new UnsupportedOperationException();
}

@Override
public void logout() {
throw new UnsupportedOperationException();
}

/**
* 其中这个方法是必须要实现的,声明为抽象
*/
@Override
abstract public void afterPropertiesSet();
}

接下来修改我们具体的登录实现类,可以发现中间套了一层到好处是不用实现接口里面所有的方法,只需要有需要的实现即可,例如现在微信登录不需要登出功能,我们就可以这样写

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
/**
* 使用登录登录具体策略
*
* @author Eureka
* @since 2022/10/4 12:02
*/
@Slf4j
@Component
public class WXrLoginStrategy extends AbstractLoginHandle {

private static final String WX_LOGIN_STRATEGY = "WX_LOGIN_STRATEGY";

@Override
public void login(Map<String, String> params) {
// 放具体的登录策略
if (log.isDebugEnabled()) {
log.debug("用户微信登录,参数为:{}", params);
}
}

@Override
public void afterPropertiesSet() throws Exception {
// 将自己注册到我们自己定义的工厂中
LoginFactory.register(WX_LOGIN_STRATEGY, this);
}
}

我们开看一下现在的类图:

image-20221004130520035

现在的这个结构就算比较合理的了,但是还可以拓展,这里就不展开了

最后再提一嘴,我们这里的工厂模式是不是不属于上面提到的任何一种设计模式,其实这上面的模板方法模式和严格意义上的也有区别,希望读者始终记住,请不要死板的套用设计模式,网上随便搞篇博客上来就是写各种接口抽象类,你会发现其实用起来不是特别的顺手,只有多总结多归纳多思考,当我们真正理解后,就不会局限于那种设计模式了,而是下意识就会去这样设计,一看类图就知道这里不合理,做到无招胜有招

2.2 原型模式

原型模式(Prototype Pattern)

原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式是实现了一个原型接口,该接口用于创建当前对象的克隆。当直接创建对象的代价比较大时,则采用这种模式。例如,一个对象需要在一个高代价的数据库操作之后被创建。我们可以缓存该对象,在下一个请求时返回它的克隆,在需要的时候更新数据库,以此来减少数据库调用

现在我们来想一个场景,例如我们现在要写周报,大家作为互联网打工人肯定每周都是要向老板反馈工作内容的,现在我们发现周报里面需要填很多信息,例如姓名、部门、职位等等,这些信息基本都不会变,但是每次填周报我们又需要填一遍,这样显然不合理,我们其实想的是,写周报只需要填汇报内容即可,如下图所示:

image-20221004152031177

那我们应该怎么办呢?是不是希望能够保存一下上一次编辑的模板,下次直接修改内容就可以了,我们用一个类来表示一下

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
@Data // 自动生成getter setter 和 toString方法
@Accessors(chain = true) // 开启链式编程
class WeekReport{
private int id;
private String emp;
private String summary;
private String plain;
private String suggestion;
private String department;
private LocalDateTime submitDate;
}
public class AppTest {
public static void main(String[] args) {
WeekReport weekReport = new WeekReport();
weekReport.setEmp("奈李")
.setSummary("本周主要完成了七大设计原则和工厂模式的学习")
.setPlain("在下周的工作中完成原型模式学习")
.setDepartment("互联网事业部")
.setSubmitDate(LocalDateTime.now());
// 简单输出一下
System.out.println(weekReport);
// 第二周周报
WeekReport weekReport2 = new WeekReport();
weekReport2.setEmp("奈李")
.setSummary("本周主要完成了剩下设计模式的学习")
.setPlain("在下周会完成阿里巴巴开发手册学习")
.setDepartment("互联网事业部")
.setSubmitDate(LocalDateTime.now());
System.out.println(weekReport2);
}
}

我们会发现其实下一周要进行汇报时,我们只需要修改总结和下周计划的内容,但是现在我们却只能再新建一个对象重复上述代码

我们想要什么,想要的是直接克隆出一个对象来,将需要改变的填一下,不变的用之前的就好

我们现在通过克隆来实现上面的需求,在Java中只需要实现Cloneable,并重写克隆方法即可

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
@Data // 自动生成getter setter 和 toString方法
@Accessors(chain = true) // 开启链式编程
class WeekReport implements Cloneable {
private int id;
private String emp;
private String summary;
private String plain;
private String suggestion;
private String department;
private LocalDateTime submitDate;

@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

public class AppTest {
public static void main(String[] args) throws CloneNotSupportedException {
WeekReport weekReport = new WeekReport();
weekReport.setEmp("奈李")
.setSummary("本周主要完成了七大设计原则和工厂模式的学习")
.setPlain("在下周的工作中完成原型模式学习")
.setDepartment("互联网事业部")
.setSubmitDate(LocalDateTime.now());
// 简单输出一下
System.out.println(weekReport);
// 第二周周报 直接copy第一周的周报改改进行
WeekReport weekReport2 = (WeekReport) weekReport.clone();
weekReport2.setSummary("本周主要完成了剩下设计模式的学习")
.setPlain("在下周会完成阿里巴巴开发手册学习")
.setSubmitDate(LocalDateTime.now());
System.out.println(weekReport2);
}
}

这就是原型设计模式,是不是感觉挺简单的,这里还有几个点要注意一下

  • 上面的克隆其实只是浅拷贝
  • 使用clone方法创建对象,并不会调用该对象的构造器
  • 因为Object类的clone方法是一个本地方法,它直接操作内存中的二进制流,特别是复制大对象时,性能的差别非常明显。

2.2.1 深拷贝浅拷贝

类型 特点
浅拷贝 Object类的clone方法只会拷贝对象中的基本的数据类型,对于数组、容器对象、引用对象等都不会拷贝,这就是浅拷贝
深拷贝 会将原本对象中的数组、容器对象、引用对象等另行拷贝(完全是新的对象)

有的同学在这里总是傻傻分不清楚,就知道浅拷贝就是引用类型不拷贝,然后指向之前对象的堆空间,分不清成员变量中的引用是啥

image-20221004161314782

上面的可以把setSubmitDate看做一个指针,你弄了一个新对象给它,当然会在堆空间里面新申请一块内存,当然就不一样了

image-20221004163053907

那我们如何深拷贝呢?其实也很简单,既然原来的Object.clone()方法无法克隆引用类型,那我们自己来克隆就好了

其中大部分类其实都是实现了clone方法的

1
2
3
4
5
6
7
8
@Override
protected Object clone() throws CloneNotSupportedException {
WeekReport cloneWeekReport = (WeekReport) super.clone();
// Date类型需要自己手动进行克隆
Date cloneSubmitDate = (Date) cloneWeekReport.getSubmitDate().clone();
cloneWeekReport.setSubmitDate(cloneSubmitDate);
return cloneWeekReport;
}

但是这样显然很麻烦,如果我们克隆的是自己定义的对象,或者是对象里面套对象,这样层层嵌套的形式,显然就有点麻烦了

2.2.2 封装深拷贝工具

所以我们一般是用序列化和反序列化来做的,并且会封装成一个工具,这个拷贝在开发中还是非常常见的,例如我们经常将一个PO转换为一个VO(就是拷贝对象),一般用Spring自带的BeanUtils,但是它只能浅拷贝,我们现在自己封装一个,可以选择继承Spring自带的BeanUtils,或者就放deepClone这一个方法就行

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
/**
* 拷贝Bean
*/
@Slf4j
public final class BeanUtil extends BeanUtils {

private BeanUtil() {
throw new UnsupportedOperationException();
}

/**
* 拷贝属性
*/
public static <T> T copyProperties(Object source, Class<T> targetClass) {
if (checkNull(source, targetClass)) {
return null;
}
try {
T newInstance = targetClass.newInstance();
copyProperties(source, newInstance);
return newInstance;
} catch (Exception e) {
log.error("error: ", e);
return null;
}
}

@SuppressWarnings("unchecked")
public static <T extends Serializable> T deepClone(T object) {
T cloneObject = null;
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
objectOutputStream.writeObject(object);
objectOutputStream.close();
ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray());
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
cloneObject = (T) objectInputStream.readObject();
objectInputStream.close();
} catch (ClassNotFoundException | IOException e) {
log.info("拷贝异常::", e);
}
return cloneObject;
}

public static <T> T copyProperties(Object source, Class<T> targetClass, String... ignoreProperties) {
if (checkNull(source, targetClass)) {
return null;
}
try {
T newInstance = targetClass.newInstance();
copyProperties(source, newInstance, ignoreProperties);
return newInstance;
} catch (Exception e) {
log.error("error: ", e);
return null;
}
}

/**
* 拷贝集合
*/
public static <T> List<T> copyProperties(List<?> sources, Class<T> targetClass) {
if (checkNull(sources, targetClass) || sources.isEmpty()) {
return new ArrayList<>();
}
return sources.stream().map(source -> copyProperties(source, targetClass)).collect(Collectors.toList());
}

private static <T> boolean checkNull(Object source, Class<T> targetClass) {
return Objects.isNull(source) || Objects.isNull(targetClass);
}

}

现在在来看看原来的拷贝,这里需要注意进行序列化的对象需要实现Serializable接口

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
@Data // 自动生成getter setter 和 toString方法
@Accessors(chain = true) // 开启链式编程
class WeekReport implements Serializable {
private static final long serialVersionUID = 4455534412412L;
private int id;
private String emp;
private String summary;
private String plain;
private String suggestion;
private String department;
private Date submitDate;

}

public class DeepCloneTest {
public static void main(String[] args) throws CloneNotSupportedException, InterruptedException {
WeekReport weekReport = new WeekReport();
weekReport.setEmp("奈李")
.setSummary("本周主要完成了七大设计原则和工厂模式的学习")
.setPlain("在下周的工作中完成原型模式学习")
.setDepartment("互联网事业部")
.setSubmitDate(new Date());
// 简单输出一下
System.out.println("weekReport的时间:" + weekReport.getSubmitDate());
// 第二周周报 直接copy第一周的周报改改进行
WeekReport weekReport2 = BeanUtil.deepClone(weekReport);
weekReport2.setSummary("本周主要完成了剩下设计模式的学习")
.setPlain("在下周会完成阿里巴巴开发手册学习");
// 这样才是修改同一个引用指向堆空间里面的值
weekReport2.getSubmitDate().setTime(0);
System.out.println(weekReport == weekReport2);
System.out.println("weekReport2的时间:" + weekReport2.getSubmitDate());
System.out.println("weekReport的时间:" + weekReport.getSubmitDate());
}
}

image-20221004175235620

2.2.3 原型模式小结

是不是感觉其实原型模式也没啥,确实也没啥,所以23中设计模式其实有的模式其实还是挺简单的

image-20221004180317166

2.3 建造者模式

建造者模式(Builder Pattern)

建造者模式(Builder Pattern)使用多个简单的对象一步一步构建成一个复杂的对象。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。一个 Builder 类会一步一步构造最终的对象。该 Builder 类是独立于其他对象的。

  • 优点: 1、建造者独立,易扩展。 2、便于控制细节风险。
  • 缺点: 1、产品必须有共同点,范围有限制。 2、如内部变化复杂,会有很多的建造类。

例如我们现在有一个电脑类,里面有一些属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@Accessors(chain = true)
class Compute {
private String cpu;
private String gpu;
private String memory;
private String hd;
}


public class BuilderTest {
public static void main(String[] args) {
Compute compute = new Compute()
.setCpu("i7-10700")
.setGpu("3080 Ti")
.setMemory("32G")
.setHd("1T");
System.out.println(compute);
}
}

这样其实也有些问题:

  • 对象实例化的时候就必须要为每一个属性赋值,比较麻烦
  • 对于使用者来说,相当于给你一堆零件,然后需要自己组装

现在来改进一下,这里作者需要专门创建一个ComputeBuilder类,来专门负责组装电脑的过程

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
@Data
@Accessors(chain = true)
class Compute {
private String cpu;
private String gpu;
private String memory;
private String hd;
}

// 电脑构建者类,并且必须关联一个产品
class ComputeBuilder {
private Compute compute = new Compute();

// 构建方法
public Compute builder() {
return compute.setCpu("i7-10700")
.setGpu("3080 Ti")
.setMemory("32G")
.setHd("1T");
}
}

public class BuilderTest {
public static void main(String[] args) {
// 创建一个建造者
ComputeBuilder computeBuilder = new ComputeBuilder();
// 创建电脑
Compute compute = computeBuilder.builder();
System.out.println(compute);
}
}

上述代码的好处是:

  • 由建造者隐藏了创建对象的复杂过程

但是这样的缺点是什么?

  • 好像封装的太厉害了,无论客户需要什么,返回都是一样的配置

我们再改造一下

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
@Data
@Accessors(chain = true)
class Compute {
private String cpu;
private String gpu;
private String memory;
private String hd;
}

interface IcomputeBuilder{
Compute builder();
}

// 高级配置
class HighComputeBuilder implements IcomputeBuilder{
private Compute compute = new Compute();

// 构建方法
public Compute builder() {
return compute.setCpu("i7-10700")
.setGpu("3080 Ti")
.setMemory("32G")
.setHd("1T");
}
}

class MiddleComputeBuilder implements IcomputeBuilder{
private Compute compute = new Compute();

// 构建方法
public Compute builder() {
return compute.setCpu("i5-9500")
.setGpu("2080 Ti")
.setMemory("16G")
.setHd("1T");
}
}

class LowComputeBuilder implements IcomputeBuilder{
private Compute compute = new Compute();

// 构建方法
public Compute builder() {
return compute.setCpu("i3-8500")
.setGpu("1080 Ti")
.setMemory("8G")
.setHd("500G");
}
}

public class BuilderTest {
public static void main(String[] args) {
// 创建一个建造者
IcomputeBuilder highBuilder = new HighComputeBuilder();
IcomputeBuilder middleBuilder = new MiddleComputeBuilder();
IcomputeBuilder lowBuilder = new LowComputeBuilder();
// 创建最厉害的电脑
Compute highCompute = highBuilder.builder();
System.out.println(highCompute);
// 中等的电脑
Compute middleCompute = middleBuilder.builder();
System.out.println(middleCompute);
// 一般的电脑
Compute lowCompute = lowBuilder.builder();
System.out.println(lowCompute);
}
}

这样以来用户可以通过选取不同的建造者,来生产不同的产品,但是这里还是有问题

  • 我们发现在不同的构造者中,有重复的代码,既然有重复的代码,那就有坏味道
  • 构建的过程是不稳定的,如果某个建造者遗漏掉了哪一步,那么生产出来的产品就是不合格的,但是编译器却不会报错

我们得再进行改造

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
@Data
@Accessors(chain = true)
class Compute {
private String cpu;
private String gpu;
private String memory;
private String hd;
}

interface IcomputeBuilder {
IcomputeBuilder cpu();

IcomputeBuilder gpu();

IcomputeBuilder memory();

IcomputeBuilder hd();

Compute builder();
}

// 高级配置
class HighComputeBuilder implements IcomputeBuilder {
private Compute compute = new Compute();

@Override
public IcomputeBuilder cpu() {
compute.setCpu("i7-10700");
return this;
}

@Override
public IcomputeBuilder gpu() {
compute.setGpu("3080 Ti");
return this;
}

@Override
public IcomputeBuilder memory() {
compute.setMemory("32G");
return this;
}

@Override
public IcomputeBuilder hd() {
compute.setHd("1T");
return this;
}

// 构建方法
public Compute builder() {
return compute;
}
}


public class BuilderTest {
public static void main(String[] args) {
// 创建一个建造者
IcomputeBuilder highBuilder = new HighComputeBuilder();
// 创建最厉害的电脑
Compute highCompute = highBuilder.cpu().gpu().memory().hd().builder();
System.out.println(highCompute);
}
}

我们看这样进行构建的优点:

  • 建造者类中的建造过程是稳定的。不会漏掉某一步!!这样当客户端想扩展建造者时,也不会漏掉某一步

缺点:

  • 如果有多个建造者,代码任然会有重复
  • 现在又变成了客户端自己配置电脑,又违反了迪米特法则。(这相当于,你去电脑城配电脑,虽然不用你亲自组装电脑,但是你必须指挥那个装机boy,下一步该干啥,下一步该干啥,虽然隐藏细节,但是还是要指挥

我们想要的是连指挥过程也要隐藏起来

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
@Data
@Accessors(chain = true)
class Computer {
private String cpu;
private String gpu;
private String memory;
private String hd;
}

interface IComputerBuilder {
IComputerBuilder cpu();

IComputerBuilder gpu();

IComputerBuilder memory();

IComputerBuilder hd();

Computer builder();
}

// 隐藏指挥命令的细节
class Director {
public Computer build(IComputerBuilder computerBuilder) {
// 指挥builder进行组装
return computerBuilder.cpu().gpu().memory().hd().builder();
}
}

// 高级配置
class HighComputerBuilder implements IComputerBuilder {
private Computer computer = new Computer();

@Override
public IComputerBuilder cpu() {
computer.setCpu("i7-10700");
return this;
}

@Override
public IComputerBuilder gpu() {
computer.setGpu("3080 Ti");
return this;
}

@Override
public IComputerBuilder memory() {
computer.setMemory("32G");
return this;
}

@Override
public IComputerBuilder hd() {
computer.setHd("1T");
return this;
}

// 构建方法
public Computer builder() {
return computer;
}
}


public class BuilderTest {
public static void main(String[] args) {
// 创建一个建造者
IComputerBuilder highBuilder = new HighComputerBuilder();
// 创建指挥者
Director director = new Director();
// 由指挥者进行指挥
Computer highComputer = director.build(highBuilder);
System.out.println(highComputer);
}
}
  • 建造者负责建造,指挥者负责指挥,创建对象的过程是稳定的(IComputerBuilder接口负责稳定),创建对象的过程也不会有重复代码(指挥者完成)
  • 当需要拓展新的产品时,不需要修改原来的代码,只需要实现构建者接口,然后交给指挥者完成即可,这里就将构造和流程分别由构造者和指挥者进行解耦
  • 建造者作为中间层,进行解耦

最后画一下UML类图:

image-20221004194602718

2.3.1 建造者与工厂模式的区别

  • 工厂模式只负责创建实例,并不关心里面的属性和构建的过程;建造者模式更加关注构建的过程,通过一些规范的流程、标准构建合格的产品

2.3.2建造者模式总结

image-20221004193616662

2.4 组合模式

我们先来看下面的代码,目前我们将公共代码抽象成了MenuComponent

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
89
90
91
92
93
94
95
96
package com.fx.structurePattren.composite.a;

import lombok.Data;

import java.util.ArrayList;
import java.util.List;

/**
* 但凡有菜单的地方,都可以使用组合设计模式
* <pre>
* 菜单和菜单项
*
* 菜单
* |--湖南菜
* |-- 辣椒炒肉
* |-- 普通辣
* |-- 爆辣
* |-- 鸭
* |-- 永州血鸭
* |-- 啤酒鸭
* |--江西菜
* |-- 小炒黄牛肉
* |-- 明基虾
* </pre>
*
* <p>
* 目前下面代码的问题是
* <p> 1. 因为现在的菜单只能添加菜单项,不能再添加其他菜单
* <p> 2. 不能出现嵌套的菜单形式
*
* <p>
* 重构代码想要达到的效果如下
* <p> 1. 菜单可以添加菜单和菜单项
* <p> 2. 菜单项不能添加菜单项
*
* @author Eureka <liangfengyuan1024@gmail.com>
* @since 2025/11/9 14:35
*/
public class TestApp {
public static void main(String[] args) {
Menu menu = new Menu("湖南菜", "湖南人爱吃的菜");
menu.add(new MenuItem("鸭", "永州血鸭"));
menu.add(new MenuItem("蚂蚁上树", "永州血鸭"));
Menu menu2 = new Menu("辣椒炒肉", "湖南人爱吃辣椒炒肉");
/*
* =====>>>> cannot compile <=========
* 因为现在的菜单只能添加菜单项,不能再添加其他菜单
*/
// menu.add(menu2);
menu.print("\t |--");
}

/**
* 菜单组件,用于抽取出菜单和菜单项的共性
*/
@Data
static class MenuComponent {
private String name;
private String description;

public MenuComponent() {}

public MenuComponent(String menuName, String description) {
setName(menuName);
setDescription(description);
}
}

@Data
static class Menu extends MenuComponent {
private List<MenuItem> menuItemList = new ArrayList<>();
public Menu(String menuName, String description) {
super(menuName, description);
}
public void add(MenuItem menuItem) {
menuItemList.add(menuItem);
}
public void print(String prefix) {
System.out.println("menu " + getName() + "--" + getDescription());
for (MenuItem menuItem : menuItemList) {
menuItem.print(prefix);
}
}
}

@Data
static class MenuItem extends MenuComponent{
public MenuItem(String menuName, String description) {
super(menuName, description);
}
public void print(String prefix) {
System.out.println(prefix + getName() + "--" + getDescription());
}
}
}

优化后,核心点就是将print方法抽象到父类中,在menu递归时调用print方法会由子类进行实现,对应menu就会进行递归

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
public class TestApp {
public static void main(String[] args) {
Menu menu = new Menu("湖南菜", "湖南人爱吃的菜");
menu.add(new MenuItem("鸭", "永州血鸭"));
menu.add(new MenuItem("蚂蚁上树", "粉丝肉末"));

Menu menu2 = new Menu("辣椒炒肉", "湖南人爱吃辣椒炒肉");
menu2.add(new MenuItem("普通辣", "普通辣"));
menu2.add(new MenuItem("爆辣", "爆辣"));

Menu menu3 = new Menu("江西菜", "江西菜");
menu3.add(new MenuItem("小炒黄牛肉", "鲜嫩可口"));
menu3.add(new MenuItem("明基虾", "香辣美味"));

menu2.add(menu3);
menu.add(menu2);

MenuItem mi3 = new MenuItem("剁椒鱼头", "经典湘菜");
menu.add(mi3);

menu.print("");
}

@Data
abstract static class MenuComponent {
private String name;
private String description;

public MenuComponent() {
}

public MenuComponent(String menuName, String description) {
setName(menuName);
setDescription(description);
}

protected abstract void print(String prefix);
}

@Data
static class Menu extends MenuComponent {
private List<MenuComponent> menuItemList = new ArrayList<>();

public Menu(String menuName, String description) {
super(menuName, description);
}

public void add(MenuComponent menuComponent) {
menuItemList.add(menuComponent);
}

public void print(String prefix) {
// 使用前缀打印菜单名称
System.out.println(prefix + "menu " + getName() + "--" + getDescription());

// 为子项创建新的前缀(增加缩进)
String newPrefix = prefix + "\t |--";
// 下面会递归调用子项的print方法
for (MenuComponent menuComponent : menuItemList) {
menuComponent.print(newPrefix);
}
}
}

@Data
static class MenuItem extends MenuComponent {
public MenuItem(String menuName, String description) {
super(menuName, description);
}

public void print(String prefix) {
System.out.println(prefix + getName() + "--" + getDescription());
}
}
}

// 打印结果
menu 湖南菜--湖南人爱吃的菜
|--鸭--永州血鸭
|--蚂蚁上树--粉丝肉末
|--menu 辣椒炒肉--湖南人爱吃辣椒炒肉
|-- |--普通辣--普通辣
|-- |--爆辣--爆辣
|-- |--menu 江西菜--江西菜
|-- |-- |--小炒黄牛肉--鲜嫩可口
|-- |-- |--明基虾--香辣美味
|--剁椒鱼头--经典湘菜

现在变化来了,xu’yao

3. 结构型模式

结构型模式

这些设计模式关注类和对象的组合。继承的概念被用来组合接口和定义组合对象获得新功能的方式

image-20221004234400584

3.1 装饰器模式

装饰器模式(Decorator Pattern)

装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装。

这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能。

3.1.1 装饰器模式案例

我们现在来看一个业务场景,星巴克卖咖啡,一开始只有四种咖啡,分别是:Decaf低咖咖啡、Espresso浓缩咖啡、DrakRoast焦糖咖啡、HouseBlend混合咖啡

由于这四种咖啡都有共性,所以很久之前的开发人员给他们上了一个父类Beverage,饮料类

image-20221005003737053

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
@Data
abstract class Beverage {
private String description;

public Beverage(String description) {
this.description = description;
}

// 花费
public abstract double cost();
}

class Decaf extends Beverage {

public Decaf() {
super("无糖咖啡");
}

@Override
public double cost() {
return 1;
}
}

class Espresso extends Beverage {

public Espresso() {
super("浓缩咖啡");
}

@Override
public double cost() {
return 5;
}
}

class DrakRoast extends Beverage {

public DrakRoast() {
super("焦糖咖啡");
}

@Override
public double cost() {
return 15;
}
}

class HouseBlend extends Beverage {

public HouseBlend() {
super("混合咖啡");
}

@Override
public double cost() {
return 10;
}
}

/////////////// 请注意上述代码已经由作者写死了,无法改变 ///////////////

public class AppTest {
public static void main(String[] args) {
Beverage decaf = new Decaf();
Beverage espresso = new Espresso();
Beverage drakRoast = new DrakRoast();
Beverage houseBlend = new HouseBlend();
System.out.println(String.format("咖啡描述:%s,咖啡价格:%.2f", decaf.getDescription(), decaf.cost()));
System.out.println(String.format("咖啡描述:%s,咖啡价格:%.2f", espresso.getDescription(), espresso.cost()));
System.out.println(String.format("咖啡描述:%s,咖啡价格:%.2f", drakRoast.getDescription(), drakRoast.cost()));
System.out.println(String.format("咖啡描述:%s,咖啡价格:%.2f", houseBlend.getDescription(), houseBlend.cost()));
}
}

这样看代码好像并没有什么问题,可是现在变化来了,现在星巴克老板为了提高竞争力,需要往咖啡里面加调料,我们假设之前的代码是作者写死了的,我们无法改变,那么现在如何

例如调料我们现在有摩卡、泡沫、白糖、牛奶,我们现在想要给上面写的四个类加上牛奶,但是由于我们不能修改源代码并且符合开闭原则,我们只能通过继承的方式

1
2
3
4
5
6
7
8
9
// 为牛奶的Decaf咖啡创建一个类
class DecafWithMilk{

}
// 为加牛奶的Espresso咖啡创建一个类
class EspressonWithMilk{

}
...

好像感觉到不对劲了叭,这才是一种调料的组合,如果我需要摩卡 + 白糖、摩卡 + 牛奶 。。。等等组合?还维护的下去吗?现在会造成类爆炸的问题,我们看一下会有多少类,结论是有多少组合就会产生多少类

image-20221005002759551

如果你非常强大,硬着头皮写完了所有类,现在老板说又要加一种调料,之前是四种,组合有15中组合,现在五种调料,有31中,六种调料有63种,这合理吗?老板说一句话的功夫,如果不懂设计模式,那就得通宵加班

其实我们并不需要为每一种调料都添加,我们用装饰器模式完成这个功能:

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
///////////////  请注意上述代码已经由作者写死了,无法改变  ///////////////

/**
* 装饰器模式<br/>
* 这里我们让调料类继承自饮料类,显然违背了继承中的"is a"关系,但是在装饰器模式中这个原则就是需要违背<br/>
* 尽管调料不是饮料,但是为了解决问题,我们也只能让调料去继承饮料
*/
abstract class Condiment extends Beverage {
// 不仅需要继承饮料还需要关联饮料,让调料类关联饮料
protected Beverage beverage;

public Condiment(Beverage beverage) {
super("调料");
this.beverage = beverage;
}
}

class Milk extends Condiment {
public Milk(Beverage beverage) {
super(beverage);
}

@Override
public double cost() {
return beverage.cost() + 0.2;
}

@Override
public String getDescription() {
return beverage.getDescription() + " 牛奶";
}
}

class Mocha extends Condiment {
public Mocha(Beverage beverage) {
super(beverage);
}

@Override
public double cost() {
return beverage.cost() + 0.5;
}

@Override
public String getDescription() {
return beverage.getDescription() + " 摩卡";
}
}

public class AppTest {
public static void main(String[] args) {
Beverage decaf = new Decaf();
// 现在想要加牛奶
Milk milk = new Milk(decaf);
// 加摩卡
Mocha mocha = new Mocha(milk);
System.out.println(String.format("咖啡描述:%s,咖啡价格:%.2f", milk.getDescription(), milk.cost()));
System.out.println(String.format("咖啡描述:%s,咖啡价格:%.2f", mocha.getDescription(), mocha.cost()));
}
}
1
2
咖啡描述:无糖咖啡 牛奶,咖啡价格:1.20
咖啡描述:无糖咖啡 牛奶 摩卡,咖啡价格:1.70

可能读者看到上面的代码有点蒙蔽,请多看两遍,但是上述的代码确实在不改动源代码的前提下,完成了动态添加调料的功能!

我们重点看这几行代码:

1
2
3
4
// 现在想要加牛奶
Milk milk = new Milk(decaf);
// 加摩卡
Mocha mocha = new Mocha(milk);

这几行就是精华了,我们称之为想要添加什么功能就用对应的类装饰就行了!,我们把这种类称为ConcreteDecorator(具体装饰类)

我们不能用传统的继承关系看上面的代码,认为 Milk是不能继承Condiment的,我们仔细看 Milk到底做了啥

1
2
3
4
5
6
class Milk extends Condiment {
public Milk(Beverage beverage) {
super(beverage);
}
。。。
}

可以看到Milk不仅继承了Beverage饮料类,还关联了饮料类,关联是想要获取想要装饰的对象,继承是为了在原有的基础上对装饰对象的装饰

好好理解,多看几遍上面的代码,总结一下装饰器模式的几个角色

  • Component(抽象构件角色):它是具体构件和抽象装饰类的共同父类,以规范准备接受附加责任的对象(Beverage)
  • ConcreteComponent(具体构件):抽象构件角色的子类(或实现),具体的组件对象,装饰器可以给它增加额外的职责(decaf、Espresso、DrakRoast、HouseBlend)
  • Docorator(装饰器):也是抽象构件角色的子类,持有一个抽象构件角色的引用,通过该引用可以调用装饰之前构件对象的方法,并通过其子类扩展该方法,以达到装饰的目的(Condiment)
  • ConcreteDecorator(具体装饰类):具体的装饰类,实现为需要装饰的构件添加新的职责(Milk、Mocha)

记住关键步骤:

  • 继承 + 依赖:继承需要装饰的类的父类,拥有共性;组合想要装饰的类,用于装饰(加强)
  • 无限套娃:通过套娃,对需要装饰的类进行装饰、加强

image-20221005094935518

小结一下装饰器模式的优缺点:

优点:

  • 装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能
  • 多层装饰比较复杂

3.1.2 jdk中的装饰器模式

jdk中用到了很多装饰器模式,例如流里面就有很多的装饰类,所以我们在学校Java流的时候一定会觉得为什么流这么多

image-20221005100658015

我们随便写一段流的代码:

1
2
3
4
5
6
7
8
// 字节流
InputStream in = new FileInputStream("xxx.txt");
// 包装为缓冲流,减少来回次数提升读写速度
BufferedInputStream bis = new BufferedInputStream(in);
// 包装为字符流
InputStreamReader inputStreamReader = new InputStreamReader(bis, StandardCharsets.UTF_8);
// 关闭流
inputStreamReader.close();

我们看其实流为了应对不同的场景,也是一层一层的包装下来的,我们看看继承结构

image-20221005103651065可以看到我们通过装饰器FilterInputStream,让本来没有联系的两个流FileInputStream与BufferedInputStream产生了关联,这种关联及时继承 + 关联/依赖,从而达到可以装饰(套娃)的效果,我们来看看FilterInputStream的源码

image-20221005104609213

好好看你就会发现,作者又是写Stack的那个水货(无意冒犯🤣),好那么如果现在我也想写一个装饰类该怎么办呢,该继承谁呢?装饰类当然要继承装饰类(FilterInputStream),并组合想要装饰的类

其实Reader也有装饰类叫FilterReader,我们通过装饰器模式写一个BufferReader来自己实现一次读一行的能力

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
class MyBufferedReader extends Reader {
private final Reader in;

public MyBufferedReader(Reader in) {
this.in = in;
}

public String readLine() throws IOException {
StringBuilder sb = new StringBuilder();
while (true) {
int n = in.read();
if (n == '\n') {
break;
}
sb.append((char) n);
}
return sb.toString();
}

@Override
public int read(char[] cbuf, int off, int len) throws IOException {
return 0;
}

@Override
public void close() throws IOException {
in.close();
}
}


public class StreamTest {
public static void main(String[] args) throws IOException {
Reader fileReader = new FileReader("F:\\1.txt");
MyBufferedReader myBufferedReader = new MyBufferedReader(fileReader);
String line = myBufferedReader.readLine();
System.out.println(line);
String line2 = myBufferedReader.readLine();
System.out.println(line2);
}
}

可以看到我并没有改变原来的代码FileReader,但是在它的基础上添加了新的能力

3.1.3 装饰器模式总结

装饰器模式我们一定要记住:装饰器不但要继承需要装饰类的抽象类,还要关联它

image-20221005115633577

3.2 适配器模式

适配器模式

适配器模式(Adapter Pattern)是作为两个不兼容的接口之间的桥梁,将一个类的接口转换成客户希望的另一个接口, 使得原本由于接口不兼容不能再一起工作的类,可以在一起工作

主要分为三类:类适配器模式、对象适配器模式、接口适配器模式

适配器我们在生活中经常使用,例如很多油车上面,没有给手机充电的usb口,我们只能去买一个车载充电器

image-20221005135317862

那么代码中的适配器是什么样子的呢?假设我们这里现在有一段祖传的代码,能实现两数相加的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
class Calc {
public int add(int a, int b) {
return a + b;
}
}


public class AppTest {
public static void main(String[] args) {
Calc calc = new Calc();
System.out.println(calc.add(1,4));
}
}

现在变化来了,客户想要计算三个数的和?该怎么办呢?使用适配器模式我们直接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CalcAdapter {
// 注意组合优于继承
private final Calc calc;

public CalcAdapter(Calc calc) {
this.calc = calc;
}

public int add(int a, int b, int c) {
return calc.add(calc.add(a, b), c);
}
}

public class AppTest {
public static void main(String[] args) {
CalcAdapter calcAdapter = new CalcAdapter(new Calc());
System.out.println(calcAdapter.add(1, 4, 8));
}
}

好,适配器模式我们就讲完了

image-20221003171512629

开玩笑开玩笑,上面的场景也太简单了,就算是不会设计模式的同学硬想也能想出来,我们看看生产中的适配器是如何操作的

3.2.1 jdk中的适配器模式

基础扎实的同学应该知道其实在java线程里面的Runnable和Callable两个接口也是通过适配转换的

我们随便写一段代码:

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
public class ThreadTest {
// 线程池,线程任务提交给线程池执行
public static ExecutorService executorService = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 线程池运行Runnable方式
FutureTask<Class<?>> futureTask01 = new FutureTask<>(new Runnable() {
@Override
@SneakyThrows
public void run() {
System.out.println("线程池运行Runnable方式");
Thread.sleep(3000);
}
}, String.class);
executorService.submit(futureTask01);
// 线程池运行Callable方式
FutureTask<String> futureTask02 = new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(3000);
System.out.println("线程池运行Callable方式");
// 返回一句话
return "线程池运行Callable方式返回:" + Thread.currentThread().getName();
}
});
executorService.submit(futureTask02);
System.out.println(futureTask02.get());
}
}

我们看到创建了两种不同类型的任务,一种是基于Runnable接口,是无返回值的任务;一种基于Callable接口,是有返回值类型的任务,但是,请看,居然都可以通过executorService#submit()方法提交到线程池中运行?难道这个方法是一个重载方法吗?

其实我们点进源码里面进行参看,发现居然是同一个方法

image-20221005143459209

看来是FutureTask这个类搞得鬼,接下来我们看FutureTask类的两个构造器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 使用 Callable 进行初始化
public FutureTask(Callable<V> callable) {
if (callable == null)
throw new NullPointerException();
this.callable = callable;
this.state = NEW; // ensure visibility of callable
}

// 使用 Runnable 初始化,并传入 result 作为返回结果。
// Runnable 是没有返回值的,所以 result 一般没有用,置为 null 就好了
public FutureTask(Runnable runnable, V result) {
this.callable = Executors.callable(runnable, result);
this.state = NEW; // ensure visibility of callable
}

我们看这一行代码Executors.callable(runnable, result),明明写的是Executors#callable,但是传进去的确是一个Runnable,有猫腻,我们进去看看

1
2
3
4
5
6
public static <T> Callable<T> callable(Runnable task, T result) {
if (task == null)
throw new NullPointerException();
// 在这里进行了适配,将Runnable -> Callable
return new RunnableAdapter<T>(task, result);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//RunnableAdapter类
static final class RunnableAdapter<T> implements Callable<T> {
final Runnable task;
final T result;
RunnableAdapter(Runnable task, T result) {
this.task = task;
this.result = result;
}
public T call() {
task.run();
return result;
}
}

发现没有这个类,长得像什么,像适配器,首先它实现了Callable接口,又组合了Runnable接口,并且通过写了一个和Callable同名的call方法将Runnable#run

  • 这是非常典型的适配模型,想要把 Runnable 适配成 Callable,首先要实现 Callable 的接口,接着在 Callable 的 call 方法里面调用被适配对象(Runnable)的方法
  • 当然也可以将Callable适配成Runnable类,那为什么不呢?因为Callable可以提供更多的方法,例如获取返回值

可能有同学觉得,好像还是很简单,我只要记住适配器类继承要适配对象的抽象类,并组合适配对象就行了,这也不难嘛

确实关键点就是这两步,但是这种解耦、开闭的思想我们一定要融入平时的编码中,不要一上来就去改源代码

3.2.2 Spring中的适配器模式

鉴于有的读者可能觉得上面的栗子还是太简单的原因,这里来看一个稍微复杂一些的适配器模式

Spring中适配器运用的地方也有很多,这里分析两个

  • 当SpringMVC处理请求到DispatcherServlet时,会通过HandlerAdapter处理器适配器调用具体的处理器
  • AOP中的AdvisorAdapter,它有三个实现AfterReturningAdviceAdapter 、ThrowsAdviceAdapter 、MethodBeforeAdviceAdapter

首先第一个SpringMVC中DispatcherServlet类下有一个获得处理器适配器的方法:

1
2
3
4
5
6
7
8
9
10
11
12
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
if (this.handlerAdapters != null) {
//遍历所有的处理器适配器,找到支持处理当前handler的处理器适配器(HandlerAdapter是一个接口,其实返回的是一个实现HandlerAdapter的子类)
for (HandlerAdapter adapter : this.handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
}
throw new ServletException("No adapter for handler [" + handler +
"]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler");
}
1
2
3
4
5
6
7
8
9
10
11
public interface HandlerAdapter {
// 判断是否支持当前的适配器
boolean supports(Object handler);

@Nullable
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;

long getLastModified(HttpServletRequest request, Object handler);

}

HandlerAdapter适配器的子类有:

image-20221005150423315
为什么需要这些适配器呢,因为如果处理器的类型不同,有多重实现方式,那么调用方式就不是确定的,如果直接调用 Controller 方法,就得不断使用 if else 来进行判断是哪一种子类然后执行。那么如果后面要扩展 Controller,就得修改原来的代码,这样违背了开闭原则;

  • Spring定义了一个适配接口,使得每一种Controller有一种对应的适配器实现类;
  • 适配器代替 controller执行相应的方法;
  • 扩展Controller时,只需要增加一个适配器类就完成了SpringMVC的扩展

接下来就是切面里的适配器了,我们知道在Spring AOP里面切面一共有五种增强(Advice),分别是:

  • 前置通知(前置增强) - before() 执行方法前通知
  • 返回通知(返回增强) - afterReturn 方法正常结束返回后的通知
  • 异常抛出通知(异常抛出增强)一afetrThrow()
  • 最终通知一after无论方法是否发生异常,均会执行该通知。
  • 环绕通知——around包围一一个连接点(join point)的通知,如方法调用。这是最强大的一种通知类型。环绕通知可以在方法调用前后完成自定义的行为。它也会选择是否继续执行连接点或直接返回它们自己的返回值或

我们想想这些Advice难道全部是不一样的逻辑吗?其实不是,我们其实只需要写一个总的钩子函数,分别在上述的五个地方进行拦截,当预留五个适配器,分别调用总的钩子函数对应位置的逻辑即可,总的思想类似于线程池有七个参数,但是我们可以通过其内置函数调用少于七个参数的方法,间接调用最大的那个方法

这里不展开看源码了,我们看一看适配器的部分就行

1
2
3
4
5
6
7
public interface AdvisorAdapter {
//判断通知类是否匹配
boolean supportsAdvice(Advice advice);
//传入通知类,返回对应的拦截类
MethodInterceptor getInterceptor(Advisor advisor);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MethodBeforeAdviceAdapter implements AdvisorAdapter, Serializable {
//判断是否匹配MethodBeforeAdvice通知类
@Override
public boolean supportsAdvice(Advice advice) {
return (advice instanceof MethodBeforeAdvice);
}
//传入MethodBeforeAdvice,转换为MethodBeforeAdviceInterceptor拦截类
@Override
public MethodInterceptor getInterceptor(Advisor advisor) {
MethodBeforeAdvice advice = (MethodBeforeAdvice) advisor.getAdvice();
return new MethodBeforeAdviceInterceptor(advice);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MethodBeforeAdviceInterceptor implements MethodInterceptor, Serializable {
//成员变量,通知类
private MethodBeforeAdvice advice;

//定义了有参构造器,外部通过有参构造器创建MethodBeforeAdviceInterceptor
public MethodBeforeAdviceInterceptor(MethodBeforeAdvice advice) {
Assert.notNull(advice, "Advice must not be null");
this.advice = advice;
}

//当调用拦截器的invoke方法时,就调用通知类的before()方法,实现前置通知
@Override
public Object invoke(MethodInvocation mi) throws Throwable {
//调用通知类的before()方法,实现前置通知
this.advice.before(mi.getMethod(), mi.getArguments(), mi.getThis() );
return mi.proceed();
}

}

3.2.3 适配器模式总结

image-20221005151636229

3.3 代理模式

代理模式

在代理模式(Proxy Pattern)中,一个类代表另一个类的功能。这种类型的设计模式属于结构型模式。

在代理模式中,我们创建具有现有对象的对象,以便向外界提供功能接口

代理模式真的用的太多了,我愿称之为框架中的模式NO1

3.3.1 问题引入

现在我们手上有一个计算器类,一共有四个方法:

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
interface Calculate {
int add(int x, int y);

int reduce(int x, int y);

int multiply(int x, int y);

double divide(int x, int y);
}

class CalculateImpl implements Calculate {
public int add(int x, int y) {
return (x + y);
}

public int reduce(int x, int y) {
return (x - y);
}

public int multiply(int x, int y) {
return x * y;
}

@Override
public double divide(int x, int y) {
return x / y;
}
}

public class AppTest {
public static void main(String[] args) {
Calculate calculate = new CalculateImpl();
System.out.println(calculate.add(1, 4));
System.out.println(calculate.reduce(1, 4));
System.out.println(calculate.multiply(1, 4));
System.out.println(calculate.divide(1, 4));
}
}

假设我们现在想要统计这四个方法的耗时情况,该怎么做?

  • 通过适配器模式装换接口,并在新的适配类中统一记录耗时情况
  • 多写一些子类来完成

我只能说上面的方法也可以,但是不够优雅,这里才四个方法,如果有四千个呢?有的同学会问怎么可能有四千个方法,我只能说too young to simple,一个商业项目里面的接口有个小几万个太正常不过了,如果想对这些方法做增强,用我们之前学的所有设计模式都完不成,必须要用动态代理,物理上完不成,我们就得用魔法——动态代理 + 发射

3.3.2 jdk动态代理

我们先学一下jdk中动态代理的api,看如何使用动态代理,先别问为什么,先把api记住,我们可以通过Proxy类创建一个代理类

1
Proxy.newProxyInstance()

有三个参数,分别是:

image-20221005161215388

  • 类加载器:要创建一个类的实例(在类构造器被执行之前),需要通过类加载器将这个类的字节码文件加载到JVM中(也可以说是从磁盘加载到内存中)
  • 传入要代理类的接口的字节码文件,因为可以通过传入不同的字节码生成不同的对象,所以称之为动态代理
  • 我们传入的是接口的字节码文件,生成的代理类势必需要实现接口里面的方法,那么这些方法被实现的方法体具体是什么内容呢?这恰恰就是由第三个参数决定的

可能有点蒙,我们看下面具体的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyHandle implements InvocationHandler {

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("呵呵哒");
return 0;
}
}

public class AppTest {
public static void main(String[] args) {
// 创建代理对象
Class<?>[] interfaceClass = {ICalculate.class};
ICalculate proxy = (ICalculate) Proxy.newProxyInstance(AppTest.class.getClassLoader(), interfaceClass, new MyHandle());
System.out.println(proxy.add(1, 3));
}
}

我们现在来思考,这样创建的代理对象在执行proxy.add(1, 3)时,是会执行ICalculate接口里面的add方法吗?还是MyHandle#invoke方法,我们运行一下就可以知道,是执行MyHandle#invoke里面的方法

1
2
3
4
5
6
7
8
9
proxy.add(1, 3);
proxy.reduce(1, 3);
proxy.multiply(1, 3);
proxy.divide(1, 3);
// 输出
呵呵哒
呵呵哒
呵呵哒
呵呵哒

小结一下这个api的使用,Proxy#newProxyInstance()一共有三个参数,分别是类加载器,用来加载接口类的字节码;接口类数组的字节码,用来动态代理生成不同的对象;接口中方法的实现方法体,即执行代理类的方法时,会调用传入的InvocationHandler

接下来我们看InvocationHandler的参数,这个参数就简单多了,例如我执行方法是proxy.add(1, 2),那么对应的就是:

image-20221005163238710

证明一下:

1
2
3
4
5
6
7
8
9
10
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(String.format("方法名为:%s,参数为:%s", method.getName(), Arrays.toString(args)));
return 0;
}
// 输出
方法名为:add,参数为:[1, 3]
方法名为:reduce,参数为:[1, 3]
方法名为:multiply,参数为:[1, 3]
方法名为:divide,参数为:[1, 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
30
31
32
class MyHandle implements InvocationHandler {

private final Object target;

public MyHandle(Object target) {
this.target = target;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(String.format("方法名为:%s,参数为:%s", method.getName(), Arrays.toString(args)));
long start = System.currentTimeMillis();
// 利用反射调用真实方法
Object result = method.invoke(target, args);
System.out.println(String.format("耗时:%s ms", System.currentTimeMillis() - start));
// 返回到代理对象的方法调用处
return result;
}
}

public class AppTest {
public static void main(String[] args) {
ICalculate iCalculate = new CalculateImpl();
// 创建代理对象,需要三个参数,分别是
Class<?>[] interfaceClass = {ICalculate.class};
ICalculate proxy = (ICalculate) Proxy.newProxyInstance(AppTest.class.getClassLoader(), interfaceClass, new MyHandle(iCalculate));
System.out.println(proxy.add(1, 3));
System.out.println(proxy.reduce(1, 3));
System.out.println(proxy.multiply(1, 3));
System.out.println(proxy.divide(1, 3));
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
方法名为:add,参数为:[1, 3]
耗时:0 ms
4
方法名为:reduce,参数为:[1, 3]
耗时:0 ms
-2
方法名为:multiply,参数为:[1, 3]
耗时:0 ms
3
方法名为:divide,参数为:[1, 3]
耗时:0 ms
0.0

好代理模式讲完了

0

怎么可能,就学了一个api就完了???这搞得比工厂模式还简单了。当然不是,这才学了一个API,这都还没到模式的这一步,这才哪到哪,六分之一的内容都还没到

我们来想想这样的缺点是什么?

  • 太复杂了,有的用户不会使用动态代理
  • 代码耦合了,每次需要去修改代理类里面的处理逻辑

那我们再封装封装,让用户开箱即用

3.3.3 封装jdk动态代理帮助类

我们来封装封装上面的代码

我们定义一个接口,用来描述代理类对应方法执行前后需要拓展执行的方法,其实这种接口我们喜欢将其称之为:切面,也可以定义为Interceptor拦截器

1
2
3
4
5
interface Aspect {
void before(Object target, Method method, Object[] args);

void after(Object target, Method method, Object[] args, Object returnVal);
}

在我们的handle上添加切面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyHandle implements InvocationHandler {

private final Object target;
private final Aspect aspect;

public MyHandle(Object target, Aspect aspect) {
this.target = target;
this.aspect = aspect;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 方法执行前的钩子函数
aspect.before(target, method, args);
Object result = method.invoke(target, args);
// 方法执行后的钩子函数
aspect.after(target, method, args, result);
// 返回到代理对象的方法调用处
return result;
}

}

代理类:

1
2
3
4
5
6
7
class MyProxy {
public static Object getProxy(Object cls, Aspect aspect) {
// 拿到代理对象实现的所有接口字节码
Class<?>[] interfaceClass = cls.getClass().getInterfaces();
return Proxy.newProxyInstance(cls.getClass().getClassLoader(), interfaceClass, new MyHandle(cls, aspect));
}
}

使用:

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
public class AppTest {
public static void main(String[] args) {
ICalculate iCalculate = new CalculateImpl();
// 创建代理对象,需要三个参数,分别是
ICalculate proxy = (ICalculate) MyProxy.getProxy(iCalculate, new Aspect() {
@Override
public void before(Object target, Method method, Object[] args) {
System.out.println(String.format("方法名为:%s,参数为:%s", method.getName(), Arrays.toString(args)));
}

@Override
public void after(Object target, Method method, Object[] args, Object returnVal) {
System.out.println(String.format("返回结果为:%s", returnVal.toString()));
}
});
System.out.println(proxy.add(1, 3));
System.out.println(proxy.reduce(1, 3));
System.out.println(proxy.multiply(1, 3));
System.out.println(proxy.divide(1, 3));
}
}
// 输出
方法名为:add,参数为:[1, 3]
返回结果为:4
4
方法名为:reduce,参数为:[1, 3]
返回结果为:-2
-2
方法名为:multiply,参数为:[1, 3]
返回结果为:3
3
方法名为:divide,参数为:[1, 3]
返回结果为:0.0
0.0

可以看到这样就简单了很多,应对不同的需求我们就去定制不同的代理类和切面类,实现不同的需求。有兴趣的同学可以去看hutool工具包下面封装的代理工具类,那个工具类封装更多的方法,而且中文注释,挺好理解的

好代理模式讲完了(梅开二度😜),上面只是简单的封装,其实还没有到代理模式的核心,我们来看看上面代理模式的缺点:

  • 首先jdk动态代理基于接口,如果想要代理的对象没有并没有实现接口,就无法代理
  • 你会发现,代理类的所有方法都被加强了,也就是说任何一个方法执行都会调用Aspect里面的逻辑,没有选择的余地了

首先第二个问题,读者可以将MyHandle也单独拆出来,如果只想要执行一个或多个方法,通过打标记的方式,例如传入add方法后拿着执行方法比较一下,再执行,或者使用适配器模式做适配类,再或者在Aspect里面做判断,这些都比较简单,这里不演示了,需要注意的是:

  • 一般框架中切面都是将职责进行细分了的,并用集合存起来,实现某个接口就能拿到这个集合,再将自己的切面或者说拦截器放进去,就能执行响应的功能

但是其实无论怎么做,还是会依赖于具体类里面具体的方法,还不是不够理想,读者写别急,解决方案其实是SpringAOP里面的Spring动态代理,通过用注解给需要执行的方法做标记的方式执行代理,有点类似于简单工厂中Spring解决的问题

我们来看第一个问题如何解决

3.3.4 Cglib动态代理

其实没有实现接口就无法代理的问题,也是jdk动态代理的问题,解决方法就是换成cglib动态代理,cglib是基于类的动态代理,它的原理是对指定的目标类生成一个子类, 并覆盖其中方法实现增强,但因为采用的是继承,所 以不能对final修饰的类进行代理。

在pom.xml文件中引入cglib的相关依赖

1
2
3
4
5
6
7
<!-- https://mvnrepository.com/artifact/cglib/cglib -->
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>2.2.2</version>
</dependency>

定义类

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
public class CglibInterceptor implements MethodInterceptor {

//目标对象
private Object target;
//通过构造器传入目标对象
public CglibInterceptor(Object target) {
this.target = target;
}

/**
* 获得代理对象
* @return 返回一个代理对象
*/
public Object getProxy(){
//1. 通过Enhancer对象中的create()方法生成一个类,用于生成代理对象
Enhancer enhancer=new Enhancer();
//2. 设置父类(将目标类作为代理类的父类)
enhancer.setSuperclass(target.getClass());
//3. 设置拦截器,回调对象为本身对象
enhancer.setCallback(this);
//4. 生成代理类对象,并返回给调用者
return enhancer.create();
}

/**
* 拦截器:
* 1. 目标对象的方法调用
* 2. 行为增强
* @param o cglib生成的动态代理类的实例
* @param method 实体类被调用的被代理的方法的应用
* @param objects 参数列表
* @param methodProxy 生成的代理类,对方法的代理应用
* @return 返回行为增强后的代理对象
* @throws Throwable 抛出异常给系统
*/
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {

//增强行为
System.out.println("intercept方法执行前的增强行为");
//调用目标类中的方法
Object object = methodProxy.invoke(target, objects);
//增强行为
System.out.println("intercept方法执行后的增强行为");
return object;
}
}

可以看到其实都只是API的调用,多看几遍就能够理解清楚

3.3.5 JDK动态代理和CGLIB代理的区别

  • JDK动态代理实现接口,Cglib动态代理继承思想
  • JDK动态代理( 目标对象存在接口时)执行效率高于Ciglib
  • 如果目标对象有接口实现,选择JDK代理, 如果没有接口实现选择Cglib代理

最优选择:如果目标对象存在接口实现,优先选择JDK动态代理,反之这选择cglib动态代理

3.3.6 Spring中的代理模式

TODO

3.3.7 Dubbo中的代理模式

TODO

3.3.8 代理模式总结

image-20221005183117633

3.4 桥接模式

桥接模式

桥接(Bridge)是用于把抽象化与实现化解耦,使得二者可以独立变化。这种类型的设计模式属于结构型模式,它通过提供抽象化和实现化之间的桥接结构,来实现二者的解耦。

4. 行为型模式

行为型模式
行为型模式设计模式特别关注对象之间的通信

image-20221005151852748

4.1 策略模式

策略模式

策略模式(Strategy Pattern)中,定义算法族,分别封装起来,让他们之间可以互相替换,此模式让算法的变化独立于使用算法的客户

4.1.1 问题引入

现在我们有一个Duck抽象类,它有四个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract void display();// 抽象方法

public void quack() {
System.out.println("鸭子嘎嘎叫~~");
}

public void swim() {
System.out.println("鸭子会游泳~~");
}

public void fly() {
System.out.println("鸭子会飞翔~~");
}

现在我们给其添加三个子类

  • WildDuck 野鸭
  • PekingDuck 北京鸭
  • ToyDuck 玩具鸭

现在问题产生了:

  1. 野鸭不需要重写父类的方法
  2. 北京鸭需要重写父类的fly方法,因为北京鸭不会飞
  3. ToyDuck需要重写父类的所有方法,这跟我们想要通过继承来复用代码的想法背道相驰

问题总结:由于继承带来的问题,对类的局部改动,尤其是超类的改动,会影响其他部分。会有溢出效应

4.1.2 策略模式解决问题的核心

通过将继承改成组合+聚和的方式,来减少溢出效应

  1. 把变化的代码从不变的代码中分离出来
  2. 针对接口编程而不是具体类(定义策略接口)
  3. 多用组合/聚和,少用继承(客户通过组合方式使用策略)

4.1.3 策略模式原理

从下面的类图可以看到,客户context有成员便令strategy或者其他的策略接口,至于需要使用到哪个策略,我们可以在构造器中指定

image-20220710202957039

栗子代码:

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
public abstract class Duck {

FlyBehavior flyBehavior;// 定义策略接口
// 其他属性也可以声明为策略接口

public Duck() {

}

public void setFlyBehavior(FlyBehavior flyBehavior) {
this.flyBehavior = flyBehavior;
}

public abstract void display();

public void quack() {
System.out.println("鸭子嘎嘎叫~~");
}

public void swim() {
System.out.println("鸭子会游泳~~");
}

public void fly() {
// 改进
if (flyBehavior != null) {
flyBehavior.fly();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public class PekingDuck extends Duck {

public PekingDuck() {
flyBehavior = new NoFlyBehavior();
}

@Override
public void display() {
System.out.println("北京鸭~~");
}

}

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
new WildDuck().fly();
PekingDuck pekingDuck = new PekingDuck();
pekingDuck.fly();
// 动态改变其行为
pekingDuck.setFlyBehavior(new BadFlyBehavior());
pekingDuck.fly();
new ToyDuck().fly();
}
}
打印结果:
飞翔技术很好
不能飞翔
飞翔技术不好
不能飞翔

4.1.4 策略模式在JDK中的应用

  • JDK的Arrays的Comparator就使用了策略模式
  • 代码分析+Debug源码+模式角色分析
  • 在JDK中的Comparator中运用到了策略接口(策略模式)
1
2
3
4
5
6
7
8
9
10
11
12
Integer[] data={9,1,2,8,4,3};
//需求,实现升序排列,返回-1放左边,1放右边,0保持不变
//实现了策略接口的对象
Comparator<Integer> comparator= (o1, o2) -> {
if(o1 > o2) {
return 1;//自定义策略
}else if(o1 < o2){
return -1;
}else {
return 0;
}
};

4.1.5 策略模式总结

image-20221005153125667

在状态模式(State Pattern)中,类的行为是基于它的状态改变的。这种类型的设计模式属于行为型模式。

在状态模式中,我们创建表示各种状态的对象和一个行为随着状态对象改变而改变的 context 对象。

4.2 观察者模式

观察者模式

观察者模式又被称为发布-订阅(Publish/Subscribe),当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知依赖它的对象。观察者模式属于行为型模式。

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
package com.fx.behaviorPattern.observer.d;


import lombok.Data;

import java.util.ArrayList;
import java.util.List;

public class Test {
public static void main(String[] args) {
Role role = new Role();
role.setName("角色");
role.setHp(100);

Panel panel = new Panel(role);
role.addObserver(panel);

BallPanel ballPanel = new BallPanel(role);
role.addObserver(ballPanel);

HeadPanel headPanel = new HeadPanel(role);
role.addObserver(headPanel);

Monster monster = new Monster();
monster.attack(role);
monster.attack(role);
monster.attack(role);
}
}

@Data
class Role {
private String name;
/**
* 血量
**/
private int hp;
/**
* 魔法值(蓝)
**/
private int mp;
/**
* 所有观察者
**/
private List<Observer> observers = new ArrayList<>();

public void addObserver(Observer obj) {
observers.add(obj);
}

public void removeObserver(Observer obj) {
observers.remove(obj);
}

public void notifyObservers() {
observers.forEach(Observer::notifyObserver);
}

public void setHp(int hp) {
// 更新血条的时候,一定要通知三个地方:1. 血条、球、面板
this.hp = hp;
System.out.println("血条更新为:" + hp);

System.out.println("球形更新为:" + hp);
System.out.println("面板更新为:" + hp);
}

@Override
public String toString() {
return "Role{" +
"name='" + name + '\'' +
", hp=" + hp +
", mp=" + mp +
'}';
}
}

class Monster {
public void attack(Role r) {
r.setHp(r.getHp() - 10);
r.notifyObservers();
}
}

/**
* 观察者们需要一个方法来接收数据
*/

interface Observer {
void notifyObserver();
}

/**
* 观察者模式拥有很多变体
*/
class Panel implements Observer {
/**
* 访问者模式其中一种实现就是要持有数据的引用 也就是推拉的形式
* 推,让数据源推消息给观察者
* 拉,观察者拉取自己关心的数据
*/
private final Role r;

public Panel(Role r) {
this.r = r;
}

@Override
public void notifyObserver() {
System.out.printf("Panel 更新血条了值为:%d\n", r.getHp());
}
}

class BallPanel implements Observer {
private final Role r;

public BallPanel(Role r) {
this.r = r;
}

@Override
public void notifyObserver() {
System.out.printf("Panel 更新血条了值为:%d\n", r.getHp());
}
}

class HeadPanel implements Observer {
private final Role r;

public HeadPanel(Role r) {
this.r = r;
}

@Override
public void notifyObserver() {
System.out.printf("Panel 更新血条了值为:%d\n", r.getHp());
}
}

4.2.3 观察者模式总结

image-20231105234455568

4.3 访问者模式

访问者(Visitor)模式:封装一些作用于某种数据结构中的各元素的操作,它可以在不改变这个数据结构的前提下定义作用于这些元素的新的操作

🧨访问者模式的本质是想实现多重派发 (multiple dispatch),由于Java中只有函数参数重载实现的单派发(single dispatch),所以需要这个模式去模拟,如果用支持多派发的语言如clojue或者common lisp、js,就不需要访问者模式了,在golang中也可以通过接口和类型断言来模拟实现多派发。

img

抽象访问者(Visitor)角色:定义接口,声明一个或多个访问操作。
具体访问者(ConcreteVisitor)角色:实现抽象访问者所声明的接口,也就是抽象访问者所声明的各个访问操作。
抽象元素(Visitable)角色:声明一个接受操作,接受一个访问者对象作为一个参数。
具体元素结点(ConcreteElement)角色:实现抽象结点所规定的接受操作。
数据结构对象(ObjectStructure)角色:可以遍历结构中的所有元素,提供一个接口让访问者对象都可以访问每一个元素。

下面用一个经典的墨子骑马🏇的故事来描述java遇到的多派发的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Mozi {
public void ride(Horse h) {
System.out.println("Riding a horse");
}
public void ride(WhiteHorse wh) {
System.out.println("Riding a white horse");
}
public void ride(BlackHorse bh) {
System.out.println("Riding a black horse");
}
public static void main(String[] args) {
Horse wh = new WhiteHorse();
Horse bh = new BlackHorse();
Mozi mozi = new Mozi();
mozi.ride(wh);
mozi.ride(bh);
}
}
interface Horse { }
class WhiteHorse implements Horse {}
class BlackHorse implements Horse {}

运行后输出两次Riding a horse

明显只有第一个方法被调用到了

image-20231103002821746

我们来思考一下问题出在那里,并且想想实际工作中什么场景会遇到这样的问题

问题很明显,我们使用了多态,虽然具有不同的真实类型,但是它们的静态类型都是一样的,均是Horse类型,并且重载方法的分派是根据静态类型进行的。这个分派过程在编译时期就完成了

现在让我们来完善一些概念🎃

  • 变量被声明时的类型叫做变量的静态类型(Static Type) 又叫明显类型(Apparent Type)
  • 变量所引用的对象的真实类型又叫做变量的实际类型(Actual Type)

上面例子中Horse就是静态类型,编译期就确认了;那两匹🐎就是实际类型

根据对象的类型而对方法进行的选择,就是分派(Dispatch)。根据分派发生的时期,可以将分派分为两种,即静态分派和动态分派

  • 静态分派(Static Dispatch) 发生在编译时期,分派根据静态类型信息发生。方法重载(Overload)就是静态分派。(所谓的:编译时多态)
  • 动态分派(Dynamic Dispatch) 发生在运行时期,动态分派动态地置换掉某个方法。面向对象的语言利用动态分派来实现方法置换产生的多态性。(所谓的:运行时多态)

我们上面墨子骑马🏇的例子其实就是java使用方法重载的方式来支持静态分派

当然java也支持动态分派,在java中通过方法的置换(Overriding)支持动态分派,举个例子

1
2
3
4
>String s1 = "ab";  
>Object o = s1 +"c";
>String s = "abc";
>boolean b = o.equals(s);

上面的代码输出为true,让我们分析一下,其实调用 o.equals的时候,o的类型是Object,按道理调用的应该是Object.equals,我们都知道比较的是内存地址,肯定和s的地址不一样(别给我扯什么串池复用对象啥的,可以看字节码,这里不是一个对象),但是这里返回true了,是因为调用的其实不是Object.equals,而是String.equals,这样是java中所谓的通过Overriding实现的动态分发

分派的类型

一个方法所属的对象叫做方法的接收者,方法的接收者与方法的参量统称做方法的宗量。根据分派可以基于多少种宗量,可以将面向对象的语言划分为单分派语言和多分派语言

  • 单元分派语言根据一个宗量的类型(真实类型)进行对方法的选择(C++、Java以及Smaltalk)
  • 多分派语言根据多于一个的宗量的类型对方法进行选择(CLOS和Cecil)

通过上面的例子我们可以看出:

C++和Java就是动态的单分派语言,因为这两种语言的动态分派仅仅会考虑到方法的接收者的类型,同时又是静态的多分派语言,因为这两种语言对重载方法的分派会考虑到方法的接收者的类型和方法所有参量的类型。

在一个支持动态单分派的语言里面,有两个条件决定了一个请求会调用哪一个操作:一是请求的名字,二是接收者的真实类型。单分派限制了方法的选择过程,使得只有一个宗量可以被考虑到,这个宗量通常就是方法的接收者。在JAVA语言里面,如果一个操作是作用于某个类型不明的对象上面的。那么这个对象的真实类型测试仅会发生一次。这个就是动态的单分派的特征。

一言以蔽之,JAVA语言支持静态的多分派和动态的单分派

回到之前出现的问题上,如果一个方法根据两个宗量的类型来决定执行不同的代码,这就是“双分派”或者“多重分派”。Java不支持动态的多分派。但可以通过使用设计模式,在Java语言里面实现动态的双重分派(ps:就是伪双重分派是由两次的单分派组成)。

如果解决呢?一般有两种做法

4.3.1 类型判断解决多派分发问题

在方法里使用instanceof判断真实类型,比如(java.awt.Component的源码)

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
java.awt.Component#processEvent

/**
* Processes events occurring on this component. By default this
* method calls the appropriate
* <code>process&lt;event&nbsp;type&gt;Event</code>
* method for the given class of event.
* <p>Note that if the event parameter is {@code null}
* the behavior is unspecified and may result in an
* exception.
*
* @param e the event
* @see #processComponentEvent
* @see #processFocusEvent
* @see #processKeyEvent
* @see #processMouseEvent
* @see #processMouseMotionEvent
* @see #processInputMethodEvent
* @see #processHierarchyEvent
* @see #processMouseWheelEvent
* @since 1.1
*/
protected void processEvent(AWTEvent e) {
if (e instanceof FocusEvent) {
processFocusEvent((FocusEvent)e);
} else if (e instanceof MouseEvent) {
switch(e.getID()) {
case MouseEvent.MOUSE_PRESSED:
case MouseEvent.MOUSE_RELEASED:
case MouseEvent.MOUSE_CLICKED:
case MouseEvent.MOUSE_ENTERED:
case MouseEvent.MOUSE_EXITED:
processMouseEvent((MouseEvent)e);
break;
case MouseEvent.MOUSE_MOVED:
case MouseEvent.MOUSE_DRAGGED:
processMouseMotionEvent((MouseEvent)e);
break;
case MouseEvent.MOUSE_WHEEL:
processMouseWheelEvent((MouseWheelEvent)e);
break;
}
} else if (e instanceof KeyEvent) {
processKeyEvent((KeyEvent)e);
} else if (e instanceof ComponentEvent) {
processComponentEvent((ComponentEvent)e);
} else if (e instanceof InputMethodEvent) {
processInputMethodEvent((InputMethodEvent)e);
} else if (e instanceof HierarchyEvent) {
switch (e.getID()) {
case HierarchyEvent.HIERARCHY_CHANGED:
processHierarchyEvent((HierarchyEvent)e);
break;
case HierarchyEvent.ANCESTOR_MOVED:
case HierarchyEvent.ANCESTOR_RESIZED:
processHierarchyBoundsEvent((HierarchyEvent)e);
break;
}
}
}

这种方法实现的双重分派都格外的冗长、复杂和容易出错,也不符合开闭原则

4.3.2 反转球实现动态分发

通过两次的调用来实现,比如下面剪刀石头布的游戏:

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
public enum Outcome { WIN, LOSE, DRAW }


interface Item {
Outcome compete(Item it);

Outcome eval(Paper p);

Outcome eval(Scissors s);

Outcome eval(Rock r);
}

class Paper implements Item {
public Outcome compete(Item it) {
return it.eval(this);
}

public Outcome eval(Paper p) {
return DRAW;
}

public Outcome eval(Scissors s) {
return WIN;
}

public Outcome eval(Rock r) {
return LOSE;
}

public String toString() {
return "Paper";
}
}

class Scissors implements Item {
public Outcome compete(Item it) {
return it.eval(this);
}

public Outcome eval(Paper p) {
return LOSE;
}

public Outcome eval(Scissors s) {
return DRAW;
}

public Outcome eval(Rock r) {
return WIN;
}

public String toString() {
return "Scissors";
}
}

class Rock implements Item {
public Outcome compete(Item it) {
return it.eval(this);
}

public Outcome eval(Paper p) {
return WIN;
}

public Outcome eval(Scissors s) {
return LOSE;
}

public Outcome eval(Rock r) {
return DRAW;
}

public String toString() {
return "Rock";
}
}

public class RoShamBo1 {
static final int SIZE = 20;
private static Random rand = new Random(47);

public static Item newItem() {
switch (rand.nextInt(3)) {
default:
case 0:
return new Scissors();
case 1:
return new Paper();
case 2:
return new Rock();
}
}

public static void match(Item a, Item b) {
System.out.println(a + " vs. " + b + ": " + a.compete(b));
}

public static void main(String[] args) {
for (int i = 0; i < SIZE; i++)
match(newItem(), newItem());
}
}

RoshamBol.match()有2个item参数,通关过Item.compete()方法开始2路分发,要判定a的类型,分发机制会在a的实际类型的compete()内部起到分发作用。compete()方法通关过eval()来为另一个类型实现第二次分发, 将自身(this)作为参数调用eval(),能够调用重载过的eval()方法,这能够保留第一次分发的类型信息,第二次分发完成时,就能知道两个Item对象的具体类型了。

这种实现也就是“访问者模式”的精华。

4.3.4 golang实现多派发

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
package main

import (
"fmt"
)

type Shape interface {
Area() float64
}

type Circle struct {
Radius float64
}

func (c Circle) Area() float64 {
return 3.14 * c.Radius * c.Radius
}

type Rectangle struct {
Width float64
Height float64
}

func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

func CalculateArea(shape Shape) {
area := shape.Area()
fmt.Printf("Area: %.2f\n", area)
}

func main() {
circle := Circle{Radius: 5}
rectangle := Rectangle{Width: 4, Height: 3}

CalculateArea(circle) // 输出:Area: 78.50
CalculateArea(rectangle) // 输出:Area: 12.00
}

4.3.5 js实现多派发

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
class Shape {
area() {
throw new Error('Method not implemented');
}
}

class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}

area() {
return Math.PI * this.radius * this.radius;
}
}

class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}

area() {
return this.width * this.height;
}
}

function calculateArea(shape) {
const area = shape.area();
console.log(`Area: ${area.toFixed(2)}`);
}

const circle = new Circle(5);
const rectangle = new Rectangle(4, 3);

calculateArea(circle); // 输出:Area: 78.54
calculateArea(rectangle); // 输出:Area: 12.00

4.3.6 java需要借助观察者模式或者访问者模式实现

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
interface Shape {
void accept(ShapeVisitor visitor);
}

class Circle implements Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

public double getRadius() {
return radius;
}

public void accept(ShapeVisitor visitor) {
visitor.visit(this);
}
}

class Rectangle implements Shape {
private double width;
private double height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

public double getWidth() {
return width;
}

public double getHeight() {
return height;
}

public void accept(ShapeVisitor visitor) {
visitor.visit(this);
}
}

interface ShapeVisitor {
void visit(Circle circle);
void visit(Rectangle rectangle);
}

class AreaCalculator implements ShapeVisitor {
public void visit(Circle circle) {
double area = Math.PI * circle.getRadius() * circle.getRadius();
System.out.printf("Area of circle: %.2f\n", area);
}

public void visit(Rectangle rectangle) {
double area = rectangle.getWidth() * rectangle.getHeight();
System.out.printf("Area of rectangle: %.2f\n", area);
}
}

public class Main {
public static void main(String[] args) {
Shape circle = new Circle(5);
Shape rectangle = new Rectangle(4, 3);

ShapeVisitor areaCalculator = new AreaCalculator();
circle.accept(areaCalculator); // 输出:Area of circle: 78.54
rectangle.accept(areaCalculator); // 输出:Area of rectangle: 12.00
}
}

4.4 状态机模式

状态机模式

在状态模式(State Pattern)中,类的行为是基于它的状态改变的。这种类型的设计模式属于行为型模式

在状态模式中,我们创建表示各种状态的对象和一个行为随着状态对象改变而改变的 context 对象

在状态模式中有三个主要角色:

  • 环境类(Context)角色:也称上下文,它定义了客户端需要的接口,内部维护一个当前的状态,并负责具体状态的切换
  • 抽象状态(State)角色:定义一个接口,用以封装环境对象中的特定状态所对应的行为,可以有一个或多个行为
  • 具体状态(Concrete State)角色:实现抽象状态所对应的行为,并且在需要的情况下进行状态切换

开发中用的最多的是:当状态变化时,其内部形态也要跟着变化,从而使其行为也发生改变

打个比方,当订单的状态由待支付变成已支付时,我们需要它能够自动完成例如订单持久化等操作

4.4.1 问题引入

在没有状态机模式之前,我们大概率是直接搞个字段status放到表里面,然后在业务代码里面去修改里面的状态,然后在需要用到状态的地方再去查一下db,我们想想这样有哪些弊端?

  • 状态修改完后不能马上通知下游业务,或者需要调用者显示的去通知下游业务,这样的话每个调用者都会冗余一份一样的代码(状态改变的业务代码散落在各个地方)

  • 大部分业务都需要依赖db中保存的status状态(违背依赖倒置原则),如果status字段需要新增类型,下游业务需要修改对应逻辑(违背开闭原则)

image-20230320213126454

  • 状态值一多,在各个状态之间执行状态的流转,会变得十分困难(臭名昭著的if-else),例如下面的代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Interger status = 0;
    // 场景一:订单支付成功后
    if (当前订单状态是待支付) {
    status = 1; // 变成待发货
    }
    // 场景二:发货
    if (当前订单已支付) {
    status = 2; // 变成代发货
    }
    // 场景三:用户下完单,30分钟未支付,变成超时订单
    if (...) {
    status = 4; // 变成超时订单
    }
    // 场景四:订单超时24小时,自动关闭
    if (...) {
    status = 5; // 关闭订单
    }
    // ......场景10086...

4.4.3 状态模式解决问题

例如在

image-20230320211616680

4.4.3 市面上的状态机

目前开源的状态机实现方案有:

  • spring-statemachine(1.4k star)
  • squirrel-foundation (2k star)
  • stateless4j (790 star)
  • cola stateMachine(9.3k star)

上述的状态机也有一些问题:

1️⃣ 太复杂

由于基本实现了UML State Machine中所有的功能,搞得体积非常庞大、臃肿、重量级。很多方法根本用不到,例如:状态的嵌套(substate)、状态的并行(parallel、fork、join)、子状态机等等

2️⃣ 性能差

  • 这些状态机都是有状态的(Stateful),有状态意味着多线程并发情况下如果同时操控单个实例,很容易出现线程安全问题
  • 微服务盛行的今天,如果一个请求创建一个状态机实例,会导致内存占用过高。如果状态机创建过程复杂,也可能会造成性能瓶颈

其中用的比较多的spring-statemachine的缺点还有:

  • StateMachine对spring事务不支持(关键很致命)
  • stateMachine无法抛出异常,异常会被状态机给消化掉

这里推荐阿里开源的状态机(https://github.com/alibaba/COLA):

  • 代码简单,易于学习,便于根据我们的业务线基础上做二次开发。

  • 无状态,减少内存消耗,线程先天安全。

  • 先天支持事务

  • 先天会自己抛出异常,不会像spring stateMachine出现将异常吃掉情况(当然通过反射确实可以将异常抛出)。

  • 包小,摒弃了spring stateMachine中没有必要的功能,功能相对单一,包自然就小了。同时减少了资源的浪费

4.4.4 spring状态机

https://www.cnblogs.com/Zero-Jo/p/13891075.html

4.4.5阿里状态机-cola stateMachine

https://www.cnblogs.com/Zero-Jo/p/14622937.html

l

设计模式7大原则

思维导图

image-20251123171202976

我们知道只有运用好设计原则和设计模式,才能让我们写出更加优秀的代码或者设计更好软件架构,在实际开发中,由于许多不遵守设计原则和设计模式的硬编码,导致项目代码及其晦涩难懂的例子比比皆是,作为一个软件开发人员,写出高质量、易懂、已维护的代码应该是我们每个人应该做到的

1. 六大设计原则

我们知道设计模式一共有23种,虽然模式很多,但是都是为了遵守和实现六大设计原则而诞生的,如果我们能够理解六大设计原则,相信我们也能像令狐冲修炼独孤九剑一样无招胜有招,毕竟功夫的最高境界就是忘记招式,下意识使用的就是最合理的招式

其中我们要注意的是,其实六大设计模式主要是为了满足变,这个字,可能是需求变更、可能是场景变更,但是运用好六大设计模式后我们写出的代码就能很好的应对不断变化的场景,做到任他东南西北风我自岿然不动的境界

1.1 单一职责

**单一职责原则(Single Responsibility Principle, SRP)**:一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。

简单的来讲:就是小到一个方法、一个类,大到一个框架,都只负责一件事情

例如:

  • Math.round(),只负责完成四舍五入的功能,其他的不负责
  • Reader类,只负责读取字符(文本文件)
  • SpringMVC,只负责简化MVC的开发(框架)

举个栗子,我们知道,中国有四大发明,分别是造纸术、指南针、火药、活字印刷术,这里要注意的是活字印刷术,而不是印刷术,我们思考一下为什么印刷术出现的比活字印刷术早,但是并没有被列入四大发明呢?活字印刷术看起来不应该只是印刷术的改良版本吗?

这就是单一职责的核心:通过高内聚、低耦合的设计方案,刚庞大的系统拆成单一职责的小功能,再通过灵活组合的方式完成功能,这样做最大的好处就是可以通过不断的组合,应对不断变化的场景

举个我们在编码中最容易遇到的情况,我们有的时候看别人的函数实现,有的恶心的代码可能一个函数中就有大几百行,然后你们全部都是业务逻辑,比如支付功能的函数中有查询商品库存 -> 查询用户余额 -> 确认订单 -> 调用支付接口这五个步骤,有的同学写代码上去就是搜哈,一股脑全写完,下次遇到个退货的功能需要用到查询商品库存、查询用户余额的代码,直接copy过去,造成代码臃肿,可读性差,正确的方式应该是将其封装成一个个的方法或函数,这样可以做到减少重复代码的效果

关于单一职责,这里就不用代码举例了,大家记住在平时编码中记住单一职责、不断组合就行

1.2 开闭原则

开闭原则(Open Close Principle)

开闭原则就是说对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类

举个栗子,我现在有一个刮胡刀,刮胡刀的功能应该就是刮胡子,但是我现在想要它拥有吹风机的能力

  • 违法开闭原则的做法是,把吹风机的功能加上了,可能就不能刮胡子了
  • 符合开闭原则的做法是,把吹风功能加上,且没有影响之前刮胡子的功能

例如我现在有一个商品类Goods,这个类之前有一个方法是获取它的价格,例如:

1
2
3
4
5
6
7
8
9
10
public class Goods {
private BigDecimal price;

public void setPrice(BigDecimal price) {
this.price = price;
}
public BigDecimal getPrice() {
return this.price;
}
}

现在变化来了,当前商品需要打8折进行销售,不符合开闭原则的做法就是直接进原来的代码中进行修改,例如直接在getter方法中修改

1
2
3
4
public BigDecimal getPrice() {
// BigDecimal.multiply就是乘法,BigDecimal可以防止精度丢失
return this.price.multiply(new BigDecimal("0.8"));
}

这样显然就是不满足开闭原则的,因为我们对源代码进行了修改,如果下次是打七折,那是不是又要去改源代码呢

正确的做法应该是写一个子类DiscountGoods来拓展父类的功能,再在子类上进行修改,这样就不会破坏父类的功能,又能满足需求

1
2
3
4
5
6
public class DiscountGoods extends Goods{
@Override
public BigDecimal getPrice() {
return super.getPrice().multiply(new BigDecimal("0.8"));
}
}

这就叫对扩展开发,对修改关闭。我们在用设计模式编码时应该时刻注意的是,改源码是一件非常危险的事情,因为一个功能并不是只有你在使用,很容易造成牵一发而动全身的效果

但是如果我们因为要遵守开闭原则,每次对功能进行修改的时候,都去新写一个类,这样的会很繁琐,所以我们的准则是:

  • 如果一个类是自己写的,自己修改不会影响该类在其他地方的效果(不会牵一发而动全身),那你就可以随意修改
  • 如果不是自己写的,自己不清楚修改后会带来什么样的影响,那就不要修改,而要符合开闭原则

1.3 接口隔离原则

接口隔离原则(Interface Segregation Principle)

使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口

接口隔离原则在我们设计接口的时候也是非常容易忽略从而造成问题的的一个原则,例如我现在要要设计一个动物的接口,统一动物的行为,我们可能会这样写:

1
2
3
4
5
public interface Animal{
void eat();
void swim();
void fly();
}

我们看这三个行为,分别是吃、游泳和飞,我们定义的是动物的接口,这样好像并没有什么问题,动物确实拥有这三个行为,但是问题就在于动物这个接口范围太大了,并不是所以的动物都同时拥有这三个行为

例如下面的小狗类中,狗由于不会非,所以不应该有方法fly() 的实现!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Dog implements Animal {
@Override
public void eat() {
System.out.println("小狗在吃东西");
}
@Override
public void swim() {
System.out.println("小狗会狗刨");
}
@Override
public void fly() {
throw new UnsupportedOperationException("小狗不会飞");
}
}

我们现在将这个大接口拆分一下:

1
2
3
4
5
6
7
8
9
10
11
interface Eatable{
void eat();
}

interface Swimable{
void swim();
}

interface Flyable{
void fly();
}

再不断的组合,实现不同的接口,其实核心思想还是高内聚,低耦合,通过不断组合不可分割的功能完成最终需要的功能

是不是现在有一点点无招胜有招的感觉了,感觉习惯之后自然而然就好感觉这块设计有问题,然后设计更好能应对变化的方案

1
2
3
4
5
6
7
8
9
10
public class Dog implements Eatable, Swimable {
@Override
public void eat() {
System.out.println("小狗在吃东西");
}
@Override
public void swim() {
System.out.println("小狗会狗刨");
}
}

1.4 依赖倒置原则

依赖倒置原则(Dependence Inversion Principle)

这个是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。实际中开发的实践就是,面向接口编程

  • 上层不应该依赖于下层
  • 它们都应该依赖于抽象

依赖倒置在实际编码中通常采取的是:上层不能依赖于下层,他们都应该依赖于抽象

这里区分上下层的方法为:调用别的方法的就是上层,被调用的就是下层

举个栗子:我们现在有三个类,互相有依赖关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
public void feed(Dog dog) {
System.out.println("开始喂dog...");
}
}

class Dog {
public void eat() {
System.out.println("狗啃骨头");
}
}

// ================================================================
public class AppTest {
public static void main(String[] args) {
Person person = new Person();
Dog dog = new Dog();
person.feed(dog);
}
}

首先我们在依赖倒置原则里面非常重要的一点是,要区分依赖中的上层和下层,我们时刻要注意调用别的方法的就是上层,被调用的就是下层,所以这里的层级关系为:AppTest是Person的上层,Person是Dog的上层

我们来仔细思考一下上面的代码,这里好像没什么问题,但是我一直强调的是设计模式是为了应对变化,现在变化来了,现在客户端Person不仅需要喂狗,还需要喂猫,我们很容易直接添加一个Cat类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Cat {
public void eat() {
System.out.println("小猫吃鱼");
}
}
public class AppTest {
public static void main(String[] args) {
Person person = new Person();
Dog dog = new Dog();
Cat cat = new Cat();
// 喂狗
person.feed(dog);
// 喂猫
person.feed(cat);
}
}

这样明显会报错,因为之前的代码中只能喂狗,不能喂猫!

image-20221002220740763

那怎么办呢?我直接重载一个方法,让Person类可以喂猫不就好了吗?

1
2
3
4
5
6
7
8
class Person {
public void feed(Dog dog) {
System.out.println("开始喂dog...");
}
public void feed(Cat dog) {
System.out.println("开始喂Cat...");
}
}

好家伙,这是不是为了应对变化直接改源码了?首当其冲的就是破坏了开闭原则,其次如果每次要多喂养一种动物就要去重载一个方法,这显然也不合理叭,这就是因为上层依赖于下层

读者可以先自己试着将这几个类的UML类图画出来,可能有的读者不太会画,这里补充一下UML类图的画法

我们知道类和类之间的关系有:关联、依赖、泛化、实现(空心三角箭头的虚线表示实现,实现接口)

image-20221003000126551

其中关联又可以分为组合 + 聚和,如果没有细分,可以画成实线 + 箭头,不用画菱形

  • 组合关系是强关联,失去关联方,两者都不完整,例如大雁和翅膀,就是强关联,大雁不能失去翅膀
  • 聚和是弱关联,失去关联方,被关联方依旧完整,例如雁群和大雁,失去一只大雁,不影响雁群整体

image-20221003000746778

现在我们来画一下上面人喂动物的UML类图,由于动物作为形参传入人类中,所以明显这是依赖关系,我们用虚线三角画即可

image-20221003001510933

我们看出上面代码的问题,就是每当一个新的类需要依赖时,就要重载一个方法,这里就违反了依赖倒置原则,每当下层发生改变时,上层都要一起改变,这样的设计没有拓展性,我们不应该依赖于具体的类,而应该依赖于抽象的接口!

我们想要的结果是下层代码发生变化,对于上层来说是无感知的!代码也不需要改动,这就是依赖倒置的核心!

我们回过头来分析问题,我们人类的动作是什么?是喂养动物!不是喂狗、喂猫,狗和猫只是动物的实现!所以我们应该进行依赖倒置,依赖抽象不依赖实现,这里我们只需要依赖一个抽象的动物类或者接口即可

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
class Person {
public void feed(Animal animal) {
System.out.println("开始喂dog...");
}
}

interface Animal {
void eat();
}

class Dog implements Animal{
@Override
public void eat() {
System.out.println("狗啃骨头");
}
}

class Cat implements Animal{
@Override
public void eat() {
System.out.println("小猫吃鱼");
}
}

// ================================================================
public class AppTest {
public static void main(String[] args) {
Person person = new Person();
Dog dog = new Dog();
Cat cat = new Cat();
// 喂狗
person.feed(dog);
// 喂猫
person.feed(cat);
}
}

我们来看一下类图的变化:

image-20221003003659847

这里可能有的读者会有疑问,为什么下层变了,上层不知道要叫依赖倒置,就叫下层变了,上层不知道不行吗?

看上面的图!之前的箭头是向下指的,是依赖具体的实现,现在箭头倒置过来了,大家都依赖于抽象!这就叫依赖倒置,如果看不明白翻到上面再看一遍下来,好好体会这个倒置的作用。这样以来,不论怎么改变,只需要不断添加新的依赖关系依赖抽象即可,以不变应万变。这里读者再三强调,所以的设计原则核心思想都是一个字,变,都是为了以不变应万变

对上层来说,就是分层,解耦,就是一个分字

image-20221003010625140

编码中我们时刻要注意的就是:

  • 上层不应该依赖于下层
  • 它们都应该依赖于抽象

其中这种思想在工作中生活中也有很多栗子,很多时候问题都是出现在太依赖某些东西了,当依赖的东西变化,自己就乱了

举几个栗子:

  • 在软件开发公司,老板不能具体依赖一些人或者某些语言开发系统,例如java、go、csharp等等,如果产生依赖了就会导致如果现在会Java的跑了,公司没有会Java的了,只能更换架构体系,但是如果领导只管最终的结果,不管具体实现过程,那么矛盾就转移到下层了

  • 例如在用人体系,老板不应该直接管理众多的员工,如果过度依赖某些员工,势必会造成问题,一个好的公司不是靠人,而是靠制度、靠规范,应该让所有人依赖制度,老板只需要管理好制度,就能管理好所有人!这就是依赖倒置,在生活中这就叫画饼!

    一个大企业不可能去管理每个员工,但是只要管理好抽象的规则制度,让员工都遵守,就可能以不变应万变!!!

    image-20221003012933045

  • 例如在教育孩子的时候不应该过分管控孩子的行为,不如给孩子设立目标或者崇高的理想,由外驱力转化为孩子的内驱力,这样才能达到更好的效果。当然这样的例子还有很多,这里就不举例了,再举例下去就变成哲学了

1.5 迪米特法则

迪米特法则(最少知道原则)(Demeter Principle)

为什么叫最少知道原则,就是说:一个实体应当尽量少的与其他实体之间发生相互作用,使得系统功能模块相对独立

  • 一个类,对于其他类,要知道的越少越好,其实就是封装的思想,封装内部细节,向外暴露提供功能的接口
  • 只和朋友通讯,朋友是指:
  • 类中的字段
  • 方法的参数
  • 方法的返回值
  • 方法中实例化出来的对象
  • 对象本身
  • 集合中的泛型

我们来看对于类的例子,现在我们有一个电脑类,里面有一些电脑关机时的操作,然后还有一个人类,里面有一个方法为关闭电脑,需要组合电脑类,并执行电脑类里面的方法

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
class Compute {
public void saveData() {
System.out.println("正在保存数据");
}

public void killProcess() {
System.out.println("正在关闭程序");
}

public void closeScreen() {
System.out.println("正在关闭屏幕");
}

public void powerOff() {
System.out.println("正在断电");
}
}

class Person {
Compute compute = new Compute();
public void shutDownCompute() {
compute.saveData();
compute.killProcess();
compute.closeScreen();
compute.powerOff();
}
}

这样看上去好像也没什么问题,但是现在变化来了,如果现在关机操作的步骤有几十上百项呢?难道我们要在shutDownCompute方法中去调用上百个方法吗?这里的问题就是:

  • 对于Person类而言,知道Compute中细节太多了
  • 其实不需要知道这么多细节,只要知道关机按钮在哪里就行,不需要知道具体的关机流程
  • 如果使用者在调用方法时的顺序出错,例如把关电和保存数据的顺序弄错,就容易导致问题

所以正确的方法就是尽量高内聚设计,隐藏实现细节,只暴露出单独的接口实现单一的功能

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
class Compute {
private void saveData() {
System.out.println("正在保存数据");
}

private void killProcess() {
System.out.println("正在关闭程序");
}

private void closeScreen() {
System.out.println("正在关闭屏幕");
}

private void powerOff() {
System.out.println("正在断电");
}

public void shutDownCompute() {
this.saveData();
this.killProcess();
this.closeScreen();
this.powerOff();
}
}

class Person {
Compute compute = new Compute();

public void shutDown() {
compute.shutDownCompute();
}
}

那么这个封装和暴露的火候该怎么掌握呢?我们接下来看看对于朋友而言的最少知道原则

  • 如果对于作为返回类型、方法参数、成员属性、局部变量的类,不需要过多的封装,应该提供应有的细节,由调用者自己弄清楚细节并承担异常的后果,这样由我们直接创造的对象,我们就能把它称为我们的朋友

    image-20221003132817525

  • 但是如果这个对象不是我们自己获得的,而是由被人提供的,就不是朋友,即朋友的朋友并不是自己的朋友

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class AppTest {
    public void func() {
    AppBean appBean = BeanFactory.getAppBean();
    // 朋友的朋友就不是朋友了
    appBean.getStr();
    }

    }

    class BeanFactory {
    public static AppBean getAppBean() {
    return new AppBean();
    }
    }

    class AppBean {
    public String getStr() {
    return "";
    }
    }

    那么想要和这个AppBean做朋友该怎么办呢?需要在系统里面造出许多小方法,将朋友的朋友变成自己的朋友,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class AppTest {
    public void func() {
    AppBean appBean = BeanFactory.getAppBean();
    // 朋友的朋友就不是朋友了
    this.getStr(appBean);
    }
    /* 将朋友的朋友的细节转换为自己熟悉的方法 */
    public String getStr(AppBean appBean){
    return appBean.getStr();
    }
    }

    有的同学可能觉得有点鸡肋这样,确实迪米特法则的缺点就是会制造出很多小方法,让代码结构混乱,所以有的时候适当违反一下也是可以的,但是封装和暴露的思想我们一定要有,后面我们的门面模式和中介者模式其实也是基于迪米特法则的,读者先不要急,看到后面再回顾这一段,相信会有跟好的理解

1.6 里式替换原则

里氏代换原则(Liskov Substitution Principle)

里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一,里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。

  • 简单的来讲,任何能够用父类实现的地方,都应该可以使用其子类进行透明的替换。替换就是子类对象替换父类对象
  • 子类对象替换父类后,不会有任何影响
  • 是否有is-a的关系
  • 有is-a关系后,要考虑子类替换父类后会不会出现逻辑变化

这里我们来看一下方法重写的定义:

  • 方法重写是指:在子类和父类中,出现了返回类型相同、方法名相同、方法参数相同的方法时,构成方法重写
  • 子类重写父类时,子类的访问修饰符不能比父类更加严格
  • 子类重写父类时,不能比父类抛出更多的异常

如果我们故意在子类中抛出比父类更多的异常会怎么样呢?

image-20221003140143641

如果没有比父类更多的异常,父类现在在执行方法时就会进行catch,并且能够捕获子类中更少的异常,所以这样进行替换时,就不会影响代码的结构,做到透明、无感知

有很多的例子都可以用里式替换进行解释,著名的例子有:

  • 鸵鸟非鸟问题
  • 长方形正方形问题

1.7 组合优于继承原则

组合优于继承原则(Composite Reuse Principle)

组合优于继承原则强调的是在复用时要尽量使用关联关系,少用继承

  • 组合,是一种强关联关系,整体对象和局部对象的生命周期是一样的,类似于大雁和翅膀的关系
  • 整体对象负责局部对象的生命周期;
  • 局部对象不能被其他对象共享;
  • 如果整体对象被销毁或破坏,那么局部对象也一定会被销毁或破坏
  • 聚和,它是一种弱关联,是 【整体和局部】之间的关系,且局部可以脱离整体独立存在,类似于雁群和其中一只大雁的关系
  • 代表局部的对象有可能会被多个代表整体的对象所共享,而且不一定会随着某个代表整体的对象被销毁或破坏而被销毁或破坏,甚至代表局部的对象的生命周期可以超越整体
  • 总而言之,组合是值的关联(Aggregation by Value),而聚合是引用的关联(Aggregation by Reference)

我们在之前又讲过,关联关系有两种,实心菱形的是组合、空心菱形的是聚和,如果不区分就用虚线指向,组合是作为成员变量作为另一个类的引用,聚和是作为形参或者局部变量作为另一个类的引用

image-20221003000126551

组合大家在平时编码的时候一定经常使用,举一个简单的例子,如果我们现在要有链表实现队列应该怎么做呢?队列的特点就是先进先出,完全可以用链表实现,我们可以用继承关系来做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Queue <E> extends LinkedList<E> {
/**
* 入队
*/
public void enQueue(E element){
this.add(element);
}

/**
* 出队
*/
public E deQueue(){
return this.remove(0);
}

}

我们发现这样并没有什么问题,队列类继承自链表类,并暴露自己提供给外界的方法,但是当我们调用这个Queue时就会发现问题:

image-20221003154018680

好家伙,我的Queue本来只需要入队和出队两个方法,但是居然有这么多细节的方法供我使用,这就违背了迪米特法则,一个类的内部实现应该不要提供给外界,只暴露该提供的方法,这就是继承的问题,继承复用破坏包装,因为继承将基类的实现都暴露给派生类

如果我们换成组合该怎么做呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Queue<E> {
// 成员变量 -> 组合关系
LinkedList<E> list = new LinkedList<>();
/**
* 入队
*/
public void enQueue(E element) {
list.add(element);
}

/**
* 出队
*/
public E deQueue() {
return list.remove(0);
}
}

所以如果我们仅仅只是为了复用代码,可以优先考虑组合,如果是为了实现多态,可以优先继承

我们也来看一个反例叭,其实在Java中有很多不合理的设计,例如Serializable接口,Date类等等,这里就讲一个java.util.Stack的糟糕设计

image-20221003160116593

点进源码中看我们发现,原来是继承了Vector类,让其拥有了链表的能力,看着这个兄弟设计模式也没学好

image-20221003160243437

官方也意识到了这个设计不合理的地方,推荐我们使用Deque来实现栈

image-20221003160720506

1.8 设计原则总结

image-20221003155302341

其实我们看完了这些设计原则,就会发现其实都是为了应对不断变化的,在看一些源码中,例如Spring的源码、dubbo的源码、netty的源码中也是非常严谨的遵守这些开发规范的

l

互联网是如何运作的:图解步骤详解

当你阅读这篇文章时,你正在见证历史。过去五十年间,科技和互联网的功能发生了翻天覆地的变化,如今已成为我们日常生活中便捷的系统。

但正如你可能已经猜到的那样,互联网并非一直都是现在这个样子,也并非一直如此流行。事实上,在2000年,只有52%的美国成年人表示他们使用互联网;但到了2018年,这个数字跃升至82%[1]。

从最初让你来到这里的问题:“互联网是如何运作的?”到网上购物、与家人朋友交流,互联网彻底改变了我们的生活、协作和学习方式。但这一切是如何开始的?互联网又是如何演变成我们今天所熟知的无处不在的系统呢?

互联网在我们的日常生活中扮演着重要的角色。

  • 2000 年,只有 52% 的美国成年人使用互联网 [1]
  • 2018年,这一数字跃升至89%。
  • 2013年,美国成年人中,只有8%的人在家不使用宽带互联网,但拥有智能手机。
  • 2018年,这一数字上升至20%。

互联网如何运作信息图

要充分了解互联网的运作方式以及我们是如何走到今天的,我们需要从头开始。

互联网简史

1969 年 10 月 29 日,一个名为 ARPANET(高级研究计划署)的组织启动了互联网的第一个版本(也称为 ARPANET),连接了犹他大学、加州大学圣巴巴拉分校、加州大学洛杉矶分校和斯坦福研究所的四台主要计算机 [2]。

当这个计算机网络连接起来后,大学就可以从一个组织到另一个组织以及在内部访问文件和传输信息。

随着研究人员不断完善系统,他们持续连接其他大学的计算机,包括麻省理工学院、哈佛大学和卡内基梅隆大学。最终,ARPANET 更名为“互联网”。

在这个阶段,谁使用了互联网?

互联网早期只有计算机专家、科学家、工程师和图书管理员使用,他们必须学习复杂的系统才能使用它,但随着技术的进步和消费者的适应,它成为了世界各地人们必不可少的工具。

互联网的功能是如何以及何时发生变化的?

20世纪70年代是互联网发展的重要转型时期。电子邮件于1972年问世,全国各地的图书馆实现了互联互通,最重要的是,由于传输控制协议和互联网协议(TCP/IP)架构的出现,信息交换变得更加顺畅。

这些协议的发明有助于规范通过网络发送和接收信息的方式,使信息传输更加一致,无论你在哪里或以何种方式访问互联网。

互联网何时变得用户友好?

1986年,美国国家科学基金会通过资助NSFNET(一个遍布全国的超级计算机网络),将互联网的发展推向了新的高度。

这些超级计算机为个人计算奠定了基础,弥合了仅用于学术目的的计算机和用于执行日常任务的计算机之间的差距。

1991年,明尼苏达大学开发了第一个用户友好的互联网界面,使用户能够更便捷地访问校园文件和信息。内华达大学里诺分校在此基础上继续完善这一易用界面,并引入了搜索功能和索引功能。

消费者是什么时候开始使用互联网的?

随着互联网的发展不断演变和重点转移,美国国家科学基金会于 1995 年 5 月停止了对互联网骨干网 (NSFNET) 的赞助。

这一变革取消了互联网商业用途的所有限制,并最终促成了互联网的多元化发展和快速增长。此后不久,AOL、CompuServe 和 Prodigy 也加入了 Delphi 的行列,开始向消费者提供商业互联网服务。

上世纪九十年代末, WiFi和 Windows 98的问世标志着科技行业致力于发展互联网的商业化。这一举措使微软等公司得以接触到新的受众群体——消费者(比如您)。

如今互联网的使用情况如何?

快进到今天。据估计,现在有 30 亿人使用互联网,其中许多人每天都使用互联网来帮助他们从 A 点到达 B 点、与亲人联系、在工作中协作,或者了解更多重要问题,例如互联网是如何运作的?[3]

随着科技的进步和互联网渗透到我们生活的方方面面,预计会有更多人使用互联网。研究人员预测,到 2030 年,互联网用户将达到 75 亿,连接到互联网的设备将达到 5000 亿台 [4]。

互联网是如何运作的?

现在你已经对互联网的发展历程有了一些了解,让我们来探讨一下当前的问题:“互联网是如何运作的?”

互联网是一个全球性的计算机网络,它通过互连的设备传输各种数据和媒体。它的工作原理是使用数据包路由网络,该网络遵循互联网协议 (IP) 和传输控制协议 (TCP) [5]。

TCP 和 IP 协同工作,确保无论你使用什么设备或在哪里使用,互联网上的数据传输都是一致且可靠的。

数据通过互联网传输时,是以消息和数据包的形式进行的。通过互联网发送的数据称为消息,但在发送消息之前,消息会被分解成更小的部分,称为数据包。

这些消息和数据包使用互联网协议 (IP) 和传输控制协议 (TCP) 从一台计算机传输到另一台计算机。IP 是一套规则系统,用于管理信息如何通过互联网连接从一台计算机发送到另一台计算机。

通过数字地址(IP 地址),IP 系统接收有关如何传输数据的进一步指令。

传输控制协议 (TCP) 与 IP 协同工作,确保数据传输的可靠性和稳定性。这有助于防止数据包丢失,确保数据包按正确顺序重组,并避免因延迟而对数据质量产生负面影响。

想知道从浏览器启动到搜索结果,互联网是如何运作的吗?让我们一步一步地了解这个过程[7][8][9]。

当你在浏览器中输入网址时……

步骤 1:您的电脑或设备通过调制解调器或路由器连接到互联网。这些设备共同使您能够连接到全球其他网络 [6]。

您的路由器使多台计算机能够加入同一网络,而调制解调器则连接到您的 ISP(互联网服务提供商),ISP 为您提供有线或 DSL 互联网服务。

步骤二:输入网址,也称为URL(统一资源定位符)。每个网站都有其唯一的URL,它会告诉你的网络服务提供商(ISP)你想访问哪个网站。

步骤 3:您的查询被推送至您的 ISP,ISP 连接到多个服务器,这些服务器存储和发送数据,例如 NAP 服务器(网络访问保护)和 DNS(域名服务器)。

接下来,你的浏览器会通过 DNS 查找你在搜索引擎中输入的域名对应的 IP 地址。DNS 会将你在浏览器中输入的文本域名转换成数字 IP 地址。

  • 例如:Google.com 变为 64.233.191.255

步骤 4:您的浏览器向目标服务器发送超文本传输协议 (HTTP) 请求,以使用 TCP/IP 将网站的副本发送到客户端。

第五步:服务器批准请求后,会向您的计算机发送“200 OK”消息。然后,服务器以数据包的形式将网站文件发送到浏览器。

步骤 6:当浏览器重新组装数据包时,网站加载完毕,您可以学习、购物、浏览和互动。

第七步:尽情享受搜索结果吧!

互联网的未来

无论您是在查找有关互联网如何运作的信息、在线观看您最喜欢的电影,还是在网上浏览旅游优惠,不可否认的是,互联网带我们去了很多地方,而且它还将继续这样做!

虽然现在互联网似乎没有发生什么变化,但很有可能,当我们回顾过去,看到我们走了多远,我们使用这项技术的方式发生了多大的变化,最终,我们会发现,我们自己也是互联网历史的一部分。

[1] 皮尤互联网研究中心;互联网/宽带概况介绍

[2] 沃尔特·豪;《互联网简史》

[3] 资金;以下是互联网用户数量

[4] 思科;物联网

[5] Lifewire;TCP(传输控制协议)详解

[6] Mozilla;网络如何运作

[7] 事物是如何运作的;互联网是如何运作的?

[8] 如何成为极客;互联网是如何运作的?

[9] Medium,《互联网是如何运作的》

互联网简史

几个世纪以来,哲学家和作家们一直在构想一个共享的世界知识库。我们今天所知的互联网是如何形成的?

重大突破[2]

  • 1969 年 10 月 29 日: ARPANET(后来更名为互联网)成功建立了加州大学洛杉矶分校和斯坦福研究所之间的连接。
  • 20 世纪 60 年代末:图书馆实现了目录的自动化和联网,不再依赖于 ARPANET。
  • 20世纪70年代:传输控制协议和互联网协议(TCP/IP)的建立,推动了互联网技术的成熟。这些协议的发明有助于规范信息在网络上的发送和接收方式。
  • 1986年:美国国家科学基金会资助了NSFNET,它是互联网56Kbps的主干网。当时由于联邦资金用于运营和维护,因此存在一些商业限制。
  • 1991年:创建了用户友好的互联网界面。
  • 1992 年 7 月: Delphi 成为第一家提供互联网接入的全国性商业在线服务商。
  • 1995年5月:互联网上所有商业用途的限制都被取消。这使得互联网得以多元化发展并迅速壮大。
  • 1997年: WiFi发明。
  • 1998年: Windows 98上市。
  • 2007年:智能手机广泛普及。
  • 2009年: 4G网络推出。
  • 如今:全球有30亿人使用互联网。[3]
  • 到 2030 年:预计互联网用户将达到 75 亿,连接到互联网的设备将达到 5000 亿台。[4]

互联网是如何运作的?

互联网是一个全球性的计算机网络,它通过互联设备传输各种数据和媒体。它的工作原理是利用数据包路由网络,该网络遵循互联网协议(IP)和传输控制协议(TCP)。[5]

消息 + 数据包

  • 通过互联网发送的数据称为消息。
  • 信息在发送之前会被分割成称为数据包的小部分。

互联网协议(IP)

  • 管理信息如何通过互联网连接从一台计算机发送到另一台计算机的规则
  • 规定了计算机应如何通过发送附带数字地址(IP 地址)的数据来向其他计算机发送信息。
    • 公共 IP 地址:可通过互联网访问
    • 私有IP地址:分配给封闭网络(例如家庭网络或企业网络)中的设备,该网络无法通过互联网访问。

传输控制协议(TCP)

  • 与 IP 协同工作,确保数据传输的可靠性和稳定性。
  • 无丢包,无延迟影响数据质量,数据包按正确顺序重新组装。

上网时会发生什么……

步骤 1:您的电脑或设备通过调制解调器或路由器连接到互联网,从而可以连接到全球其他网络。[6]

路由器允许多台计算机加入同一网络,而调制解调器连接到您的互联网服务提供商 (ISP),ISP 提供有线或 DSL 互联网。

你的个人电脑被称为客户端,而不是服务器。

  • 客户端计算机通过互联网服务提供商 (ISP) 连接到互联网。
    • 例如:您的手机连接到移动网络或您的笔记本电脑连接到 WiFi。
  • 服务器是直接连接到互联网的计算机。
    • 例如:存储网页、网站或应用程序的计算机。

步骤二:输入网址,也称为URL。URL是统一资源定位符的缩写。

步骤 3:您的查询将被处理并推送至您的 ISP。您的 ISP 拥有多个服务器,用于存储和发送数据,例如 NAP 服务器(网络访问保护)和 DNS(域名服务器)。

您的浏览器会通过 DNS 查找您在浏览器中输入的域名对应的 IP 地址。

  • DNS 将您在浏览器中输入的基于文本的域名转换为基于数字的 IP 地址。
    • 例如: Google.com 变为 64.233.191.255

步骤 4:浏览器向目标服务器发送超文本传输协议 (HTTP) 请求,以使用 TCP/IP 将网站的副本发送给客户端。

  • HTTP:用于互联网通信的语言。
  • HTTPS: HTTP 的安全版本,您的浏览器和网站之间的所有通信都经过加密。

步骤五:服务器批准请求并向客户端计算机发送“200 OK”消息。然后,服务器以数据包的形式将网页文件发送到浏览器。

步骤 6:浏览器重新组装数据包,网页加载。

第七步:尽情上网吧![7][8][9]

l

在本节中,我们将通过一个完整的网络流量示例,逐步演示计算机在通过计算机网络进行通信时将经历哪些步骤。本示例将整合其他章节中的大量信息,是一个全面而完整的示例。

大多数人都会惊讶地发现,计算机发送一个简单的数据包竟然需要这么多步骤,而且其中有很多步骤对于普通用户来说是完全隐藏的,除非你事先知道,否则你永远不会知道。

如果您已经浏览过本网站的大部分内容,那么您可能已经对本教程可能包含的步骤数量有所了解。

下面的示例展示了一个小型家庭网络,其中一台计算机刚刚启动。该计算机已手动配置了 IP 地址,但尚未与网络通信。用户坐在计算机旁,打开 Web 浏览器并尝试访问 www.iis.se。

首先,我们来看一下整体情况。这张图展示了示例中的网络拓扑结构。在接下来的很多步骤中,我们会放大查看网络中最相关的部分,以避免每次都绘制完整的网络拓扑图。

网络拓扑示例

现在让我们从交通示例讲解开始!

步骤 1:计算机想要发送流量

一台连接到家庭网络的计算机刚刚启动。该计算机已手动配置了 IP 地址、子网掩码、DNS 服务器和默认网关。DNS 服务器和默认网关地址都指向家庭路由器的局域网 IP 地址。

计算机用户打开网页浏览器并访问www.iis.se

首先,Web浏览器会指示计算机上的操作系统建立计算机与www.iis.se之间的通信。

网页浏览器指示操作系统建立会话

步骤 2:DNS

这部分又细分为许多子步骤。

步骤 2a:DNS 缓存

计算机操作系统会检查其 DNS 缓存,以确定是否已知道www.iis.se的 IP 地址。由于计算机刚刚启动,之前从未访问过www.iis.se,因此 DNS 缓存为空。

计算机检查其 DNS 缓存

现在计算机必须向其 DNS 服务器查询www.iis.se的 IP 地址。

步骤 2b:组合 DNS 查询

计算机将构建一个 DNS 查询,并将其发送到它配置使用的 DNS 服务器 192.168.1.1。

DNS 查询的目标地址是 192.168.1.1,源 IP 地址是计算机自身的 IP 地址,即 192.168.1.5。

DNS 使用 UDP 作为传输协议。DNS 查询的目标端口是 53/UDP。当 DNS 查询到达 DNS 服务器时,服务器可以通过查看目标端口 53/UDP 来判断该消息是发往 DNS 服务器程序的,并将消息转发给正在运行的 DNS 程序。

计算机操作系统还必须随机化一个源端口,该端口也会写入消息中。

计算机构建 DNS 查询

但是,当计算机将 DNS 查询组合起来时,它注意到必须检查应该将数据包发送到哪个目标 MAC 地址。

所以目前,操作系统会将数据包放入内存中的一个队列中,然后开始确定要使用的目标 MAC 地址。

步骤 2c:检查 ARP 表中是否存在有效的 MAC 地址

计算机现在将检查其 ARP 表,看看它是否知道与路由器 IP 地址 192.168.1.1 关联的 MAC 地址。

但由于计算机刚刚启动,尚未学习任何 ARP 条目,因此其 ARP 表完全为空。

计算机检查其ARP表

步骤 2d:向网络发送 ARP 请求

现在,计算机必须向网络中的其他设备构建一个 ARP 请求。该请求将被发送到目标 MAC 地址 FF:FF:FF:FF:FF:FF,即广播地址。结果是,局域网上的所有其他计算机和设备都将收到该请求并读取其内容。

计算机发送 ARP 查询

家用路由器收到ARP请求并读取消息,因为该请求发送到了广播MAC地址FF:FF:FF:FF:FF:FF。

家用路由器可以从消息中看到计算机正在请求 IP 地址为 192.168.1.1 的设备。由于路由器配置为使用该 IP 地址,因此家用路由器将通过构建 ARP 应答并将其发送回计算机来响应此消息。

步骤 2e:来自路由器的 ARP 回复

当计算机收到 ARP 回复时,操作系统会读取该回复。它会将回复添加到 ARP 表中,以便在几分钟内记住与 192.168.1.1 关联的 MAC 地址。

路由器对计算机的 ARP 回复

现在计算机终于收集到了发送 DNS 消息所需的所有信息。

步骤 2f:发送 DNS 查询

现在,计算机将向 DNS 服务器发送 DNS 查询,该服务器作为一项服务运行在家庭路由器 192.168.1.1 上。

家用路由器收到查询后,发现这是一个针对路由器自身 IP 地址和 MAC 地址的 DNS 查询,并意识到它必须处理这个 DNS 查询并发送一个答案。

PC 发送 DNS 查询

步骤 2g:家用路由器检查其 DNS 缓存

家用路由器本身就是一个DNS服务器,但它也依赖于互联网上的其他DNS服务器。家用路由器不可能知道互联网上每一个DNS地址。因此,它会根据需要向负责不同域名(例如example.com)的DNS服务器发出请求。

家用路由器也像电脑一样配备了DNS缓存。每次家用路由器处理来自电脑的DNS查询时,它都会将DNS回复保存在自身的DNS缓存中一段时间。这样做是为了避免重复处理相同的DNS查询,从而加快响应速度。

家用路由器会检查其 DNS 缓存

在这种情况下,家用路由器很久没有收到关于www.iis.se 的查询,因此路由器的 DNS 缓存中不存在该域名。所以,路由器必须向其配置的互联网 DNS 服务器请求解析此 DNS 查询。

步骤 2h:家用路由器准备并发送其 DNS 查询

现在,路由器准备向其 DNS 服务器发送 DNS 查询。路由器在首次启动并从互联网服务提供商 (ISP) 获取公网 IP 地址时,通过 DHCP 从 ISP 获知了可用的 DNS 服务器。

因此,家用路由器会准备一个DNS查询,方法是将查询信息封装在一个UDP消息中,目标端口为53/UDP,源端口为随机UDP端口。然后,它会将该消息封装到一个IP数据包中。该IP数据包会从家用路由器的公网IP地址发送到DNS服务器地址。

家用路由器发送 DNS 查询

当家用路由器准备好数据包并准备发送时,它会查看路由表以确定发送路径。路由表显示,到达内部局域网 192.168.1.0 的最佳路径是通过 LAN 端口,但此数据包需要发送到互联网上的另一个 IP 网络。因此,家用路由器选择 WAN 端口作为最佳目标地址。

在此阶段,家用路由器可能需要执行 ARP 请求来查找下一跳路由器 115.20.97.113 的 MAC 地址,但我们假设家用路由器已经在其 ARP 缓存中获得了此信息。

步骤 2i:DNS 查询通过互联网进行路由

这里的一些步骤已经过简化和缩短。

互联网上每个接收到 DNS 请求的路由器都会执行以下操作:

  • 收到包裹
  • 查看目标 IP 地址,以确定数据包的去向。
  • 它会查看路由表,以确定数据包的最佳路径。
  • 从数据包中移除旧的MAC 地址并添加新的 MAC 地址。它会使用自身出接口上的 MAC 地址作为流量的源 MAC 地址,并将下一跳路由器的 MAC 地址作为目标 MAC 地址。
  • 将数据包发送到下一跳路由器

来自家庭路由器的DNS查询通过互联网进行路由。

步骤 2j:DNS 服务器响应

最终,数据包到达 DNS 服务器,DNS 服务器将处理该数据包并准备响应。

就像普通电脑一样,服务器也有IP地址、子网掩码和默认网关。因此,它的工作方式与普通电脑非常相似。

DNS服务器向家庭路由器做出响应

步骤 2k:家用路由器可以向计算机发送 DNS 应答。

现在,在收到来自 DNS 服务器的 DNS 回复后,家用路由器终于可以创建 DNS 回复并将其发送到计算机,以便让计算机知道www.iis.se的 IP 地址。

家用路由器向计算机发送 DNS 应答

步骤 3:计算机建立与 www.iis.se 的会话

在这个阶段,很多事情同时发生。

计算机主要会启动一个名为“TCP 三次握手”的过程,这是 TCP 通信的建立阶段,包含计算机和服务器之间的三条消息。在使用 TCP 时(例如网页浏览),TCP 会尽力确保一切运行正常,包括通过握手建立会话。这样做是为了让服务器做好接收会话的准备,并确定通信应使用的端口。

TCP 三次握手由三条消息组成:

  • 第一个消息由计算机发送,名为“SYN”,代表同步(Synchronise)。它告知对方我们希望同步 TCP 会话的设置。该消息还包含计算机随机选择的 TCP 源端口。
  • 第二条消息是服务器的回复,称为“SYN-ACK”,即同步确认(Synchronise Acknowledgement)的缩写。这表示服务器确认已收到该消息,并已准备好建立通信会话。
  • 第三条消息由计算机发送,通过发送“ACK”(确认)来结束会话。这意味着一切准备就绪。

计算机的ARP缓存中已经存储了家庭路由器IP地址的正确ARP信息。因此,计算机可以通过家庭路由器向互联网发送任何数据包,而无需事先进行ARP查找。

但这也是本例中计算机首次尝试直接与路由器之外的物体通信。TCP 三次握手将在计算机和互联网上的 Web 服务器之间直接进行。

在之前的DNS查找过程中,计算机只是与家用路由器通信。家用路由器再与互联网上的DNS服务器通信。但计算机与互联网上的任何IP地址之间并没有直接通信。

区别在于,现在计算机想要直接与互联网上的某些内容通信,那么家用路由器就必须对流量执行地址转换。

步骤 3a:计算机发送 TCP SYN 消息

接下来,我们将仔细研究计算机通过初始化 TCP 三次握手来建立 TCP 会话时发生的情况。

为此,计算机操作系统会随机生成一个用于通信的 TCP 源端口。然后,它会组装 TCP SYN 消息并将其发送到 Web 服务器。该 TCP 消息不包含任何其他数据,它只是一个空的 TCP 消息。

当 TCP SYN 消息通过路由器时,路由器会对该消息执行 NAT 转换。路由器还会将执行的 NAT 转换信息保存在其 NAT 表中,以便跟踪会话并对任何回复执行反向 NAT 转换。

计算机向Web服务器发送TCP SYN消息

图片显示的是从计算机到Web服务器的TCP SYN数据包。

  • “我想与您同步一个 TCP 会话”

步骤 3b:Web 服务器回复 TCP SYN-ACK 包

这里可以看到 Web 服务器返回给计算机的 TCP SYN-ACK 响应:

  • “好的,我可以安排会话了,我确认已收到您的消息。”

计算机向 Web 服务器发送 TCP SYN-ACK 消息

该数据包与路由器上的 NAT 表条目匹配,以便路由器能够看到应该将数据包转发到哪个 LAN 计算机以及如何对数据包执行 NAT。

步骤 3c:计算机发送 TCP 确认信号

最后,计算机向Web服务器发送TCP ACK确认包。

  • “那么我确认,从现在开始,我们正式成立一个会议!”

计算机向Web服务器发送TCP ACK

只要计算机和服务器持续通信,它们就会一直使用同一个会话进行通信。这包括使用相同的TCP端口,这样沿途的所有设备都能跟踪会话、地址转换等信息。

会话可能只持续足够长的时间以下载网页,或者网络服务器和计算机可以选择保持会话更长时间,以防用户想要继续浏览网页。

步骤 4:网页浏览器与 Web 服务器通信

一旦操作系统建立了 TCP 会话,操作系统就会通知 Web 浏览器现在可以开始与 Web 服务器通信了。

网络浏览器会使用HTTP协议来实现这一点,HTTP协议是互联网上传输网页的标准协议。

计算机和Web服务器之间建立HTTP会话

这也意味着我们已经完成了示例,其中包括建立通信的大部分步骤。从现在开始,计算机和服务器可以相互通信以传输网页,直到传输完成。然后,它们可以选择发送所谓的 TCP RESET 消息来结束会话,该消息会通知所有设备会话已结束。

l

一文搞懂网络知识,IP、子网掩码、网关、DNS、端口号

网络的基本概念

  • 客户端:应用 C/S(客户端/服务器) B/S(浏览器/服务器)
  • 服务器:为客户端提供服务、数据、资源的机器
  • 请求:客户端向服务器索取数据
  • 响应:服务器对客户端请求作出反应,一般是返回给客户端数据

img

URL

  • Uniform Resource Locator(统一资源定位符)
  • 网络中每一个资源都对应唯一的地址——URL

IP 、子网掩码 、路由器 、DNS

img

IP地址

IP地址是IP协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址(每个机器都有一个编码,如MAC上就有一个叫MAC地址的东西)的差异。是32位二进制数据,通常以十进制表示,并以“.”分隔。IP地址是一种逻辑地地址,用来标识网络中一个个主机,在本地局域网上是惟一的。

img

IP

IP(网络之间互连的协议)它是能使连接到网上的所有计算机网络实现相互通信的一套规则,规定了计算机在因特网上进行通信时应当遵守的规则。任何厂家生产的计算机系统,只要遵守IP协议就可以与因特网互连互通。IP地址有唯一性,即每台机器的IP地址在全世界是唯一的。这里指的是网络上的真实IP它是通过本机IP地址和子网掩码的”与”运算然后再通过各种处理算出来的(要遵守TCP协议还要加报文及端口什么的,我没有细追究,现在还用不上,反正暂时知道被处理过的就行了),顺便教大家查自己真实IP的方法:

img

img

子网掩码

要想理解什么是子网掩码,就不能不了解IP地址的构成。互联网是由许多小型网络构成的,每个网络上都有许多主机,这样便构成了一个有层次的结构。IP地址在设计时就考虑到地址分配的层次特点,将每个IP地址都分割成网络号和主机号两部分,以便于IP地址的寻址操作。
IP地址的网络号和主机号各是多少位呢?如果不指定,就不知道哪些位是网络号、哪些是主机号,这就需要通过子网掩码来实现。什么是子网掩码子网掩码不能单独存在,它必须结合IP地址一起使用。子网掩码只有一个作用,就是将某个IP地址划分成网络地址和主机地址两部分子网掩码的设定必须遵循一定的规则。与IP地址相同,子网掩码的长度也是32位,左边是网络位,用二进制数字“1”表示;右边是主机位,用二进制数字“0”表示。假设IP地址为“192.168.1.1”子网掩码为“255.255.255.0”。其中,“1”有24个,代表与此相对应的IP地址左边24位是网络号;“0”有8个,代表与此相对应的IP地址右边8位是主机号。这样,子网掩码就确定了一个IP地址的32位二进制数字中哪些是网络号、哪些是主机号。这对于采用TCP/IP协议的网络来说非常重要,只有通过子网掩码,才能表明一台主机所在的子网与其他子网的关系,使网络正常工作。

常用的子网掩码有数百种,这里只介绍最常用的两种子网掩码。

  • 子网掩码是“255.255.255.0”的网络:
    最后面一个数字可以在0~255范围内任意变化,因此可以提供256个IP地址。但是实际可用的IP地址数量是256-2,即254个,因为主机号不能全是“0”或全是“1”。

  • 子网掩码是“255.255.0.0”的网络:
    后面两个数字可以在0~255范围内任意变化,可以提供255²个IP地址。但是实际可用的IP地址数量是255²-2,即65023个。

IP地址的子网掩码设置不是任意的。如果将子网掩码设置过大,也就是说子网范围扩大,那么,根据子网寻径规则,很可能发往和本地主机不在同一子网内的目标主机的数据,会因为错误的判断而认为目标主机是在同一子网内,那么,数据包将在本子网内循环,直到超时并抛弃,使数据不能正确到达目标主机,导致网络传输错误;如果将子网掩码设置得过小,那么就会将本来属于同一子网内的机器之间的通信当做是跨子网传输,数据包都交给缺省网关处理,这样势必增加缺省网关(文章下方有解释)的负担,造成网络效率下降。因此,子网掩码应该根据网络的规模进行设置。如果一个网络的规模不超过254台电脑,采用“255.255.255.0”作为子网掩码就可以了,现在大多数局域网都不会超过这个数字,因此“255.255.255.0”是最常用的IP地址子网掩码;假如在一所大学具有1500多台电脑,这种规模的局域网可以使用“255.255.0.0”。

网关

网关实质上是一个网络通向其他网络的IP地址。比如有网络A和网络B,网络A的IP地址范围为“192.168.1.1192.168.1.254”,子网掩码为255.255.255.0;网络B的IP地址范围为“192.168.2.1192.168.2.254”,子网掩码为255.255.255.0。在没有路由器的情况下,两个网络之间是不能进行TCP/IP通信的,即使是两个网络连接在同一台交换机(或集线器)上,TCP/IP协议也会根据子网掩码(255.255.255.0)判定两个网络中的主机处在不同的网络里。而要实现这两个网络之间的通信,则必须通过网关。如果网络A中的主机发现数据包的目标主机不在本地网络中,就把数据包转发给它自己的网关,再由网关转发给网络B的网关,网络B的网关再转发给网络B的某个主机。网络B向网络A转发数据包的过程也是如此 所以说,只有设置好网关的IP地址,TCP/IP协议才能实现不同网络之间的相互通信。那么这个IP地址是哪台机器的IP地址呢?网关的IP地址是具有路由功能的设备的IP地址,具有路由功能的设备有路由器、启用了路由协议的服务器(实质上相当于一台路由器)、代理服务器(也相当于一台路由器)。

路由器(Windows下叫默认网关,网关就是路由,路由就是网关不要蒙)

如果搞清了什么是网关,默认网关也就好理解了。就好像一个房间可以有多扇门一样,一台主机可以有多个网关。默认网关的意思是一台主机如果找不到可用的网关,就把数据包发给默认指定的网关,由这个网关来处理数据包。现在主机使用的网关,一般指的是默认网关。
下方是百度百科给出的解释

img

如何设置默认网关 一台电脑的默认网关是不可以随随便便指定的,必须正确地指定,否则一台电脑就会将数据包发给不是网关的电脑,从而无法与其他网络的电脑通信。默认网关的设定有手动设置和自动设置两种方式。

  • 手动设置:手动设置适用于电脑数量比较少、TCP/IP参数基本不变的情况,比如只有几台到十几台电脑。因为这种方法需要在联入网络的每台电脑上设置“默认网关”,非常费劲,一旦因为迁移等原因导致必须修改默认网关的IP地址,就会给网管带来很大的麻烦,所以不推荐使用。需要特别注意的是:默认网关必须是电脑自己所在的网段中的IP地址,而不能填写其他网段中的IP地址。
  • 自动设置:自动设置就是利用DHCP服务器来自动给网络中的电脑分配IP地址、子网掩码和默认网关。这样做的好处是一旦网络的默认网关发生了变化时,只要更改了DHCP服务器中默认网关的设置,那么网络中所有的电脑均获得了新的默认网关的IP地址。这种方法适用于网络规模较大、TCP/IP参数有可能变动的网络。另外一种自动获得网关的办法是通过安装代理服务器软件(如MS Proxy)的客户端程序来自动获得,其原理和方法和DHCP有相似之处。由于篇幅所限,就不再详述了。

缺省网关

缺省网关(Default Gateway)是计算机网络中一个如何将数据包转发到其他网络中的节点。在一个典型的TCP / IP网络,节点(如服务器、工作站和网络设备)都有一个定义的默认路由设置(指向默认网关)。可以在没有特定路由的情况下,明确出发送数据包的下一跳IP地址。
下方是百度百科给出的解释:

img

可以看出缺省网关就是默认网关,那么有人会说既然有一样为什么又凭空多出来一个缺省网关,我的理解是这样的,应该说默认网关是缺省网关的一个子集。缺省网关有一个定义的默认路由设置(指向默认网关),缺省网关就相当于一个代理服务器暂时管理发送的数据包,当发送到目标主机时先由目标主机的缺省网关接收再找到对应的默认网关,就相当于缺省网关是父类,默认网关是子类~~

DNS服务器

域名服务器(Domain Name Server)。在Internet上域名与IP地址之间是一一对应的,域名虽然便于人们记忆,但机器之间只能互相认识IP地址,它们之间的转换工作称为域名解析,域名解析需要由专门的域名解析服务器来完成,DNS就是进行域名解析的服务器 。

DHCP服务器

DHCP指的是由服务器控制一段IP地址范围,客户机登录服务器时就可以自动获得服务器分配的IP地址和子网掩码。提升地址的使用率。

MAC地址

MAC地址就如同我们身份证上的身份证号码,具有全球唯一性。(知道这个就行了,不用往下看了)
MAC(Media Access Control,介质访问控制)地址

  • 前24位叫做组织唯一标志符(Organizationally Unique Identifier,即OUI),是由IEEE的注册管理机构给不同厂家分配的代码,区分了不同的厂家。
  • 后24位是由厂家自己分配的,称为扩展标识符。同一个厂家生产的网卡中MAC地址后24位是不同的。
    网卡的物理地址通常是由网卡生产厂家烧入网卡的EPROM(一种闪存芯片,通常可以通过程序擦写),它存储的是传输数据时真正赖以标识发出数据的电脑和接收数据的主机的地址。

也就是说,在网络底层的物理传输过程中,是通过物理地址来识别主机的,它一定是全球唯一的。比如,著名的以太网卡,其物理地址是48bit(比特位)的整数,如:44-45-53-54-00-00,以机器可读的方式存入主机接口中。以太网地址管理机构(除了管这个外还管别的)(IEEE)(IEEE:电气和电子工程师协会)将以太网地址,也就是48比特的不同组合,分为若干独立的连续地址组,生产以太网网卡的厂家就购买其中一组,具体生产时,逐个将唯一地址赋予以太网卡。
在一个稳定的网络中,IP地址和MAC地址是成对出现的。如果一台计算机要和网络中另一外计算机通信,那么要配置这两台计算机的IP地址,MAC地址是网卡出厂时设定的,这样配置的IP地址就和MAC地址形成了一种对应关系。在数据通信时,IP地址负责表示计算机的网络层地址,网络层设备(如路由器)根据IP地址来进行操作;MAC地址负责表示计算机的数据链路层地址,数据链路层设备(如交换机)根据MAC地址来进行操作。IP和MAC地址这种映射关系由ARP(Address Resolution Protocol,地址解析协议)协议完成。

服务器

  1. 服务器的分类
    按照软件开发阶段来分,服务器可以大致分为2种
    (1)远程服务器
    别名:外网服务器、正式服务器
    使用阶段:应用上线后使用的服务器
    使用人群:供全体用户使用
    速度:服务器的性能、用户的网速
    (2)本地服务器
    别名:内网服务器、测试服务器
    使用阶段:应用处于开发、测试阶段使用的服务器
    使用人群:仅供公司内部的开发人员、测试人员使用
    速度:由于是局域网,所以速度飞快,有助于提高开发测试效率
  2. 本地服务器的选择
    远程服务器就是本地内网服务器开放外网访问而已
    如果处于学习、开发阶段,自己搭建一个本地服务器即可

端口号

端口包括物理端口和逻辑端口。物理端口是用于连接物理设备之间的接口,逻辑端口是逻辑上用于区分服务的端口。TCP/IP协议中的端口就是逻辑端口,通过不同的逻辑端口来区分不同的服务。
端口有什么用呢?我们知道,一台拥有IP地址的主机可以提供许多服务,比如Web服务、FTP服务、SMTP服务等,这些服务完全可以通过1个IP地址来实现。那么,主机是怎样区分不同的网络服务呢?显然不能只靠IP地址,因为IP 地址与网络服务的关系是一对多的关系。实际上是通过“IP地址+端口号”来区 分不同的服务的。

  • 公认端口(Well-Known Ports)
    这类端口也常称之为”常用端口”。这类端口的端口号从0到1023,它们紧密绑定于一些特定的服务。通常这些端口的通信明确表明了某种服务的协议,这种端口是不可再重新定义它的作用对象。80端口实际上总是HTTP通信所使用的,而23号端口则是Telnet服务专用的。
  • 注册端口(Registered Ports)
    端口号从1025到49151。分配给用户进程或应用程序。这些进程主要是用户选择安装的一些应用程序,而不是分配好的公认端口的常用程序。
  • 动态和/或私有端口(Dynamic and/or Private Ports)
    之所以称为动态端口,因为它一般不固定分配某种服务,而是动态分配。
l

VIM多光标插件——vim-visual-multi

插件简介 mg979/vim-visual-multi

vim宏操作和多光标插件

1
VIM多光标插件,提供vim/nvim快速操作多光标编辑的能力

顺便在这里附上我自己的nvim配置仓库地址 yaocccc/nvim

安装

1
2
推荐使用vim-plug安装
Plug 'mg979/vim-visual-multi', {'branch': 'master'}

配置

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
" 贴上本人的配置
let g:VM_theme = 'ocean'
let g:VM_highlight_matches = 'underline'
let g:VM_maps = {}
let g:VM_maps['Find Under'] = '<C-n>'
let g:VM_maps['Find Subword Under'] = '<C-n>'
let g:VM_maps['Select All'] = '<C-d>'
let g:VM_maps['Select h'] = '<C-Left>'
let g:VM_maps['Select l'] = '<C-Right>'
let g:VM_maps['Add Cursor Up'] = '<C-Up>'
let g:VM_maps['Add Cursor Down'] = '<C-Down>'
let g:VM_maps['Add Cursor At Pos'] = '<C-m>'
let g:VM_maps['Add Cursor At Word'] = '<C-w>'
let g:VM_maps['Remove Region'] = 'q'

" 从上至下的意义:
" ['Find Under'] -> 选中光标下的词(ctrl+n继续向下选中相同文本)
" ['Find Subword Under'] -> 选中光标下的词(ctrl+n继续向下选中相同文本)
" ['Select All'] -> 选中文件中所有光标下的词
" ['Select h'] -> 从光标往左选中文本(ctrl+n继续向下选中相同文本)
" ['Select l'] -> 从光标往右选中文本(ctrl+n继续向下选中相同文本)
" ['Add Cursor Up'] -> 向上添加一个光标(原光标+上光标 继续使用则继续添加)
" ['Add Cursor Down'] -> 向下添加一个光标(原光标+下光标 继续使用则继续添加)
" ['Add Cursor At Pos'] -> 将当前光标添加入多光标列表中
" ['Add Cursor At Word'] -> 将当前光标所在词的词首加上多光标列表中
" ['Remove Region'] -> 移除当前光标

VIMRC

基本概念

!!!重要!!!
!!!重要!!!
!!!重要!!!

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
简单来说该插件提供三种模式:
多光标 normal 模式
多光标 visual 模式
多光标 insert 模式

可以对应vim的三模式
如何切换:
normal <-> visual 模式互相转换: Tab
normal --> insert 进入插入模式: i、a、c等 和 vim原生的按键是一样的
insert --> normal 返回普通模式: Esc

normal模式 可以使用 hjkl w e b f t $ ^ 0 等等按键来移动 多光标中的全部光标(不可以使用上下左右 PS: 上下左右可以用来移动原光标)
visual模式 可以使用 hjkl w e b f t $ ^ 0 等等按键来选中 多光标中的全部光标(不可以使用上下左右 PS: 上下左右可以用来移动原光标)
insert模式 可以使用 上下左右 等等按键来移动 多光标中的全部光标

如何从正常VIM模式中进入多光标模式
1: VIM处于normal模式
2: 用以下快捷键即可
['Find Under'] -> 选中光标下的词(ctrl+n继续向下选中相同文本)
['Find Subword Under'] -> 选中光标下的词(ctrl+n继续向下选中相同文本)
['Select All'] -> 选中文件中所有光标下的词
['Select h'] -> 从光标往左选中文本(ctrl+n继续向下选中相同文本)
['Select l'] -> 从光标往右选中文本(ctrl+n继续向下选中相同文本)
['Add Cursor Up'] -> 向上添加一个光标(原光标+上光标 继续使用则继续添加)
['Add Cursor Down'] -> 向下添加一个光标(原光标+下光标 继续使用则继续添加)
['Add Cursor At Pos'] -> 将当前光标添加入多光标列表中
['Add Cursor At Word'] -> 将当前光标所在词的词首加上多光标列表中

如何从多光标模式中退出
一股脑Esc就对了


使用案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
当前有以下文本,试完成以下案例 原始光标初始位置在第1行的l位置

let aa = 1;
let bbb = 2;
let cccc = 3;
let ddddd = 4;


案例1: 将 全部let 改成 const
案例重点: 如何添加多光标

方案1: ctrl+d c const
1: ctrl+d 选中文件中所有光标下的词
2: c 从visual模式进入插入模式,并变更当前选中的文本
方案2: ctrl+n ctrl+n ctrl+n ctrl+n c const
1: ctrl+n 选中光标下的词 * 4次
2: c 从visual模式进入插入模式,并变更当前选中的文本
方案3: ctrl+下 ctrl+下 ctrl+下 xxx i const
1: ctrl+下 * 3次 将共计4个l位置的光标添加进多光标列表
2: xxx删除 i进入插入模式
...


案例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
案例2: 将 124行的 let 改成 const
案例重点: 如何取消/跳过某光标

方案1: ctrl+d 下下q c const
1: ctrl+d 选中文件中所有光标下的词
2: 下下 移动原始光标到第三行 q 取消当前光标
3: c 从visual模式进入插入模式,并变更当前选中的文本
方案2: ctrl+n ctrl+n 下下 ctrl+n c const
1: ctrl+n 选中光标下的词 * 2次
2: 下下 移动原始光标到第四行
3: ctrl+n 选中光标下的词
4: c 从visual模式进入插入模式,并变更当前选中的文本
方案3: ctrl+下 下下 ctrl+x xxx i const
1: ctrl+下 将共计2个l位置的光标添加进多光标列表
2: 下下 移动原始光标到第四行
3: ctrl+x 添加当前位置光标进多光标列表
4: xxx删除 i进入插入模式
...


案例2

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
案例3: 将文本内容改成
案例重点: 多光标下normal和visual的切换 光标的跳转等
let aa = 1 * aa;
let bbb = 2 * bbb;
let cccc = 3 * cccc;
let ddddd = 4 * ddddd;

方案1: ctrl+d tab w tab e y tab ww a * 空格 esc p
1: ctrl+d 选中文件中所有光标下的词
2: tab 从visual模式转换到normal模式
3: w 跳到 下个词首(aa、bbb等所在词)
4: tab 从normal模式转换到visual模式
5: e选中到词尾 y复制选中部分
6: tab 从visual模式转换到normal模式
7: ww a ... 光标跳转并插入 * 空格
8: esc 从 insert模式回归到normal
9: p粘贴刚刚选中的多个词
方案2: ctrl+下 ctrl+下 ctrl+下 w tab e y tab ww a * 空格 esc p
1: ctrl+下 * 3次 将共计4个l位置的光标添加进多光标列表
2: w 跳到 下个词首(aa、bbb等所在词)
3: tab 从normal模式转换到visual模式
4: e选中到词尾 y复制选中部分
5: tab 从visual模式转换到normal模式
6: ww a ... 光标跳转并插入 * 空格
7: esc 从 insert模式回归到normal
8: p粘贴刚刚选中的多个词

案例3

PS

1
2
3
4
5
6
7
8
9
建议将列出的按键配置都自己试一遍
该插件还有不少其他用法,例如:
自定义搜索文本跳转
多行对齐
...

不过我不会 有需要进阶的同好可以看原仓库说明


mg979/vim-visual-multi

l

GT系列蓝牙双模键盘使用手册

GT系列当前包括BLE60 、 Omega45 、 Farad69、Omega50四款键盘,四款键盘均采用的LotKB开源固件进行驱动,功能按键等设定基本一致:

功能按键说明

休眠按键:P – 同时按下Lshift+Rshift时按此键可以手动进入休眠模式,按任意键可以唤醒。(此功能按键修改配列不会改变)

关机按钮:ESC或~ – 同时按下Lshift+Rshift时按此键可以手动进入关机模式,关机后需要按背部的Bootloader按钮才可重新开机。长期不用或携带外出建议关机。

切换蓝牙设备:Q/W/E – 同时按下Lshift+Rshift时按Q/W/E可以在已绑定的蓝牙设备之间进行切换。

重新开启蓝牙广播:R – 同时按下Lshift+Rshift时按R可以重新开启蓝牙广播,用于切换设备后进行绑定。

显示蓝牙键盘状态灯:L 或 ? – 在电池供电情况下,状态灯会自动熄灭,如要显示当前状态灯,同时按下Lshift+Rshift时按L或?,将显示状态灯5秒。

锁定WIN键:G – 同时按下Lshift+Rshift时按此键,将锁定WIN键,再次同时按下Lshift+Rshift时按此键,将恢复WIN键,重启将自动恢复WIN键。

切换连接模式按键:M – 在通过USB和蓝牙同时连接一台设备(也可通过USB连接一台设备、蓝牙连接另一台设备)的情况下,同时按下Lshift+Rshift时按此键可以切换连接模式。如未同时使用USB模式和蓝牙模式,此按键无效。

进入DFU刷机模式:B – 同时按下Lshift+Rshift时按此键可以重启到DFU刷机模式。也可在唤醒的同时按Space+B进入DFU刷机模式。

清空当前设备蓝牙绑定信息按键:O – 同时按下Lshift+Rshift时按此键可以清空当前蓝牙绑定信息。仅清空当前设备,其余绑定设备不会清空。

重置键盘:I – 同时按下Lshift+Rshift时按此键可以重置键盘。重置键盘会清空所有的蓝牙绑定信息、自定义的配列、存储的键盘配置信息。

RGB灯效控制:调整颜色: A/S/D/F/C/V – 同时按下Lshift+Rshift时按A/S/D/F/C/V,分别是增加饱和度、降低饱和度、增加亮度、降低亮度、增加色调、降低色调。

RGB灯效控制:调整模式:Z – 同时按下Lshift+Rshift时按Z,循环灯效。

RGB灯效控制:开关RGB:X – 同时按下Lshift+Rshift时按X,切换RGB灯的开关。

PCB背部多功能按钮 – 背部按钮属于多功能按钮。键盘正常模式下,按下一秒后松手键盘关机;按下三秒后松手键盘进入DFU刷机模式;按下10秒后松手,键盘将重置。在断电状态,按下背部按钮不放,同时按下键盘第一行首个按键或最后一个按键,然后通电,将强制进入DFU模式。

如果要绑定并切换多设备,按以下步骤操作:

一、初次绑定

1、键盘上按下双Shift+Q,然后再按下双shift+R,在需要绑定键盘的设备上搜索蓝牙键盘,在搜索到相应蓝牙键盘名称后,将第一个设备绑定到键盘Q键上;

2、键盘上按下双Shift+W,然后再按下双shift+R,然后绑定第二个设备到键盘W键;;

3、键盘上按下双Shift+E,然后再按下双shift+R,然后绑定第三个设备到键盘E键;

4、通过双shift+Q/W/E进行输入设备的切换

二、更换绑定

其他说明

状态提示灯说明

GT系列蓝牙键盘状态灯一般为3颗,以Omega45为例:

img

如要更换某个设备的绑定,如要新绑定一个设备到W键上。
1、将W键绑定设备上的蓝牙键盘进行删除(如windows系统进入系统设备删除蓝牙键盘;Android系统进入设置-蓝牙后删除蓝牙键盘);

2、键盘上按下双Shift+W,切换到W设备;

3、然后键盘上再按下双shift+O,删除W上绑定的设备,绑定新的设备到键盘W键上;

相关参数的设定 – 考虑到耗电问题,正常键盘扫描按键输入为10ms一次,回报率为100Mhz;如果15秒不按键转入慢速扫描,100ms一次,当有按键按下,又自动转入正常扫描速度10ms一次;如果20分钟无按键行为将自动转入休眠模式,此时要重新启用键盘,只需要按任意键就可唤醒,唤醒动作后约1-2s可以正常输入。

在线编译

通过在线编辑,可以可视化配置按键配列、生成固件。

通过此网站 :lotkb.glab.online 或 访问导航中“在线编译” 。可以可视化修改按键,更改按键更加方便;可以更改一些参数设定,如自动休眠时间、RGB灯数量等。

LotKB采用的低功耗蓝牙:Bluetooth Low Energy,并且是5.0版本,可向下兼容蓝牙4.0,但不支持蓝牙3.0及以下。由于采用的较新的协议,需要较新的系统才能支持。如:
iOS 6以上;Android 4.3以上;Windows 8.1以上;Windows Phone 8.1;较新的MacOS。
除了操作系统以外,也需要硬件设备支持,至少要硬件设备支持蓝牙4.0以上才行。从时间上来说,可能要2011年以后的设备才能支持蓝牙4.0,也才可能使用此键盘的蓝牙功能

键盘使用说明

本页面最后修改时间2025-06-09

  • 本页面操作说明全系列键盘通用,所有内容基于最新版固件,如与当前键盘实际不符,建议升级固件
  • 更多键盘专有说明可以访问键盘产品信息页面

相关概念

开机

GT系列三模键盘PCB没有硬件电源开关,只需要接入电池或插入USB通电后,键盘就会自动开机。

软关机后,可以插入USB线 / 按击一下背部多功能按钮 / 按下旋钮中键 实现开机。

关机

按下背部多功能按钮1秒以上后(不超过3秒)松手 或 系统功能按键Lshift+Rshift+Backspace或~进入关机状态。

插入 USB 时,无法关机(关机后马上会被唤醒)。

自动睡眠

在一定时间不敲击按键后,键盘将自动进入睡眠模式以便省电。进入自动睡眠的时间可以自由设定。

自动睡眠后,按任意键可唤醒键盘重新工作。

插入 USB 后,由 USB 供电,自动睡眠功能不会启用,如处于睡眠状态也将自动唤醒。

手动睡眠

同时按下Lshift+Rshift+P可以手动进入睡眠模式

手动睡眠后,按任意键可唤醒键盘重新工作。

如果启用了唤醒按键功能,手动睡眠后唤醒时需要按Space+U才能唤醒键盘。

插入 USB 后,将直接唤醒键盘重新工作,并且无法手动睡眠。

蓝牙连接设备

如需通过 蓝牙连接 进行输入,确保键盘处于蓝牙连接模式,并完成 配对蓝牙设备 的过程。

2.4G连接设备

如需通过 2.4G无线 进行输入,确保键盘处于2.4G无线连接模式,将 2.4G接收器 插入设备的USB口,并完成2.4G接收器配对。

USB 连接设备

直接将 USB 线缆插入到键盘的 USB 接口,键盘即自动切换至 USB 模式,使用 USB 输入。

2.4G接收器模式

将 USB 线缆插入到键盘的 USB 接口,将键盘2.4G无线模式切换至 2.4G接收模式,此时当前键盘可作为其他2.4G键盘的接收器使用。

有线 / 无线状态切换

同时按下Lshift+Rshift+M可以在USB / 无线 状态之间切换,如未插入USB线,此切换功能无效。

USB连接状态下,可通过Lshift+Rshift+Q/W/E直接跳转到对应蓝牙或2.4G通道。

省电模式

在没有接入 USB 电源的情况下,为提升续航,键盘处于省电模式。

在省电模式下,所有的指示灯仅在变化后亮起 5 秒,然后熄灭。键盘在无操作 15 秒后转入慢速扫描模式,无操作 600 秒后自动睡眠。(前述三个时间均支持通过配置工具自定义)

慢速扫描模式下,按键检测将可能略有延迟。在慢速扫描模式下按任意按键即可恢复到快速扫描模式。

自动睡眠后,按下键盘的任意按键可唤醒键盘。

电量显示

电量百分比仅供参考

由于测量数据是根据电压估算,显示电量和实际可能存在误差,损耗较大的电池电量百分比可能无法达到 100%。

在新版本的 iOS、安卓和 Windows 上,应该能够正常的显示蓝牙设备的电量百分比。

对于安卓手机,如果没有正常的显示电量,可以尝试下载 BatON 软件来获取蓝牙设备电量。

如USB与2.4G无线连接状态,或其他无法显示电量的情况,可以通过Lshift+Rshift+H输出电量

通过Lshift+Rshift+H输出电量时,N代表未能检测到数值,F代表电池电量满,数值代表电量百分比。

新版固件支持通过键盘控制面板显示电量

全键无冲(NKRO)

此键盘支持 NKRO(全键无冲)模式。USB、蓝牙、2.4G模式均支持全键无冲模式。

唤醒按键

启用唤醒按键功能后,手动睡眠后唤醒需按Space+U唤醒键盘。
自动睡眠时,不需要按唤醒按键,可任意键唤醒。

默认不启用唤醒按键功能,可通过Lshift+Rshift+I启用或禁用。

DFU 模式

说明

采用nRF52810/nRF52811芯片的键盘,由于空间不足不支持DFU模式,请采用线刷方式更新固件

DFU模式为键盘内置的蓝牙刷机模式,在通过手机nRF Connect程序升级时,必须确保在蓝牙通讯模式,并进入DFU模式。

同时按下Lshift+Rshift+B可以重启到DFU刷机模式。也可长按PCB背部多功能按钮4秒以上后(不超过9秒)松手重启到DFU刷机模式。

USB ISP 模式

USB ISP 模式为USB芯片的烧录模式,在更新USB固件时需要先进入USB ISP模式。

短接PCB背部K1位置两个焊盘,并插入USB线可手动进入USP ISP模式;

用烧录工具烧录USB固件时,烧录工具会自动跳转USB ISP模式,无需手动短接K1。

键盘控制面板

键盘控制面板是一个网页应用程序,可通过网页程序控制键盘的各种功能。

包括获取键盘各种信息、控制键盘功能、更改灯光和层级、更改输出模式等。

键盘需2024年12月7日后固件支持接入键盘控制面板,接收器需2025年1月4日后固件支持接入键盘控制面板。

键盘控制面板支持作为网页程序进行安装,安装后支持离线使用。

轴灯版PCB

轴灯版PCB采用键盘主控直接驱动RGB灯,所以无复杂灯效,仅有单色常亮、单色呼吸和彩虹循环,可手动调色,可视为单色轴灯的增强版本。

轴灯分为轴灯模式和指示灯模式,出厂默认为指示灯模式,可通过Lshift+Rshift+L 或 通过配置工具设置一颗状态灯开关按键 在指示灯模式和轴灯模式之间切换。

指示灯模式时,不可控制,以不同颜色指示不同状态,详细参考 状态提示灯说明

要控制轴灯,请先切换到轴灯模式。轴灯模式时,采用Lshift+Rshift+Z X C V等RGB控制功能调整轴灯。

RGB轴灯版PCB

RGB轴灯版PCB采用WS2812 RGB灯,支持各种丰富绚丽的灯效(同QMK灯效),支持键盘状态指示灯、USB与蓝牙状态指示灯。

可采用Lshift+Rshift+Z X C V等RGB控制功能调整RGB轴灯。

或 接入配置工具,找到 灯光 功能,将RGB阵列相关按键设定到你指定的按键上控制RGB灯光。

RGB轴灯同时兼容指示灯,指示灯可通过配置工具设置一颗状态灯开关按键进行开关

指示灯支持独立运行,建议在使用电池时,关闭RGB轴灯,开启指示灯,指示灯将可自动关闭节能

系统功能按键说明

特别提示
  • 默认采用Lshift+Rshift做为功能键触发按键
  • 可以通过自行编译固件使用Lctrl+Rctrl 或 Win+ESC等做为触发按键。
  • 更改后下表所列功能按键需做相应调整,如触发按键更改为Win+ESC,则按下Win+ESC+P为睡眠。
  • 系统功能按键为内置功能,不因修改了按键而消失,只要您确保LShift/RShift等功能触发按键存在即可使用
  • PAD数字键盘等较少按键的键盘,没有Shift等按键,下列系统功能可以通过配置工具-层级/功能-键盘功能进行设定。
  • 新版固件也支持通过键盘控制面板控制各种系统功能
功能 按键 功能说明
睡眠 Lshift+Rshift+P 手动进入睡眠模式,按任意键可以唤醒。
关机 Lshift+Rshift+~或Backspace 手动进入关机模式。新版固件将ESC更换成了Backspace。 关机后需要插入USB线 或 短按背部多功能按钮开机。 长期不用或携带外出建议关机。
重启 Lshift+Rshift+N 手动重启键盘,用于遇到部分故障后重启修正。
有线/无线状态切换 Lshift+Rshift+M 在USB有线和无线同时工作时,可以切换有线/无线连接模式。 如未同时使用USB有线和无线,按键无效。
无线模式切换 Lshift+Rshift+U 在键盘支持BLE5.0与2.4G无线两种无线模式时,在两种模式之间切换。
无线收发模式切换 Lshift+Rshift+J 最新固件中,当处于2.4G无线模式时,可在2.4G接收 [接收器] 和2.4G发送 [键盘] 两种2.4G无线模式之间切换。
切换蓝牙设备/无线接收器 Lshift+Rshift+Q/W/E 可以在已配对的蓝牙设备/无线接收器之间进行切换,Q/W/E代表不同蓝牙连接通道/无线接收器
重启蓝牙广播 2.4G无线配对 Lshift+Rshift+R 蓝牙模式:重新开启蓝牙广播,用于手动进行连接或切换设备后配对。 2.4G无线模式:发起配对通讯,与2.4G接收器进行配对。
进入DFU Lshift+Rshift+B 重启到DFU刷机模式。 也可长按PCB背部多功能按钮4秒以上后松手重启到DFU刷机模式。
清空当前配对 Lshift+Rshift+O 蓝牙模式:清空当前蓝牙设备配对信息。仅清空当前设备,其余配对设备不会清空。 2.4G无线模式:解除与当前2.4G接收器的配对
输出电量 Lshift+Rshift+H 通过键盘输出当前键盘剩余电量。输出N为检测未稳定,F为满电,数字为电量百分比。
状态灯开关 Lshift+Rshift+L 无轴灯PCB/RGB轴灯PCB:开启或关闭状态指示灯显示(注:不包括键盘大小写等)。 轴灯版PCB:使轴灯在轴灯模式和指示灯模式之间切换
多功能按钮 PCB背部按钮属于多功能按钮。 键盘正常模式下,按下1秒以上后(不超过3秒)松手键盘关机; 按下4秒以上后(不超过9秒)松手键盘进入DFU刷机模式; 按下10秒以上后松手,键盘将重置。 关机状态短按一下开机。
唤醒按键 Space+U 启用唤醒按键功能后,手动睡眠后唤醒需按Space+U唤醒键盘。 自动睡眠时,不需要按唤醒按键,可任意键唤醒。
切换唤醒按键 Lshift+Rshift+I 启用或禁用唤醒按键功能。 启用唤醒按键功能后,手动睡眠后唤醒需按Space+U唤醒键盘
切换默认层 Lshift+Rshift+数字键 Lshift+Rshift+1切换默认层到第2层。 Lshift+Rshift+0切换默认层到第1层。 睡眠或关机后唤醒自动恢复第1层为默认层
RGB调整颜色 A/S/D/F/C/V 轴灯版(轴灯模式下)为控制轴灯;非轴灯版为控制底灯 同时按下Lshift+Rshift时按A/S/D/F/C/V, 分别是增加饱和度、降低饱和度、增加亮度、降低亮度、增加色调、降低色调。
RGB灯效循环 Lshift+Rshift+Z RGB轴灯版为控制RGB轴灯在灯效之间循环 轴灯版(轴灯模式下)为控制轴灯在常亮、呼吸、多彩变换之间循环 非轴灯版为控制底灯在32种灯效之间循环
RGB灯开关 Lshift+Rshift+X RGB轴灯版为RGB轴灯开关 轴灯版(轴灯模式下)为轴灯开关 非轴灯版为RGB底灯开关

配对蓝牙设备

初次配对

  1. 键盘上按下Lshift+Rshift+Q,切换到蓝牙通道1️⃣,然后再按下Lshift+Rshift+R开启广播;
  2. 在需要连接键盘的设备上搜索蓝牙键盘,搜索到相应蓝牙键盘名称后,将第一个设备配对到键盘蓝牙通道1️⃣(Q键);
    • 各个操作系统 配对蓝牙键盘 的方式请自行搜索相关文档
    • 如果仅有一个蓝牙设备,可以配对到任意蓝牙通道,但是建议配对到蓝牙1️⃣
    • 如不需要多设备切换,可不再配对设备到蓝牙通道2️⃣和蓝牙通道3️⃣
  3. 键盘上按下Lshift+Rshift+W,切换到蓝牙通道2️⃣,然后再按下Lshift+Rshift+R开启广播,再配对第二个设备到键盘蓝牙通道2️⃣(W键 );
  4. 键盘上按下Lshift+Rshift+E,切换到蓝牙通道3️⃣,然后再按下Lshift+Rshift+R开启广播,再配对第三个设备到键盘蓝牙通道3️⃣(E键 );
  5. 通过Lshift+Rshift+Q/W/E切换到不同的蓝牙通道,可在多个配对设备之间切换。

更换配对

如需更换某个设备的配对,例:要重新配对一个设备到蓝牙通道2️⃣(W键 )。

  1. 将W键配对的设备上的蓝牙键盘进行删除(如windows系统进入系统设备删除蓝牙键盘;Android系统进入设置-蓝牙后删除蓝牙键盘);
  2. 键盘上按下Lshift+Rshift+W,切换到蓝牙通道2️⃣;
  3. 键盘上再按下Lshift+Rshift+O,删除蓝牙通道2️⃣配对的设备;
  4. 如有必要,按下Lshift+Rshift+R开启广播;
  5. 在需要配对键盘的设备上搜索蓝牙键盘,搜索到相应蓝牙键盘名称后,将新的设备配对到键盘蓝牙通道2️⃣(W键 );

Tip

上述切换蓝牙通道及删除配对设备等,也可通过BT 1 / BT 2 / BT 3 / 无线 解除配对 等按键进行操作

采用BT 1 / BT 2 / BT 3按键切换蓝牙通道后,需要按下无线 广播配对手动开启蓝牙广播

2.4G接收器配对

  1. 将接收器插入电脑USB口通电
  2. 按下Lshift+Rshift+U 将键盘切换到2.4G无线模式
  3. 键盘未配对:可多次按下Lshift+Rshift+R启动配对通讯,直到正确连接输出按键
  4. 键盘已配对:按下Lshift+Rshift+O删除配对信息,可多次按下Lshift+Rshift+R启动配对通讯,直到正确连接输出按键
  5. 配对多个接收器:按下Lshift+Rshift+Q/W/E切换不同的无线通道,然后重复执行2-4三个步骤完成与不同接收器的配对
  6. 通过Lshift+Rshift+Q/W/E切换到不同的无线通道,可在多个配对的无线接收器之间切换。
  7. 如PAD等小键盘,无Lshift、Rshift,可通过配置工具配置切换 无线模式/无线 广播配对/无线 解除配对/BT 1 / BT 2 / BT 3按键,或通过键盘控制面板,启动配对通讯、删除配对信息、切换无线接收器

Tip

配对前请确保键盘固件更新到支持三模固件的版本,并确保接收器和键盘的固件版本对应。

状态提示灯说明

GT系列无线键盘无灯版指示灯一般为3颗,每个键盘指示灯的位置稍有不同,但是指示逻辑基本一致:

  • 蓝色灯-蓝牙连接成功、蓝牙输出
  • 绿色灯-USB输出
  • 红色灯-2.4G无线输出
  • 蓝色指示灯闪烁-蓝牙通道1️⃣广播中
  • 绿色指示灯闪烁-蓝牙通道2️⃣广播中
  • 红色指示灯闪烁-蓝牙通道3️⃣广播中
  • USB连接状态下,状态灯常亮
  • 蓝牙连接状态下,指示灯5秒后自动熄灭(可自定义常亮时长)
  • 蓝牙广播时闪烁30秒后未连接自动熄灭。

GT系列无线键盘单色RGB轴灯版默认采用轴灯作为指示,不同颜色代表不同状态:

  • 蓝色-蓝牙连接成功、蓝牙输出
  • 绿色-USB输出
  • 青色-2.4G无线输出
  • 粉色-蓝牙通道1️⃣广播中
  • 黄色-蓝牙通道2️⃣广播中
  • 红色-蓝牙通道3️⃣广播中
  • USB连接状态下,状态灯常亮
  • 蓝牙连接状态下,指示灯5秒后自动熄灭(可自定义常亮时长)
  • 蓝牙广播30秒后未连接自动熄灭。

GT系列无线键盘炫彩RGB轴灯版默认采用某颗轴灯作为指示,不同颜色代表不同状态:

  • 绿色-USB输出
  • 蓝色-蓝牙通道1️⃣输出
  • 红色-蓝牙通道2️⃣输出
  • 橙色-蓝牙通道3️⃣输出
  • 青色-2.4G无线1️⃣输出
  • 紫色-2.4G无线2️⃣输出
  • 粉色-2.4G无线3️⃣输出
  • 黄色-2.4G无线接收器接收模式启用
  • 指示灯可通过配置工具设置一颗状态灯开关按键进行开关
  • 指示灯支持独立运行,建议在使用电池时,关闭RGB轴灯,开启指示灯,指示灯将可自动关闭节能

关于充电

  • PCB自带锂电池充电功能,可通过USB连线进行充电。
  • 可插入电脑USB口充电,也可插入5V电源适配器充电。
  • 最常见的5V电源适配器就是手机充电头,但是不建议采用快充头进行充电,因为快充头支持9V/12V等较高电压输出,可能导致烧毁PCB。
  • 锂电池充电电流最大支持400mah左右(老版200mah),1000mah电池大概需要3小时左右能充满。

关于续航

  • 无轴灯版本,1000mah电池可使用约90天(每日8小时)
  • 新版带编码器与RGB矩阵灯的版本,1000mah电池可使用约80天(每日8小时)1
  • RGB轴灯耗电量非常大,开启轴灯后1000mah电池可使用仅约5天。2
  • 具体续航时间因硬件及使用环境等差异而不同,如电池实际容量(排除虚标)大小及损耗情况,无线信号强度等均对续航有影响

硬件运作方式

硬件上采用nRF52系列芯片+CH55x芯片协同实现的USB、蓝牙、2.4G无线功能

  • nRF52系列芯片当前支持nRF52810/nRF52811/nRF52832三款芯片
  • CH55x芯片主要使用CH552T或CH554T,也支持同系列的CH552G等芯片
  • nRF52主要负责 运行键盘主控程序、蓝牙通讯、2.4G通讯、灯光控制,是主功能芯片。
  • CH55x主要负责 USB通讯,包括转发nRF52数据到USB主机,转发配置数据到nRF52,烧录nRF52芯片固件

本页面最后修改时间2025-06-09

本页面操作说明全系列键盘通用,所有内容基于最新版固件,如与当前键盘实际不符,建议升级固件 更多键盘专有说明可以访问键盘产品信息页面

l

vim快捷键

行跳转快捷键

  • 文件行跳转
    • gg: 跳转到文件第一行。
    • G(Shift+g) : 跳转到文件最后一行。
    • nG(如10G) : 跳转到第n行。
    • :n(如:10+ 回车) : 跳转到第𝑛行。
    • H/ M/L : 跳转到当前屏幕的最高行(High)、中间行(Middle)、最低行(Low)。
  • 行内跳转
    • 0(数字零) : 跳转到当前行行首。
    • ^(Shift+6) : 跳转到当前行第一个非空字符。
    • $(Shift+4) : 跳转到当前行行尾。
  • 其他
    • Ctrl+o: 返回到跳转前的位置。
    • %: 跳转到与之匹配的括号行

vim-multiple-cursors插件快捷键

vim-multiple-cursors插件在Vim 中实现了类似Sublime Text/VS Code 的多光标编辑功能。核心在于使用Ctrl-n选择下一个匹配项,Ctrl-p跳回上一个,Ctrl-x跳过当前项。进入多光标模式后,可使用c(修改)、I(行首插入)、A(行尾插入) 等命令批量编辑。

核心快捷键(Normal/Visual 模式)

  • <C-n>(Ctrl+n) : 启动多光标模式并选择当前光标下的单词,或选择下一个匹配项。
  • <C-p>(Ctrl+p) : 移除上一个光标,撤销上一次的Ctrl-n操作。
  • <C-x>(Ctrl+x) : 跳过当前匹配的单词。
  • <C-g>(Ctrl+g) : 退出多光标模式,返回到单个光标。

使用流程

  1. 移动光标到想要修改的单词上。
  2. 按下<C-n>选中该单词。
  3. 继续按下<C-n>选中接下来的匹配项。
  4. 按下c(修改), i(插入), a(追加) 等模式键进入编辑模式。
  5. 输入新内容(所有光标处会同步修改)。
  6. 按下<Esc>退出多光标编辑模式。

可视模式(Visual Mode)使用

在可视模式下选中一段文本,然后按<C-n>,该插件会在选中的每一行行尾添加光标,方便批量操作。

常见问题与技巧

  • 退出: 如果选错了,使用<C-g>取消。
  • 兼容性: 建议将默认的<C-n>映射为其他键,以防与插件快捷键冲突。
  • 适用场景: 批量修改变量名、在多行开头添加注释符、快速格式化数据。

在 Vim 中使用 NERDTree 时,切换焦点的常用方法有:

1. 标准切换方法

  • Ctrl + w + w:在所有窗口间循环切换
  • Ctrl + w + h/j/k/l:向左/下/上/右切换窗口

2. 从 NERDTree 直接返回原文件

  • t:在新标签页打开文件(NERDTree 中)
  • T:在后台标签页打开文件
  • i:在水平分割窗口中打开
  • s:在垂直分割窗口中打开

3. 快速切换回原窗口

如果 NERDTree 在左侧,当前编辑文件在右侧:

  • Ctrl + w + l:从 NERDTree 切换到右侧窗口

4. NERDTree 特定命令

  • q:关闭 NERDTree 窗口
  • 重新打开::NERDTreeToggle 或 :NERDTreeFocus

5. 我的常用配置(在 .vimrc 中)

vimrc

1
2
3
4
" 使用 Ctrl+n 切换 NERDTree
map <C-n> :NERDTreeToggle<CR>
" 自动切换焦点到文件
let NERDTreeQuitOnOpen = 1 " 打开文件后自动关闭 NERDTree

最简单的方式:直接按 Ctrl + w + w 即可在 NERDTree 和编辑窗口间切换。

l