无需在 Vaadin Flow Web 应用程序的服务器端写入文件即可下载的动态创建内容

Dynamically-created content for download without writing a file on server-side in Vaadin Flow web app

在我的 Vaadin Flow 网络应用程序(版本 14 或更高版本)中,我想向我的用户显示 link 以下载数据文件。

本次下载的内容可能比较大。所以我不想一下子将内存中的全部内容具体化。我想连续生成大块内容,一次下载一个大块,以尽量减少内存使用。想象一下,例如,来自数据库的大量行,我们一次将一行提供给下载。

我知道 Vaadin Flow 中的 Anchor 小部件。但是我如何将一些动态创建的内容挂接到这样的小部件?

此外,鉴于此数据是即时动态生成的,我希望用户计算机上下载文件的名称默认为特定前缀,后跟 YYYYMMDDTHHMMSS 格式的当前日期时间。

警告:我不是这方面的专家。我在此处提供的示例代码似乎运行正常。我通过研究有限的文档和阅读网络上的许多其他帖子来拼凑这个解决方案。我的可能不是最好的解决方案。


有关详细信息,请参阅 Vaadin 手册的 Dynamic Content 页。

我们在您的问题中包含三个主要部分:

  • Vaadin 网络应用程序页面上的小部件,为用户提供下载。
  • 动态内容创建者
  • 在用户机器上创建的文件的默认名称

前两个我有解决方案,但第三个没有。

下载插件

如问题中所述,我们确实使用了 Anchor 小部件(参见 Javadoc)。

我们在布局上定义了一个成员变量。

private Anchor anchor;

我们通过传递一个 StreamResource object. This class is defined in Vaadin. Its job here is to wrap a class of our making that will produce an implementation extending the Java class InputStream 来实例化。

输入流通过从其 read 方法返回一个 int 的值是预期八位位组的数字,0-255,一次提供一个八位位组的数据。当到达数据末尾时,read返回负数。

在我们的代码中,我们实现了一个 makeStreamOfContent 方法作为 InputStream 工厂。

private InputStream makeInputStreamOfContent ( )
{
    return GenerativeInputStream.make( 4 );
}

在实例化我们的 StreamResource 时,我们传递了一个引用该 makeInputStreamOfContent 方法的方法引用。我们在这里有点抽象,因为尚未生成输入流或任何数据。我们只是在搭建舞台;动作稍后发生。

传递给 new StreamResource 的第一个参数是要在用户客户端计算机上创建的文件的默认名称。在此示例中,我们使用了缺乏想象力的名称 report.text

anchor = 
    new Anchor( 
        new StreamResource( "report.text" , this :: makeInputStreamOfContent ) , 
        "Download generated content" 
    )
;

接下来,我们在 HTML5 anchor 元素上设置 download 的属性。此属性向浏览器表明我们打算在用户单击 link.

时下载目标
anchor.getElement().setAttribute( "download" , true );

您可以通过将锚点小部件包裹在 Button 中来显示图标。

downloadButton = new Button( new Icon( VaadinIcon.DOWNLOAD_ALT ) );
anchor.add( downloadButton );

如果使用这样的图标,您应该从 Anchor 小部件中删除文本标签。相反,将任何需要的文本放在 Button 中。因此,我们会将空字符串 ("") 传递给 new Anchor,并将标签文本作为第一个参数传递给 new Button

anchor = 
    new Anchor( 
        new StreamResource( "report.text" , this :: makeInputStreamOfContent ) , 
        "" 
    )
;
anchor.getElement().setAttribute( "download" , true );
downloadButton = 
    new Button( 
        "Download generated content" , 
        new Icon( VaadinIcon.DOWNLOAD_ALT ) 
    )
;
anchor.add( downloadButton );

动态内容创建者

我们需要实现一个 InputStream subclass,以提供给我们的下载小部件。

InputStream 抽象 class 提供了除其中一种方法以外的所有方法的实现。我们只需要实现 read 方法就可以满足我们项目的需要。

这是一种可能的实现方式。实例化 GenerativeInputStream 对象时,传递要生成的行数。数据一次生成一行,然后逐个八位字节提供给客户端。完成该行后,将生成另一行。所以我们通过一次只处理一行来节省内存。

提供给客户端的八位字节是由 Joel Spolsky 组成 UTF-8 text of our row. Each character of intended text may consist of one or more octets. If you do not understand this, read the entertaining and informative post The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!) 的八位字节。

package work.basil.example;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.IntSupplier;

// Generates random data on-the-fly, to simulate generating a report in a business app.
//
// The data is delivered to the calling program as an `InputStream`. Data is generated
// one line (row) at a time. After a line is exhausted (has been delivered octet by octet
// to the client web browser), the next line is generated. This approach conserves memory
// without materializing the entire data set into RAM all at once.
//
// By Basil Bourque. Use at your own risk.
// © 2020 Basil Bourque. This source code may be used by others agreeing to the terms of the ISC License.
// https://en.wikipedia.org/wiki/ISC_license
public class GenerativeInputStream extends InputStream
{
    private int rowsLimit, nthRow;
    InputStream rowInputStream;
    private IntSupplier supplier;
    static private String DELIMITER = "\t";
    static private String END_OF_LINE = "\n";
    static private int END_OF_DATA = - 1;

    // --------|  Constructors  | -------------------
    private GenerativeInputStream ( int countRows )
    {
        this.rowsLimit = countRows;
        this.nthRow = 0;
        supplier = ( ) -> this.provideNextInt();
    }

    // --------|  Static Factory  | -------------------
    static public GenerativeInputStream make ( int countRows )
    {
        var gis = new GenerativeInputStream( countRows );
        gis.rowInputStream = gis.nextRowInputStream().orElseThrow();
        return gis;
    }

    private int provideNextInt ( )
    {
        int result = END_OF_DATA;

        if ( Objects.isNull( this.rowInputStream ) )
        {
            result = END_OF_DATA; // Should not reach this point, as we checked for null in the factory method and would have thrown an exception there.
        } else  // Else the row input stream is *not*  null, so read next octet.
        {
            try
            {
                result = rowInputStream.read();
                // If that row has exhausted all its octets, move on to the next row.
                if ( result == END_OF_DATA )
                {
                    Optional < InputStream > optionalInputStream = this.nextRowInputStream();
                    if ( optionalInputStream.isEmpty() ) // Receiving an empty optional for the input stream of a row means we have exhausted all the rows.
                    {
                        result = END_OF_DATA; // Signal that we are done providing data.
                    } else
                    {
                        rowInputStream = optionalInputStream.get();
                        result = rowInputStream.read();
                    }
                }
            }
            catch ( IOException e )
            {
                e.printStackTrace();
            }
        }

        return result;
    }

    private Optional < InputStream > nextRowInputStream ( )
    {
        Optional < String > row = this.nextRow();
        // If we have no more rows, signal the end of data feed with an empty optional.
        if ( row.isEmpty() )
        {
            return Optional.empty();
        } else
        {
            InputStream inputStream = new ByteArrayInputStream( row.get().getBytes( Charset.forName( "UTF-8" ) ) );
            return Optional.of( inputStream );
        }
    }

    private Optional < String > nextRow ( )
    {
        if ( nthRow <= rowsLimit ) // If we have another row to give, give it.
        {
            nthRow++;
            String rowString = UUID.randomUUID() + DELIMITER + Instant.now().toString() + END_OF_LINE;
            return Optional.of( rowString );
        } else // Else we have exhausted the rows. So return empty Optional as a signal.
        {
            return Optional.empty();
        }
    }

    // --------|  `InputStream`  | -------------------
    @Override
    public int read ( ) throws IOException
    {
        return this.provideNextInt();
    }
}

默认文件名

我找不到完成最后一部分的方法,默认文件名包含内容生成的时间。

关于这一点,我什至在 Stack Overflow 上发了一个问题:Download with file name defaulting to date-time of user event in Vaadin Flow app

问题是 link 小部件后面的 URL 在页面加载和实例化 Anchor 小部件时创建了一次。之后,当用户阅读页面时,时间流逝。当用户最终点击link开始下载时,当前时刻晚于URL中记录的时刻。

似乎没有简单的方法可以将 URL 更新为用户点击事件或下载事件的当前时刻。

提示

顺便说一句,对于实际工作,我不会用自己的代码构建导出的行。相反,我会使用诸如 Apache Commons CSV to write the Tab-delimited or Comma-separated values (CSV) 内容之类的库。

资源

Viritin

Vaadin API 对于下载动态提供的文件有点违反直觉。我建议使用像 Flow Viritan to solve the issue. Check my year old blog entry.

这样的附加组件

我修改了DynamicFileDownloader in flow-viritin a bit. Now (since 0.3.5) you can override the file name dynamically. See change in GitHub