ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Spring Resource源码彻底搞懂Java继承与多态

用Spring Resource源码彻底搞懂Java继承与多态 先说一个我经常遇到的现象很多人学 Java 基础的时候继承和多态能倒背如流Animal、Dog、Cat的代码写得飞起但只要一打开 Spring 这类框架的源码立刻就懵了——明明每个类都认识连起来不知道在干嘛。其实这不是你基础差而是教材里的例子太“干净”了它只教你语法不教你设计。所以这篇文章我想换一个讲法直接用 Spring 的Resource体系当主线把继承、多态、重写、重载、静态绑定、动态绑定这些知识点全部串起来讲。Resource这个体系非常合适类不多、层次清楚、几乎每个继承关系背后都有一个真实需求。把这套东西吃透你不但能真正读懂框架源码Java 面试里关于继承和多态的高频追问也能应对得更从容。下面是详细拆解。1. 为什么我建议用 Spring Resource 学继承与多态1.1 三层继承结构接口、抽象类、实现类Spring 的Resource体系做的是一件很简单的事统一抽象“外部资源”。一个配置文件可以在 classpath 下可以在文件系统里也可以在一个远程 URL 上甚至在内存字节数组里。从使用者的角度看它们的共同点是“都能给我一个InputStream让我读数据”。为了表达这种统一抽象Spring 设计了典型的三层结构顶层接口Resource继承自InputStreamSource定义了一个资源应该具有的所有行为中间层抽象类AbstractResource把一些公共逻辑先实现掉留给子类一个更轻量的实现起点底层一堆具体实现类比如ClassPathResource、FileSystemResource、UrlResource、ByteArrayResource等各自对接一种真实资源形态。这三层结构正好对应 Java 继承体系里最重要的三种角色接口定义契约抽象类提供骨架具体类完成实现。1.2 和教科书 Animal 例子差在哪Animal、Dog、Cat的例子你肯定写过class Animal { void speak() {} } class Dog extends Animal { void speak() { System.out.println(汪汪); } } class Cat extends Animal { void speak() { System.out.println(喵喵); } }这个例子能说明语法但说明不了设计。因为没人会在真实项目里为了让动物叫而写一个继承体系。真实项目里的继承每一个层级都有它存在的理由接口是为了让调用方不依赖具体类型抽象类是为了让多个实现类共享逻辑具体类是为了响应不同场景的需求。Resource体系正好把这三层理由展示得清清楚楚。你不需要去背“继承是什么”看一遍AbstractResource里那些默认方法自然就理解了“抽象类到底抽象了什么”。1.3 这篇你能带走什么这篇文章接下来会做几件事先拆Resource继承体系的结构然后从调用方ResourceLoader看多态如何生效再扎到 JVM 层面讲清楚构造器执行顺序、方法重写、静态绑定和动态绑定最后带你手写一个自定义Resource把整套思路平移到业务代码里。如果你正在准备 Java 面试这篇文章可以直接当复习材料如果你是想提升读源码能力的中级开发者这篇文章给你一个切入框架源码的锚点。2. Resource 接口与 AbstractResource继承的价值不在代码复用2.1 接口只做一件事定义资源的行为契约Resource接口继承自InputStreamSource而InputStreamSource只有一个方法public interface InputStreamSource { InputStream getInputStream() throws IOException; }这是整个体系的基石——任何资源最终都要能变成流。Resource在此基础上又扩展了一批方法大致可以分成几组判断能力exists()、isReadable()、isOpen()、isFile()定位能力getURL()、getURI()、getFile()、createRelative()元信息contentLength()、lastModified()、getFilename()、getDescription()。注意这些方法并不关心资源“从哪里来”只关心“你能不能提供这些信息”。这就是接口的意义它站在使用方的角度定义契约。Spring 容器加载配置时它的诉求是“给我一个InputStream我要读配置”至于流来自 classpath 还是文件系统容器根本不在意。getDescription()这个方法很少被单独提到但它其实很重要。看到这个名字你可能会觉得它只是用来打印日志的。确实如此但它的价值远超想象所有Resource实现都会重写这个方法当资源找不到、流打开失败时异常信息里带着的就是这个描述比如class path resource [application.yml]、file [/etc/config/app.properties]。排查问题的时候一眼就能定位是哪个资源出了问题。2.2 AbstractResource 如何用默认实现减轻子类负担AbstractResource是理解“抽象类到底有什么用”的最佳样本。它实现了Resource接口的大部分方法子类只需要关心自己真正特殊的地方。最典型的是exists()的默认实现逻辑。它没有规定死“存在”的判定方式而是先尝试getFile()拿不到文件就尝试getURL()再不行就认为资源不存在。这个试探链路非常聪明因为不同子类能力不同FileSystemResource能直接返回文件UrlResource只能返回 URL父类不硬性规定路线而是给了一个通用策略。isOpen()的默认实现也很有意思它默认返回false。为什么因为大部分资源——比如 classpath 下的文件、文件系统里的文件——都是可以重复读取的你打开流读一次再打开还能读一次。但InputStreamResource这种包装了“一次性流”的资源不一样流读完了就没了所以它必须把isOpen()重写成true。调用方可以根据isOpen()决定要不要对资源做缓存。这就是抽象类典型的“模板方法”思路把公共流程固化下来把变化点留给子类去覆盖。2.3 抽象类没有抽象方法也可能是“抽象”的这里有一个容易被忽视的 Java 语法细节。AbstractResource类声明为抽象类但它内部并没有显式声明任何abstract方法。它实现Resource接口时唯独没有实现getInputStream()和getDescription()于是这两个从接口继承来的方法依然是抽象的。这意味着什么意味着只要一个类实现了接口但没把接口方法全部实现即使它自己不写abstract关键字这个类也天然是抽象的不能new。子类必须至少把getInputStream()和getDescription()实现了才能实例化。换句话说抽象类的“抽象性”不只来自显式的abstract方法还可以来自“未实现的接口方法”。搞清楚这一点很多源码里的抽象类你都不会再看错。2.4 为什么不用接口 default 方法一撸到底Java 8 之后接口也能写方法体了那AbstractResource还有存在的必要吗这个问题面试里经常被问到答案可以从三个角度拆第一时间角度。AbstractResource在 Java 8 之前就存在了接口默认方法是后来的补充不能拿新语法去否定老设计。第二能力角度。抽象类能持有共享状态比如保存一个ClassLoader、保存一个Path抽象类的方法访问权限更灵活可以用protected暴露给子类抽象类还可以定义final方法禁止子类覆盖。这些接口都做不到接口的成员默认是public。第三兼容角度。接口加default方法主要是为了“给接口加方法时不破坏已有实现类”。比如 Spring 后来给Resource加了新方法就用默认方式补上避免所有实现类立刻报错。这和抽象类解决“复用骨架代码”是完全不同的两个问题。所以结论是在需要“骨架复用”的场景抽象类仍然不可替代接口默认方法侧重“演进兼容”。两者是配合关系不是替代关系。3. 具体资源实现类继承后的差异化玩法3.1 ClassPathResource类路径资源背后的路径清洗逻辑ClassPathResource是处理 classpath 下资源的实现类用法很简单你给它一个classpath:application.xml这样的路径它负责通过类加载器去找到这个资源。但它有一个隐藏的细节很多人不知道——路径清洗。你传入的路径可能以/开头比如/config/app.properties但在ClassLoader.getResourceAsStream()的语义里类路径下的资源路径是不以/开头的。所以ClassPathResource在构造器里会做一次normalizePath()去掉开头的斜杠、把反斜杠转成正斜杠、清理路径中的冗余部分。这个细节很容易被忽略但如果你自己写代码加载类路径资源时遇到路径对不上八成就是没做清洗。它的getInputStream()实现也很直白Override public InputStream getInputStream() throws IOException { if (this.clazz ! null) { return this.clazz.getResourceAsStream(this.path); } else { return this.classLoader.getResourceAsStream(this.path); } }这里有两个可选的加载入口如果有Class就用Class.getResourceAsStream()如果没有就用ClassLoader.getResourceAsStream()。拿到流还好说拿不到就抛FileNotFoundException异常信息里带上getDescription()的返回值。还有一个值得注意的点它重写了equals()和hashCode()比较的是path和classLoader。为什么要重写因为在 Spring IoC 容器里经常要判断“这个资源是不是已经被处理过了”比如配置去重。如果两个ClassPathResource指向同一个路径、同一个类加载器它们就是同一个资源这个判断离不开equals()。3.2 FileSystemResource文件系统的直接映射FileSystemResource和ClassPathResource是同一层的“兄弟类”都直接继承AbstractResource但底层数据源完全不同。FileSystemResource包装的是File或PathgetInputStream()本质就是Files.newInputStream()getFile()直接返回底层文件对象isFile()永远是true。这两个类的差异恰好展示了“同一个父类不同实现策略”的多态价值。我用表格对比一下方法ClassPathResourceFileSystemResourcegetInputStream()通过 ClassLoader 加载Files.newInputStream()getFile()需将 URL 转 File直接返回 FileisFile()依赖 URL 协议判断恒为 truegetDescription()class path resource [xxx]file [xxx]调用方拿到一个Resource引用时根本不需要知道它内部是走类加载器还是文件流统一调getInputStream()就行。这就是多态最直观的体现同一个方法调用在不同实现类上执行了完全不同的逻辑。3.3 重写父类方法的两个真实案例FileUrlResource 与 ClassPathContextResource如果说上面的“兄弟类”是继承的常规操作那FileUrlResource就是“子类重写父类方法”的典范。它继承自UrlResource专门处理file:协议的 URL而它重写的最核心方法就是getFile()。为什么要重写因为直接new File(url.getFile())在很多场景下不靠谱。URL 里的文件路径可能包含 URL 编码后的空格比如/home/my%20file.txt直接转换成文件路径会得到错误的文件名。FileUrlResource换了一种更稳妥的解析方式让file:URL 能正确地转成File对象。你看父类UrlResource的实现不是错的只是不够好于是子类在特定分支上覆盖了父类的行为。这个“因为场景不满足而重写”的动机比教科书里“为了演示重写而重写”有说服力得多。ClassPathContextResource也是一个很好的例子。它是DefaultResourceLoader内部定义的一个ClassPathResource子类重写了createRelative()方法。正常情况下ClassPathResource.createRelative()返回一个普通的ClassPathResource而ClassPathContextResource重写后返回的是ClassPathContextResource本身目的是保持“上下文资源”的类型一致性避免相对路径创建时退化成普通资源。这个改动很小但能让你看到重写在真实代码里的另一个用途修正或增强父类方法的行为。3.4 这些差异怎么体现多态把上面三个小节串起来看你会发现整个Resource继承体系里方法调用分成了两条路从AbstractResource继承下来的默认实现比如exists()、isOpen()子类不重写就直接复用子类按需重写的方法比如getInputStream()、getDescription()、getFile()每个实现类都有自己的逻辑。调用方持有一个Resource引用时它调用getInputStream()到底走哪个实现取决于这个引用实际指向的对象类型。这就是多态的运行期行为。在编译期你根本确定不了只有运行到那一行JVM 才知道真正的对象是ClassPathResource还是FileSystemResource。4. ResourceLoader从调用方视角再看多态4.1 DefaultResourceLoader 的分发逻辑继承体系的生产方看完了现在我们切换视角站在调用方看多态为什么好用。Spring 里负责把“资源字符串”转换成Resource对象的接口叫ResourceLoader最常用的实现是DefaultResourceLoader。它的核心方法getResource(String location)内部逻辑大致是这样public Resource getResource(String location) { // 先让自定义 ProtocolResolver 处理 Resource resource getResourceByProtocol(location); if (resource ! null) { return resource; } if (location.startsWith(/)) { // 以 / 开头按路径资源处理 return getResourceByPath(location); } else if (location.startsWith(CLASSPATH_URL_PREFIX)) { // 以 classpath: 开头创建 ClassPathResource return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader()); } else { try { URL url new URL(location); // 可解析成 URLfile 协议走 FileUrlResource其余走 UrlResource return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url)); } catch (MalformedURLException ex) { // 无法解析成 URL按路径资源处理 return getResourceByPath(location); } } }注意看这段代码的模式它没有去判断“这个路径对应的文件存不存在”“这个 URL 指向什么内容”只根据字符串前缀做了一件事——返回一个合适的Resource实现。真正的资源读取行为全部被延迟到了Resource内部。调用方拿到Resource之后只会调接口方法完全不关心背后具体是哪个类。这就是面向接口编程的核心价值ResourceLoader负责“选择合适的实现类”上层业务负责“面向接口消费资源”生产方和消费方被成功解耦。4.2 ProtocolResolver不改源码的扩展点DefaultResourceLoader还支持一个高端玩法ProtocolResolver。如果你自定义了一个协议比如oss://bucket/object默认的ResourceLoader根本不知道该怎么处理因为oss不是标准 URL 协议new URL()会抛异常。但你可以注册一个ProtocolResolver在getResourceByProtocol()阶段提前拦截public interface ProtocolResolver { Resource resolve(String location, ResourceLoader resourceLoader); }DefaultResourceLoader会遍历所有注册的ProtocolResolver只要有一个能返回非空Resource就用它。这意味着你可以不修改 Spring 的任何源码就为框架增加一种全新的资源类型。从多态的角度看这其实是策略模式和多态的叠加把“字符串如何映射到 Resource”开放成策略接口每种策略是一个独立的实现运行时动态路由。Spring 后面能衍生出ServletContextResource等一堆资源类靠的就是这套可扩展能力。4.3 多态好用的前提和容易踩的坑多态很好用但它不是没有约束。有三件事我建议你牢牢记住第一编译期以静态类型为准。Resource r new ClassPathResource(...)那r只能调用Resource接口里定义的方法。如果你想调用ClassPathResource独有的方法必须强制类型转换转换前最好用instanceof判断一下。第二运行期按实际类型分派。接口方法被调用时JVM 会根据对象的实际类型找到最合适的重写版本这就是动态绑定。第三字段和静态方法不参与多态。这个坑很多人第一次遇到都懵了下一节我会专门展开讲。5. 继承与多态底层机制构造器、重写、绑定一次说清楚5.1 new 一个子类时到底发生了什么继承体系里对象的创建顺序是一条主线。看这段代码public class Parent { static { System.out.println(1 Parent static); } { System.out.println(3 Parent instance); } public Parent() { System.out.println(4 Parent constructor); } } public class Child extends Parent { static { System.out.println(2 Child static); } { System.out.println(5 Child instance); } public Child() { System.out.println(6 Child constructor); } }执行new Child()时输出顺序是1 Parent static 2 Child static 3 Parent instance 4 Parent constructor 5 Child instance 6 Child constructor这个顺序背后的原因不复杂类加载阶段执行静态块父类先于子类所以先打印 1 再打印 2创建实例阶段JVM 必须先把父类的实例空间初始化好再初始化子类部分所以父类的实例块和构造器会先执行父类构造器执行时子类的实例变量还没有被赋值还停留在默认值阶段。super()这条隐式调用链是关键子类构造器的第一行你没写也会默认有会调用父类构造器。如果父类没有无参构造器子类必须在构造器第一行显式调用super(参数)。5.2 构造器调用可重写方法的经典陷阱理解了上面的执行顺序你就可以理解一个经典的坑父类构造器里调用了一个可被重写的方法。public class Parent { public Parent() { show(); } void show() { System.out.println(Parent); } } public class Child extends Parent { private String name child; Override void show() { System.out.println(name); } }执行new Child()输出是什么如果你认为是Parent那就错了。父类构造器执行时JVM 调用show()会动态绑定到子类的重写版本上但此时子类的name字段还没有被赋值为child它的默认值是null。所以输出是null。这个例子说明了一个非常重要的设计原则不要在构造器里调用可重写的方法。如果父类构造器一定要做一些初始化操作最好提供一个protected的init()钩子方法并用final修饰明确禁止子类重写或者干脆等子类构造完成后由框架回调。Spring 里的InitializingBean#afterPropertiesSet()、PostConstruct其实都在做同一件事避免“构造期间调用虚方法”的陷阱。5.3 方法重写规则清单与 Override 的真实作用方法重写的规则面试几乎必考这里列一个完整清单方法名和参数列表必须完全相同返回类型可以相同也可以是父类返回类型的子类型这叫协变返回类型访问权限不能比父类方法更严格可以更宽松private方法不能被重写只能被隐藏static方法不能被重写只能被隐藏final方法不能被重写受检异常不能扩大范围可以缩小或保持构造器不能重写接口方法在实现类里必须用public修饰。Override注解最大的价值是编译期校验。如果你在子类方法上标注了它但父类方法签名改了编译会直接报错提示你这里“不是重写”。没有这个注解你以为是重写实际可能变成了重载或者干脆是子类的一个普通新方法这种 bug 特别隐蔽。重载和重写的区别也可以顺便总结一下维度重写重载方法签名必须一致参数列表必须不同返回类型支持协变不要求也不建议依赖修饰符不能更严格无限制绑定时机运行期动态绑定编译期静态绑定目的替换父类行为提供同名的多种入口5.4 静态绑定与动态绑定重载和重写分别在什么时机确定Java 的方法调用分为两种绑定方式。重载在编译期就已经确定调用哪个方法了它看的是变量的静态类型。比如class Printer { void print(Parent p) { System.out.println(parent); } void print(Child c) { System.out.println(child); } } Parent p new Child(); Printer printer new Printer(); printer.print(p); // 输出 parent编译期按 Parent 类型选了 print(Parent)这里虽然p的实际类型是Child但重载选择参数时看的是声明类型Parent所以走的是print(Parent)方法。重写则不同。普通实例方法调用在字节码层面使用的是invokevirtual指令JVM 会根据调用者的实际类型去方法表里查找最合适的实现从实际类型开始逐层向上查找找到就先命中。这就是动态绑定。有一个比较容易混淆的场景重载加组合public void handle(Resource resource) { System.out.println(resource.getDescription()); }这里的handle方法参数类型是Resource编译期确定但方法体里的resource.getDescription()是动态绑定运行期根据实际对象是ClassPathResource还是FileSystemResource去执行不同实现。Java 是一门单分派语言方法调用的接收者——也就是this——会参与动态绑定而方法参数的重载选择是静态的。理解这一点很多“看似多态却没有多态”的问题都会豁然开朗。5.5 字段隐藏与静态方法隐藏看起来像多态其实不是很多人不知道Java 里字段访问和静态方法调用都不参与多态。看这段代码class Parent { String name parent; static void hi() { System.out.println(parent hi); } } class Child extends Parent { String name child; static void hi() { System.out.println(child hi); } }执行Parent p new Child(); System.out.println(p.name); // 输出 parent p.hi(); // 输出 parent hi是不是有点反直觉实际类型明明是Child为什么访问的字段和静态方法都是父类的因为字段和静态方法在 Java 里的绑定方式是“静态绑定”编译器直接根据变量的静态类型来确定用哪个成员。子类的同名字段和同签名静态方法被称为“隐藏”了父类的成员而不是“重写”。所以我对你的建议是设计类的时候尽量避免子类和父类使用同名字段接口里不要定义可变的业务字段。否则代码读起来会有严重的误导性你以为在多态其实在访问父类隐藏成员。6. 实战自写一个 StringResource 并迁移设计思路6.1 最小实现类需要哪些代码前面讲了一大堆最有效的验证方式是自己动手写一个Resource实现。只继承AbstractResource的话最少只需要实现两个方法getInputStream()和getDescription()。我写了一个超简单的StringResource把内存里的字符串当成一个可读取的资源public class StringResource extends AbstractResource { private final String content; private final String description; public StringResource(String content) { this(content, string resource); } public StringResource(String content, String description) { this.content content; this.description description; } Override public String getDescription() { return this.description; } Override public InputStream getInputStream() { return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); } Override public long contentLength() { return content.getBytes(StandardCharsets.UTF_8).length; } }为什么要重写contentLength()因为父类AbstractResource的默认实现是尝试通过getFile()或getURL()拿到长度而内存字符串资源既没有文件也没有 URL父类那套逻辑会抛异常。这个例子恰好演示了一个道理继承抽象类之后哪些方法要重写取决于你的资源形态能不能满足父类默认实现的假设条件。6.2 运行结果与分析写一个简单的测试Resource resource new StringResource(hello, resource, custom string resource); System.out.println(resource.getDescription()); System.out.println(resource.isOpen()); byte[] bytes resource.getInputStream().readAllBytes(); System.out.println(new String(bytes, StandardCharsets.UTF_8));输出custom string resource false hello, resourcegetDescription()返回了我自定义的描述isOpen()返回了父类默认的false表示这是一个可重复读取的资源getInputStream()能正常拿到流。这个StringResource已经可以传给任何只依赖Resource接口的代码比如你自己封装的一个配置文件读取工具。你不需要它和ClassPathResource、FileSystemResource有任何直接关系它们只是实现了同一个接口调用方就能一视同仁。6.3 把同一套设计思路搬进业务代码最后一步我们把“接口 抽象类 具体实现 策略选择器”这套组合拳迁移到日常业务里。举一个订单导出的例子。先定义一个接口public interface OrderExporter { byte[] export(OrderQuery query); String formatName(); }再定义抽象类把公共流程固化成模板方法public abstract class AbstractExcelExporter implements OrderExporter { protected final OrderRepository orderRepository; protected AbstractExcelExporter(OrderRepository orderRepository) { this.orderRepository orderRepository; } Override public byte[] export(OrderQuery query) { ListOrder orders orderRepository.find(query); validate(orders); Workbook workbook buildWorkbook(orders); afterBuild(workbook); return writeToBytes(workbook); } protected abstract Workbook buildWorkbook(ListOrder orders); protected void afterBuild(Workbook workbook) { // 默认空的钩子方法子类按需覆盖 } protected void validate(ListOrder orders) { if (orders null || orders.isEmpty()) { throw new IllegalArgumentException(没有可导出的订单数据); } } }然后写两个具体实现public class V1ExcelExporter extends AbstractExcelExporter { // 用 HSSF 实现 buildWorkbook } public class V2ExcelExporter extends AbstractExcelExporter { // 用 SXSSF 实现 buildWorkbook支持大数据量 }上层调用方只依赖OrderExporter通过一个选择器按需获取实例OrderExporter exporter exporterFactory.getExporter(v2); byte[] data exporter.export(query);以后新增V3版本只需要新增一个类上层代码一行都不用改。这个模式不是 Spring 专属它就是我们刚分析的ResourceLoader思路的业务复刻版调用方面向接口生产方按需替换。6.4 什么时候该继承什么时候该组合讲了这么多继承的好处最后必须泼一盆冷水继承是手段不是目的。能用组合优先用组合这条原则叫“合成复用原则”。你判断要不要继承先问自己一个问题两个类之间真的有“is-a”关系吗ClassPathResource是一个ResourceFileSystemResource也是一个Resource语义成立继承才合理。如果你只是想让某个类复用父类的工具方法但两者没有清晰的从属关系不要硬继承。比如为了用HashMap的方法就去继承HashMap这是典型的错误用法应该用组合内部持有一个HashMap实例再暴露自己需要的方法。Resource体系之所以经典就是因为它严格遵守了这个原则接口表达语义抽象类提供复用具体类是真实的“资源”每一步都没有越界。我个人带新人时有个习惯会让他们把DefaultResourceLoader.getResource()那几十行代码对着源码抄一遍然后自己写两个不同的Resource实现跑一遍测试。这个过程比背十遍“多态的三个必要条件”有用得多。你在实际开发里再遇到“接口和抽象类怎么选”“重写和重载到底怎么用”回头看这篇文章里的Resource体系应该就有自己的答案了。剩下的就是打开 IDE动手验证。
返回列表