一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情。
上个文章介绍到Okio 是一个库,可补充java.io
并使java.nio
访问、存储和处理数据变得更加容易。它最初是 OkHttp的一个组件,它是 Android 中包含的功能强大的 HTTP 客户端。它训练有素,可以解决新问题。
ByteStrings 和 Buffers
Okio 围绕两种类型构建,将大量功能打包到一个简单的 API 中:
- ByteString是一个不可变的字节序列。对于字符数据,
String
是基础。ByteString
是 String 失散多年的兄弟,可以很容易地将二进制数据视为一个值。这个类符合人体工程学:它知道如何将自己编码和解码为十六进制、base64 和 UTF-8。 - 缓冲区是一个可变的字节序列。就像
ArrayList
,您不需要提前调整缓冲区的大小。您将缓冲区作为队列读取和写入:将数据写入末尾并从前面读取。没有义务管理职位、限制或容量。
在内部,ByteString
并Buffer
做一些聪明的事情来节省 CPU 和内存。如果您将 UTF-8 字符串编码为ByteString
,它会缓存对该字符串的引用,因此如果您稍后对其进行解码,则无需执行额外的操作。
Buffer
实现为段的链表。当您将数据从一个缓冲区移动到另一个缓冲区时,它会重新分配段的所有权,而不是复制数据。这种方法对多线程程序特别有用:与网络对话的线程可以与工作线程交换数据,而无需任何复制或仪式。
Sources and Sinks
设计的一个优雅部分java.io
是如何对流进行分层以进行加密和压缩等转换。Okio 包含自己的流类型,称为Source
and Sink
,其工作方式类似于InputStream
and OutputStream
,但有一些关键区别:
- 超时。 流提供对底层 I/O 机制超时的访问。与
java.io
套接字流不同,两者read()
和write()
调用都遵循超时。 - 易于实施。
Source
声明了三个方法:read()
、close()
和timeout()
. 没有像available()
单字节读取这样导致正确性和性能意外的危险。 - 使用方便。 尽管and的实现只有三个方法可以编写,但调用者被赋予了带有 和接口的丰富 API。这些界面在一个地方为您提供所需的一切。
Source``Sink
**BufferedSource
BufferedSink
- 字节流和字符流之间没有人为的区别。 都是数据。以字节、UTF-8 字符串、big-endian 32 位整数、little-endian shorts 形式读取和写入;无论你想要什么。没有了
InputStreamReader
! - 易于测试。 该类
Buffer
实现了两者BufferedSource
,BufferedSink
因此您的测试代码简单明了。
源和接收器与InputStream
和互操作OutputStream
。您可以将 anySource
视为InputStream
,也可以将 anyInputStream
视为 Source
。同样对于Sink
和OutputStream
。
版本要求
Okio 2.x supports Android 4.0.3+ (API level 15+) and Java 7+.
Okio 3.x supports Android 4.0.3+ (API level 15+) and Java 8+.
Okio depends on the Kotlin standard library. It is a small library with strong backward-compatibility.
历史版本
Our change log has release history.
implementation("com.squareup.okio:okio:3.0.0")
复制代码
案例
按行读取文本文件
用FileSystem.source(Path)
打开源流以读取文件。返回的Source
接口非常小,用途有限。相反,我们用缓冲区包装源。这有两个好处:
- 它使 API 更强大。 代替提供的基本方法
Source
,BufferedSource
有几十种方法可以简洁地解决最常见的问题。 - 它使您的程序运行得更快。 缓冲允许 Okio 以更少的 I/O 操作完成更多工作。
每个Source
打开的都需要关闭。打开流的代码负责确保它是关闭的。
在这里,我们使用 Java 的try
块来自动关闭我们的源代码。
public void readLines(Path path) throws IOException {
try (Source fileSource = FileSystem.SYSTEM.source(path);
BufferedSource bufferedFileSource = Okio.buffer(fileSource)) {
while (true) {
String line = bufferedFileSource.readUtf8Line();
if (line == null) break;
if (line.contains("square")) {
System.out.println(line);
}
}
}
}
复制代码
API 会读取所有数据,readUtf8Line()
直到下一行分隔符—— \n
、\r\n
或文件末尾。它将该数据作为字符串返回,并在末尾省略分隔符。当它遇到空行时,该方法将返回一个空字符串。如果没有更多数据要读取,它将返回 null。
上面的 Java 程序可以通过内联fileSource
变量和使用花哨的for
循环而不是 a来更紧凑地编写
public void readLines(Path path) throws IOException {
try (BufferedSource source = Okio.buffer(FileSystem.SYSTEM.source(path))) {
for (String line; (line = source.readUtf8Line()) != null; ) {
if (line.contains("square")) {
System.out.println(line);
}
}
}
}
复制代码
该readUtf8Line()
方法适用于解析大多数文件。对于某些用例,您还可以考虑readUtf8LineStrict()
. 它是相似的,但它要求每一行都以\n
or结尾\r\n
。如果它在此之前遇到文件末尾,它将抛出一个EOFException
. 严格变体还允许字节限制来防御格式错误的输入。
public void readLines(Path path) throws IOException {
try (BufferedSource source = Okio.buffer(FileSystem.SYSTEM.source(path))) {
while (!source.exhausted()) {
String line = source.readUtf8LineStrict(1024L);
if (line.contains("square")) {
System.out.println(line);
}
}
}
}
复制代码
写一个文本文件
上面我们使用 aSource
和 aBufferedSource
来读取文件。要编写,我们使用 aSink
和 a BufferedSink
。缓冲的优点是相同的:更强大的 API 和更好的性能。
public void writeEnv(Path path) throws IOException {
try (Sink fileSink = FileSystem.SYSTEM.sink(path);
BufferedSink bufferedSink = Okio.buffer(fileSink)) {
for (Map.Entry<String, String> entry : System.getenv().entrySet()) {
bufferedSink.writeUtf8(entry.getKey());
bufferedSink.writeUtf8("=");
bufferedSink.writeUtf8(entry.getValue());
bufferedSink.writeUtf8("n");
}
}
}
复制代码
没有编写一行输入的 API;相反,我们手动插入我们自己的换行符。大多数程序应该硬编码"\n"
为换行符。在极少数情况下,您可以使用 System.lineSeparator()
:"\n"
它"\r\n"
在 Windows 和"\n"
其他任何地方返回。
fileSink
我们可以通过内联变量和利用方法链来更紧凑地编写上述程序:
public void writeEnv(Path path) throws IOException {
try (BufferedSink sink = Okio.buffer(FileSystem.SYSTEM.sink(path))) {
for (Map.Entry<String, String> entry : System.getenv().entrySet()) {
sink.writeUtf8(entry.getKey())
.writeUtf8("=")
.writeUtf8(entry.getValue())
.writeUtf8("n");
}
}
}
复制代码
在上面的代码中,我们对writeUtf8()
. 进行四次调用比下面的代码更有效,因为 VM 不必创建和垃圾收集临时字符串。
sink.writeUtf8(entry.getKey() + "=" + entry.getValue() + "\n"); // Slower!
复制代码
UTF-8
在上面的 API 中你可以看到 Okio 真的很喜欢 UTF-8。早期的计算机系统遭受了许多不兼容的字符编码:ISO-8859-1、ShiftJIS、ASCII、EBCDIC 等。编写支持多个字符集的软件非常糟糕,我们甚至没有表情符号!今天我们很幸运,世界各地都对 UTF-8 进行了标准化,在遗留系统中很少使用其他字符集。
如果您需要另一个字符集,readString()
并且writeString()
可以为您服务。这些方法要求您指定字符集。否则,您可能会意外创建只能由本地计算机读取的数据。大多数程序应该只使用 UTF-8 方法。
在对字符串进行编码时,您需要注意字符串表示和编码的不同方式。当字形有重音或其他修饰时,它可以表示为单个复杂代码点 ( é
) 或简单代码点 ( e
) 后跟其修饰符 ( ´
)。当整个字形是称为NFC的单个代码点时;当它是多个时,它是NFD。
尽管我们在 I/O 中读取或写入字符串时都使用 UTF-8,但当它们在内存中时,Java 字符串使用称为 UTF-16 的过时字符编码。这是一种糟糕的编码,因为它对大多数字符使用 16 位 char
,但有些不适合。特别是,大多数表情符号使用两个 Java 字符。这是有问题的,因为String.length()
返回了一个令人惊讶的结果:UTF-16 字符的数量而不是字形的自然数量。
在大多数情况下,Okio 可以让您忽略这些问题并专注于您的数据。但是当您需要它们时,可以使用方便的 API 来处理低级 UTF-8 字符串。
用于Utf8.size()
计算将字符串编码为 UTF-8 而不实际编码所需的字节数。这在像协议缓冲区这样的以长度为前缀的编码中很方便。
用于BufferedSource.readUtf8CodePoint()
读取单个可变长度代码点,并 BufferedSink.writeUtf8CodePoint()
写入一个。
public void dumpStringData(String s) throws IOException {
System.out.println(" " + s);
System.out.println(" String.length: " + s.length());
System.out.println("String.codePointCount: " + s.codePointCount(0, s.length()));
System.out.println(" Utf8.size: " + Utf8.size(s));
System.out.println(" UTF-8 bytes: " + ByteString.encodeUtf8(s).hex());
System.out.println();
}
复制代码
黄金价值
Okio 喜欢测试。该库本身经过大量测试,它具有在测试应用程序代码时通常很有帮助的功能。我们发现一种非常有用的模式是“黄金价值”测试。此类测试的目的是确认用早期版本的程序编码的数据可以被当前程序安全地解码。
我们将通过使用 Java 序列化对值进行编码来说明这一点。尽管我们必须否认 Java 序列化是一个糟糕的编码系统,并且大多数程序应该更喜欢 JSON 或 protobuf 等其他格式!无论如何,这里有一个方法,它接受一个对象,序列化它,并将结果作为 a 返回ByteString
:
private ByteString serialize(Object o) throws IOException {
Buffer buffer = new Buffer();
try (ObjectOutputStream objectOut = new ObjectOutputStream(buffer.outputStream())) {
objectOut.writeObject(o);
}
return buffer.readByteString();
}
复制代码
这里发生了很多事情。
- 我们创建一个缓冲区作为序列化数据的保存空间。这是一个方便的替代品
ByteArrayOutputStream
。 - 我们向缓冲区询问其输出流。写入缓冲区或其输出流总是将数据附加到缓冲区的末尾。
- 我们创建一个
ObjectOutputStream
(Java 序列化的编码 API)并编写我们的对象。try 块负责为我们关闭流。请注意,关闭缓冲区无效。 - 最后我们从缓冲区中读取一个字节串。该
readByteString()
方法允许我们指定要读取的字节数;在这里,我们没有指定计数来阅读整个内容。从缓冲区读取总是消耗缓冲区前面的数据。
使用我们serialize()
方便的方法,我们可以计算和打印一个黄金值
Point point = new Point(8.0, 15.0);
ByteString pointBytes = serialize(point);
System.out.println(pointBytes.base64());
复制代码
我们将其打印ByteString
为base64,因为它是一种适合嵌入测试用例的紧凑格式。程序打印这个:
rO0ABXNyAB5va2lvLnNhbXBsZXMuR29sZGVuVmFsdWUkUG9pbnTdUW8rMji1IwIAAkQAAXhEAAF5eHBAIAAAAAAAAEAuAAAAAAAA
复制代码
这就是我们的黄金价值!我们可以再次使用 base64 将其嵌入到我们的测试用例中,以将其转换回ByteString
:
ByteString goldenBytes = ByteString.decodeBase64("rO0ABXNyAB5va2lvLnNhbXBsZ"
+ "XMuR29sZGVuVmFsdWUkUG9pbnTdUW8rMji1IwIAAkQAAXhEAAF5eHBAIAAAAAAAAEAuA"
+ "AAAAAAA");
复制代码
下一步是将ByteString
back 反序列化为我们的值类。此方法与 serialize()
上述方法相反:我们将一个字节字符串附加到缓冲区,然后使用 ObjectInputStream
:
private Object deserialize(ByteString byteString) throws IOException, ClassNotFoundException {
Buffer buffer = new Buffer();
buffer.write(byteString);
try (ObjectInputStream objectIn = new ObjectInputStream(buffer.inputStream())) {
return objectIn.readObject();
}
}
复制代码
现在我们可以根据黄金值测试解码器:
ByteString goldenBytes = ByteString.decodeBase64("rO0ABXNyAB5va2lvLnNhbXBsZ"
+ "XMuR29sZGVuVmFsdWUkUG9pbnTdUW8rMji1IwIAAkQAAXhEAAF5eHBAIAAAAAAAAEAuA"
+ "AAAAAAA");
Point decoded = (Point) deserialize(goldenBytes);
assertEquals(new Point(8.0, 15.0), decoded);
复制代码
Point
通过这个测试,我们可以在不破坏兼容性的情况下更改类的序列化。