由浅入深理解 IOC 和 DI
共 15327字,需浏览 31分钟
·
2020-09-06 15:35
点击上方蓝色字体,选择“标星公众号”
优质文章,第一时间送达
作者 | 踏雪彡寻梅
来源 | urlify.cn/A3Ub2u
开闭原则 OCP(Open Closed Principle)
对扩展开放,对修改封闭。
修改一处代码可能会引起其他地方的
bug
,最好的方式就是新增业务模块/类代替原来的业务模块/类,使出现bug
的几率变小。必须满足此原则的代码才能算作好的可维护的代码。
面向抽象编程
只有面向抽象编程,才能够逐步实现开闭原则。
统一方法的调用。
统一对象的实例化。
面临的两个问题:
可实现面向抽象编程的语法:
接口(
interface
)抽象类(
abstract
)只有有了接口和抽象类的概念,多态性才能够得到很好的支持。
面向抽象编程的目的: 实现可维护的代码,实现开闭原则。
面向抽象 -> OCP -> 可维护的代码
逐步理解实现 IOC 和 DI 的过程(LOL Demo 示例)
比较尴尬的编写程序添加需求/更改需求的做法
程序示例:
各英雄类
/**
*
* Camille 英雄
*
*
* @author 踏雪彡寻梅
* @version 1.0
* @date 2020/7/28 - 10:21
* @since JDK1.8
*/
public class Camille {
public void q() {
System.out.println("Camille Q");
}
public void w() {
System.out.println("Camille W");
}
public void e() {
System.out.println("Camille E");
}
public void r() {
System.out.println("Camille R");
}
}
/**
*
* Diana 英雄
*
*
* @author 踏雪彡寻梅
* @version 1.0
* @date 2020/7/28 - 10:00
* @since JDK1.8
*/
public class Diana {
public void q() {
System.out.println("Diana Q");
}
public void w() {
System.out.println("Diana W");
}
public void e() {
System.out.println("Diana E");
}
public void r() {
System.out.println("Diana R");
}
}
/**
*
* Irelia 英雄
*
*
* @author 踏雪彡寻梅
* @version 1.0
* @date 2020/7/28 - 10:16
* @since JDK1.8
*/
public class Irelia {
public void q() {
System.out.println("Irelia Q");
}
public void w() {
System.out.println("Irelia W");
}
public void e() {
System.out.println("Irelia E");
}
public void r() {
System.out.println("Irelia R");
}
}选择英雄释放技能 main 函数
/**
*
* 传统编写程序添加需求/更改需求的做法
*
*
* @author 踏雪彡寻梅
* @version 1.0
* @date 2020/7/28 - 10:01
* @since JDK1.8
*/
public class Main {
public static void main(String[] args) {
// 选择英雄
String name = Main.getPlayerInput();
// 新增英雄时需要改此处代码
switch (name) {
case "Diana":
Diana diana = new Diana();
diana.r();
break;
case "Irelia":
Irelia irelia = new Irelia();
irelia.r();
break;
case "Camille":
Camille camille = new Camille();
camille.r();
break;
default:
break;
}
}
private static String getPlayerInput() {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个英雄的名称: ");
return scanner.nextLine();
}
}从上面的代码,可以看出以下几点:
当增加新的英雄时,需要修改
switch
处的代码,增加新的case
。各个
case
中的代码都存在着new
一个某某英雄,并且调用了释放技能的方法。在真实项目中,大量存在着这样的
new
是不好的,因为真实项目中类和类的依赖是非常之多的,这个类依赖那个类,那个类又依赖了另一个类。如果大量存在着这样的
new
操作,代码间的耦合度将变得非常高,当某个类的需求产生变化的时候,一旦修改代码,其他依赖这个类的地方就很有可能引起很多bug
,同时依赖的地方也可能需要修改大量的代码。通过上面例子也可以看出,在创建实例对象之后,会调用这个对象的方法,上面的例子只是简单地调用了一个方法,而在真实项目中,依赖的类可能需要调用它的很多方法。
所以一旦依赖的这个类的代码产生了变化,比如某某方法不用了,依赖的地方就需要删除这个调用,而依赖这个类的类很可能有许多个,就需要更改很多地方的代码,可见耦合度之高,这也就是为什么这种代码一旦修改,就很可能出现多个
bug
的原因。所以,对于这种代码,是需要优化和改良的,不能依赖的太过具体,而是要依赖抽象,即面向抽象编程,下面就一步步演进这个过程,达到逐步理解
IOC
和DI
的目的。
使用 interface 接口统一方法的调用
程序示例:
英雄技能接口类
/**
*
* 英雄技能接口类
*
*
* @author 踏雪彡寻梅
* @version 2.0
* @date 2020/7/28 - 10:31
* @since JDK1.8
*/
public interface ISkill {
void q();
void w();
void e();
void r();
}各英雄类
/**
*
* Camille 英雄
*
*
* @author 踏雪彡寻梅
* @version 2.0
* @date 2020/7/28 - 10:21
* @since JDK1.8
*/
public class Camille implements ISkill {
@Override
public void q() {
System.out.println("Camille Q");
}
@Override
public void w() {
System.out.println("Camille W");
}
@Override
public void e() {
System.out.println("Camille E");
}
@Override
public void r() {
System.out.println("Camille R");
}
}
/**
*
* Diana 英雄
*
*
* @author 踏雪彡寻梅
* @version 2.0
* @date 2020/7/28 - 10:00
* @since JDK1.8
*/
public class Diana implements ISkill {
@Override
public void q() {
System.out.println("Diana Q");
}
@Override
public void w() {
System.out.println("Diana W");
}
@Override
public void e() {
System.out.println("Diana E");
}
@Override
public void r() {
System.out.println("Diana R");
}
}
/**
*
* Irelia 英雄
*
*
* @author 踏雪彡寻梅
* @version 2.0
* @date 2020/7/28 - 10:16
* @since JDK1.8
*/
public class Irelia implements ISkill {
@Override
public void q() {
System.out.println("Irelia Q");
}
@Override
public void w() {
System.out.println("Irelia W");
}
@Override
public void e() {
System.out.println("Irelia E");
}
@Override
public void r() {
System.out.println("Irelia R");
}
}选择英雄释放技能 main 函数
/**
*
* 使用 interface 统一方法的调用
*
*
* @author 踏雪彡寻梅
* @version 2.0
* @date 2020/7/28 - 10:29
* @since JDK1.8
*/
public class Main {
public static void main(String[] args) throws Exception {
ISkill iSkill;
// 选择英雄
String name = Main.getPlayerInput();
// 新增英雄时也需要改此处代码
// 这个 switch 提取成一个方法(例如工厂模式)之后,这里的代码就会变得简单
// 只有一段代码不负责对象的实例化,即没有 new 的出现,才能保持代码的相对稳定,才能逐步实现 OCP
switch (name) {
case "Diana":
iSkill = new Diana();
break;
case "Irelia":
iSkill = new Irelia();
break;
case "Camille":
iSkill = new Camille();
break;
default:
throw new Exception();
}
// 调用技能,现在这个版本使用接口统一了方法的调用,但还不能统一对象的实例化
// 统一了方法的调用是意义非常重大的
// 真实项目中,方法的调用可能非常的多或者复杂,这种情况下把方法的调用统一起来,集中在一个接口的方法上面,这个意义非常重大
iSkill.r();
}
private static String getPlayerInput() {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个英雄的名称: ");
return scanner.nextLine();
}
}从以上代码示例可得出以下几点:
实质: 一段代码如果要保持稳定,就不应该负责对象的实例化。
如果各个类中有大量的实例化对象的过程,那么一旦产生变化,影响将非常大。
所以仅仅达到统一方法的调用还不足够,还需要达到统一对象的实例化。
统一了方法的调用是意义非常重大的。
抽象的难点在于将
new
对象这个操作变得更加的抽象,而不是具体。真实项目中,方法的调用可能非常的多或者复杂,这种情况下把方法的调用统一起来,集中在一个接口的方法上面,这个意义非常重大。
单纯的
interface
可以统一方法的调用,但是它不能统一对象的实例化。面向对象很多时候都是在做两件事情: 实例化对象,调用方法(完成业务逻辑)。
由以上几点可得出只有一段代码不负责对象的实例化,即没有
new
的出现,才能保持代码的相对稳定,才能逐步实现OCP
。(表象)当然,对象实例化是不可能消除的,我们需要把对象实例化的过程转移到其他的代码片段里,即把所有这些对象实例化的过程全部隔离到一个地方,这样子除了这个地方外的其他地方的代码就会变得非常稳定(最简单的方式为使用工厂模式,接下来的版本将演示这个过程)。
使用工厂模式把对象实例化的过程隔离
三种子模式:
对工厂的一种抽象。
对生产的对象的一种抽象。
简单工厂模式
普通工厂模式
抽象工厂模式
使用简单工厂模式把对象实例化的过程转移到其他的代码片段里:
程序示例:
英雄技能接口类
/**
*
* 英雄技能接口类
*
*
* @author 踏雪彡寻梅
* @version 3.0
* @date 2020/7/28 - 10:31
* @since JDK1.8
*/
public interface ISkill {
void q();
void w();
void e();
void r();
}各英雄类
/**
*
* Camille 英雄
*
*
* @author 踏雪彡寻梅
* @version 3.0
* @date 2020/7/28 - 10:21
* @since JDK1.8
*/
public class Camille implements ISkill {
@Override
public void q() {
System.out.println("Camille Q");
}
@Override
public void w() {
System.out.println("Camille W");
}
@Override
public void e() {
System.out.println("Camille E");
}
@Override
public void r() {
System.out.println("Camille R");
}
}
/**
*
* Diana 英雄
*
*
* @author 踏雪彡寻梅
* @version 3.0
* @date 2020/7/28 - 10:00
* @since JDK1.8
*/
public class Diana implements ISkill {
@Override
public void q() {
System.out.println("Diana Q");
}
@Override
public void w() {
System.out.println("Diana W");
}
@Override
public void e() {
System.out.println("Diana E");
}
@Override
public void r() {
System.out.println("Diana R");
}
}
/**
*
* Irelia 英雄
*
*
* @author 踏雪彡寻梅
* @version 3.0
* @date 2020/7/28 - 10:16
* @since JDK1.8
*/
public class Irelia implements ISkill {
@Override
public void q() {
System.out.println("Irelia Q");
}
@Override
public void w() {
System.out.println("Irelia W");
}
@Override
public void e() {
System.out.println("Irelia E");
}
@Override
public void r() {
System.out.println("Irelia R");
}
}生产英雄的工厂类
/**
*
* 英雄工厂类,生产或实例化英雄类,把对象实例化的过程隔离
*
*
* @author 踏雪彡寻梅
* @version 3.0
* @date 2020/7/28 - 21:11
* @since JDK1.8
*/
public class HeroFactory {
/**
* 简单工厂实例化英雄类
*
* @param name 英雄名称
* @return 返回英雄名称对应的实例
*/
public static ISkill getHero(String name) throws Exception {
ISkill iSkill;
// 变化是导致代码不稳定的本质原因
// 所有的变化最终其实都要交给不同的对象去处理,当业务或用户的输入有了变化的时候,必须要创建不同的对象去响应这些变化
// 这里的变化: 用户的输入,选择英雄导致的不稳定,根据用户的输入实例化不同的对象
// 也例如改动程序使用的数据库,从 MySQL 更改为 Oracle
// 如何消除这个变化?
// 思考:
// 1. 这里是用户只能够输入一个字符串,把输入的字符串转换为一个对象
// 2. 但是如果用户能够直接输入一个对象传给程序,这个 switch 就可以被干掉(使用反射解决,把输入的字符串转换为一个对象)
switch (name) {
case "Diana":
iSkill = new Diana();
break;
case "Irelia":
iSkill = new Irelia();
break;
case "Camille":
iSkill = new Camille();
break;
default:
throw new Exception();
}
return iSkill;
}
}选择英雄释放技能 main 函数
/**
*
* 使用简单工厂模式把对象实例化的过程转移到其他的代码片段里(IOC 的雏形)
*
*
* @author 踏雪彡寻梅
* @version 3.0
* @date 2020/7/28 - 21:06
* @since JDK1.8
*/
public class Main {
public static void main(String[] args) throws Exception {
// 选择英雄
String name = Main.getPlayerInput();
// 调用工厂方法,这里把 new 的操作干掉了,这里的代码已经相对稳定了,新增英雄时这里的代码不需要再更改,只需要更改工厂方法的代码
// 对于 main 方法而言,它实现了 OCP,而工厂方法中的代码还没有实现 OCP
// 虽然这里的代码已经相对稳定了,但是还引用着 HeroFactory 工厂类,对于这行代码,还不是非常稳定,还存在着可能更换修改的可能
// 例如说: HeroFactory 的 getHero 方法是个实例方法,那么 HeroFactory 也需要 new 出来,这种情况下会存在着修改代码的可能
// 如果业务逻辑足够复杂,可能存在很多这种工厂类,这样看起来对于工厂类而言,需求变更时还是需要改动很多代码
// 当然也可以使用抽象工厂将工厂抽象化使这里变得稳定起来,但这里不演示了,这里只是演示一个如何隔离变化的过程,所以假设这里是稳定的,是一个超级工厂,能够生产项目的各种对象
// 当假设有一个超级的工厂之后,这个工厂可以兼容整个项目的工厂,把整个项目的所有的变动都封装到一起,从而保证除了这个超级工厂之外的代码都是稳定的,这样这个工厂就有了意义
// 其实 IOC 也就是相当于一个非常大的容器一样,把所有的变化都集中到了一个地方,写其他的代码就会变得非常容易,不再需要在整个项目中到处更改代码,如果出现了变化,只需要让容器去负责改变即可
// spring ioc 中的 ApplicationContext 就类似于这个超级工厂,通过 ApplicationContext 可以获取各种各样的对象,不过 ApplicationContext 在 spring 中给的是一个接口,即抽象工厂模式
// 需要注意的是: 生产对象只是 IOC 的一部分,不是 IOC 的全部
ISkill iSkill = HeroFactory.getHero(name);
// 调用技能
iSkill.r();
}
private static String getPlayerInput() {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个英雄的名称: ");
return scanner.nextLine();
}
}从以上例子可得出以下几点:
其实
IOC
就是将这些不稳定(变化)给封装、隔离到了一块,保证其他地方的代码是稳定的。变化是导致代码不稳定的本质原因。
那么如何消除这些变化呢?
在上面的示例中,用户只能输入一个字符串,然后在工厂方法内去判断用户的输入的变化,创建不同的对象去响应这些变化。
同时,如果有新增的英雄,势必要工厂方法中的
switch
代码。那么如果有这么一个机制,可以实现用户的输入输入进来就是一个对象,然后就创建这个对象进行响应,而不是像上面的判断字符串,那么就可以干掉
switch
,使这里的代码变得更加简单,更加稳定。对于这种机制,也就是反射机制,下面的版本将演示这个过程。
用户的输入、用户的选择、用户的操作造成的变化。
软件自身的业务需求或技术选择有了变化。
对于技术选择的改变,例如从使用
MySQL
更换到Oracle
,如果将这个变化提取到配置文件中,那么配置文件的变化是允许的,并不违反OCP
。配置文件是属于系统外部的,而不属于代码本身。(这里的配置文件也可以理解为用户的输入,把需求的变化隔离到了配置文件中)
注意事项:
变化有这么两大类变化:
所有的变化最终其实都要交给不同的对象去处理,当业务或用户的输入有了变化的时候,必须要创建不同的对象去响应这些变化。
代码中总是会存在不稳定,要尽可能地隔离这些不稳定,保证其他的代码是稳定的。隔离不稳定其实就是在隔离变化。
使用反射隔离工厂中的变化,让用户直接输入一个对象
对于这个版本,只有工厂类发生了变动,所以只展示工厂类的代码和 main 函数的代码
程序示例:
生产英雄的工厂类
/**
*
* 英雄工厂类,生产或实例化英雄类,把对象实例化的过程隔离
*
*
* @author 踏雪彡寻梅
* @version 4.0
* @date 2020/7/28 - 21:11
* @since JDK1.8
*/
public class HeroFactory {
/**
* 简单工厂实例化英雄类
*
* @param name 英雄名称
* @return 返回英雄名称对应的实例
*/
public static ISkill getHero(String name) throws Exception {
// 使用反射隔离工厂中的变化,让用户直接输入一个对象,即把用户输入的字符串转换为一个对象
// 反射的作用: 动态的创建对象
// 根据输入的英雄名称获取元类
// 类是对象的抽象,描述对象
// 元类是类的抽象,是对类的描述
// 需要注意在名称前加上包路径 cn.xilikeli.lol.v4.hero.
name = "cn.xilikeli.lol.v4.hero." + name;
Class> classA = Class.forName(name);
// 通过元类实例化对应的实例对象
// 注意点: java8 之后 newInstance 已经废弃
// 新版本中使用 classA.getDeclaredConstructor().newInstance()
Object obj = classA.newInstance();
// 强制转型返回
return (ISkill) obj;
}
}选择英雄释放技能 main 函数
/**
*
* 使用反射隔离工厂中的变化,让用户直接输入一个对象,即把用户输入的字符串转换为一个对象
*
*
* @author 踏雪彡寻梅
* @version 4.0
* @date 2020/7/28 - 22:26
* @since JDK1.8
*/
public class Main {
public static void main(String[] args) throws Exception {
// 选择英雄
String name = Main.getPlayerInput();
ISkill iSkill = HeroFactory.getHero(name);
// 调用技能
iSkill.r();
}
private static String getPlayerInput() {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个英雄的名称: ");
return scanner.nextLine();
}
}从以上示例可得出以下几点:
因为现在的实现使用起来还不方便,是一个正向的思维。每次创建一个对象都需要引入这个工厂类调用其方法。也就是说工厂的方式在实现的逻辑中是正向的创建对象,而
IOC
是反向的,是容器根据需求主动注入的。那么有什么方法可以让工厂类不出现,直接可以拿到需要的对象?
这就是
IOC
和DI
需要做的事情。IOC
和DI
的雏形至此也就出来了。如果需要的对象可以由一个什么东西例如某个容器中将其传进来,这个需要的对象就可以不需要使用工厂类创建了,此时代码变得更加简单、更加稳定。
这种形式就是
IOC
和DI
的体现,把对象的控制权交给了容器,即控制反转;获取对象时只需要直接使用它即可,容器会自动把这个对象创建好给传入进来,即依赖注入。在前面也谈到了配置文件,其实
IOC
简单理解就是工厂模式 + 反射机制 + 配置文件
组成了一个大的容器,我们需要什么就配置什么,容器就会创建对象,把创建好的对象提供给我们,这个过程中我们没有感受到创建对象的正向过程,而是感受到使用的对象是容器给我们的,这是一个反向的过程,也就是控制权由我们反转到了容器中,容器掌控着创建对象的权利,即控制反转。现在这个版本并没有运用到任何
IOC
和DI
的原理,只是让代码变得非常稳定。现在这种形式是正向思维,虽然现在是实现了需要什么就可以返回什么,但是现在拿到对象的方式依然是需要什么然后去调用什么类下面的什么方法得到什么的方式。
而
IOC
是控制反转,现在这里并没有反转,同时也没有注入,现在只是实现了OCP
。现在这种形式,每次输入都进行一次反射是性能比较低的,因为频繁地反射会使性能变低。
而
Spring
中取到或实例化一个对象之后,会把这个对象放到它的缓存中去,下次要再取或创建相同的对象的时候,不会进行反射,而是从缓存中拿(和DI
有关系)。在使用了反射机制之后,已经消除了所有的变化,不管输入的是什么,代码都不需要再做更改了,代码已经变得非常稳定了。
Spring
内部其实也是使用了类似现在这种工厂模式 + 反射的机制,但是要比现在实现的这种形式更加地完善更加地聪明。需要注意的是: 现在这种工厂模式 + 反射的机制还不是
IOC
和DI
。那么,问题来了,现在已经实现了
OCP
,那么还需要IOC
和DI
干嘛?到了此处,也可以隐隐约约地明白了
IOC
和DI
到底是个什么东西了,接下来再对这两个东西解释一下,以便理解地更加深刻。
IOC/DI/DIP
DIP(Dependency Inversion Principle,依赖倒置)
高层: 抽象,抽象也就是站在更高的角度概括具体。
低层: 具体的实现。
高层模块不应该依赖低层模块,两者都应该依赖抽象。
抽象不应该依赖细节。
细节应该依赖抽象。
倒置: 正常编写代码时可能会
new
一个对象,即依赖了一个具体;而倒置就是不依赖具体而是反过来依赖一个接口(抽象)。DI
容器其实是在装配这一个个的对象。
即各个类依赖了各个类,他们彼此之间的装配不是由他们在代码中
new
出来的。而是全部交给容器,由容器来装配。当把装配的过程交给了容器了之后,我们在编写类时的只需要负责类的编写就行了,而不需要关心如何装配。由容器来决定某个类依赖的对象到底是哪一个对象,由它把对象注入到类里。
同时在编写类的时候,也不需要关心依赖的对象,因为依赖的不是一个具体的对象,而是一个抽象,例如接口。最终实例化的是这个接口的哪一个实现类,编写类的时候是不需要关心的,只需要关心接口即可,对于实例化哪个实现类则由容器来决定。这就保证了我们在编写一个个类的时候类是独立的,保证了代码的稳定性。保证了系统的耦合度是非常低的。即面向抽象的重要性,面向接口去编程。
容器在创建一个对象实例的时候可以把这个对象依赖的对象实例注入进去。相当于我们自己写代码的时候
new
对象时把一个对象传进去或赋值以及可以调用set
方法传入一个对象或赋值。属性注入
public class A {
private IC ic;
// 属性注入,容器在实例化 A 的时候会 set 一个 ic 进来
public void setIc(IC ic) {
this.ic = ic;
}
}构造注入
public class A {
private IC ic;
// 构造注入,容器在实例化 A 的时候会给构造函数传一个 ic 进来
public A(IC ic) {
this.ic = ic;
}
}接口注入(用的较少)
对象与对象之间的相互作用必定是要产生依赖的,这个是不可避免的,关键是产生依赖的方式是多种多样的,比如
new
的方式,不过这个方法不好,因为是一个具体实例化的过程,如果依赖的类的代码改变了,使用这个依赖的类的地方就会变得不稳定。更好的方式: 不再使用
new
的方式,而是让容器(容器可以理解为在系统的最上层)把需要依赖的类的抽象(例如接口)的实现给注入进来,虽然也产生了依赖,但是依赖的形式是不同的,是注入进来的,并且注入进来的类是抽象的实现,依赖只是依赖抽象的接口,相比new
来说产生的依赖不这么具体。依赖注入的几种形式:
依赖注入的原理
依赖注入在更高角度的意义
IOC
整个程序运行的控制权是谁的?
IOC 举例
如果需求固定不变,就没有什么问题。
但是如果存在着变化,就会有问题。一旦产生了变化,程序员就要去更改控制代码(变化指的不是新增的业务代码,而是指控制代码,控制代码: 例如原来
new
了一个什么,要改成new
一个新的什么)。如果此时反转过来,程序员不再控制这些控制代码,而是交给别人控制,所有除了控制代码之外的代码都是非常稳定的。也就是反过来是用户在控制代码,也可以理解为产品经理来控制整个应用程序。
例如: 原来用的
MySQL
,产品经理改成了Oracle
,程序员肯定要负责新增代码实现Oracle
的功能,但是至于应用程序用的是原来的MySQL
还是新加的Oralce
,现在不再由程序员去控制,而是产品经理去控制,控制权由产品经理决定,即控制反转。其实本质上还是由程序员决定的。
只负责生产一个个积木,不再负责积木的搭建。
由玩具/用户负责使用生产的这些一个个积木,搭建出各种各样的形状。
原来的话可能是直接把积木给组装好,如果用户说不想要这个组装好的积木,就需要厂家来改(控制);但现在不一样了,因为生产的只是一个个的积木(可以理解为类),至于怎么去组装它,则是玩家和用户来构建了(用哪些类交给产品经理或其他人去决定)。
积木生产厂家(程序员)
当在一个类(这里用
A
表示)中使用new
来创建一个需要的对象时,主控类为A
类。如果应用了
DI
之后,有了容器的概念,此时主控方为容器,主控的地方不再是A
类了,这个其实就是实现了控制反转,全部交给了容器去控制。IOC
本身概念非常抽象和模糊,只是展示了一种思想,并没有给出具体的实现。而
DI
可以看做IOC
的一个具体的实现。从
DI
的角度理解IOC
IOC 的奥义
粉丝福利:108本java从入门到大神精选电子书领取
???
?长按上方锋哥微信二维码 2 秒 备注「1234」即可获取资料以及 可以进入java1234官方微信群
感谢点赞支持下哈