分享

网络编程入门教程

 夜猫速读 2022-05-05 发布于湖北

1、网络编程简介

1. 前言

在计算机诞生之初,它们的体积非常庞大,它们常常被摆放在一个很大的房间内,这些房间通常都装有玻璃墙,你可以透过玻璃欣赏这个神奇的电子物种。一般来说,只有大学或者大型研究机构才会拥有先进的电子计算机,如果你需要通过这些大型计算机完成一些工作,需要走进机房,面对面的和它交流。现在看来,通过这些奇迹工作很不方便,但在当时来说,代表了最先进的科技实力。

随着 PC 的迅猛发展,以及计算机网络的诞生,很快这些计算机就被连接在了一起,你再不需要走进机房面对面和它交流了,通过远程就可以和他们交流了。当然,早期的计算机网络也是来源于大学和科研机构。然而计算机技术的发展真是太过迅猛了,经过短短几十年,从最早的专业科研机构的计算机网络,到现在的移动互联网、物联网,手机、家电、交通工具都实现了互联。

2. 什么是网络编程

我们今天所讨论的网络编程是指编写特定的应用程序,使得接入到计算机网络中的设备能够相互通信。网络设备之间的通信,需要程序员设计好特定的协议,然后通过编程语言实现这些协议,最终由计算机设备来执行。在计算机网络的发展过程中,通用的、基础的网络协议已经被实现在计算机操作系统中,通常叫做网络协议栈。而操作系统为程序员提供了网络相关的 API ,通常把它叫做 Socket。为此,网络编程也叫 Socket 编程套接字编程

任何事物的发展都有它的起源和历史,现在我们了解一下 Socket 的发展史。

3. Socket 发展史

我们现在以及今后所讨论的 Socket 都叫做 Berkeley (伯克利) Socket,是由加州大学伯克利分校的计算机系统研究小组发布的。从历史上来看,Berkeley Socket 是随着 4.x BSD(Berkeley Software Distribution) 操作系统一起发布的。BSD 操作系统最早是在贝尔实验室的 Unix V6 之上增加了一些新的功能,或者是一些优化,所以不是完全自己实现的,这个版本就是 BSD1。从 BSD1 开始,中间经历了好几个版本,直到 BSD 4.2 开始支持了 TCP/IP 软件。关于 Berkeley Socket 的发布历史年表,如下:

  • 4.2 BSD(1983)第一个包含 TCP/IP 的版本。

  • 4.3 BSD(1986)对 TCP/IP 的支持更加完善。

  • 4.3 BSD Tahoe(1988) 支持慢启动、拥塞避免等。

  • 4.3 BSD Reno(1990)支持 TCP 头部预测、SLIP 头部压缩等。

  • 4.4 BSD(1993)支持多播、长肥管道修改等。

由于 BSD 是基于 AT&T 的 UNIX 发布的,核心代码都是 AT&T 的 UNIX,所以受限于 AT&T License。伯克利的研究人员决定开发可以自由发布的系统,所以在 4.3 BSD Tahoe 基础之上,对系统中原有 AT&T 的源码进行重写,发布了 BSD Net/x 系列,它的历史年表如下:

  • BSD Net/1(1989)针对网络部分进行移植。

  • BSD Net/2(1991)对系统中网络以外的组件进行了移植,

  • BSD Net/3(1994)也叫 4.4 BSD-Lite,是一个系统功能比较完善的版本,符合 BSD license 的规定。后来出现的 OpenBSD、FreeBSD 都是基于 4.4 BSD-Lite 修改的。

4. 为什么要学习网络编程

现代人的生活已经离不开网络,不知道你是否体会过不带手机、家里不能上网的心情。不管你在意还是不在意,网络就在你身边,如影随形。想象一下,现代社会如果没有网络又会是什么状况呢?

当然,作为一名程序员,就算你不需要编写网络程序,但也应该理解网络通信原理。各种网络中间件、分布式框架、微服务框架,其中很大一部分工作都是在处理网络通信的逻辑,要想学习这些中间件不懂网络通信是很难学好的。另外,一些知名的网络库,它里面包含了许多优秀的设计理念和编程技巧,也是非常值得研读的,对于个人技术能力的提升具有很大的帮助。

5. 如何学习网络编程

要想学习网络编程,需要学习计算机网络基础知识。比如,TCP/IP 协议栈工作原理,分组在路由器中的存储、转发原理,可靠协议 TCP 的工作原理,不可靠协议 UDP 的工作原理等。

由于网络协议栈的发展是离不开操作系统的,所以需要学习操作系统的基础知识,比如进程、线程的基本概念。

学习编程必须要动手实践,只有当你亲自上机调试一些程序,你才能体会的更深刻,掌握的更牢固。

最后,你需要学会使用 tcpdump 和 Wireshark 这两个抓包工具,这俩个工具是解决很多网络相关问题的利器,可以说是开发人员工具箱里面必备工具。tcpdump 是 linux 系统自带的工具,而 Wireshark 是具有图形界面的工具,一般在 Windows 系统上使用。同时,要学会应用 ipconfig、netstat 等网络工具。

本系列文章的所有示例代码路径:

https://github.com/haska1025/imooc-sock-core-tech/tree/master/java_netprogramming

6. 小结

文中涉及到的所有示例程序都采用 JAVA 语言实现,JAVA 作为一门平台无关语言,天生就对网络编程提供了很好的支持。所以,需要具备一定的 JAVA 语言基础。当然,选择一款自己喜欢的 IDE 也是很重要的,这样可以提高你学习编程的效率。

另外,在调试网络程序的过程中,经常需要检查端口是否被占用,检查服务是否监听成功,定位消息收发失败等问题。所以需要学习一下 ipconfig、netstat 的使用方法,这两个工具都是系统自带的,使用非常方便。最好在你的个人电脑上安装一个 Wireshark 工具,方便问题的分析。

2、网络参考模型及协议族介绍

1. 前言

对于现代人来说,大家对计算机网络Internet因特网这些术语都很熟悉。计算机网络就是由能够相互通信的终端和其他通信子网中的通信设备连接在一起,而组成的计算机系统。通常把网络中的设备叫做节点,包括:PC、打印机、手机,路由器、交换机、网关等。在网络发展的早期,计算机网络仅仅是由科研院所、大学等研究机构的计算机组成。今天看来,组成计算机网络的终端系统不仅仅是计算机了,还包括手机、汽车、家用电器、摄像头等很多类型的终端。Internet 是指世界范围内的计算机网络互连在一起形成的系统,中文叫做互联网

随着技术的发展,计算机网络的信号传输方式也在不断的变化,从同轴电缆到光纤,从有线到无线。通常来讲,计算机网络是通过有线连接或者无线连接进行信号传输的。在短距离传输信号通常是通过有线或者无线传输,比如通过双绞线线或者 WIFI 传输。在长距离传输信号是通过光纤传输。

计算机网络各节点之间的通信就是进行数据包的传输,然而计算机不像人可以独立思考,需要程序员为它们设计一组通信协议,它们彼此才能正常收发数据包。所以学习计算机网络,一定是离不开学习各种通信协议的。那么什么是协议呢?

2. 什么是协议

我们知道人类是通过语言进行沟通的。比如,帅哥 A 说:“夜猫编程的课程都很棒!”。而美女 B 是 imooc 迷,随声附和道:“嗯,确实!”。然而美女 C 完全不了解 imooc, 她可能默不作声,或者反问:“夜猫编程是什么?”。这意味着,人们的沟通是基于一定的前提假设的,是基于一些大家都能理解的、约定俗成的规定的。我们把这些约定或者规则叫做协议(Protocol)

两台计算机之间的通信也是模拟人类交流的,通信之前也需要有一些约定,也就是说要提前设计好协议

计算机网络协议的规模非常庞大,数据包的收发过程也非常复杂。为了使计算机网络容易理解、传播和实现,科学家们对网络进行了分层设计,并对其进行了标准化,最终形成了经典的 ISO/OSI 参考模型TCP/IP 参考模型

3. ISO/OSI 七层模型和 TCP/IP 四层模型

网络分层的主要是采取分治策略,使得复杂问题简单化,网络模块组件化。每一层都有自己的职责,每一层对上一层提供服务,这样实现了职责单一,进而提高了组件的复用性。。在网络标准化过程中,国际标准化组织(ISO)把计算机网络分为 7 层,叫做开放系统互联模型(OSI)。同时,TCP/IP 四层模型正处于开发阶段。最终,OSI 模型和 TCP/IP 模型有一些差异,对比图如下:

从图中可以看出,ISO/OSI 七层模型和 TCP/IP 四层模型之间存在两个差异:

  • TCP/IP 参四层模型没有表示层会话层。那么,TCP/IP 参考模型不需要表示层会话层吗?答案是:“如果需要这两层,就由应用程序员来实现”。经过这么多年发展来看,需要表示层和会话层的场景并不多。

  • TCP/IP 模型好像没有链路层,其实在网络协议实现过程中,链路层包含了网卡驱动部分和物理介质部分,所以通常把二者统一叫做链路层

各层具体功能解释如下:

  • 应用层(Application Layer) 是由应用程序自定义的协议格式。不同的领域,对网络应用程序的需求是不同的,必须给用户自定义协议格式的权利。最广泛的应用层协议应该是 HTTP 了吧,几乎每个人都在用。

  • 表示层(Presentation)主要是定义数据格式。比如,加密和解密、压缩和解压缩。在 TCP/IP 模型中,表示层协议包含在应用层里。比如 SSL/TLS 协议。

  • 会话层(Session Layer)用于在两个通信实体之间建立会话、维护会话、终止会话。设置检查点,当系统出现崩溃拉起后,通过寻找检查点恢复运行。在 TCP/IP 模型中,会话层协议包含在应用层里。比如 NetBIOS 协议。

  • 传输层(Transport Layer) 是在两个通信主机之间进行报文传送。在 TCP/IP 模型中最重要的两个传输协议就是 TCP 和 UDP,前者是可靠的、面向字节流的传输;后者是不可靠的、面向数据报(Datagram)的传输。

提示:通常把 TCP 传输的报文称作报文段(Segment)通常把 UDP 传输的报文称作消息(Message)或者是数据报(Datagram)

  • 网络层(Network Layer)是将 IP 分组从源端路由到目的端。网络层是为传输层服务的,在发送端的网络层收到传输层的数据后,必要时会对数据切片以后再封包发送。实现网络层协议是路由器的主要职责,是构建通信子网的基础。比如, RIP、OSPF 就是最基础的路由协议。路由器是通过 IP 分组的目的 IP 地址查找本地路由表,寻找转发的目标端。所以路由器的主要工作就是路由选择转发,IP 分组是逐跳(hop)转发的。

提示:通常把网络层传输的报文叫做 IP 分组(Packet)

  • 链路层(Data Link Layer)是为网络层服务的。发送的时候将网络层的数据分片,封装成帧(Frame),然后顺序发送。与网络层实现端到端的路由不同,链路层是实现节点之间的数据传送。常用的链路层协议就是以太网(Ethernet)协议。

提示:通常把链路层传输的报文叫做帧(Frame)

  • 物理层(Physical Layer)是描述如何在物理介质中传输 bit 位。比如,用多少伏电压表示“1”,用多少伏电压表示“0”等。

我们把分层的网络参考模型叫做协议栈(Protocol Stack)。目前所有网络协议栈的实现都采用了 TCP/IP 参考模型。协议栈的实现是包含在操作系统内核中的,比如 Windows 和 Unix-like 系统。

4. TCP/IP 协议族

TCP/IP 协议族包含了很多协议,一些比较重要的、常用的协议展现如下:

依据 TCP/IP 参考模型,我们把协议栈从上到下分为 4 层,每一层都有相应的协议。

  1. 应用层

  • HTTP(Hypertext Transfer Protocol)超文本传输协议,只要你浏览页面就会用到此协议。

  • SMTP(Simple Mail Transfer Protocol)简单邮件传输协议,用于电子邮件传输。

  • FTP(File Transfer Protocol)文件传输协议,用于文件的上传和下载。现在 FTP 貌似用的不多了,早期主要是在公司内部、大学等研究机构用的比较多。

  • RTMP(Real-Time Messaging Protocol)实时消息协议,用于实时流媒体传输,主要用在音视频直播领域,属于 Adobe 公司出品。不过,从 2020 年开始,Adobe 就不再支持 RTMP 协议了。

  • SNMP(Simple Network Management Protocol)简单网络管理协议,用于电信网络设备的管理。比如,监控设备告警,对设备进行业务配置等。主要是在思科、华为等电信网络设备上用的比较多。其实,应用层除了以上诸多知名协议外,也可以是用户自定义协议。

  1. 传输层

  • TCP(Transmission Control Protocol)传输控制协议,是面向连接的、可靠的、面向字节流的传输协议。TCP 应用非常广泛,是端到端传输的基石。

  • UDP(User Datagram Protocol)用户数据报协议,是无连接的、不可靠的、面向消息的传输协议。UDP 实时性好,效率高,在音视频传输中有着广泛的应用。

  1. 网络层

  • IPv4 (Internet Protocol version 4)此协议主要是用于 IP 分组的路由转发,是路由器主要实现的协议。我们经常说的 IP 地址是指 IPv4 地址,用 32 bit 来表示。

  • IPv6 (Internet Protocol version 6)此协议工作原理类似 IPv4。之所以设计 IPv6 的目的是因为上世纪 90 年代中期,因特网爆炸式的增长,32 位 IPv4 地址不够用了,为此才设计了 128 位的 IPv6 地址。

  • ICMP(Internet Control Message Protocol)因特网控制消息协议,主要是用于显示网络错误。比如,我们用 ping 的时候,有时会显示“网络不可达”的错误。

  • ICMPv6 (Internet Control Message Protocol Version 6)整合了 ICMP 协议,针对 IPv6 开发的协议。

  • IGMP(Internet Group Management Protocol)因特网组管理协议,主要是用于 IP Multicast 的场景,比如观看 IP 电视节目。

  1. 链路层

  • ARP(Address Resolution Protocol)地址解析协议,主要是用于生成 IP 地址和物理地址(比如以太网 MAC 地址)的映射表,用于数据包的快速转发。

  • RARP(Reverse Address Resolution Protocol)反向地址解析协议,主要是用于生成物理地址和 IP 地址的映射。

以上这些协议属于计算机网络的基础协议,需要很好的掌握其工作原理。

5. 小结

本文开篇简单介绍了网络的基本概念,重点介绍了 ISO/OSI 参考模型TCP/IP 参考模型的结构,各层的基本功能,以及二者的对比。其实,ISO/OSI 参考模型只是一个用于教学的理想模型,并没有产品实现。而目前的网络协议栈都是采用 TCP/IP 参考模型

关于 TCP/IP 参考模型中的协议有很多,我们重点介绍了常用的几个协议,这些协议是日常工作中必不可少的,需要重点掌握。尤其是以太网数据帧节点转发原理,IP 分组的存储转发原理,可靠性 TCP 的工作原理和不可靠性 UDP 的工作原理,建议重点学习。

3、网络编程概念介绍

1. 前言

我们知道计算机网络就是将各种设备通过有线或无线连接在一起,这些设备有终端设备,比如 PC、手机、打印机等;还有各种网络核心设备,比如路由器、交换机、网关等。通常把网络设备叫做节点,这些节点都工作在计算机网络的 TCP/IP 参考模型之上。

链路层实现节点之间的数据转发。比如以太网协议,包含独立的以太网帧头,帧头中包含了源 MAC 地址、目标 MAC 地址,通过 MAC 地址实现节点之间的数据帧转发。

网络层也叫 Internet 协议层,主要职责就是提供端到端的网络传输,比如主机到主机的通信。网络层最重要的一个设备就是路由器路由器的主要职责是提供路由选择转发,将分组从源主机转发到目的主机。每台主机和路由器的每个端口都配有 IP 地址,路由器是通过 IP 地址实现分组转发的。分组在路由器之间的转发是一个逐跳转发的过程。网络层不保证分组传输的可靠性。

传输层包含两个非常重要的协议:TCP 和 UDP。TCP 是可靠传输协议,面向连接的,可以保证数据段(Segment)顺序。UDP 是不可靠传输协议,无连接的,不保证数据报(Datagram)的顺序。一台主机可以同时运行多个 TCP 或 UDP 应用程序,不同应用程序之间是通过端口号(Port)来识别。

2. IP 地址

IP 地址有 IPv4 和 IPv6 两个版本。IPv4 地址长度是 32 bit,4 个字节,每个字节是独立取值,通常用点分十进制的形式表示。例如,192.168.0.100。IPv4 地址范围是 0.0.0.0 ~ 255.255.255.255,最多包含 4294967296(2^32) 个 IP 地址。而 IPv6 的地址格式是八元组形式,比如 2001:0DB8::1428:57ab。

本节只讨论 IPv4 地址。IPv4 地址通常划分成网络 ID主机 ID两部分。比如:

IP 地址分类划分如下:

分类起始地址结束地址
A0.0.0.0127.255.255.255
B128.0.0.0191.255.255.255
C192.0.0.0223.225.255.255
D224.0.0.0239.255.255.255

IP 地址分类划分缺乏灵活性,对于 A 类地址来说,网络 ID 只有 2^7 = 128 个,但是主机 ID 多达 2^24 = 16777216 个,主机 ID 浪费很大。对于 C 类地址来说,网络 ID 可以有 2^21 = 2097152 个,但是主机 ID 只有 2^8 = 256 个,对于有些组织来说主机 ID 不够划分。于是 1993 年出现了 CIDR(Classless Inter-Domain Routing)的编址策略,叫做无类别域间路路由选择

CIDR 编址是一种 IP 地址的压缩表示方式,将 IP 地址分为网络前缀主机标识两部分,形如 A.B.C.D/L 的表示方式,L 是一个小于 32 的十进制数字,代表网络前缀占用 L 个比特,主机标识占用 32 - L 个比特。比如,200.101.80.0/20 表示网络前缀占用 20 个比特,主机标识占用 12 个比特。

在 CIDR 编址方式下,如何通过 IP 地址快速确定网络 ID 呢?是通过子网掩码来确定的。对于形如 A.B.C.D/L 的子网,子网掩码是由 L 个 bit 1 和 32 - L 个 bit 0 组成的二进制数。只要把 A.B.C.D 和子网掩码做一个按位与(&)运算,就可以得到网络 ID。可以说,形如 A.B.C.D/L 的表示,可以唯一确定一个网络 ID,我们通常把 A.B.C.D/L 表示叫做网段。你可以说 A.B.C.D/L 表示了一个网段,网段就是形如 A.B.C.D/L 的表示形式。

比如,200.101.80.0/20 网段的子网掩码的二进制形式是 11111111 11111111 11110000 00000000,十进制形式是 255.255.240.0。将 200.101.80.0 和 255.255.240.0 做按位与(&)运算,得到的网络 ID 是 200.101.80.0。

那么 IP 地址 200.101.96.1 是 200.101.80.0/20 网段的 IP 吗?我们只需要把 200.101.96.1 和 255.255.240.0 做一个按位与(&)运算,查看结果是否等于 200.101.80.0 即可。

采用 CIDR 编码方式优势如下:

  • 简单灵活

  • 有效利用 IP 地址空间

  • 减小路由表规模。

比如 200.101.80.0/20 网段中的 IP 地址 200.101.80.100,如果按照分类,属于 C 类地址,网络 ID 占用 24 个 bit,主机 ID 占用 8 个 bit;如果采用 CIDR 方式,网络 ID 占用 20 个 bit,主机 ID 占用 12 个 bit。对于主机较多的网络,极大地提高了 IP 地址的利用率。

3. 端口号

端口号是用 16 bit 无符号整数表示的,取值范围是 0~65535,总共可以分配 65536 个端口号。端口号属于稀缺资源,是由 Internet Assigned Numbers Authority (IANA)统一管理和分配的。端口号当前分配状况:

  • 0 ~ 1023此区间内的端口号叫做知名端口号,已经被系统或者是一些知名的服务所占用,比如:

端口号用途
20 , 21用于 FTP 协议
23用于 telnet 协议
80用于著名的 HTTP 服务
443用于 HTTPS 服务
  • 1023 ~ 65535此区间端口号也有很多被知名的应用占有,比如:

端口号用途
1433用于 SQL Server 服务等等
1935用于 RTMP 服务
3306用于 MySQL 服务
8080作为 HTTP 服务的另外一个端口号

4. 域名及域名解析

不管是 IPv4 地址,还是 IPv6 地址,都是用一串数字表示的。计算机喜欢处理数字化的 IP 地址,而我们人类并不喜欢数字,因为数字不直观、不便于记忆、不利于使用。试想一下,如果让你去记忆各大网站的 IP 地址,我相信你能记住的 IP 地址不会超过 10 个。

那么该如何化解这个尴尬呢?科学家们又发明了一套字符型的地址方案,即用主机名(Host Name)来唯一标识一台主机,主机名是用我们人类自己的语言来命名,当然目前主要是英语了。这里需要强调的是 Host Name 也可以唯一标识一台主机,是另外一套地址方案。只不过 Host Name 是方便人类使用的,而计算机还是用 IP 地址。

在互联网中,有不计其数的主机,要保证这些主机的唯一性,必须用一套统一的 Host Name 编码方案。目前应用最广泛的就是域名系统(DNS,Domain Name System),DNS 是一套分布式系统,DNS 所包含的两项最主要的工作就是:域名分配域名解析域名分配就是为互联网中的主机分配一个唯一的域名域名解析就是将域名转换为IP 地址的过程。

域名是一颗树形结构,包含了许多层级:

  • 根级域,是指一个无名的树根。

  • 顶级域,顶级域分为 generic TLDs(gTLDs),country-code TLDs(ccTLDs)、internationalized country-code TLDs(IDN,ccTLDs)、infrastructure TLD。我们在图中列举的 net、edu、org、gov、com 是指 gTLDs,即通用顶级域。比如 .com。

  • 二级域,是指顶级域的下一级。比如 .imooc。

  • 子域,是指二级域以下的所有层级。其实也可以叫做三级域、四级域等。比如 www。

比如,拿 www.baidu.com 来说,.com 是顶级域;.imooc 是二级域;www 是子域。

域名是由美国非营利性机构维护的,它管理着全世界的域名系统。比如,所有的顶级域(TLD)都是由 ICANN 来规定。

域名解析是通过本地域名解析服务 Resolver 或者远程域名解析服务器获取 IP 地址的过程。

5. 小结

IP 地址可以唯一标识一台主机,Port 可以唯一标识某台主机上的某个应用程序。IP 和 Port 的组合经常叫做 Endpoint,可以唯一标识互联网中的一个应用程序。在网络编程过程中是离不开 IP 和 Port 的,所以掌握 IP 地址的划分,以及端口号的基本知识是非常有必要的。

在互联网中,域名可以唯一标识一台主机,理解域名分配、域名解析过程,对解决一些复杂网络问题有很大的帮助。

4、互联网标准化组织

1. 前言

互联网(Internet)行业两个非常重要的标准化组织是 IETF(Internet Engineering Task Force)和 W3C(Internet Engineering Task Force)。IETF 主要负责 ISO 参考模型相关协议的标准化,比如 TCP、IP、OSPF、DNS 等。W3C 主要是负责 WWW 相关协议的标准化,比如 HTML、XML、SOAP 等。

2. IETF RFC

TCP/IP 标准都是以 RFC(Request for Comments)文档出版。RFC 格式诞生于 1969年,是 ARPANET 项目的开创性成果。如今 RFC 已经是 TCP/IP 标准的的官方发行渠道。请看下图来了解 RFC 的主要来源机构。

  • 互联网工程任务组 IETF(Internet Engineering Task Force)是一个开放标准组织,主要工作是开发和推广自愿的 Internet 标准,专注于工程和标准制定的短期问题研究。

  • 互联网研究任务组 IRTF(Internet Research Task Force)是一个专注于和 Internet 相关的、长期问题的研究。

  • 互联网体系结构委员会 IAB(Internet Architecture Board)是 IETF 的委员会,也是 ISOC 的咨询机构。其职责是对 IETF 活动进行监督。

  • 互联网协会 ISOC(Internet Society)是成立于 1992 年的美国非营利性组织,领导着互联网标准的推广、教育、政策方面工作。

RFC 主要是由工程师和计算机科学家以备忘录的形式撰写的。很多 RFC 是实验性的,并不是标准,IETF 会吸收一些 RFC 建议,然后将其作为标准发布。RFC 标准都是以 "RFC xxx"的形式发布的,xxx 是编号,比如 RFC 1122。RFC 经过几十年的发展,到写作此文为止,最新编号是 RFC 8900。关于 RFC 的索引库,可以参考 rfc index

IETF 主要涉及的领域:

  • Internet 主要是 IP 层相关协议的开发、扩展。比如,IPv6、DHCP 等。

  • Routing 负主要是 IP 路由相协议的开发。比如,MPLS、BGP、OSPF 等。

  • Transport 主要是 QoS、端到端传输相关协议开发。

  • Security 主要是网络安全相关协议开发。

  • Network O&M(Operations And Management)主要是网络服务、管理相关工作,比如,SNMP、MIB 等。

  • User Services 主要是提供标准化过程中的文档。

  • Application 主要是应用协议开发和扩展。比如,FTP、SMTP、TELNET。

3. W3C

W3C 也是一个互联网标准组织,是 1994 年由 Tim Berners-Lee 发起的,目前也是由此人领导的。W3C 最初的想法是建立统一、兼容的 HTML 标准,并且建议各个浏览器厂商采用 W3C 标准,从而解决各个厂商之间浏览器不兼容的问题。W3C 的主要工作范围是 WWW(World Wide Web)。W3C 的目标是用 Web 将人类以一种更高效、更公平的方式连接在一起。W3C 的成员来源于各大公司或者研究机构,个人只能以特邀专家的形式参与。

W3C 是一个非盈利性组织,由四个机构共同管理:

  • 欧洲信息学和数学研究联合会(ERCM)

  • 美国麻省理工学院计算机科学与人工智能实验室(MIT CSAIL)

  • 日本的 Keio 大学

  • 中国北京航空航天大学

W3C 目前分了很多工作组,比如 HTML 工作组、CSS 工作组,SVG 工作组。每个工作组的最终目标是发布 Web 标准规范,官方叫做 W3C 推荐(Recommendation)。W3C Recommendation 标准化过程:

  • 记录(Note)记录,也叫编辑草案,一般来源于 W3C 成员的提交,或者是内部员工的想法,或者是相关方不完善的提议。记录不一定会产生工作组,也不一定会形成 Recommendation。当某项提交被 W3C 认可,就会成立一个工作组,其中包括会员和其他相关方。工作组会发布工作草案。

  • 工作草案 Working Draft (WD)发布工作草案是标准化的第一阶段。当工作草案发布给社区,经过社区评审以后,可能会产生一些不一致的意见,这需要草案的负责人进一步修改和完善,最终达成一致意见。当工作草案经过几轮评审后,没有分歧,就会发布候选推荐。

  • 候选推荐 Candidate Recommendation (CR)候选推荐是比工作草案更接近标准的版本。进入这一阶段,意味着工作组对该标准达到其目标非常有信心。候选推荐的目的是从开发社区获得更多的帮助。因为有些标准是比较复杂,需要会员的帮助。

    候选推荐可能会进一步更改,但修改范围局限在比较重要的特性。

  • 提议推荐 Proposed Recommendation (PR)提议推荐是比较完善的版本,不会进行大范围修改,只是进行 bug 的修复。到这一阶段,文档需要提交给顾问委员会批准。

  • W3C 推荐 W3C recommendation (REC)W3C 推荐是规范的最高级别了,到这一阶段,规范经过很多轮的测试和评审,经过了理论和实践的考验,被 W3C 所接受,鼓励大范围的实现和推广。

4. 小结

学习计算机网络,就是学习各种协议。如果不了解历史,直接研究某个协议,会有一种不适感、枯燥感,总是想知道为什么要这样设计呢?如果了解了互联网标准化历史和标准化过程,再去研究各种协议,会有一种亲切感,觉得协议的设计就是合情合理的。

TCP/IP 协议族的实现都包含在 RFC 标准中,Web 应用中的协议都包含在 W3C 标准中。在工作中如果需要学习某种协议,直接参考 RFC 和 W3C 官方标准即可。

5. 参考

[1]: [rfc_index] https://www./rfc-index2.html

5、大小端序之争

1. 前言

在 C 语言中,内置的基本类型有 char、short、int、long、double 等,对于整型类型来说,还区分 signed 和 unsigned。在 Java 语言中,内置类型也有 char、short、int、long、double 等,只不过 Java 没有 unsigned 类型。char 类型在 C 语言是占用 1 字节长度,而在 Java 语言中占用 2 字节长度。而其他类型不管在 C 语言中,还是在 Java 语言中,都是占用多个字节长度。

我们知道 CPU 访问内存是通过地址总线完成的,一块连续的内存空间是经过编址的,每一个地址编号对应 1 字节长度的内存空间,地址空间是从低地址到高地址增长的。如果要在内存中存储 0xAABBCCDD 这样一个长度为 4 字节的十六进制整数,需要 4 字节的内存空间。内存空间示意如下:

     100  101  102  103  -------> 内存地址由低到高增长的方向   +----+----+----+----+   |    |    |    |    |   +----+----+----+----+代码块12345

那么 0xAA 是存储在地址编号为 100 的空间呢?还是存储在地址编号为 103 的空间呢?这就是本节要讨论的字节序的问题。

字节序大端序(Big-Endian)和小端序(Little-Endian)之分。对于前面提到的十六进制整数 0xAABBCCDD 来说,如果按照大端序在内存中存储,那么从低地址到高地址的存储顺序依次是 0xAA、0xBB、0xCC、0xDD;如果按照小端序在内存中存储,那么从低地址到高地址的存储顺序依次是 0xDD、0xCC、0xBB、0xAA。

文字描述还是有些抽象,我们通过一张图来直观感受一下内存字节序。

2. 计算机的字节序

在操作系统课程中,我们学过现代操作系统的内存管理机制是虚拟内存管理机制,对于 32 位系统来说,每一个进程都有 4G( 2^32)字节长度的虚拟地址空间,也叫线性地址空间。我们先看一张图。

图中用内存地址 0x90000001 ~ 0x9000000A 表示了 10 字节的内存地址空间,每一个地址代表 1 字节的内存。当一个多字节整数存储在内存中时,会涉及到字节序的问题。

我们首先搞清楚两个术语:最高有效位最低有效位。我们知道,人类习惯的阅读顺序是从左到右,对于一个多位数字来说,经常把它的最左边叫做高位,把它的最右边叫做低位。而在计算机中,对于一个多位数字的描述,也有类似的专业术语,把左边的最高位叫做最高有效位(MSB,most significant bit);把右边最低位叫做最低有效位(LSB,least significant bit)。

下图展示了在内存中存储 16 进制整数 0xAABBCCDD 的不同方式。图中用内存地址 0x90000000 ~ 0x90000003 表示了长度为 4 字节的内存地址空间。

如果按照小端序来存储,0xAABBCCDD 在内存中从低地址到高地址的存储顺序是 0xDD、0xCC、0xBB、0xAA,存储顺序和人类习惯的阅读顺序是相反的。

如果按照大端序来存储,0xAABBCCDD 在内存中从低地址到高地址的存储顺序是 0xAA、0xBB、0xCC、0xDD,存储顺序和人类习惯的阅读顺序是相同的。可以类比人类的阅读顺序,更容易理解,也便于记忆。

大小端序是由于 CPU 架构的不同导致的,在历史上 IBM System/360 、Motorola 6800 / 6801、SPARC 是大端序;Intel 架构、ARM 架构是小端序。另外,JAVA 存储多字节整数,也是采用大端序

通过简单的程序,很容易测试出来我们当前系统所采用的字节序类型。

3. 通过 C 程序测试字节序

通过 C 语言程序来测试字节序非常简单,大致思路如下:

  • 定义一个整形变量,然后将 0xAABBCCDD 赋值给该变量。

  • 按照从低地址到高地址的顺序打印此变量的内容。

  • 将打印结果的顺序和 0xAABBCCDD 的顺序进行对比,观察二者的变化。

代码片段如下:

  1 #include <stdio.h>  2  3 void check_endian()  4 {  5     int n = 0xAABBCCDD;  6  7     unsigned char *ptr_n = (unsigned char*)&n;  8  9     for (int i=0; i < 4; ++i){ 10         printf("%X\n", *ptr_n++); 11     } 12 }代码块123456789101112

代码中有两个需要注意的地方:

Tips:

  1. 需要将 int 型变量 n 的地址赋值给了 unsigned char 型指针变量,如果是赋值给 char 型变量,那么打印结果是:

FFFFFFDDFFFFFFCCFFFFFFBBFFFFFFAA代码块1234

原因是 printf 在打印的时候会将 char 提升为 int,0xAA,0xBB 最高位是 1,所以会当做符号位扩展。如果是 unsigned char,会提升为 unsigned int,符号位扩展是 0。

  1. 打印结果的时候用 %x 或者 %X 进行格式化输出。

C 语言程序输出结果:

DDCCBBAA代码块1234

从输出结果可以看出我的系统是以小端序来存储整数的。

4. Java ByteOrder

我们知道 Java 是平台无关的编程语言,它是运行在 Java 虚拟机之上的,而 Java 虚拟机又是运行在 Native 系统上的。那么,如何通过 Java 程序检测系统本身的字节序呢?可以通过 java.nio.ByteOrder 类来测试当前 Native 系统的字节序。调用 ByteOrder 的 nativeOrder 方法,就能返回系统本身的字节序。另外,ByteOrder 还定义了两个 ByteOrder 类型的常量常用:

  • ByteOrder.BIG_ENDIAN 表示大端序

  • ByteOrder.LITTLE_ENDIAN 表示小端序

检测程序也很简单,如下:

public static void testByteOrder(){    System.out.println("The native byte order: " + ByteOrder.nativeOrder());}代码块123

检测结果如下:

The native byte order: LITTLE_ENDIAN代码块1

5. Java ByteBuffer 的字节序

那么 JVM 作为一部独立运行的机器,它的字节序又是如何呢?通过 Java 程序测试字节序的思路和 C 程序的一致,代码片段如下:

 public static void checkEndian() {     int x = 0xAABBCCDD;     ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES);     buffer.putInt(x);     byte[] lbytes = buffer.array();     for (byte b : lbytes){         System.out.printf("%X\n", b);     } }代码块1234567891011

关于 JAVA 程序需要说明的是 JAVA 中没有指针的概念,所以不能通过取地址的方式直接打印内存的值。需要借助 JAVA 的 ByteBuffer,将 int 型数值存储到 ByteBuffer 中,然后将 ByteBuffer 转换成字节数组,通过打印数组的方式来达到我们的目的。引用 ByteBuffer 需要通过语句 import java.nio.ByteBuffer; 导入ByteBuffer 类。

JAVA 测试结果:

AABBCCDD代码块1234

从输出结果可以看出 ByteBuffer 默认是以大端序来存储整数的,因为 Java 虚拟机本身采用的就是大端序,ByteBuffer 也要和整个系统保持一致。当然,ByteBuffer 也提供了 ByteBuffer order()ByteBuffer order(ByteOrder bo) 方法,用来获取和设置 ByteBuffer 的字节序。

另外,像一些多字节 Buffer,如 IntBuffer、LongBuffer,它们的字节序规则如下:

  • 如果多字节 Buffer 是通过数组(Array)创建的,那么它的字节序和底层系统的字节序一致。

  • 如果多字节 Buffer 是通过 ByteBuffer 创建的,那么它的字节序和 ByteBuffer 的字节序一致。

测试程序如下:

    public static void checkByteBuffer(){        ByteBuffer byteBuffer = ByteBuffer.allocate(Long.BYTES);        long [] longNumber = new long[]{          0xAA,0xBB,0xCC,0xDD        };        LongBuffer lbAsArray = LongBuffer.wrap(longNumber);        System.out.println("The byte order for LongBuffer wrap array: " + lbAsArray.order());        LongBuffer lbAsByteBuffer = byteBuffer.asLongBuffer();        System.out.println("The byte order for LongBuffer from ByteBuffer: " + lbAsByteBuffer.order());    }代码块1234567891011

执行结果:

The byte order for LongBuffer wrap array: LITTLE_ENDIANThe byte order for LongBuffer from ByteBuffer: BIG_ENDIAN代码块12

如果在上面的 checkByteBuffer 方法中,首先将对象 byteBuffer 的字节序设置为 ByteOrder.LITTLE_ENDIAN(通过 ByteBuffer 的 order 方法设置),然后再创建 lbAsByteBuffer 对象,那么 lbAsByteBuffer 的字节序该是什么呢?

6. 网络字节序

前面两小节讨论的都是 CPU、Java 虚拟机的字节序,通常叫做主机(host)字节序。在网络编程中,字节流在网络中传输是遵循大端序的,也叫网络字节序

由于 Java 虚拟机的字节序和网络字节序是一致的,对于 Java 程序员来说,通常不太关心字节序的问题。然而,当 Java 程序和 C 程序进行通信的时候,需要关心字节序的问题。

7. 小结

本文主要是介绍了 CPU 架构带来的多字节数值在内存中存储时的字节序问题,字节序分为大端序小端序。在计算机网络中,大端序也叫做网络字节序;相应的主机上的存储顺序叫做主机字节序

在 Java 程序中,由于 Java 程序是在 Java 虚拟机上运行,Java 虚拟机的字节序是大端序。然而 Java 虚拟机运行的 Native 系统的字节序是不确定的,可以通过 java.nio.ByteOrder 的 nativeOrder 方法来确定。

对于 Java 网络编程中广泛应用的 ByteBuffer,则默认是大端序,当然你也可以根据需要设置它的字节序。对于多字节数值 Buffer,比如 IntBuffer、LongBuffer,则需要根据他们创建时所依赖的结构,来判定它们的字节序。

本节内容相对简单,学习起来也会轻松很多,但是非常重要,需要掌握。

6、Java Socket 地址结构

1. 前言

我们知道计算机网络中连接的设备有很多,比如 PC、手机、打印机、路由器、交换机、网关等,通常把这些网络设备叫做节点(Node)。每一个节点都分配有唯一的 IP 地址,用以标识此设备。IP 地址包含 32 位 IPv4 和 128 位 IPv6 两个版本。由于 IP 地址是一串数字或者是字节序列,对计算机是友好的,但是对我们人类非常不友好,不利于传播、记忆。为此,计算机科学家又开发了一套 DNS 系统,给每一台计算机分配了唯一的、对人类友好的主机名字,通常叫做域名。比如,www.yemao.com夜猫编程主站的域名。当然,有的主机会分配多个域名。

人们常说生活没有那么简单,往往是解决了一个老问题,又引出了新问题。当你开发了 DNS 系统以后,我们人类确实方便了,可是域名对计算机来说不方便,计算机更喜欢 IP 地址。这就又需要解决 IP 地址和域名之间相互解析、映射的问题,当然这些问题在 DNS 系统中都得到了妥善的处理。域名解析系统是一个分布式集群系统,是一个树形结构。一次域名解析可能需要经过本地缓存、本地域名服务器、远程域名服务器之间多次交互。

从上面的描述可以看出,IP 地址和域名之间的相互解析是一套非常复杂的机制。好在操作系统将这一套复杂的机制进行了封装,以 API 的形式提供给网络程序员,这样极大的简化了编程的复杂度。

一般操作系统都提供了 C 语言接口 getaddrinfogetnameinfo,前者的功能是通过域名获取 IP 地址,后者的功能是通过 IP 地址获取域名。

在 Java 平台中,java.net.InetAddress 类实现了完整的 IP 地址和域名之间的相互解析机制。

2. InetAddress 类的体系结构

java.net.InetAddress 类的体系结构如下:

各类的功能说明:

  • InetAddress 是 Java IP 地址的包装类,也是域名解析的核心类。

  • Inet4Address 代表了 IPv4 地址格式的封装,一般程序员不需要关心此类。

  • Inet6Address 代表了 IPv6 地址格式的封装,一般程序员不需要关心此类。

  • InetSocketAddress 是 Socket 地址的封装,它通过私有内部类 InetSocketAddressHolder 间接包装了 InetAddress 结构和 端口号(Port)。在网络编程中,通常把 Socket 地址叫做 Endpoint,用 <IP, Port> 的组合来表示。

在网络编程中,应用最为频繁的两个类是 InetSocketAddress 和 InetAddress。其中,InetSocketAddress 类对 InetAddress 和 Port 进行了封装,形成了完整的 Socket 地址。而 InetAddress 核心实现就是域名解析和缓存。

InetAddress 类没有 public 构造方法,提供了一组 public static 工厂方法用以创建 InetAddress 实例。接下来,我们重点分析一下 getByName 和 getByAddress 两类方法。

3. getByName 方法

InetAddress 提供了两个公有静态方法 getByName 和 getAllByName 来构造 InetAddress 实例,它们的原型如下:

// 创建单个 InetAddress 实例public static InetAddress getByName(String host) throws UnknownHostException// 创建多个 InetAddress 实例public static InetAddress[] getAllByName(String host) throws UnknownHostException代码块1234

这两个方法都会连接域名解析服务器进行域名解析,具体工作原理如下:

  • 首先会检查传入参数 host,也就是域名。如果传入参数为 null,那么会返回以 loopback 地址构造的 InetAddress 结构。

  • 如果输入参数 host 是一个 IP 地址,那么根据 IP 地址是 IPv4 还是 IPv6,分别构造 Inet4Address 或 Inet6Address 结构,并且返回。

  • 查询本地 Cache,如果本地 Cache 中已经存在 host 相应的地址,则直接返回。

  • 如果本地 Cache 查询失败,则遍历本地注册的 name services。如果有定制的 name services 注册,那么会调用此定制的 name services。如果没有定制的 name services,那么会调用 default name services,最终会调用系统的 getaddrinfo 函数。getaddrinfo 是一个 POSIX 标准函数,一般系统都会实现。

getByName 方法的应用非常简单,示例如下:

public static void testInetAddressByName(String host){        try {            InetAddress addr = InetAddress.getByName(host);            System.out.println("getByName addr=" + addr.toString());            InetAddress[] addrs = InetAddress.getAllByName(host);            for (InetAddress a: addrs){                System.out.println("getAllByName addr=" + a.toString());            }        } catch (UnknownHostException e) {            e.printStackTrace();        }    }代码块12345678910111213

测试 wwww.imooc.com 域名,执行结果如下:

getByName addr=www.imooc.com/115.182.41.103getAllByName addr=www.imooc.com/115.182.41.103getAllByName addr=www.imooc.com/117.121.101.144getAllByName addr=www.imooc.com/115.182.41.180getAllByName addr=www.imooc.com/117.121.101.40getAllByName addr=www.imooc.com/117.121.101.134getAllByName addr=www.imooc.com/115.182.41.163代码块1234567

需要注意的是 getByName 方法会抛出 UnknownHostException 异常,需要捕获。

4. getByAddress 方法

如果你有明确的 IP 地址,并不需要进行域名解析,可以调用 InetAddress 提供的另一组工厂方法 getByAddress,方法原型如下:

public static InetAddress getByAddress(byte[] addr) throws UnknownHostExceptionpublic static InetAddress getByAddress(String host, byte[] addr) throws UnknownHostException代码块123

这是两个重载的 public static 方法,功能都类似:

  • 第一个重载的 getByAddress 方法提供一个参数,即用 byte [] 类型的数组表示的 IP 地址。

  • 第二个重载的 getByAddress 方法提供两个参数,用 String 类型表示的域名(host),和用 byte [] 类型的数组表示的 IP 地址。

  • 二者都不进行域名解析,只是根据输入参数构造 InetAddress 实例。

  • 接收 host 输入参数的 getByAddress 方法不保证域名和 IP 地址的对应关系,也不保证域名是否可以访问。

getByAddress 方法应用的示例代码如下:

public static void testInetAddressByAddr()    {        byte[] ips = new byte[]{ (byte)192, (byte)168,1,101};        try {            InetAddress addr = InetAddress.getByAddress(ips);            System.out.println("getByAddress addr=" + addr.toString());            InetAddress addr2 = InetAddress.getByAddress("www.example.com", ips);            System.out.println("getByAddress with host addr=" + addr2.toString());        } catch (UnknownHostException e) {            e.printStackTrace();        }    }

我们输入 192.168.1.101,执行结果如下:

getByAddress addr=/192.168.1.101getByAddress with host addr=www.example.com/192.168.1.101代码块12

5. InetAddress 的 Cache 策略

由于域名解析需要客户端和域名服务器经过很多次交互,一般都比较耗费时间,所以 InetAddress 提供了 Cache 机制。这样,当客户程序调用 getByName 解析域名的时候,首先是从 Cache 中查找,这样可以极大提高域名解析的效率。

域名绑定的 IP 地址可能会发生变化,所以 Cache 中存储的 IP 地址也是有生命周期的。Java 提供了两个全局参数可以用来配置 Cache 的有效时间。

  • networkaddress.cache.ttl成功解析的域名在 Cache 中的存活时间。

  • networkaddress.cache.negative.ttl解析失败的域名在 Cache 中的存活时间。

实际上除了 Java 本地有 Cache 机制,域名解析服务器也是有 Cache 机制的,目的都是相同的。

6. 小结

InetAddress 类在网络编程中的应用是非常频繁的,了解域名解析机制有利于我们更好的应用此类的功能。在实际产品应用中都是通过 getByName 方法构造 InetAddress 实例的,尽量避免通过 getByAddress 方法构造 InetAddress 实例。这样可以提高程序的维护性。当然,在实验室的内网环境中进行开发测试,往往采用的是私有 IP 地址,这时可以通过 getByAddress 方法来构造 InetAddress 实例。

7、如何创建 Java TCP Socket

1. 前言

TCP 的英文全称是 Transmission Control Protocol,翻译成中文叫做传输控制协议,它是 TCP/IP 协议族中非常重要的一个传输层协议。TCP 是一个面向连接的、面向字节流的、可靠的传输层协议,有丢包重传机制、有流控机制、有拥塞控制机制。TCP 保证数据包的顺序,并且对重复包进行过滤。相比不可靠传输协议 UDP,TCP 完全是相反的。

对于可靠性要求很高的应用场景来说,选择可靠 TCP 作为传输层协议肯定是正确的。例如,著名的 HTTP 协议和 FTP 协议都是采用 TCP 进行传输。当然 TCP 为了保证传输的可靠性,引入了非常复杂的保障机制,比如:TCP 连接建立时的三次握手和连接关闭时的四次挥手机制,滑动窗口机制,发送流控机制,慢启动和拥塞避免机制等。当然,操作系统的网络协议栈已经实现了这些复杂的机制,

本小节主要是介绍通过 Java 语言编写 TCP 客户端、服务器程序的方法。

编写 TCP 客户端、服务器程序主要分为如下几个步骤:

  • 创建客户端 Socket,连接到某个服务器监听的端口,需要指定服务器监听的 host 和 port。host 可以是 IP 地址,也可以是域名。

  • 创建服务端 Socket,绑定到一个固定的服务端口,监听客户端的连接请求。

  • 客户端发起连接请求,完成三次握手过程。

  • TCP 连接建立成功后,双方进行数据流交互。

  • 数据流交互完成后,关闭连接。

2. 传统 TCP 客户端和服务器建立过程

为了更好地理解编写 TCP 客户端和服务器程序的步骤,下图展示了通过 C 语言 Socket API 编写客户端和服务器程序的过程。

图中的矩形方框都是 C 函数,很好的展示了客户端和服务器 Socket 的建立过程。对于 Java 语言来说,只是应用面向对象的思维对上面的过程进行了抽象,下来我们就探讨一下如何编写 Java 客户端和服务器程序。

3. Java Socket 类分析

Java 语言抽象了 java.net.Socket 类,表示一个 Socket,既可以用在客户端,又可以用在服务器端。其实 java.net.Socket 也是一个包装类,对外抽象了一组公共方法,具体实现是在 java.net.SocketImpl 类中完成的,它允许用户自定义具体实现。java.net.Socket 类包含的主要功能如下:

  • 创建 Socket,具体就是创建一个 java.net.Socket 类的对象。

  • 建立 TCP 连接,可以通过 java.net.Socket 类的构造方法完成,也可以调用它的 connect 方法完成。

  • 将 Socket 绑定到本地接口 IP 地址或者端口,可以调用 java.net.Socket 类的 bind 方法完成。

提示:服务器需要做 bind 操作,客户端一般不需要做 bind 操作。

  • 关闭连接,可以调用 java.net.Socket 类的 close 方法完成。

  • 接收数据,可以通过 java.net.Socket 类的 getInputStream 方法,返回一个 java.io.InputStream 对象实现数据接收。

  • 发送数据,可以通过 java.net.Socket 类的 getOutputStream 方法,返回一个 java.io.OutputStream 对象实现数据发送。

java.net.Socket 类提供了一组重载的构造方法,方便程序员选择,大体分为四类:

  • 可以传入服务器的 host 和 port 参数

原型如下:

  public Socket(String host, int port)        throws UnknownHostException, IOException  public Socket(InetAddress address, int port) throws IOException代码块123

对于 host 参数,你可以传入 IP 地址或者是域名。当然,你可以传入构造好的 InetAddress 地址结构。

java.net.Socket 的构造方法中,首先会构造一个 InetAddress 地址结构,然后进行域名解析,最后调用它的 connect 方法和服务器建立连接。

  • 可以传入绑定的本地地址参数

原型如下:

  public Socket(String host, int port, InetAddress localAddr,  int localPort) throws IOException  public Socket(InetAddress address, int port, InetAddress localAddr,  int localPort) throws IOException代码块12

这类构造方法也可以传入 host 和 port 外,功能和上面类似。另外,还可以传入 localAddr 和 localPort,会调用 java.net.Socket 类的 bind 方法,绑定在本地的接口地址和端口。

  • 无参构造方法

  public Socket()代码块1

此构造方法,除了构造一个 java.net.Socket 类的对象,并不会去 connect 服务器。你需要调用它的 connect 方法连接服务器。

public void connect(SocketAddress endpoint, int timeout) throws IOException代码块1

自己调用 connect 方法,需要构造 SocketAddress 结构,当然你可以设置连接的超时时间,单位是毫秒(milliseconds)。

  • 访问代理服务器

public Socket(Proxy proxy) 代码块1

当你需要访问某个代理服务器时,可以调用此构造方法,Socket 会自动去连接代理服务器。

创建一个简单的 java.net.Socket 客户端,示例代码如下:

import java.io.BufferedInputStream;import java.io.BufferedOutputStream;import java.io.IOException;import java.io.OutputStream;import java.net.InetSocketAddress;import java.net.Socket;import java.net.SocketAddress;public class TCPClient {    // 服务器监听的端口号    private static final int PORT = 56002;    private static final int TIMEOUT = 15000;    public static void main(String[] args) {        Socket client = null;        try {            // 在构造方法中传入 host 和 port            // client = new Socket("192.168.43.49", PORT);            // 调用无参构造方法            client = new Socket();            // 构造服务器地址结构            SocketAddress serverAddr = new InetSocketAddress("192.168.0.101", PORT);            // 连接服务器,超时时间是 15 毫秒            client.connect(serverAddr, TIMEOUT);            System.out.println("Client start:" + client.getLocalSocketAddress().toString());            // 向服务器发送数据            OutputStream out = new BufferedOutputStream(client.getOutputStream());            String req = "Hello Server!\n";            out.write(req.getBytes());            // 不能忘记 flush 方法的调用            out.flush();            System.out.println("Send to server:" + req);            // 接收服务器的数据            BufferedInputStream in = new BufferedInputStream(client.getInputStream());            StringBuilder inMessage = new StringBuilder();            while(true){                int c = in.read();                if (c == -1 || c == '\n')                    break;                inMessage.append((char)c);            }            System.out.println("Recv from server:" + inMessage.toString());        } catch (IOException e) {            e.printStackTrace();        } finally {            if (client != null){                try {                    client.close();                } catch (IOException e) {                    e.printStackTrace();                }            }        }    }}

这里我们创建的是阻塞式的客户端,有几点需要注意的地方:

  • 通过 OutputStream 的对象向服务器发送完数据后,需要调用 flush 方法。

  • BufferedInputStream 的 read 方法会阻塞线程,所以需要设计好消息边界的识别机制,示例代码是通过换行符 '\n’ 表示一个消息边界。

  • java.net.Socket 的各个方法都抛出了 IOException 异常,需要捕获。

  • 注意调用 close 方法,关闭连接。

4. Java ServerSocket 类分析

Java 语言抽象了 java.net.ServerSocket 类表示服务器监听 Socket,此类只用在服务器端,通过调用它的 accept 方法来获取新的连接。accept 方法的返回值是 java.net.Socket 类型,后续服务器和客户端的数据收发,都是通过 accept 方法返回的 Socket 对象完成。

java.net.ServerSocket 类也提供了一组重载的构造方法,方便程序员选择。

  public ServerSocket(int port) throws BindException, IOException  public ServerSocket(int port, int queueLength) throws BindException, IOException  public ServerSocket(int port, int queueLength, InetAddress bindAddress) throws IOException  public ServerSocket() throws IOException代码块1234
  • port 参数用于传入服务器监听的端口号。如果传入的 port 是 0,系统会随机选择一个端口监听。

  • queueLength 参数用于设置连接接收队列的长度。不传入此参数,采用系统默认长度。

  • bindAddress 参数用于将监听 Socket 绑定到一个本地接口。如果传入此参数,服务器会监听指定的接口地址;如果不指定此参数,默认会监听通配符 IP 地址,比如 IPv4 是 0.0.0.0。

  • 提示:

  • 可以通过 netstat 命令查看服务器程序监听的 IP 地址和端口号。

如果你是通过无参构造方法构造 java.net.ServerSocket 类的对象,需要手动调用它的 bind 方法,绑定监听端口和接口地址。

创建一个简单的服务器监听 Socket,示例代码如下:

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;

public class TCPServer {
    private static final int PORT =56002;

    public static void main(String[] args) {
        ServerSocket ss = null;
        try {
            // 创建一个服务器 Socket
            ss = new ServerSocket(PORT);
            // 监听新的连接请求
            Socket conn = ss.accept();
            System.out.println("Accept a new connection:" + conn.getRemoteSocketAddress().toString());

            // 读取客户端数据
            BufferedInputStream in = new BufferedInputStream(conn.getInputStream());
            StringBuilder inMessage = new StringBuilder();
            while(true){
                int c = in.read();
                if (c == -1 || c == '\n')
                    break;
                inMessage.append((char)c);
            }
            System.out.println("Recv from client:" + inMessage.toString());

            // 向客户端发送数据
            String rsp = "Hello Client!\n";
            BufferedOutputStream out = new BufferedOutputStream(conn.getOutputStream());
            out.write(rsp.getBytes());
            out.flush();
            System.out.println("Send to client:" + rsp);
            conn.close();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (ss != null){
                try {
                    ss.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        System.out.println("Server exit!");
    }
}

我们创建的阻塞式服务端,所以 java.net.ServerSocket 的 accept 方法会阻塞线程,直到新连接返回。同样,在接收客户端的消息的时候注意消息边界的处理,最后向客户端发送响应的时候,需要调用 flush 方法。

5. 小结

用 Java 语言编写 TCP 客户端和服务器程序非常方便,你只需要创建一个 java.net.ServerSocket 实例,然后调用它的 accept 方法监听客户端的请求;你只需要创建一个 java.net.Socket 实例,可以通过构造方法或者 connect 连接对应的服务器,然后就可以进行数据的收发,最后数据交互完成后,调用 close 方法关闭连接即可。

示例代码中的服务器功能还不完善,不能持续提供服务,不能同时接收多个客户端的连接请求,需要在后续的小节逐步完善。

8、Java TCP Socket 数据收发

1. 前言

TCP 是面向字节流的传输协议。所谓字节流是指 TCP 并不理解它所传输的数据的含义,在它眼里一切都是字节,1 字节是 8 比特。比如,TCP 客户端向服务器发送“Hello Server,I’m client。How are you?”,TCP 客户端发送的是具有一定含义的数据,但是对于 TCP 协议栈来说,传输的是一串字节流,具体如何解释这段数据需要 TCP 服务器的应用程序来完成,这就涉及到“应用层协议设计”的问题。

在 TCP/IP 协议栈的四层协议模型中,操作系统内核协议栈实现了链路层、网络层、传输层,将应用层留给了应用程序来实现。在编程实践中,通常有文本协议二进制协议两种类型,前者通常通过一个分隔符区分消息语义,而后者通常是需要通过一个 length 字段指定消息体的大小。比如著名的 HTTP 协议就是文本协议,通过 “\r\n” 来区分 HTTP Header 的每一行。而 RTMP 协议是一个二进制协议,通过 length 字段来指定消息体的大小。

解析 TCP 字节流的语义通常叫做消息解析,如果按照传统 C 语言函数的方式来实现,还是比较麻烦的,有很多细节需要处理。好在 Java 为我们提供了很多工具类,给我们的工作带来了极大地便利。

2. Java 字节流结构

Java 的 java.io.* 包中包含了 InputStream 和 OutputStream 两个类,是 Java 字节流 I/O 的基础类,其他具体的 Java I/O 字节流功能类都派生自这两个类。

图中只列出了我们 Socket 编程中常用的 I/O 字节流类。java.net.SocketInputStream 类是 Socket 的输入流实现类,它继承了 java.io.FileInputStream 类。java.net.SocketOutputStream 类是 Socket 的输出流实现类,它继承了 java.io.FileOutputStream 类,下来我们逐一介绍这些类的基本功能。

2.1 Java InputStream & OutputStream

java.io.InputStream 类是一个抽象超类,它提供最小的编程接口和输入流的部分实现。java.io.InputStream 类定义的几类方法:

  • 读取字节或字节数组,一组 read 方法。

  • 标记流中的位置,mark 方法。

  • 跳过输入字节,skip 方法。

  • 找出可读取的字节数,available 方法。

  • 重置流中的当前位置,reset 方法。

  • 关闭流,close 方法。

InputStream 流在创建实例时会自动打开,你可以调用 close 方法显式关闭流,也可以选择在垃圾回收 InputStream 时,隐式关闭流。需要注意的是垃圾回收机制关闭流,并不能立刻生效,可能会造成流对象泄漏,所以一般需要主动关闭。

java.io.OutputStream 类同样是一个抽象超类,它提供最小的编程接口和输出流的部分实现。java.io.OutputStream 定义的几类方法:

  • 写入字节或字节数组,一组 write 方法。

  • 刷新流,flush 方法。

  • 关闭流,close 方法。

OutputStream 流在创建时会自动打开,你可以调用 close 方法显式关闭流,也可以选择在垃圾回收 OutputStream 时,隐式关闭流。

2.2 FileInputStream & FileOutputStream

java.io.FileInputStream 和 java.io.FileOutputStream 是文件输入和输出流类,用于从本机文件系统上的文件读取数据或向其写入数据。你可以通过文件名、java.io.File 对象、java.io.FileDescriptor 对象创建一个 FileInputStream 或 FileOutputStream 流对象。

2.3 SocketOutputStream & SocketInputStream

java.net.SocketInputStream 和 java.net.SocketOutputStream 代表了 Socket 流的读写,他们分别继承自 java.io.FileInputStream 和 java.io.FileOutputStream 类,这说明 Socket 读写包含了文件读写的特性。另外,这两个类是定义在 java.net.* 包中,并没有对外公开。

2.4 FilterInputStream & FilterOutputStream

java.io.FilterInputStream 和 java.io.FilterOutputStream 分别是 java.io.InputStream 和 java.io.OutputStream 的子类,并且它们本身都是抽象类,为被过滤的流定义接口。java.io.FilterInputStream 和 java.io.FilterOutputStream 的主要作用是为基础流提供一些额外的功能,这些不同的功能都是单独的类,继承了他们的接口。例如,过滤后的流 BufferedInputStream 和BufferedOutputStream 在读写时会缓冲数据,以加快数据传输速度。

2.5 BufferedInputStream & BufferedOutputStream

java.io.BufferedInputStream 类继承自 java.io.FilterInputStream 类,它的作用是为 java.io.FileInputStream、java.net.SocketInputStream 等输入流提供缓冲功能。一般通过 java.io.BufferedInputStream 的构造方法传入具体的输入流,同时可以指定缓冲区的大小。java.io.BufferedInputStream 会从底层 Socket 读取一批数据保存到内部缓冲区中,后续通过 java.io.BufferedInputStream 的 read 方法读取数据,实际上都从缓冲区中读取,等读完缓冲中的这部分数据之后,再从底层 Socket 中读取下一部分的数据。

  • 注意:

  • 当你调用 java.io.BufferedInputStream 的 read 方法读取一个数组时,只有当读取的数据达到数组长度时才会返回,否则线程会被阻塞。

java.io.BufferedOutputStream 类继承自 java.io.FilterOutputStream 类,它的作用是为 java.io.FileOutputStream、java.net.SocketOutputStream 等输出流提供缓冲功能。一般通过 java.io.BufferedOutputStream 的构造方法传入底层输出流,同时可以指定缓冲区的大小。每次调用 java.io.BufferedOutputStream 的 write 方法写数据时,实际上是写入它的内部缓冲区中,当内部缓冲区写满或者调用了 flush 方法,才会将数据写入底层 Socket 的缓冲区。

BufferedInputStream 和 BufferedOutputStream 在读取或写入时缓冲数据,从而减少了对原始数据源所需的访问次数。缓冲流通常比类似的非缓冲流效率更高。

2.6 DataInputStream & DataOutputStream

java.io.DataInputStream 和 java.io.DataOutputStream 类继承自 java.io.FilterInputStream 和 java.io.FilterOutputStream 类,同时实现了 java.io.DataInput 和 java.io.DataOutput 接口,功能是以机器无关的格式读取或写入原始 Java 数据类型。

3. 数据读写的案例程序

我们设计一个简单的协议,每个消息的开头 4 字节表示消息体的长度,格式如下:

+-----------------+| 4 字节消息长度   |+-----------------+|                 ||   消息体         ||                 |+-----------------+代码块1234567

我们通过这个简单的协议演示 java.io.DataInputStream 、java.io.DataOutputStream 和 java.io.BufferedInputStream、java.io.BufferedOutputStream 类的具体用法。TCP 客户端和服务器的编写可以参考上一节内容,本节仅展示数据读写的代码片段。

客户端数据读写代码:

import java.io.*;import java.net.InetSocketAddress;import java.net.Socket;import java.net.SocketAddress;public class TCPClientIO {    // 服务器监听的端口号    private static final int PORT = 56002;    private static final int TIMEOUT = 15000;    public static void main(String[] args) {        Socket client = null;        try {            // 调用无参构造方法            client = new Socket();            // 构造服务器地址结构            SocketAddress serverAddr = new InetSocketAddress("127.0.0.1", PORT);            // 连接服务器,超时时间是 15 毫秒            client.connect(serverAddr, TIMEOUT);            System.out.println("Client start:" + client.getLocalSocketAddress().toString());            // 向服务器发送数据            DataOutputStream out = new DataOutputStream(                    new BufferedOutputStream(client.getOutputStream()));            String req = "Hello Server!\n";            out.writeInt(req.getBytes().length);            out.write(req.getBytes());            // 不能忘记 flush 方法的调用            out.flush();            System.out.println("Send to server:" + req + " length:" +req.getBytes().length);            // 接收服务器的数据            DataInputStream in = new DataInputStream(                    new BufferedInputStream(client.getInputStream()));            int msgLen = in.readInt();            byte[] inMessage = new byte[msgLen];            in.read(inMessage);            System.out.println("Recv from server:" + new String(inMessage) + " length:" + msgLen);        } catch (IOException e) {            e.printStackTrace();        } finally {            if (client != null){                try {                    client.close();                } catch (IOException e) {                    e.printStackTrace();                }            }        }    }}

服务端数据读写代码:

import java.io.*;import java.net.ServerSocket;import java.net.Socket;import java.io.BufferedInputStream;import java.io.DataInputStream;import java.io.BufferedOutputStream;import java.io.DataOutputStream;public class TCPServerIO {    private static final int PORT =56002;    public static void main(String[] args) {        ServerSocket ss = null;        try {            // 创建一个服务器 Socket            ss = new ServerSocket(PORT);            // 监听新的连接请求            Socket conn = ss.accept();            System.out.println("Accept a new connection:" + conn.getRemoteSocketAddress().toString());            // 读取客户端数据            DataInputStream in = new DataInputStream(                    new BufferedInputStream(conn.getInputStream()));            int msgLen = in.readInt();            byte[] inMessage = new byte[msgLen];            in.read(inMessage);            System.out.println("Recv from client:" + new String(inMessage) + "length:" + msgLen);            // 向客户端发送数据            String rsp = "Hello Client!\n";            DataOutputStream out = new DataOutputStream(                    new BufferedOutputStream(conn.getOutputStream()));            out.writeInt(rsp.getBytes().length);            out.write(rsp.getBytes());            out.flush();            System.out.println("Send to client:" + rsp + " length:" + rsp.getBytes().length);            conn.close();        } catch (IOException e) {            e.printStackTrace();        } finally {            if (ss != null){                try {                    ss.close();                } catch (IOException e) {                    e.printStackTrace();                }            }        }        System.out.println("Server exit!");    }}

注意读写消息长度需要用 readInt 和 writeInt 方法。

4. 总结

通过本节学习,你需要树立一个观念:TCP 是面向字节流的协议,TCP 传输数据的时候并不保证消息边界,消息边界需要程序员设计应用层协议来保证。将字节流解析成自定义的协议格式,需要借助 java.io.* 中提供的工具类,一般情况下,java.io.DataInputStream 、java.io.DataOutputStream 和 java.io.BufferedInputStream、java.io.BufferedOutputStream 四个类就足以满足你的需求了。DataInputStream 和 DataOutputStream 类主要是用以读写 java 相关的数据类型,BufferedInputStream 和 BufferedOutputStream 解决缓冲读写的问题,目的是提高读写效率。

本节简要介绍了 Socket 编程中常用的 I/O 流类,关于 java.io.* 包中的各种 I/O 流类不是本节的重点,需要你自己参考相关 Java 书籍。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多