为什么流行的Java Base64编码库在编码时使用OutputStreams,解码时使用InputStreams?

5
我一直在努力解决Java程序中的内存问题,其中我们将整个文件加载到内存中,对其进行base64编码,然后将其作为表单参数在post请求中使用。由于文件大小极大,这会导致OOME。
我正在研究一种解决方案,能够通过base64编码器将文件流式传输到Http Post请求的请求体中。我注意到所有流行的编码库(Guava、java.util.Base64、android.util.Base64和org.apache.batik.util)中都存在一个共同模式,即如果库支持使用流进行编码,则编码始终通过OutputStream进行,而解码始终通过InputStream进行。
我发现很难找到/确定这些决策背后的原因。考虑到这么多受欢迎且写得很好的库都采用了这种API设计,我认为这其中肯定有理由。虽然将这些解码器之一调整为InputStream或接受InputStream似乎并不是很困难,但我想知道这些编码器被设计成这样是否有一个有效的架构原因。
为什么常见的库都通过OuputStream进行Base64编码,并通过InputStream进行Base64解码?
支持我的说法的示例:
java.util.Base64
 - Base64.Decoder.wrap(InputStream stream)
 - Base64.Encoder.wrap(OutputStream stream)

android.util.Base64
 - Base64InputStream  // An InputStream that does Base64 decoding on the data read through it.
 - Base64OutputStream // An OutputStream that does Base64 encoding

google.common.io.BaseEncoding
 - decodingStream(Reader reader)
 - encodingStream(Writer writer)

org.apache.batik.util
 - Base64DecodeStream implements InputStream
 - Base64EncodeStream implements OutputStream


当然,使用允许二进制数据的HTTP POST请求,你可能会想知道为什么要进行base64编码/解码。这是一个有趣的问题,在我早期的日子里也曾苦苦思索过,但一旦你理解了它,就像骑自行车一样,就能轻松掌握了。 - Maarten Bodewes
很不幸,我无法控制另一端的API,该API指定必须将文件作为参数进行base64编码,并使用application/x-www-form-urlencoded内容类型。 关于POST请求的OutputStream包装的问题。 不知何故,我们项目中非常老的http client抽象层没有暴露出包装OutputStream的方法,但它提供了提供InputStream的方案。尝试适应这个API是我提出这个问题的原因。@MaartenBodewes 的答案正是我正在寻找的,以确认这是错误的方法。谢谢! - M. Wallace
啊,现在我明白你的意思了。我想在这种情况下,您可以使用PipedInputStream来反转流并仍然使用OutputStream,并将base 64编码器包装在其周围。请注意,这是假定多个线程(!)因为如果它阻塞,你就有点麻烦了。如果您必须保持单个线程,则可能需要为应用程序实现更具体的非阻塞InputStream。当然,PipedInputStream确实会缓冲。 - Maarten Bodewes
1
你的老同事们手头有一个指向某个资源的 InputStream,并问自己:“现在我们该如何通过 HTTP 连接发送它呢?”使用给定的 InputStream 是一种逻辑上但错误的解决方案(主要是由于阻塞调用的处理)。在 Java 9 中,有一个(便利)方法 transferTo。它是 PipedInputStream 的逻辑对应物,可以帮助程序员连接两者并减轻缓冲/循环的负担。 - Maarten Bodewes
谢谢提供背景信息,现在更有意义了。 - erickson
显示剩余2条评论
1个回答

5

当然可以反转Base64编码,但这样做没有什么实际意义。Base64用于使应用程序生成或操作的二进制数据与基于文本的外部环境兼容。因此,Base64编码数据始终在外部使用,而解码后的二进制数据在内部使用。

应用程序通常不对Base64编码数据执行任何操作;它只是需要使用时将二进制数据与另一个应用程序通信,当需要或期望文本界面时


如果要将二进制数据导出到外部,则通常会使用输出流。如果该数据需要进行Base64编码,则确保将数据发送到编码为Base64的输出流中。

如果要从外部导入二进制数据,则应使用输入流。如果该数据已经以Base64编码方式编码,则您需要先解码它,因此请确保在将其视为二进制流之前进行解码。


让我们来创建一个图像。假设您有一个在文本环境中运行但操作二进制数据的应用程序。重要的是箭头的方向是从左侧应用程序的角度考虑的。

然后您将获得以下输入(读取调用):

{APPLICATION} <- (binary data decoding) <- (base64 decoding) <- (file input stream) <- [BASE 64 ENCODED FILE]

为此,您自然会使用输入流。

现在让我们看一下输出(写入调用):

{APPLICATION} -> (binary data encoding) -> (base64 encoding) -> (file output stream) -> [BASE 64 ENCODED FILE]

为此,您自然要使用输出流。

这些流可以通过将它们链接在一起(即使用一个流作为另一个流的父级)来连接。


以下是Java的示例。请注意,在数据类本身中创建二进制编解码器有点丑陋;通常您会使用另一个类 - 希望它足以用于演示目的。

import static java.nio.charset.StandardCharsets.UTF_8;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Base64;

public class BinaryHandlingApplication {

    /**
     * A data class that encodes to binary output, e.g. to interact with an application in another language.
     * 
     * Binary format: [32 bit int element string size][UTF-8 element string][32 bit element count]
     * The integers are signed, big endian values.
     * The UTF-8 string should not contain a BOM.
     * Note that this class doesn't know anything about files or base 64 encoding.
     */
    public static class DataClass {
        private String element;
        private int elementCount;

        public DataClass(String element) {
            this.element = element;
            this.elementCount = 1;
        }

        public String getElement() {
            return element;
        }

        public void setElementCount(int count) {
            this.elementCount = count;
        }

        public int getElementCount() {
            return elementCount;
        }

        public String toString() {
            return String.format("%s count is %d", element, elementCount);
        }

        public void save(OutputStream out) throws IOException {

            DataOutputStream dataOutputStream = new DataOutputStream(out);

            // so here we have a chain of:
            // a dataoutputstream on a base 64 encoding stream on a fileoutputstream 


            byte[] utf8EncodedString = element.getBytes(UTF_8);
            dataOutputStream.writeInt(utf8EncodedString.length);
            dataOutputStream.write(utf8EncodedString);

            dataOutputStream.writeInt(elementCount);
        }

        public void load(InputStream in) throws IOException {
            DataInputStream dataInputStream = new DataInputStream(in);

            // so here we have a chain of:
            // a datainputstream on a base 64 decoding stream on a fileinputstream 

            int utf8EncodedStringSize = dataInputStream.readInt();
            byte[] utf8EncodedString = new byte[utf8EncodedStringSize];
            dataInputStream.readFully(utf8EncodedString);
            this.element = new String(utf8EncodedString, UTF_8);

            this.elementCount = dataInputStream.readInt();
        }

    }

    /**
     * Create the a base 64 output stream to a file; the file is the text oriented
     * environment.
     */
    private static OutputStream createBase64OutputStreamToFile(String filename) throws FileNotFoundException {
        FileOutputStream textOutputStream = new FileOutputStream(filename);
        return Base64.getUrlEncoder().wrap(textOutputStream);
    }

    /**
     * Create the a base 64 input stream from a file; the file is the text oriented
     * environment.
     */
    private static InputStream createBase64InputStreamFromFile(String filename) throws FileNotFoundException {
        FileInputStream textInputStream = new FileInputStream(filename);
        return Base64.getUrlDecoder().wrap(textInputStream);
    }

    public static void main(String[] args) throws IOException {
        // this text file acts as the text oriented environment for which we need to encode
        String filename = "apples.txt";

        // create the initial class
        DataClass instance = new DataClass("them apples");
        System.out.println(instance);

        // perform some operation on the data
        int newElementCount = instance.getElementCount() + 2;
        instance.setElementCount(newElementCount);

        // write it away
        try (OutputStream out = createBase64OutputStreamToFile(filename)) {
            instance.save(out);
        }

        // read it into another instance, who cares
        DataClass changedInstance = new DataClass("Uh yeah, forgot no-parameter constructor");
        try (InputStream in = createBase64InputStreamFromFile(filename)) {
            changedInstance.load(in);
        }
        System.out.println(changedInstance);
    }
}

特别注意流的链接以及当然没有任何缓冲区。我使用了URL安全的base64编码(如果您想使用HTTP GET方法)。
在您的情况下,您可以使用URL生成HTTP POST请求,并通过包装它来直接对检索到的OutputStream流进行编码。这样就不需要(广泛)缓冲base64编码数据。请参见如何访问OutputStream此处的示例。 请记住,如果您需要缓冲,则操作可能有误。 正如评论中提到的那样,HTTP POST不需要base64编码,但无论如何,现在您知道如何将base64直接编码到HTTP连接中。
java.util.Base64的特定注意事项: 虽然base64是文本,但base64流生成/消耗字节;它只是假设ASCII编码(这可能对UTF-16文本来说很有趣)。 个人认为这是一个糟糕的设计决策;即使这会稍微减慢编码速度,他们也应该包装一个ReaderWriter
为他们辩护,各种base64标准和RFC也犯了同样的错误。

“然而,为什么不使用OutputStream进行输出,而使用InputStream进行输入?”这句话将在解码和编码的上下文中给出原因后变得更加完善。我知道之后尝试在一个方向上解释了这一点,但它听起来更像是改述(“编码”和“检索”),而不是理由说明。(就记录而言,我完全同意这个答案的观点。) - BeUndead
这个回答让我感到不满意。为什么编码被认为是输出,而解码被认为是输入?如果提供类似于Base64EncodingFilterInputStream这样的东西,会有什么后备/缺陷呢? - M. Wallace
1
这种编码输入流的缺点是,你最终会在应用程序内部得到base64。为什么需要它呢?你不能像演示中那样用它来链接流。 - Maarten Bodewes
@MaartenBodewes:感谢您的更新(并超越了期望)。现在阅读起来好多了。点赞 :)。 - BeUndead
@MaartenBodewes 很棒的回复,让我非常有共鸣,谢谢。您建议不使用缓冲让我感到困惑,抱歉这是我在Java中度过的第一年,之前一直在使用DotNet将近20年。目前因为我对P8项目中文件/二进制I/O的生疏而遭受了损失。我认为缓冲是一件好事,至少对于http和Java I/O流来说是这样吧? - Stephen Patten
现在的操作系统会自动进行缓冲,因此只有在性能不佳时才使用缓冲流,但请注意它可能并没有太大帮助。在写入流之前进行缓冲也没有意义,通常TCP协议会进行缓冲(毕竟流需要分成IP数据包)。 - Maarten Bodewes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接