JEP-380:Unix 域套接字通道

SocketChannelServerSocketChannel API 提供对 TCP/IP 套接字的阻塞和多路复用非阻塞访问。在 Java 16 中,这些类现已扩展,以支持同一系统内的内部 IPC 的Unix 域 (AF_UNIX) 套接字。本帖介绍如何使用该功能,并说明其他一些用例,例如同一系统上不同 Docker 容器中的进程之间的通信。

什么是 Unix 域套接字

TCP/IP 套接字由 IP 地址和端口号寻址,用于网络通信,无论是在互联网上还是在私有网络上。另一方面,Unix 域套接字仅用于同一物理主机上的进程间通信。它们已成为 Unix 操作系统数十年的一个功能,但直到最近才添加到 Microsoft Windows 中。因此,它们不再局限于 Unix。Unix 域套接字由文件系统路径名寻址,这些路径名看起来与其他文件名非常相似:例如 /foo/barC:\foo\bar

为什么使用它们?

将 Unix 域套接字用于本地 IPC 有许多好处。

  • 性能

使用 Unix 域套接字代替环回 TCP/IP(连接到 127.0.0.1::1)会绕过 TCP/IP 协议栈,从而在延迟和 CPU 使用方面得到改善。

book cover

  • 安全性

首先,当需要对服务进行本地访问,但不进行远程网络访问时,将服务发布为本地路径名可以避免服务意外地被远程客户端访问。

其次,由于 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 协议族 INETINET6,而 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 文档。另请注意,对于两种 ServerSocketChannelgetLocalAddress 方法都会返回实际绑定的地址。

  • Unix 域套接字地址长度有限

所有操作系统都对 Unix 域套接字地址的长度施加了严格的限制。该值因平台而异,通常约为 100 字节。如果使用特别深的目录路径名,这可能会造成问题。

可以通过多种方式避免此问题,例如使用相对路径名。实际文件可以位于任意深的目录中,并且只要服务器及其客户端使用相同的当前工作目录,就可以将相对路径名安排为长度小于 100 字节。

如果系统临时目录的名称特别长,则在自动绑定服务器 (bind(null)) 时也可能发生此问题。如上所述,有特定于平台的机制可以覆盖此目录的选择。

获取远程用户凭据

JDK 16 还具有一个额外的(特定于平台的)套接字选项 (SO_PEERCRED)。它在 Unix 系统上运行,以返回一个 UnixDomainPrincipal,其中封装了已连接对等方的用户名和组名。

使用 Unix 域套接字在两个 Docker 容器之间进行通信

在此示例中,下面的简单客户端和服务器将安装在两个不同的 Alpine Linux Docker 容器中,并且已安装 JDK 16。在主机上创建一个共享卷(称为 myvol),并将其挂载在两个容器上(在 /mnt 中)。然后,在一个容器中运行的服务器创建一个绑定到 /mnt/serverServerSocketChannel,而运行在第二个容器中的客户端连接到同一地址。

    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 的现有映像,并且 ClientServer 已在主机上使用 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 特定功能目前不受支持,但可以在未来的工作中考虑

  1. Linux 抽象路径。抽象路径不链接到文件系统对象,因此在独立于拥有套接字方面与 TCP/IP 端口号的行为更相似。

  2. 数据报支持

  3. java.net.Socketjava.net.ServerSocket 的支持。传统的套接字网络类主要由于其固定使用 java.net.InetAddress 进行寻址而与 TCP/IP 绑定。

  4. 通过 Unix 域 SocketChannel 发送通道。由于 Unix 域套接字局限于单个系统,因此可以使用它们在连接的对等方之间传输除数据之外的对象。原则上,可以通过这种方式在进程之间发送由文件描述符表示的任何对象。在实践中,最有可能将功能限制为 NIO 通道对象。

结论

这是 Java 16 中 SocketChannelServerSocketChannel 中 Unix 域套接字的简要介绍。与往常一样,请查阅NIO 通道API 文档以获取完整规范。平台特定套接字选项在 jdk.net 中定义。