JEP-380:Unix 域套接字通道
Michael McMahon 于 2021 年 2 月 3 日发布SocketChannel 和 ServerSocketChannel API 提供对 TCP/IP 套接字的阻塞和多路复用非阻塞访问。在 Java 16 中,这些类现已扩展,以支持同一系统内的内部 IPC 的Unix 域 (AF_UNIX) 套接字。本帖介绍如何使用该功能,并说明其他一些用例,例如同一系统上不同 Docker 容器中的进程之间的通信。
什么是 Unix 域套接字
TCP/IP 套接字由 IP 地址和端口号寻址,用于网络通信,无论是在互联网上还是在私有网络上。另一方面,Unix 域套接字仅用于同一物理主机上的进程间通信。它们已成为 Unix 操作系统数十年的一个功能,但直到最近才添加到 Microsoft Windows 中。因此,它们不再局限于 Unix。Unix 域套接字由文件系统路径名寻址,这些路径名看起来与其他文件名非常相似:例如 /foo/bar
或 C:\foo\bar
。
为什么使用它们?
将 Unix 域套接字用于本地 IPC 有许多好处。
- 性能
使用 Unix 域套接字代替环回 TCP/IP(连接到 127.0.0.1
或 ::1
)会绕过 TCP/IP 协议栈,从而在延迟和 CPU 使用方面得到改善。
- 安全性
首先,当需要对服务进行本地访问,但不进行远程网络访问时,将服务发布为本地路径名可以避免服务意外地被远程客户端访问。
其次,由于 Unix 域套接字由文件系统对象寻址,这意味着可以应用标准 Unix(和 Windows)文件系统访问控制来限制特定用户或组根据需要访问服务。
- 便利性
在某些环境(如 Docker 容器)中,使用 TCP/IP 套接字在不同容器中的进程之间设置通信链接可能会很麻烦。以下示例介绍如何在两个 Docker 容器之间的共享卷中通过 Unix 域套接字进行通信。
- 兼容性
如下所述,一旦考虑了 TCP/IP 和 Unix 域套接字之间明显的寻址差异,就可以预期它们的行为兼容。特别是,一旦创建了一个通道并将其绑定到一个地址,与寻址无关的现有代码应该继续正常工作,无论它给定的是 Unix 域还是 TCP/IP 套接字。因此,例如,选择器可以在不进行任何代码更改的情况下处理任何类型的套接字。
如何使用 Unix 域套接字
现有的 API(Java 16 之前)继续创建 TCP/IP 套接字。因此,必须使用下面描述的新 API 来创建和绑定 Unix 域套接字。
首先,要创建一个 Unix 域客户端或服务器套接字,请按如下所示指定协议族
// Create a Unix domain server
ServerSocketChannel server = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
// Or create a Unix domain client socket
SocketChannel client = SocketChannel.open(StandardProtocolFamily.UNIX);
第二个不同之处出现在将套接字绑定到地址时。为了支持这一点,定义了一种新类型 UnixDomainSocketAddress,它必须提供给现有的 bind
方法之一。
UnixDomainSocketAddress
实例是从以下两个工厂方法之一创建的
// Create an address directly from the string name and bind it to a socket
var socketAddress = UnixDomainSocketAddress.of("/foo/bar.socket");
socket.bind(socketAddress);
// Create an address from a Path and bind it to a socket
var socketAddress1 = UnixDomainSocketAddress.of(Path.of("/foo", "bar.socket"));
socket1.bind(socketAddress1);
上述规则的一个例外是,现有方法(在 Java 16 之前定义)始终创建 TCP/IP 套接字,即获取 SocketAddress 并返回连接到给定地址的客户端套接字的便捷 open
方法。在这种情况下,协议族是从给定的地址对象推断出来的,如下所示
var inetAddr = new InetSocketAddress("host", 80);
var channel1 = SocketChannel.open(inetAddr);
var unixAddr = UnixDomainSocketAddress.of("/foo/bar.socket");
var channel2 = SocketChannel.open(unixAddr);
在上面的示例中,channel1
具有 TCP/IP 协议族 INET 或 INET6,而 channel2
具有 UNIX 协议族。
这两种套接字之间还有另一个 API 差异。受支持的选项集是不同的,正如你所料,Unix 域套接字不支持 TCP/IP 特定选项。
就是这样。这些是这两种套接字之间的 API 差异。但是,请继续阅读以了解 Unix 域套接字的一些更具体的方面,你应该了解这些方面。
深入了解 Unix 域套接字
正如上一节所示,寻址模型之间有一些共性,但也有你应该注意的重大差异。
- 套接字文件独立于其套接字而存在
TCP/IP 端口号与拥有它们的套接字紧密相关。特别是,当 TCP/IP 套接字关闭时,可以假设其端口号已释放以便重新使用(最终)。Unix 域套接字并非如此。
当一个绑定的 Unix 域套接字关闭时,它的文件系统节点会一直存在,直到该文件被明确删除。在具有相同名称的套接字文件(或任何其他类型的文件)存在的情况下,没有后续套接字可以绑定到相同名称。因此,在清理服务器关闭后的工作时,确保其套接字文件被删除是一个好习惯。
- 客户端套接字不需要绑定到特定名称
使用 TCP/IP,如果客户端套接字未明确绑定,则操作系统会隐式选择一个本地端口号。对于 Unix 域套接字,情况略有不同,在这种情况下,套接字被称为 unnamed
,而不是为其选择一个名称。未命名套接字没有相应的文件系统节点,因此不必担心在套接字关闭后删除任何文件。
客户端套接字仍然可以显式绑定到一个名称,但在这种情况下,在套接字关闭后删除套接字文件的需求仍然存在。
- 服务器套接字始终绑定到一个名称
显然,服务器必须始终绑定才能供客户端访问。但是,名称并不总是需要“众所周知”。因此,如果使用 TCP/IP 套接字调用 bind(null)
,系统会自动为你选择一个端口号,你可以使用某种带外机制将该信息传递给客户端。Unix 域 ServerSocketChannels
中存在类似机制。如果你调用 bind(null)
,则系统会自动选择一个唯一路径名在某个系统临时位置进行绑定。有关此内容以及如何更改位置的更多信息,请参阅 API 文档。另请注意,对于两种 ServerSocketChannel
,getLocalAddress
方法都会返回实际绑定的地址。
- Unix 域套接字地址长度有限
所有操作系统都对 Unix 域套接字地址的长度施加了严格的限制。该值因平台而异,通常约为 100 字节。如果使用特别深的目录路径名,这可能会造成问题。
可以通过多种方式避免此问题,例如使用相对路径名。实际文件可以位于任意深的目录中,并且只要服务器及其客户端使用相同的当前工作目录,就可以将相对路径名安排为长度小于 100 字节。
如果系统临时目录的名称特别长,则在自动绑定服务器 (bind(null)
) 时也可能发生此问题。如上所述,有特定于平台的机制可以覆盖此目录的选择。
获取远程用户凭据
JDK 16 还具有一个额外的(特定于平台的)套接字选项 (SO_PEERCRED)。它在 Unix 系统上运行,以返回一个 UnixDomainPrincipal,其中封装了已连接对等方的用户名和组名。
使用 Unix 域套接字在两个 Docker 容器之间进行通信
在此示例中,下面的简单客户端和服务器将安装在两个不同的 Alpine Linux Docker 容器中,并且已安装 JDK 16。在主机上创建一个共享卷(称为 myvol
),并将其挂载在两个容器上(在 /mnt
中)。然后,在一个容器中运行的服务器创建一个绑定到 /mnt/server
的 ServerSocketChannel
,而运行在第二个容器中的客户端连接到同一地址。
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.io.*;
import static java.net.StandardProtocolFamily.*;
public class Server {
public static void main(String[] args) throws Exception {
var address = UnixDomainSocketAddress.of("/mnt/server");
try (var serverChannel = ServerSocketChannel.open(UNIX)) {
serverChannel.bind(address);
try (var clientChannel = serverChannel.accept()) {
ByteBuffer buf = ByteBuffer.allocate(64);
clientChannel.read(buf);
buf.flip();
System.out.printf("Read %d bytes\n", buf.remaining());
}
} finally {
Files.deleteIfExists(address.getPath());
}
}
}
// assume same imports
public class Client {
public static void main(String[] args) throws Exception {
var address = UnixDomainSocketAddress.of("/mnt/server");
try (var clientChannel = SocketChannel.open(address)) {
ByteBuffer buf = ByteBuffer.wrap("Hello world".getBytes());
clientChannel.write(buf);
}
}
}
最后,Docker 命令使这一切得以实现。这假定已安装 JDK 16 的现有映像,并且 Client
和 Server
已在主机上使用 javac
编译。
// creates a shareable volume called myvol
docker volume create myvol
// In one window run an image mounting the shared volume
// Assume alpine_jdk_16 is a local image with jdk 16 installed
docker run --mount 'type=volume,destination=/mnt,src=myvol' -it alpine_jdk_16 sh
// In 2nd window run same
docker run --mount 'type=volume,destination=/mnt,src=myvol' -it alpine_jdk_16 sh
// In 3rd window: get container ids (substitute ids below as appropriate)
docker ps
docker cp Client.class 3c7e76e9f1a2:/root
docker cp Server.class 687de1ed186c:/root
// Go back and run Server in 687de1ed186c and Client in 3c7e76e9f1a2
继承的通道
自 Java 1.5 起就存在继承通道机制,并且当 Java 虚拟机通过类似于 Linux 上的 inetd
或 macOS 上的 launchd
的机制启动时,该机制始终能够返回 TCP/IP 套接字。此机制现在支持 Unix 域套接字,前提是底层启动框架也支持它们。
限制
JEP-380 专注于在主要受支持平台上通用的功能。以下 Unix/Linux 特定功能目前不受支持,但可以在未来的工作中考虑
-
Linux 抽象路径。抽象路径不链接到文件系统对象,因此在独立于拥有套接字方面与 TCP/IP 端口号的行为更相似。
-
数据报支持
-
对
java.net.Socket
和java.net.ServerSocket
的支持。传统的套接字网络类主要由于其固定使用java.net.InetAddress
进行寻址而与 TCP/IP 绑定。 -
通过 Unix 域
SocketChannel
发送通道。由于 Unix 域套接字局限于单个系统,因此可以使用它们在连接的对等方之间传输除数据之外的对象。原则上,可以通过这种方式在进程之间发送由文件描述符表示的任何对象。在实践中,最有可能将功能限制为 NIO 通道对象。
结论
这是 Java 16 中 SocketChannel
和 ServerSocketChannel
中 Unix 域套接字的简要介绍。与往常一样,请查阅NIO 通道API 文档以获取完整规范。平台特定套接字选项在 jdk.net 中定义。