Spring Boot项目打成Jar包获取不到资源文件
最近开发遇到一个怪事,后端读取资源文件失败,本地开发时可以正常返回二进制流给到前端输出,上测试环境就一顿报错。而这个代码是我直接Ctrl+C同事的,没甩锅哈,人家代码运行的好好的,不管是本地开发环境,还是测试环境和生产环境都木有问题的。(着急直接Ctrl+单击看 结论)
1. 背景说明
- 项目:Maven项目
- 功能:前端需要下载附件,这里简化为一个名单模板(下同),文件没有放在文件服务器上,放在resources资源目录下
- JDK:1.8
- Spring Boot:2.2.6
- 代码:Copy同事的,笔者为Jar包,同事为War包
2. 问题排查
因为在本地没有发现错误,所以只能加日志在测试环境上看代码运行结果了。遇到的问题有两个:
- 获取到的InputStream为null
- InputStream大小为0,表现为is.available()为0
3. 解决
3.1 获取到的IO流为null
这个问题相对就好解决了,在网上一搜已有很多网友做了说明和演示,先放下代码:
package prdbug.resources;
import cn.hutool.core.io.resource.ClassPathResource;
import lombok.extern.log4j.Log4j2;
import java.io.InputStream;
/**
* @ClassName GetResources
* @Description GetResources
* @Author Nile(576109623 @ qq.com)
* @Date 16:24 2021/1/23
* @Version 1.0
*/
@Log4j2
public class GetResources {
public static void main(String[] args) {
GetResources resources = new GetResources();
resources.getListTemplate();
}
/**
* Get List Template
* @Author Nile(576109623@qq.com)
* @Date 16:36 2021/1/23
* @return : void
*/
public void getListTemplate() {
String fileName = "ListTemplate.xlsx";
InputStream is0 = this.getClass().getClassLoader().getResourceAsStream("Template/" + fileName);
InputStream is1 = this.getClass().getResourceAsStream("/Template/" + fileName);
InputStream is2 = GetResources.class.getClassLoader().getResourceAsStream("Template/" + fileName);
InputStream is3 = GetResources.class.getResourceAsStream("/Template/" + fileName);
InputStream is4 = new ClassPathResource("Template/" + fileName).getStream();
log.info(is0);
log.info(is1);
log.info(is2);
log.info(is3);
log.info(is4);
}
}
这5中方式都可以正常获取到IO流,笔者一开始搜索问题是也是搜 “项目打成Jar包后获取不到资源文件” ,也有网友问了同样的问题,但运行结果说明跟打包方式没有关系,Jar包也可以获取到资源文件,关键在于输入的参数要正确,相对路径和根路径的问题。
INFO [main] 2021-01-23 16:35:45 (GetResources.java:30) java.io.BufferedInputStream@3e77a1ed
INFO [main] 2021-01-23 16:35:45 (GetResources.java:31) java.io.BufferedInputStream@3ffcd140
INFO [main] 2021-01-23 16:35:45 (GetResources.java:32) java.io.BufferedInputStream@23bb8443
INFO [main] 2021-01-23 16:35:45 (GetResources.java:33) java.io.BufferedInputStream@1176dcec
INFO [main] 2021-01-23 16:35:45 (GetResources.java:34) java.io.BufferedInputStream@3b2c72c2
项目打包后,resources目录项的文件会放到classes目录下,如下图。在本地环境下可以用IDE编译后看下target目录下的文件结构(编译后,导入excel这种文件IDE不会自动编译,在笔者排查这个问题的过程中,另一个同事跟笔者说遇到了同样的问题,结果过去一看classes下都没需要的文件,项目没有重新编译)。
在上面的5中方式中,1、3、5使用的是相对路径,2、4使用的是根路径。另外,在很多文章中有提到3、4不推荐使用,理由是GetResources.class.getClassLoader()会返回空,使用3、4会报NPE,具体原因倒没有看到解释,有路过旁友可以补充链接或说明。
(接下去的都使用方式1进行说明演示)
3.2 io.available()返回0
先说明一下为什么会用到这个api,之前同事获取文件并返回的代码如下,读取文件后获取字节数组,返回HttpEntity到前端。
public HttpEntity getListTemplate() {
String fileName = "ListTemplate.xlsx";
byte[] body;
try {
InputStream is = this.getClass().getClassLoader().getResourceAsStream("./Template/" + fileName);
body = new byte[is.available()];
log.info("Size of is: " + is.available());
is.read(body);
return new HttpEntity(body);
} catch (IOException e) {
// return null or throw exception.
}
return new HttpEntity<>(body);
}
在解决了3.1的问题后,使用上面代码在测试环境下获取的文件一直都是空,然后加日志打印发现is.available()为0。
在搜索了很多文章后(大部分都还是在讨论3.1如何获取IO流的问题),在这篇博客中博主提供了一种解决方案:如何读取Spring Boot打包后的资源文件。
使用工具类FileCopyUtils接收IO流,获取字节数组,测试也确实拿到了,可以拿到名单模板文件了。
import org.springframework.util.FileCopyUtils;
public HttpEntity getListTemplate() {
String fileName = "ListTemplate.xlsx";
try {
InputStream is = this.getClass().getClassLoader().getResourceAsStream("./Template/" + fileName);
byte[] body = FileCopyUtils.copyToByteArray(is);
return new HttpEntity<>(body);
} catch (IOException e) {
// return null or throw exception.
}
return new HttpEntity(null);
}
4. 一点分析
好吧,工作中的问题算是解决了,项目也上了测试环境进入测试阶段,接下来来分析下,为何这么奇怪?
通过对测试环境的日志记录分析发现,读取文件是返回的IO流并不是BufferedInputStream,而是DataInputStream,这便是Spring Boot项目Jar运行和本地运行的最大差别了!
4.1 IO流
4.1.1 BufferedInputStream
通读了下BufferedInputStream的源码,部分如下:
public class BufferedInputStream extends FilterInputStream {
// 默认buf的大小
private static int DEFAULT_BUFFER_SIZE = 8192;
// 持有字节数组
protected volatile byte buf[];
/*
* 返回IO大小,getInIfOpen()返回的是一个FileInputStream
* FileInputStream的available()调用的为BufferedInputStream的read(),即下面这个方法
* 传入的off为0,len为上面的DEFAULT_BUFFER_SIZE(8192)
*/
public synchronized int available() throws IOException {
int n = count - pos;
int avail = getInIfOpen().available();
return n > (Integer.MAX_VALUE - avail)
? Integer.MAX_VALUE
: n + avail;
}
// for(;;)死循环读取所有字节
public synchronized int read(byte b[], int off, int len) throws IOException {
getBufIfOpen(); // Check for closed stream
if ((off | len | (off + len) | (b.length - (off + len))) < 0) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int n = 0;
for (;;) {
int nread = read1(b, off + n, len - n);
if (nread <= 0)
return (n == 0) ? nread : n;
n += nread;
if (n >= len)
return n;
// if not closed but no bytes available, return
InputStream input = in;
if (input != null && input.available() <= 0)
return n;
}
}
所以可以看到在调用available()其实已经调用过一遍read(byte b[], int off, int len)方法了,返回的字节数组大小也是正常的。
4.1.2 DataInputStream
那么再来看下DataInputStream的源码,木有!!!没有重写available()
在没有重写的情况就会调用父类的available():
public abstract class InputStream implements Closeable {
public int available() throws IOException {
return 0;
}
}
好了,到此可以回答2.2的问题了:为何is.available()为0。放张继承图:
4.2 ClassLoader
现在知道了在本地开发环境和测试环境中,获取资源文件会返回不同类型的IO流,那么就要进一步问为什么会有这样的不同了。
在查阅了不多的资料后,这篇文章提供了一些思路,spring-boot-loader执行Jar文件原理,在这篇文章中提到,Spring Boot有着自己的启动器JarLauncher(当然也有WarLauncher),JarLauncher实现了两种启动方式:Jar启动和文件系统启动。其中Jar启动对应着JarFileArchive类,该类持有Java原生的JarEntry;而文件系统启动对应着ExplodedArchive类,Spring提供实现。
在使用文件系统方式启动后使用的类加载器为Spring提供的RandomAccessDataFile类,该类实现接口RandomAccessData,也是其唯一实现类。RandomAccessDataFile返回IO流源码如下:
package org.springframework.boot.loader.data;
/**
* {@link RandomAccessData} implementation backed by a {@link RandomAccessFile}.
*
* @author Phillip Webb
* @author Andy Wilkinson
* @since 1.0.0
*/
public class RandomAccessDataFile implements RandomAccessData {
@Override
public InputStream getInputStream() throws IOException {
return new DataInputStream();
}
}
上面的源码就可以解释为何会有4.1中返回不同IO类型的问题。
那么最后最后的问题,为什么本地和测试环境中会使用不同的方式启动Spring Boot项目,以及如果是War包使用的是什么加载器和返回什么类型的IO流(可以直接获取IO字节流大小io.available()正确返回),笔者惭愧就此打住了,有路过的大神可以补充链接或说明。(笔者也很想去改项目的配置看是否改变启动类配置,以及会如何运行,但会被组长K的,等自己部署好Linux后有新发现再来补充吧。。)
5. 结论
- Spring Boot项目打包为Jar包可以正常访问到资源文件,路径要正确,否则就是NPE了
InputStream is0 = this.getClass().getClassLoader().getResourceAsStream("Template/" + fileName);
InputStream is1 = this.getClass().getResourceAsStream("/Template/" + fileName);
InputStream is2 = GetResources.class.getClassLoader().getResourceAsStream("Template/" + fileName);
InputStream is3 = GetResources.class.getResourceAsStream("/Template/" + fileName);
InputStream is4 = new ClassPathResource("Template/" + fileName).getStream();
- Spring Boot启动Jar包,访问资源文件返回IO类为DataInputStream,该类没有重写available(),调用该方法会执行父类InputStream的available(),返回的是0,因此不能使用以下方式获取字节数组
InputStream is = this.getClass().getClassLoader().getResourceAsStream("./Template/" + fileName);
body = new byte[is.available()];
is.read(body);
- 可以使用工具类FileCopyUtils或手写OutputStream来获取数据
InputStream is = this.getClass().getClassLoader().getResourceAsStream("./Template/" + fileName);
byte[] body = org.springframework.util.FileCopyUtils.copyToByteArray(is);