SpringBoot:一个注解就能帮你下载任意对象

news2024/12/23 6:15:19

一 前言

下载功能应该是比较常见的功能了,虽然一个项目里面可能出现的不多,但是基本上每个项目都会有,而且有些下载功能其实还是比较繁杂的,倒不是难,而是麻烦。

如果我说现在只需要一个注解就能帮你下载任意的对象,是不是觉得非常的方便

@Download(source = "classpath:/download/README.txt")
@GetMapping("/classpath")
public void classpath() {
}
@Download
@GetMapping("/file")
public File file() {
    return new File("/Users/Shared/README.txt");
}
@Download
@GetMapping("/http")
public String http() {
    return "http://127.0.0.1:8080/concept-download/image.jpg";
}

感觉差别不大?那就听听我遇到的一个下载需求

我们有一个平台是管理设备的,然后每个设备都会有一个二维码图片,用一个字段存储的 http 地址

现在需要导出所有设备二维码图片的压缩包,图片名称需要用设备名称加 .png 后缀,需求上来说并不难,但是着实有点麻烦

  • 首先我需要将设备列表查出来

  • 然后使用二维码地址下载图片并写到本地缓存文件

  • 在下载之前需要先判断是否已经存在缓存

  • 下载时需要并发下载提升性能

  • 等所有图片下载结束后

  • 再生成一个压缩文件

  • 然后再操作输入输出流写到响应中

看着我实现了将近 200 行的代码,真是又臭又长,一个下载功能咋能那么麻烦呢,于是我就想有没有更简单的方式

我当时的需求很简单,我想着我只要提供需要下载的数据,比如一个文件路径,一个文件对象,一段字符串文本,一个http地址,或者混搭了前面所有类型的一个集合,甚至是我们自定义的某个类的实例,后面的事情我就不用管了

文件路径是一个文件还是一个目录?字符串文本需要先写入一个文本文件中?

http资源如何下载到本地?多个文件怎么压缩?最后怎么写到响应中?我才不想花时间管这些

比如就像我现在这个需求,我只要返回设备列表就行了,其他的事情我都不用管

@Download(filename = "二维码.zip")
@GetMapping("/download")
public List<Device> download() {
    return deviceService.all();
}
public class Device {
    //设备名称
    private String name;
    //设备二维码
    //注解表示该http地址是需要下载的数据
    @SourceObject
    private String qrCodeUrl;
    //注解表示文件名称
    @SourceName
    public String getQrCodeName() {
        return name + ".png";
    }
    //省略其他属性方法
}

通过在 Device 的字段上标注某些注解(或是实现某个接口)来指定文件名称和文件地址

如果能这样实现,省时省心省力,又多了写 199 行代码的摸鱼时间难道不香么

如果大家有兴趣,Github上的介绍更加详细,还包括各种高级用法以及整体架构

二 思路

下面来讲讲这个库的主要设计思路,以及中间遇到的坑,大家有兴趣可以继续往下看

其实基于一开始的设想,我觉得功能并没有多复杂,于是就决定开肝

只是万万没想到实现起来比我想象的更复杂(这是后话了)

三 基础

首先整个库基于响应式编程,但却并不是完全意义上的响应式,只能说是Mono这样的。。。奇怪组合?

为什么会这样呢,很大的一个原因是由于需要兼容webmvc和webflux,导致我仅仅是将之前实现的InputStream方式重构成了响应式,所以就出现了这样的组合

这也是我遇到的最大的一个坑,我先前已经基本调通了基于Servlet的整个下载流程,然后就想着支持一下webflux

大家都知道webmvc中,我们可以通过RequestContextHolder来获得请求和响应对象,但是在webflux中就不行了,当然我们可以在方法参数中注入

@Download(source = "classpath:/download/README.txt")
@GetMapping("/classpath")
public void classpath(ServerHttpResponse response) {
}

结合Spring自带的注入功能,我们就可以通过AOP拿到响应的入参了,但是总觉得这样写有点多余,强迫症表示不能忍

有什么办法既能把用不到的入参干掉,又能拿到响应对象呢,在网上找到了一种实现方式

/**
 * 用于设置当前的请求和响应。
 *
 * @see ReactiveDownloadHolder
 */
public class ReactiveDownloadFilter implements WebFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        return chain.filter(exchange)
                //低版本使用subscriberContext
                .contextWrite(ctx -> ctx.put(ServerHttpRequest.class, request))
                .contextWrite(ctx -> ctx.put(ServerHttpResponse.class, response));
    }
}
/**
 * 用于获得当前的请求和响应。
 *
 * @see ReactiveDownloadFilter
 */
public class ReactiveDownloadHolder {
    public static Mono<ServerHttpRequest> getRequest() {
        //低版本使用subscriberContext
        return Mono.deferContextual(contextView -> Mono.just(contextView.get(ServerHttpRequest.class)));
    }
    public static Mono<ServerHttpResponse> getResponse() {
        //低版本使用subscriberContext
        return Mono.deferContextual(contextView -> Mono.just(contextView.get(ServerHttpResponse.class)));
    }
}

通过添加WebFilter就可以获得响应对象了,但是返回值是Mono

那么可不可以通过Mono.block()阻塞得到对应的对象呢,答案是不行,由于webflux基于Netty的非阻塞线程,如果调用该方法会直接抛出异常

所以就没有任何办法了,只能将之前代码基于响应式重构

四 架构

在这里插入图片描述
对于一个下载请求,我们可以分成几个步骤,以下载多个文件的压缩包为例

  • 首先我们一般是得到多个文件的路径或对应的File对象

  • 然后将这些文件压缩生成一个压缩文件

  • 最后将压缩文件写入到响应中

但是对于我上面描述的需求,一开始就不是文件路径或对象了,而是一个http地址,然后在压缩之前还需要多一个步骤,需要先将图片下载下来

那么对于各种各样的需求我们可能需要在当前步骤中的任意位置添加额外的步骤,所以我参考了Spring Cloud Gateway 拦截链的实现方式

/**
 * 下载处理器。
 */
public interface DownloadHandler extends OrderProvider {
    /**
     * 执行处理。
     *
     * @param context {@link DownloadContext}
     * @param chain   {@link DownloadHandlerChain}
     */
    Mono<Void> handle(DownloadContext context, DownloadHandlerChain chain);
}
/**
 * 下载处理链。
 */
public interface DownloadHandlerChain {
    /**
     * 调度下一个下载处理器。
     *
     * @param context {@link DownloadContext}
     */
    Mono<Void> next(DownloadContext context);
}

这样每个步骤就可以单独实现一个DownloadHandler,步骤与步骤之间可以任意的组合添加

五 下载上下文

在此基础上使用一个贯穿整个流程的上下文DownloadContext,方便共享和传递步骤之间的中间结果

对于上下文DownloadContext也提供了DownloadContextFactory可以用于自定义上下文

同时提供了DownloadContextInitializer和DownloadContextDestroyer用于在上下文初始化和销毁时扩展自己的逻辑

六 下载类型支持

我们需要下载的数据的类型是不固定的,比如有文件,有http地址,也会有之前我希望的自定义的类的实例

所以我将所有的下载对象抽象成了Source,表示一个下载源,这样文件可以实现为FileSource,http地址可以实现为HttpSource,然后通过对应的SourceFactory来匹配创建

比如FileSourceFactory可以匹配File并且创建FileSource,HttpSourceFactory可以匹配http://前缀并且创建HttpSource

/**
 * {@link Source} 工厂。
 */
public interface SourceFactory extends OrderProvider {
    /**
     * 是否支持需要下载的原始数据对象。
     *
     * @param source  需要下载的原始数据对象
     * @param context {@link DownloadContext}
     * @return 如果支持则返回 true
     */
    boolean support(Object source, DownloadContext context);
    /**
     * 创建。
     *
     * @param source  需要下载的原始数据对象
     * @param context {@link DownloadContext}
     * @return 创建的 {@link Source}
     */
    Source create(Object source, DownloadContext context);
}

那么对于我们自定义的类要怎么支持呢,之前提到可以在类上标注注解或是实现特定的接口,那么就用我实现的注解的方式来大概讲一讲吧

其实逻辑很简单,只要能熟练的运用反射就完全没问题,我们再来看一看用法

@Download(filename = "二维码.zip")
@GetMapping("/download")
public List<Device> download() {
    return deviceService.all();
}
public class Device {
    //设备名称
    private String name;
    //设备二维码
    //注解表示该http地址是需要下载的数据
    @SourceObject
    private String qrCodeUrl;
    //注解表示文件名称
    @SourceName
    public String getQrCodeName() {
        return name + ".png";
    }
    //省略其他属性方法
}

首先我定义了一个注解@SourceModel标注在类上表示需要被解析,然后定义了一个@SourceObject注解标注在需要下载的字段(或方法)上,这样我们就可以通过反射拿到这个字段(或方法)的值

基于当前支持的SourceFactory就能创建出对应的Source,接下来使用@SourceName指定名称,也同样可以通过反射获得这个方法(或字段)的值并依旧通过反射设置到创建出来的Source上

这样就能非常灵活的支持任意的对象类型了

七 并发加载

对于像http这种网络资源,我们需要先并发加载(多个文件时)到本地的内存中或是缓存文件中来提升我们的处理效率

当然我可以直接定死一个线程池来执行,但是每个机器每个项目甚至每个需求对于并发的要求和资源的分配都不一样

所以我提供了SourceLoader来支持自定义的加载逻辑,你甚至可以一部分用线程池,一部分用协程,剩下一部分不加载

/**
 * {@link Source} 加载器。
 *
 * @see DefaultSourceLoader
 * @see SchedulerSourceLoader
 */
public interface SourceLoader {
    /**
     * 执行加载。
     *
     * @param source  {@link Source}
     * @param context {@link DownloadContext}
     * @return 加载后的 {@link Source}
     */
    Mono<Source> load(Source source, DownloadContext context);
}

八 压缩

当我们加载完之后就可以执行压缩了,同样的我定义了一个类Compression作为压缩对象的抽象

一般来说,我们会先在本地创建一个缓存文件,然后将压缩后的数据写入到缓存文件中

不过我每次都很讨厌在配置文件中配置各种各样的路径,所以在压缩时支持内存压缩,当然如果文件比较大还是老老实实生成一个缓存文件

对于压缩格式也提供了可以完全自定义的SourceCompressor接口,你想自己实现一个压缩协议都没有问题

/**
 * {@link Source} 压缩器。
 *
 * @see ZipSourceCompressor
 */
public interface SourceCompressor extends OrderProvider {
    /**
     * 获得压缩格式。
     *
     * @return 压缩格式
     */
    String getFormat();
    /**
     * 判断是否支持对应的压缩格式。
     *
     * @param format  压缩格式
     * @param context {@link DownloadContext}
     * @return 如果支持则返回 true
     */
    default boolean support(String format, DownloadContext context) {
        return format.equalsIgnoreCase(getFormat());
    }
    /**
     * 如果支持对应的格式就会调用该方法执行压缩。
     *
     * @param source  {@link Source}
     * @param writer  {@link DownloadWriter}
     * @param context {@link DownloadContext}
     * @return {@link Compression}
     */
    Compression compress(Source source, DownloadWriter writer, DownloadContext context);
}

九 响应写入

我将响应抽象成了DownloadResponse,主要用于兼容HttpServletResponse和ServerHttpResponse

但是问题又出现了,下面是webmvc和webflux写入响应的方式

//HttpServletResponse
response.getOutputStream().write(byte b[], int off, int len);
//ServerHttpResponse
response.writeWith(Publisher<? extends DataBuffer> body);

这兼容的我脑壳疼,不过最后还是搞定了

/**
 * 持有 {@link ServerHttpResponse} 的 {@link DownloadResponse},用于 webflux。
 */
@Getter
public class ReactiveDownloadResponse implements DownloadResponse {
    private final ServerHttpResponse response;
    private OutputStream os;
    private Mono<Void> mono;
    public ReactiveDownloadResponse(ServerHttpResponse response) {
        this.response = response;
    }
    @Override
    public Mono<Void> write(Consumer<OutputStream> consumer) {
        if (os == null) {
            mono = response.writeWith(Flux.create(fluxSink -> {
                try {
                    os = new FluxSinkOutputStream(fluxSink, response);
                    consumer.accept(os);
                } catch (Throwable e) {
                    fluxSink.error(e);
                }
            }));
        } else {
            consumer.accept(os);
        }
        return mono;
    }
    @SneakyThrows
    @Override
    public void flush() {
        if (os != null) {
            os.flush();
        }
    }
    @AllArgsConstructor
    public static class FluxSinkOutputStream extends OutputStream {
        private FluxSink<DataBuffer> fluxSink;
        private ServerHttpResponse response;
        @Override
        public void write(byte[] b) throws IOException {
            writeSink(b);
        }
        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            byte[] bytes = new byte[len];
            System.arraycopy(b, off, bytes, 0, len);
            writeSink(bytes);
        }
        @Override
        public void write(int b) throws IOException {
            writeSink((byte) b);
        }
        @Override
        public void flush() {
            fluxSink.complete();
        }
        public void writeSink(byte... bytes) {
            DataBuffer buffer = response.bufferFactory().wrap(bytes);
            fluxSink.next(buffer);
            //在这里可能有问题,但是目前没有没有需要释放的数据
            DataBufferUtils.release(buffer);
        }
    }
}

只要最后都是写byte[]就可以相互转化,只不过可能麻烦一点,需要用接口回调

将FluxSink伪装成一个OutputStream,写入时把byte[]转成DataBuffer 并调用next方法,最后在flush的时候调用complete方法就行了,完美

响应写入其实就是对输入输出流的处理了,正常情况下,我们会定义一个byte[]用来缓存读到的数据,所以我也不会固定这个缓存的大小而是提供了DownloadWriter可以自定义处理输入输出流,包括存在指定编码或是Range头的情况

/**
 * 具体操作 {@link InputStream} 和 {@link OutputStream} 的写入器。
 */
public interface DownloadWriter extends OrderProvider {
    /**
     * 该写入器是否支持写入。
     *
     * @param resource {@link Resource}
     * @param range    {@link Range}
     * @param context  {@link DownloadContext}
     * @return 如果支持则返回 true
     */
    boolean support(Resource resource, Range range, DownloadContext context);
    /**
     * 执行写入。
     *
     * @param is      {@link InputStream}
     * @param os      {@link OutputStream}
     * @param range   {@link Range}
     * @param charset {@link Charset}
     * @param length  总大小,可能为 null
     */
    default void write(InputStream is, OutputStream os, Range range, Charset charset, Long length) {
        write(is, os, range, charset, length, null);
    }
    /**
     * 执行写入。
     *
     * @param is       {@link InputStream}
     * @param os       {@link OutputStream}
     * @param range    {@link Range}
     * @param charset  {@link Charset}
     * @param length   总大小,可能为 null
     * @param callback 回调当前进度和增长的大小
     */
    void write(InputStream is, OutputStream os, Range range, Charset charset, Long length, Callback callback);
    /**
     * 进度回调。
     */
    interface Callback {
        /**
         * 回调进度。
         *
         * @param current  当前值
         * @param increase 增长值
         */
        void onWrite(long current, long increase);
    }
}

十 事件

当我把整个下载流程实现之后发现其实整个逻辑还是有点复杂的,所有得想个办法能监控整个下载流程

最开始我定义了几个监听器用来回调,但是并不好用,首先我们整个架构设计的是十分灵活可扩展的,而定义的监听器类型少而且不好扩展

当我们后续添加了其他的流程和步骤后,不得不新加几类监听器或是在原来的监听器类上添加方法,十分麻烦

所以我想到使用事件的方式能更加灵活的扩展,并定义了DownloadEventPublisher用于发布事件和DownloadEventListener用于监听事件,而且支持了Spring的事件监听方式

十一 日志

基于上述的事件方式,我在此基础上实现了几种下载日志

每个流程对应的日志
加载进度更新,压缩进度更新,响应写入进度更新的日志
时间花费的日志
这些日志由于比较详细的打印了整个下载流程的信息,还帮我发现了好多Bug

十二 其他坑

最开始上下文的初始化和销毁各自对应了一个步骤分别位于最开始和最末尾,但是当我在webflux中写完响应后,发现上下文的销毁不会执行

于是我跟了下Spring的源码发现写入方法返回的是Mono.empty(),也就是说,当响应写入后就不会往下调用next方法了,所以在响应写入之后的步骤永远都不会被调用

最后就把上下文初始化和销毁单独出来了,并且在doAfterTerminate时调用销毁方法

十三 结束语

基本上的内容就是这样了,不过对于响应式这块的内容还是莫得不是很透,以及有部分操作符也不是很会用,但还是有了解到很多高级的用法

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/950341.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

纪念我的第一个稍微有用一点的python代码的成功——利用最近邻插值法实现图像的放大

一、技术来源&#xff1a; 插值算法 &#xff5c; 最近邻插值法_哔哩哔哩_bilibili 感谢这位的技术分享&#xff0c;讲解得通俗易懂 二、一些磕磕绊绊&#xff1a; 1.首先&#xff0c;pycharm的使用&#xff0c;通过file创建一个新的项目&#xff08;最好可以记住文件路径&am…

Java9-17新特性

文章目录 一、简介二、新特性接口私有方法&#xff08;JDK9&#xff09;String存储结构的变化&#xff08;JDK9&#xff09;快速创建只读集合&#xff08;JDK9、10&#xff09;文本块&#xff08;JDK13、14、15&#xff09;更直观的 NullPointerException 提示&#xff08;JDK1…

exe软件监控看门狗使用说明

作为物联网数据采集解决方案专业提供商,数采物联网 小编daq-iot在这里做以下内容介绍,并诚挚的欢迎大家讨论和交流。 1.软件概述 本软件功能用途&#xff1a;监控电脑或服务器exe程序运行&#xff0c;在exe程序由于异常或其他原因退出后&#xff0c;自动启动exe程序&#xff0…

goroutine的一点东西

前面的两篇&#xff0c;从相对比较简单的锁的内容入手(也是干货满满)&#xff0c;开始了go的系列。这篇开始&#xff0c;进入更核心的内容。我们知道&#xff0c;go应该是第一门在语言层面支持协程的编程语言(可能是我孤陋寡闻)&#xff0c;goroutine也完全算的上是go的门面。g…

文件属性查看和修改学习

这个是链接&#xff0c;相当于快捷方式&#xff0c;指向usr/bin这个目录&#xff0c;链接到这个目录

NRF52832一主多从ble_app_multilink_central

下载官方SDK后打开路径&#xff1a;nRF5SDK153059ac345\nRF5_SDK_15.3.0_59ac345\examples\ble_central\ble_app_multilink_central\pca10040\s132\arm5_no_packs 下的工程文件&#xff0c;确定把log开启 编译后下载完程序(要下载协议栈&#xff0c;这里用6.1.1的)&#xff0c…

FPGA原理与结构——时钟IP核原理学习

一、前言 在之前的文章中&#xff0c;我们介绍了FPGA的时钟结构 FPGA原理与结构——时钟资源https://blog.csdn.net/apple_53311083/article/details/132307564?spm1001.2014.3001.5502 在本文中我们将学习xilinx系列的FPGA所提供的时钟IP核&#xff0c;来帮助我们进一…

实现带头双向循环链表

&#x1f308;带头双向循环链表 描述&#xff1a;一个节点内包含两个指针&#xff0c;一个指向上一个节点&#xff0c;另一个指向下一个节点。哨兵位指向的下一个节点为头节点&#xff0c;哨兵位的上一个指向尾节点。 结构优势&#xff1a;高效率找尾节点&#xff1b;高效率插入…

RabbitMQ工作模式-主题模式

主题模式 官方文档参考&#xff1a;https://www.rabbitmq.com/tutorials/tutorial-five-python.html 使用topic类型的交换器&#xff0c;队列绑定到交换器、bingingKey时使用通配符&#xff0c;交换器将消息路由转发到具体队列时&#xff0c;会根据消息routingKey模糊匹配&am…

[学习笔记] fhq Treap 平衡树

fhq Treap 也叫无旋Treap &#xff08;好像&#xff1f;我也不知道&#xff09; 反正我带旋 Treap 是不会滴&#xff0c;其他的平衡树也不会&#xff08;但是会平板电视&#xff09; fhq Treap 好写&#xff0c;码量小&#xff0c;缺点是常数比较大 定义 二叉搜索树 二叉搜…

为什么说模电难学?因为它至少是这27个基础知识的排列组合!

1、基尔1、基尔霍夫定理的内容是什么&#xff1f; 基尔霍夫电流定律&#xff1a;在电路任一节点&#xff0c;流入、流出该节点电流的代数和为零。 基尔霍夫电压定律&#xff1a;在电路中的任一闭合电路&#xff0c;电压的代数和为零。 2、戴维南定理 一个含独立源、线性电阻…

在抖音开店卖货的流程是什么?最全解答如下,建议新手认真看完!

我是王路飞。 同样是在抖音卖货&#xff0c;为何如今大多数人都是选择在抖音开店&#xff0c;而不再是选择做账号、开直播了呢&#xff1f; 原因很简单&#xff0c;因为门槛和变现方式。 相比短视频和直播带货的起号、变现难度越来越大&#xff0c;低门槛的抖音小店显然更适…

Origin热图的做法

1.数据准备 2.绘制-选择带标签热图 3.图表调整 右边标签- 下方标签-复制格式&#xff0c;再到左边或者右边选择 粘贴所有 图中的标签及颜色 直接双击在属性框更改 主框的其他特征在属性框选择 色阶的控制 直接选择色阶的属性框

Endnote中查看一个文献的分组的具体方法——以Endnote X8为例

Endnote中查看一个文献的分组的具体方法——以Endnote X8为例 一、问题 当Endnote中使用分类方法对文献进行分组管理后&#xff0c;有时需要重新调整该文献的分组&#xff0c;则需要找到这个文献在哪个分组中。本文阐述怎样寻找一个文献的分组的位置信息。 二、解决方法 1.选…

Spooling的原理

脱机技术 程序猿先用纸带机把自己的程序数据输入到磁带中&#xff0c;这个输入的过程是由一台专门的外围控制机实现的。之后CPU直接从快速的磁带中读取想要的这些输入数据。输出也类似。 假脱机技术&#xff08;Spooling技术&#xff09; 即用软件的方式来模拟脱机技术。要…

Kubernetes技术--k8s核心技术Controller控制器

1.Controller概述 Controller是在集群上管理和运行容器的对象。是一个实际存在的对象。 2.pod和Controller之间的关系 pod通过controller实现应用的运维,包括伸缩、滚动升级等操作。 这里pod和controller通过label标签来建立关系。如下所示: 3.Deployment控制器应用场景 -1:…

RabbitMQ工作模式-发布订阅模式

Publish/Subscribe&#xff08;发布订阅模式&#xff09; 官方文档&#xff1a; https://www.rabbitmq.com/tutorials/tutorial-three-python.html 使用fanout类型类型的交换器&#xff0c;routingKey忽略。每个消费者定义生成一个队列关绑定到同一个Exchange&#xff0c;每个…

win10底部任务栏开机后长时间未响应的解决办法

https://blog.csdn.net/hj960511/article/details/128746025?share_tokenAA088876-4477-44B8-B978-5C7C7726D552&tt_fromcopy_link&utm_sourcecopy_link&utm_mediumtoutiao_ios&utm_campaignclient_share win10底部任务栏开机后长时间未响应的解决办法-CSDN博…

亚马逊云科技re:Inforce大会:为企业提供端到端的安全防护能力

2023年&#xff0c;生成式AI带来了无数的创新&#xff0c;并将会在行业应用中产生更多的新能力、新场景。与此同时&#xff0c;关于生成式AI的风险管控成为各方关注焦点&#xff0c;数据隐私、合规保护、防欺诈等&#xff0c;已成为生成式AI时代的安全合规的新话题。 随着云上业…

(一)SpringBoot 整合WebSocket 前端 uniapp 访问

第一次使用WebSocket&#xff0c;所以最需要一个及其简单的例子&#xff0c;跑通之后&#xff0c;增加自己对该技术的理解。&#xff08;技术基础介绍就免掉了&#xff0c;后面再补&#xff09; 案例逻辑&#xff1a;目前只有一个用户&#xff0c;而且是一个用户给服务器发送数…