Spring Boot项目打成Jar包获取不到资源文件

Spring Boot项目打成Jar包获取不到资源文件

​ 最近开发遇到一个怪事,后端读取资源文件失败,本地开发时可以正常返回二进制流给到前端输出,上测试环境就一顿报错。而这个代码是我直接Ctrl+C同事的,没甩锅哈,人家代码运行的好好的,不管是本地开发环境,还是测试环境和生产环境都木有问题的。(着急直接Ctrl+单击看 结论

1. 背景说明

  • 项目:Maven项目
  • 功能:前端需要下载附件,这里简化为一个名单模板(下同),文件没有放在文件服务器上,放在resources资源目录下
  • JDK:1.8
  • Spring Boot:2.2.6
  • 代码:Copy同事的,笔者为Jar包,同事为War包

2. 问题排查

​ 因为在本地没有发现错误,所以只能加日志在测试环境上看代码运行结果了。遇到的问题有两个:

  1. 获取到的InputStream为null
  2. 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. 结论

  1. 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();
  1. 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);
  1. 可以使用工具类FileCopyUtils或手写OutputStream来获取数据
InputStream is = this.getClass().getClassLoader().getResourceAsStream("./Template/" + fileName);
byte[] body = org.springframework.util.FileCopyUtils.copyToByteArray(is);
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值