Socket 入门

Socket 入门

1. 什么是Socket

1.1. Socket 的引入

​ 我们知道进程通信的方法有管道、命名管道、信号、消息队列、共享内存、信号量等等,这些方法都要求通信的两个进程位于同一个主机。如果通信双方不在同一个主机,使用 TCP/IP 协议族就能达到我们想要的效果。但是,当我们使用不同的协议进行通信时就得使用不同的接口,还得处理不同协议的各种细节,这就增加了开发的难度,软件也不易于扩展。于是UNIX BSD就发明了socket

1.2. Socket 的位置

socket屏蔽了各个协议的通信细节,使得程序员无需关注协议本身,直接使用socket提供的接口来进行互联的不同主机间的进程的通信。这就好比操作系统给我们提供了使用底层硬件功能的系统调用,通过系统调用我们可以方便的使用磁盘(文件操作),使用内存,而无需自己去进行磁盘读写,内存管理。socket 提供了 TCP/IP 协议的抽象,对外提供了一套接口,同过这个接口就可以统一、方便的使用tcp/ip协议的功能了。简单来说, Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。

图片来源于《tcp/ip协议详解卷一》

1.3. Socket 的定义

socket 一词的起源在组网领域的首次使用是在1970年2月12日发布的文献 IETF RFC33 中发现的(第6页),撰写者为 Stephen CarrSteve CrockerVint Cerf。根据美国计算机历史博物馆的记载,Croker 写道:“命名空间的元素都可称为套接字接口。一个套接字接口构成一个连接的一端,而一个连接可完全由一对套接字接口规定。(The elements of the name space are called sockets. A socket forms one end of a connection, and a connection is fully specified by a pair of sockets.)”计算机历史博物馆补充道:“这比BSD的套接字接口定义早了大约12年。”

1.4. Socket 的分类

流套接字 (Stream Socket): 主要用于 TCP 协议; 提供了双向的、有序的、无重复的、无记录边界的数据传输服务。

数据报套接字: 主要用于 UDP 协议; 它提供了双向的、无序的、有重复的、有记录边界的数据传输服务。

原始套接字: 主要用于访问底层协议,如 IP、ICMP与IGMP 等协议; 原始套接字可以保存 IP 包中的完整 IP 头部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

sequenceDiagram
Title: 面向连接的客户/服务器的时序图
Note left of Server: socket()
Note left of Server: bind()
Note left of Server: listen()
Note left of Server: accept()
Note right of Client: socket()
Note right of Client: connect()
Note left of Server: 阻塞:等待用户请求
Client ->> Server: 请求建立连接
Client ->> Server: 数据发送
Note left of Server: recv()
Note left of Server: sendto()
Server ->> Client: 数据发送
Note right of Client: recv()
Note left of Server: close()
Note right of Client: close()


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

sequenceDiagram
Title: 面向无连接的客户/服务器的时序图
Note left of Server: socket()
Note right of Client: socket()
Note left of Server: bind()
Note right of Client: bind()
Note right of Client: sendto()
Client ->> Server: 请求建立连接
Note left of Server: recvfrom()
Note left of Server: 阻塞:等待用户请求
Note left of Server: sendto()
Server ->> Client: 数据发送
Note right of Client: recvfrom()
Note left of Server: close()
Note right of Client: close()

2. Socket 常用函数

注意这里的函数都是包含在 C 语言 <socket.h> 和 <type.h> 头文件中的函数

2.1. socket()

1
int socket(int protofamily, int so_type, int protocol);
  • 点击这里或者查看这篇文章可以查看详情
  • protofamily 指协议族,常见的值有:
    • AF_INET,指定 so_pcb 中的地址要采用 ipv4 地址类型
    • AF_INET6,指定 so_pcb 中的地址要采用 ipv6 的地址类型
    • AF_LOCAL/AF_UNIX,指定 so_pcb 中的地址要使用绝对路径名
  • so_type 指定 socket 的类型,常见的值有:
    • SOCK_STREAM(基于 TCP的,数据传输比较有保障)
    • SOCK_DGRAM (是基于 UDP 的,专门用于局域网)
  • protocol 指定具体的协议,常见的值有:
    • IPPROTO_TCP,TCP 协议
    • IPPROTO_UDP,UPD 协议
    • 0,如果指定为0,表示由内核根据so_type指定默认的通信协议

​ 值得注意的是,socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而 socket() 用于创建一个 socket 描述符(socket descriptor),它唯一标识一个 socket。这个 socket 描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用 bind() 函数,否则就当调用 connect() 、listen() 时系统会自动随机分配一个端口。

2.2. bind()

1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  • addr:一个 const struct sockaddr * 指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同。
  • addrlen:对应的是地址的长度。

2.3. listen() & connect()

1
2
3
int listen(int sockfd, int backlog);

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。

listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。

connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。

2.4. accept()

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); 

accept() 函数的第一个参数为服务器的socket描述字,第二个参数为指向 struct sockaddr * 的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。

如果 accpet 成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的 TCP 连接。
TCP 服务器端依次调用 socket() bind() listen() 之后,就会监听指定的 socket 地址了。TCP 客户端依次调用 socket() connect() 之后就想 TCP 服务器发送了一个连接请求。TCP 服务器监听到这个请求之后,就会调用 accept() 函数取接收请求,这样连接就建立好了。

之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作.

2.5. read() & write()

网络I/O操作有下面几组:

1
2
3
4
5
6
7
8
9
read();write();

recv();send();

readv();writev();

recvmsg();sendmsg();

recvfrom();sendto();

负责相应的数据读写操作

2.6. close()

1
int close(int fd);

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的 socket 描述字,好比操作完打开的文件要调用 fclose() 关闭打开的文件。

3. Java Socket 常用 API

3.1. INetAddress 类 —— 套接字地址/域名解析

  • Java使用InetAddress表示IP地址

    • 定义于 java.net 包下

    • 既可以表示 IPv4 的地址,也可以表示 IPv6 的地址

  • 在 Java 的 Socket API 中,往往将一个 InetAddress 对象和一个端口号一起使用作为套接字地址

  • 通过 InetAddress 的静态方法 getByName() 可以将一个IP地址或域名转换为 InetAddress 对象

    • 当对应域名不存在或无法解析时,会抛出 java.net.UnknownHostException 异常,需要对其进行处理
  • 举例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.net.*;
class Lookup {
public static void main(String[] args) {
try {
InetAddress a = InetAddress.getByName(args[0]);
System.out.println(args[0] + ":" + a.getHostAddress());

} catch (UnknownHostException e) {
System.out.println("No address found for " + args[0]);
}
}
}


//有输出如下
----------------------------------------
> java Lookup software.nju.edu.cn
software.nju.edu.cn:219.219.120.45
> java Lookup 127.0.0.1
127.0.0.1:127.0.0.1
-----------------------------------------

3.2. Socket/ServerSocket 类 —— TCP套接字

3.2.1. Socket

Socket 类表示一个建立好的TCP连接,它可以由服务端通过主动连接创建,也可以被 ServerSocket 通过 accept 调用创建(称为主动套接字

1
2
3
4
5
6
7
8
9
10
11
//客户端主动连接
new Socket(InetAddress addr, int port)
//当目标机器不可达、连接被重置或拒绝等情况时,会抛出java.io.IOException异常


//与远程机器通信
InputStream getInputStream()//从远程机器读数据

OutputStream getOutputStream()//向远程机器写数据

void close()//关闭该套接字连接

3.2.2. ServerSocket

ServerSocket 类表示服务端创建的等待客户端来连接的TCP套接字(称为被动套接字

1
2
3
4
5
6
7
8
9
10
11
12
//绑定ServerSocket到本地端口并监听
new ServerSocket(int port) //backlog=50

new ServerSocket(int port, int backlog)

new ServerSocket(int port, int backlog, InetAddress bindAddr)

//当绑定失败(譬如端口被占用时)均抛出java.io.IOException

//通过accept调用获取一个完成三次握手的TCP连接
Socket accept()
//创建时的backlog参数表示允许完成三次握手但没被accept调用获取到的TCP连接个数,超出backlog的连接将会被拒绝

3.3. InputStream/OutputStream —— 数据的读取和写出

3.3.1. InputStream

  • InputStream类是 Java 提供的输入流抽象
    • 定义于 java.io 包下
    • 输入流有流终止和传输错误两种情况,而一般而言前者不抛出异常,后者抛出 java.io.IOException
    • 通过 read() 方法进行读取
1
2
3
4
5
abstract int read()//从输入流读取单个字节,当读取成功时,返回0-255的整数,当流终止时,返回-1

int read(byte[] b)//从输入流读取最多b.length个字节,并返回读取到b的元素的个数,当流终止时,返回-1

int read(byte[] b, int off, int len)//从输入流读取最多len个字节到b中以b[off]开头的存储空间中,当流终止时,返回-1

通过实现 InputStream 类,可以实现新的输入源,如 FileInputStream,ByteBufferInputStream 等

通过包装 InputStream 类,可以扩充和简化 InputStream的API,如 DataInputStream,BufferedInputStream 等

关于更多的Java IO,可以查看这篇文章

3.3.2 OutputStream

OutputStream类为Java提供的输出流抽象

  • 定义于 java.io 包下
  • 由于数据流的流终止由调用者决定,因此只存在传输错误一种情况,会抛出 java.io.IOException 异常
  • 通过 write() 方法进行写出
1
2
3
4
5
6
7
8
abstract void write(int b)//写出单个字节到输出流,只有低8位有效

void write(byte[] b)//将b中b.length个字节写出到输出流

void write(byte[] b, int off, int len)//将b中b[off]开头的len个字节写出到输出流

//通过close方法关闭/终止输出流
//子类通过重写close方法决定close时的行为

通过实现 OutputStream 类,可以实现新的输出源,如FileOutputStream,ByteBufferOutputStream 等

通过包装 OutputStream 类,可以扩充和简化 OutputStream 的API,如 DataOutputStream,BufferedOutputStream 等

3.4. DatagramSocket/DatagramPacket —— UDP套接字

3.4.1. DatagramSocket

DatagramSocket 类表示一个 UDP 套接字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//绑定UDP套接字到本地端口
//当绑定失败时,均会抛出java.net.SocketException异常
new DatagramSocket()//绑定套接字到任意端口
new DatagramSocket(int port)//绑定套接字到指定端口
new DatagramSocket(int port, InetAddress addr)//绑定套接字到指定IP地址和端口


close()//关闭当前UDP套接字,释放其占用的端口


//发送和接收数据报文
send(DatagramPacket p)//发送UDP报文
receive(DatagramPacket p)//接收UDP报文,会一直阻塞接收到报文,或者在设置了超时时间后,抛出java.net.SocketTimeoutException
setSoTimeout(int timeout)//设置接收UDP报文的超时时间

3.4.2 DatagramPacket

DatagramPacket类封装了一个由UDP套接字传输的数据包,其中包含:

  • 一个缓冲区:用于存放UDP报文数据
  • 目标机器的IP地址端口:当DatagramSocket 的 connect 方法被调用后,DatagramPacket 中的目标 IP 和端口将被忽略,直到DatagramSocket 的 disconnect 方法被调用
1
2
3
4
//创建DatagramPacket的载荷

new DatagramPacket(byte[] buf, int len)//创建一个用于接收的DatagramPacket类,超出len的UDP数据将被截断
new DatagramPacket(byte[] buf, int len)//创建一个用于发送的DatagramPacket类

4. 包含 TCP/UDP 的 Socket 使用

4.1. TCP 套接字编程实例

  • 客户端从标准输入流读取一行字符串,并将其写出到服务端
  • 服务端读取客户端的输入数据,将其转换为大写,并传回给客户端
  • 客户端从服务端读取数据,并将转换后的字符串输出到标准输出流,回显给用户
  • 如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package Client
import java.io.*;
import java.net.*;

class TCPClient {
public static void main(String argv[]) throws Exception{
String sentence;
String modifiedSentence;
BufferedReader inFromUser = new BufferedReader(new InputStreamReader(System.in));
Socket clientSocket = new Socket("hostname", 6789);
DataOutputStream outToServer = new DataOutputStream(clientSocket.getOutputStream());
BufferedReader inFromServer = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
sentence = inFromUser.readLine();
outToServer.writeBytes(sentence + '\n');
    modifiedSentence = inFromServer.readLine();    
System.out.println("FROM SERVER: " + modifiedSentence);   
clientSocket.close();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package Server
import java.io.*;
import java.net.*;

class TCPServer {
  public static void main(String argv[]) throws Exception{
String clientSentence;
String capitalizedSentence;
ServerSocket welcomeSocket = new ServerSocket(6789);
while(true) {
Socket connectionSocket = welcomeSocket.accept();
        BufferedReader inFromClient = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
DataOutputStream  outToClient = new DataOutputStream(connectionSocket.getOutputStream());
        clientSentence = inFromClient.readLine();
        capitalizedSentence = clientSentence.toUpperCase() + '\n';
outToClient.writeBytes(capitalizedSentence);    
}
}
}

4.2. UDP 套接字编程实例

  • 如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package Client
import java.io.*;
import java.net.*;

class UDPClient {
public static void main(String args[]) throws Exception{
BufferedReader inFromUser = new BufferedReader(new InputStreamReader(System.in));  
DatagramSocket clientSocket = new DatagramSocket();
InetAddress IPAddress = InetAddress.getByName("hostname");
byte[] sendData = new byte[1024];
byte[] receiveData = new byte[1024];
String sentence = inFromUser.readLine();
sendData = sentence.getBytes();
DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length, IPAddress, 9876);
clientSocket.send(sendPacket);
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
clientSocket.receive(receivePacket);  
String modifiedSentence = new String(receivePacket.getData());
System.out.println("FROM SERVER:" + modifiedSentence);
    clientSocket.close();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package Server
import java.io.*;
import java.net.*;

class UDPServer {
public static void main(String args[]) throws Exception{
DatagramSocket serverSocket = new DatagramSocket(9876);
byte[] receiveData = new byte[1024];
byte[] sendData  = new byte[1024];
while(true){    
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);  
serverSocket.receive(receivePacket);      
String sentence = new String(receivePacket.getData());
     InetAddress IPAddress = receivePacket.getAddress();
int port = receivePacket.getPort();
String capitalizedSentence = sentence.toUpperCase();
sendData = capitalizedSentence.getBytes();
DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length, IPAddress, port);
serverSocket.send(sendPacket);  
}
}
}

5. 参考文章

  1. 深入理解Socket
  2. AF_INET与套接字
  3. Java IO最详解