一 网络概述

1.1 网络协议

从应用的角度出发,协议可理解为“规则”,是数据传输和数据的解释的规则。假设,A、B双方欲传输文件。规定:

  • 第一次,传输文件名,接收方接收到文件名,应答OK给传输方;
  • 第二次,发送文件的尺寸,接收方接收到该数据再次应答一个OK;
  • 第三次,传输文件内容。同样,接收方接收数据完成后应答OK表示文件内容接收成功。

由此,无论A、B之间传递何种文件,都是通过三次数据传输来完成。A、B之间形成了一个最简单的数据传输规则。双方都按此规则发送、接收数据。A、B之间达成的这个相互遵守的规则即为协议。

这种仅在A、B之间被遵守的协议称之为原始协议。

当此协议被更多的人采用,不断的增加、改进、维护、完善。最终形成一个稳定的、完整的文件传输协议,被广泛应用于各种文件传输过程中。该协议就成为一个标准协议。最早的ftp协议就是由此衍生而来。

1.2 典型协议

  • 传输层 常见协议有TCP/UDP协议。
  • 应用层 常见的协议有HTTP协议,FTP协议。
  • 网络层 常见协议有IP协议、ICMP协议、IGMP协议。
  • 网络接口层 常见协议有ARP协议、RARP协议。

TCP传输控制协议(Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

UDP用户数据报协议(User Datagram Protocol)是OSI参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。

HTTP超文本传输协议(Hyper Text Transfer Protocol)是互联网上应用最为广泛的一种网络协议。

FTP文件传输协议(File Transfer Protocol)

IP协议是因特网互联协议(Internet Protocol)

ICMP协议是Internet控制报文协议(Internet Control Message Protocol)它是TCP/IP协议族的一个子协议,用于在IP主机、路由器之间传递控制消息。

IGMP协议是 Internet 组管理协议(Internet Group Management Protocol),是因特网协议家族中的一个组播协议。该协议运行在主机和组播路由器之间。

ARP协议是正向地址解析协议(Address Resolution Protocol),通过已知的IP,寻找对应主机的MAC地址。

RARP是反向地址转换协议,通过MAC地址确定IP地址。

1.3 分层模型

1.3.1 网络分层架构

为了减少协议设计的复杂性,大多数网络模型均采用分层的方式来组织。每一层都有自己的功能,就像建筑物一样,每一层都靠下一层支持。每一层利用下一层提供的服务来为上一层提供服务,本层服务的实现细节对上层屏蔽。

在这里插入图片描述

越下面的层,越靠近硬件;越上面的层,越靠近用户。至于每一层叫什么名字,对应编程而言不重要,但面试的时候,面试官可能会问每一层的名字。

业内普遍的分层方式有两种。OSI七层模型 和TCP/IP四层模型。可以通过背诵两个口诀来快速记忆:

  • OSI七层模型:物、数、网、传、会、表、应
  • TCP/IP四层模型:链、网、传、应
  1. 物理层:主要定义物理设备标准,如网线的接口类型、光纤的接口类型、各种传输介质的传输速率等。它的主要作用是传输比特流(就是由1、0转化为电流强弱来进行传输,到达目的地后再转化为1、0,也就是我们常说的数模转换与模数转换)。这一层的数据叫做比特。
  2. 数据链路层:定义了如何让格式化数据以帧为单位进行传输,以及如何让控制对物理介质的访问。这一层通常还提供错误检测和纠正,以确保数据的可靠传输。如:串口通信中使用到的115200、8、N、1
  3. 网络层:在位于不同地理位置的网络中的两个主机系统之间提供连接和路径选择。Internet的发展使得从世界各站点访问信息的用户数大大增加,而网络层正是管理这种连接的层。
  4. 传输层:定义了一些传输数据的协议和端口号(WWW端口80等),如:TCP(传输控制协议,传输效率低,可靠性强,用于传输可靠性要求高,数据量大的数据),UDP(用户数据报协议,与TCP特性恰恰相反,用于传输可靠性要求不高,数据量小的数据,如QQ聊天数据就是通过这种方式传输的)。 主要是将从下层接收的数据进行分段和传输,到达目的地址后再进行重组。常常把这一层数据叫做段。
  5. 会话层:通过传输层(端口号:传输端口与接收端口)建立数据传输的通路。主要在你的系统之间发起会话或者接受会话请求(设备之间需要互相认识可以是IP也可以是MAC或者是主机名)。
  6. 表示层:可确保一个系统的应用层所发送的信息可以被另一个系统的应用层读取。例如,PC程序与另一台计算机进行通信,其中一台计算机使用扩展二一十进制交换码(EBCDIC),而另一台则使用美国信息交换标准码(ASCII)来表示相同的字符。如有必要,表示层会通过使用一种通格式来实现多种数据格式之间的转换。
  7. 应用层:是最靠近用户的OSI层。这一层为用户的应用程序(例如电子邮件、文件传输和终端仿真)提供网络服务。

1.3.2 层与协议

每一层都是为了完成一种功能,为了实现这些功能,就需要大家都遵守共同的规则。大家都遵守这规则,就叫做“协议”(protocol)。

网络的每一层,都定义了很多协议。这些协议的总称,叫“TCP/IP协议”。TCP/IP协议是一个大家族,不仅仅只有TCP和IP协议,它还包括其它的协议,如下图:

在这里插入图片描述

1.3.3 协议功能

在这里插入图片描述

链路层

以太网规定,连入网络的所有设备,都必须具有“网卡”接口。数据包必须是从一块网卡,传送到另一块网卡。通过网卡能够使不同的计算机之间连接,从而完成数据通信等功能。网卡的地址——MAC 地址,就是数据包的物理发送地址和物理接收地址。

网络层

网络层的作用是引进一套新的地址,使得我们能够区分不同的计算机是否属于同一个子网络。这套地址就叫做“网络地址”,这是我们平时所说的IP地址。这个IP地址好比我们的手机号码,通过手机号码可以得到用户所在的归属地。

网络地址帮助我们确定计算机所在的子网络,MAC 地址则将数据包送到该子网络中的目标网卡。网络层协议包含的主要信息是源IP和目的IP。

于是,“网络层”出现以后,每台计算机有了两种地址,一种是 MAC 地址,另一种是网络地址。两种地址之间没有任何联系,MAC 地址是绑定在网卡上的,网络地址则是管理员分配的,它们只是随机组合在一起。

网络地址帮助我们确定计算机所在的子网络,MAC 地址则将数据包送到该子网络中的目标网卡。因此,从逻辑上可以推断,必定是先处理网络地址,然后再处理 MAC 地址。

传输层

当我们一边聊QQ,一边聊微信,当一个数据包从互联网上发来的时候,我们怎么知道,它是来自QQ的内容,还是来自微信的内容?
也就是说,我们还需要一个参数,表示这个数据包到底供哪个程序(进程)使用。这个参数就叫做“端口”(port),它其实是每一个使用网卡的程序的编号。每个数据包都发到主机的特定端口,所以不同的程序就能取到自己所需要的数据。

端口特点:

  • 对于同一个端口,在不同系统中对应着不同的进程
  • 对于同一个系统,一个端口只能被一个进程拥有

应用层

应用程序收到“传输层”的数据,接下来就要进行解读。由于互联网是开放架构,数据来源五花八门,必须事先规定好格式,否则根本无法解读。“应用层”的作用,就是规定应用程序的数据格式。

1.4 通信过程

两台计算机通过TCP/IP协议通讯的过程如下所示:

TCP/IP通信过程

二 Socket编程

2.1 什么是Socket

Socket,英文含义是【插座、插孔】,一般称之为套接字,用于描述IP地址和端口。可以实现不同程序间的数据通信。

Socket起源于Unix,而Unix基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket就是该模式的一个实现,网络的Socket数据传输是一种特殊的I/O,Socket也是一种文件描述符。Socket也具有一个类似于打开文件的函数调用:Socket(),该函数返回一个整型的Socket描述符,随后的连接建立、数据传输等操作都是通过该Socket实现的。

套接字的内核实现较为复杂,不宜在学习初期深入学习,了解到如下结构足矣。

套接字通讯原理示意

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

常用的Socket类型有两种:流式Socket(SOCK_STREAM)和数据报式Socket(SOCK_DGRAM)。流式是一种面向连接的Socket,针对于面向连接的TCP服务应用;数据报式Socket是一种无连接的Socket,对应于无连接的UDP服务应用。

2.2 网络应用程序设计模式

C/S模式

传统的网络应用设计模式,客户机(client)/服务器(server)模式。需要在通讯两端各自部署客户机和服务器来完成数据通信。

B/S模式
浏览器(Browser)/服务器(Server)模式。只需在一端部署服务器,而另外一端使用每台PC都默认配置的浏览器即可完成数据的传输。

优缺点

对于C/S模式来说,其优点明显。客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率。且,一般来说客户端和服务器程序由一个开发团队创作,所以他们之间所采用的协议相对灵活。可以在标准协议的基础上根据需求裁剪及定制。例如,腾讯所采用的通信协议,即为ftp协议的修改剪裁版。

因此,传统的网络应用程序及较大型的网络应用程序都首选C/S模式进行开发。如,知名的网络游戏魔兽世界。3D画面,数据量庞大,使用C/S模式可以提前在本地进行大量数据的缓存处理,从而提高观感。

C/S模式的缺点也较突出。由于客户端和服务器都需要有一个开发团队来完成开发。工作量将成倍提升,开发周期较长。另外,从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁。这也是很多用户不愿使用C/S模式应用程序的重要原因。

B/S模式相比C/S模式而言,由于它没有独立的客户端,使用标准浏览器作为客户端,其工作开发量较小。只需开发服务器端即可。另外由于其采用浏览器显示数据,因此移植性非常好,不受平台限制。如早期的偷菜游戏,在各个平台上都可以完美运行。

B/S模式的缺点也较明显。由于使用第三方浏览器,因此网络应用支持受限。另外,没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制。应用的观感大打折扣。第三,必须与浏览器一样,采用标准http协议进行通信,协议选择不灵活。

因此在开发过程中,模式的选择由上述各自的特点决定。根据实际需求选择应用程序设计模式。

2.3 TCP的C/S架构

在这里插入图片描述

2.4 简单的C/S模型通信

Server端:
Listen函数:
func Listen(network, address string) (Listener, error)

	  network:选用的协议:TCP、UDP, 	如:“tcp”或 “udp”
	  address:IP地址+端口号, 			如:“127.0.0.1:8000”或 “:8000”

Listener 接口:

type Listener interface {
			Accept() (Conn, error)
			Close() error
			Addr() Addr
}

Conn 接口:

type Conn interface {
	Read(b []byte) (n int, err error)
	Write(b []byte) (n int, err error)
	Close() error
	LocalAddr() Addr
	RemoteAddr() Addr
	SetDeadline(t time.Time) error
	SetReadDeadline(t time.Time) error
	SetWriteDeadline(t time.Time) error
}

参看 https://studygolang.com/pkgdoc 中文帮助文档中的demo:

示例代码:
TCP服务器.go

package main

import (
   "net"
   "fmt"
)

func main()  {
   // 创建监听
   listener, err:= net.Listen("tcp", ":8000")
   if err != nil {
      fmt.Println("listen err:", err)
      return
   }
   defer listener.Close()          // 主协程结束时,关闭listener

   fmt.Println("服务器等待客户端建立连接...")
   // 等待客户端连接请求
   conn, err := listener.Accept()
   if err != nil {
      fmt.Println("accept err:", err)
      return
   }
   defer conn.Close()             // 使用结束,断开与客户端链接
   fmt.Println("客户端与服务器连接建立成功...")

   // 接收客户端数据
   buf := make([]byte, 1024)        // 创建1024大小的缓冲区,用于read
   n, err := conn.Read(buf)
   if err != nil {
      fmt.Println("read err:", err)
      return
   }
   fmt.Println("服务器读到:", string(buf[:n])) // 读多少,打印多少。
}

如图,在整个通信过程中,服务器端有两个socket参与进来,但用于通信的只有 conn 这个socket。它是由 listener创建的。隶属于服务器端。

在这里插入图片描述

Client 端:

Dial函数:
func Dial(network, address string) (Conn, error)

	network:选用的协议:TCP、UDP,如:“tcp”或 “udp”
	address:服务器IP地址+端口号, 如:“121.36.108.11:8000”或 “www.itcast.cn:8000”

Conn 接口:

type Conn interface {
	Read(b []byte) (n int, err error)
	Write(b []byte) (n int, err error)
	Close() error
	LocalAddr() Addr
	RemoteAddr() Addr
	SetDeadline(t time.Time) error
	SetReadDeadline(t time.Time) error
	SetWriteDeadline(t time.Time) error
}
package main

import (
   "net"
   "fmt"
)

func main() {
   // 主动发起连接请求
   conn, err := net.Dial("tcp", "127.0.0.1:8000")
   if err != nil {
      fmt.Println("Dial err:", err)
      return
   }
   defer conn.Close()         // 结束时,关闭连接

   // 发送数据
   _, err = conn.Write([]byte("Are u ready?"))
   if err != nil {
      fmt.Println("Write err:", err)
      return
   }
}

2.5 并发的C/S模型通信

并发Server

现在已经完成了客户端与服务端的通信,但是服务端只能接收一个用户发送过来的数据,怎样接收多个客户端发送过来的数据,实现一个高效的并发服务器呢?

Accept()函数的作用是等待客户端的链接,如果客户端没有链接,该方法会阻塞。如果有客户端链接,那么该方法返回一个Socket负责与客户端进行通信。所以,每来一个客户端,该方法就应该返回一个Socket与其通信,因此,可以使用一个死循环,将Accept()调用过程包裹起来。

需要注意的是,实现并发处理多个客户端数据的服务器,就需要针对每一个客户端连接,单独产生一个Socket,并创建一个单独的goroutine与之完成通信。

//监听
    listener, err := net.Listen("tcp", "127.0.0.1:8001")
    if err != nil {
        	fmt.Println("err = ", err)
        	return
    }
    defer listener.Close()
    //接收多个用户
    for {
        	conn, err := listener.Accept()
        	if err != nil {
          		fmt.Println("err = ", err)
            		return
       	 }
       	 //处理用户请求, 新建一个协程
       	 go HandleConn(conn)
}

将客户端的数据处理工作封装到HandleConn方法中,需将Accept()返回的Socket传递给该方法,变量conn的类型为:net.Conn。可以使用conn.RemoteAddr()来获取成功与服务器建立连接的客户端IP地址和端口号:

Conn 接口:

type Conn interface {
	Read(b []byte) (n int, err error)
	Write(b []byte) (n int, err error)
	Close() error
	LocalAddr() Addr
	RemoteAddr() Addr
	SetDeadline(t time.Time) error
	SetReadDeadline(t time.Time) error
	SetWriteDeadline(t time.Time) error
}
//获取客户端的网络地址信息
addr := conn.RemoteAddr().String()
fmt.Println(addr, " conncet sucessful")

客户端可能持续不断的发送数据,因此接收数据的过程可以放在for循环中,服务端也持续不断的向客户端返回处理后的数据。

添加一个限定,如果客户端发送一个“exit”字符串,表示客户端通知服务器不再向服务端发送数据,此时应该结束HandleConn方法,同时关闭与该客户端关联的Socket。

buf := make([]byte, 2048)	 //创建一个切片,存储客户端发送的数据

for {
        //读取用户数据 
        n, err := conn.Read(buf)
        if err != nil {
            fmt.Println("err = ", err)
            return
        }
        fmt.Printf("[%s]: %s\n", addr, string(buf[:n]))
        if  "exit" == string(buf[:n-2]) { 		//自己写的客户端测试, 发送时,多了2个字符, "\r\n"
            fmt.Println(addr, " exit")
            return
        }
        //服务器处理数据:把客户端数据转大写,再写回给client
        conn.Write([]byte(strings.ToUpper(string(buf[:n]))))
 }

在上面的代码中,Read()方法获取客户端发送过来的数据,填充到切片buf中,返回的是实际填充的数据的长度,所以将客户端发送过来的数据进行打印,打印的是实际接收到的数据。
fmt.Printf("[%s]: %s\n", addr, string(buf[:n])).同时也可以将客户端的网络地址信息打印出来。

在判断客户端数据是否为“exit”字符串时,要注意,客户端会自动的多发送2个字符:“\r\n”(这在windows系统下代表回车、换行)

Server使用Write方法将数据写回给客户端,参数类型是 []byte,需使用strings包下的ToUpper函数来完成大小写转换。转换的对象即为string(buf[:n])
综上,HandleConn方法完整定义如下:

//处理用户请求
func HandleConn(conn net.Conn) {
//函数调用完毕,自动关闭conn
defer conn.Close()

          //获取客户端的网络地址信息
          addr := conn.RemoteAddr().String()
          fmt.Println(addr, " conncet sucessful")

          buf := make([]byte, 2048)

          for {
                  //读取用户数据
                 n, err := conn.Read(buf)
                 if err != nil {
                         fmt.Println("err = ", err)
                         return
            		}
            		fmt.Printf("[%s]: %s\n",  addr,  string(buf[:n]))
            	fmt.Println("len = ", len(string(buf[:n])))	

            		//if "exit" == string(buf[:n-1]) { 	// nc测试,发送时,只有 \n
                 if  "exit" == string(buf[:n-2]) {	// 自己写的客户端测试, 发送时,多了2个字符, "\r\n"
                        fmt.Println(addr, " exit")
                        return
                 }

                 //把数据转换为大写,再给用户发送
                 conn.Write([]byte(strings.ToUpper(string(buf[:n]))))
          }
}

并发Client

客户端不仅需要持续的向服务端发送数据,同时也要接收从服务端返回的数据。因此可将发送和接收放到不同的协程中。

主协程循环接收服务器回发的数据(该数据应已转换为大写),并打印至屏幕;子协程循环从键盘读取用户输入数据,写给服务器。读取键盘输入可使用 os.Stdin.Read(str)。定义切片str,将读到的数据保存至str中。

这样,客户端也实现了多任务。

客户端代码实现:

package main

import (
   "net"
   "fmt"
   "os"
)

func main() {
   // 主动发起连接请求
   conn, err := net.Dial("tcp", "127.0.0.1:8001")
   if err != nil {
      fmt.Println("Dial err:", err)
      return
   }
   defer conn.Close() 		  			// 客户端终止时,关闭与服务器通信的 socket 

   // 启动子协程,接收用户键盘输入
   go func() {
  	  str := make([]byte, 1024)   		// 创建用于存储用户键盘输入数据的切片缓冲区。
      for {                    			// 反复读取
         n, err :=os.Stdin.Read(str)    	// 获取用户键盘输入
         if err != nil {
            fmt.Println("os.Stdin.Read err:", err)
            return
         }
         // 将从键盘读到的数据,发送给服务器
         _, err = conn.Write(str[:n])   	// 读多少,写多少
         if err != nil {
            fmt.Println("conn.Write err:", err)
            return
         }
      }
   }()

   // 主协程,接收服务器回发数据,打印至屏幕
   buf := make([]byte, 1024)        		// 定义用于存储服务器回发数据的切片缓冲区
   for {
      n, err := conn.Read(buf)	  		// 从通信 socket 中读数据,存入切片缓冲区
      if err != nil {
         fmt.Println("conn.Read err:", err)
         return
      }
      fmt.Printf("服务器回发:%s\n", string(buf[:n]))
   }
}

2.6 TCP通信

下图是一次TCP通讯的时序图。TCP连接建立断开。包含大家熟知的三次握手和四次握手。

在这里插入图片描述

在这个例子中,首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序。注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。

三次握手

所谓三次握手(Three-Way Handshake)即建立TCP连接,就是指建立一个TCP连接时,需要客户端和服务端总共发送3个包以确认连接的建立。好比两个人在打电话:

Client:“喂,你听得到吗?”
Server:“我听得到,你听得到我吗?”
Client:“我能听到你,今天balabala…”

建立连接(三次握手)的过程:

  1. 客户端发送一个带SYN标志的TCP报文到服务器。这是上图中三次握手过程中的段1。客户端发出SYN位表示连接请求。序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况。
    另外,规定SYN位和FIN位也要占一个序号,这次虽然没发数据,但是由于发了SYN位,因此下次再发送应该用序号1001。
    mss表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。
  2. 服务器端回应客户端,是三次握手中的第2个报文段,同时带ACK标志和SYN标志。表示对刚才客户端SYN的回应;同时又发送SYN给客户端,询问客户端是否准备好进行数据通讯。
    服务器发出段2,也带有SYN位,同时置ACK位表示确认,确认序号是1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明最大尺寸为1024。
  3. 客户必须再次回应服务器端一个ACK报文,这是报文段3。
    客户端发出段3,对服务器的连接请求进行应答,确认序号是8001。在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出。

因此一共有三个段用于建立连接,称为“三方握手”。在建立连接的同时,双方协商了一些信息,例如,双方发送序号的初始值、最大段尺寸等。
数据传输的过程:

  1. 客户端发出段4,包含从序号1001开始的20个字节数据。
  2. 服务器发出段5,确认序号为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据。
  3. 客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。

在数据传输过程中,ACK和确认序号是非常重要的,应用程序交给TCP协议发送的数据会暂存在TCP层的发送缓冲区中,发出数据包给对方之后,只有收到对方应答的ACK段才知道该数据包确实发到了对方,可以从发送缓冲区中释放掉了,如果因为网络故障丢失了数据包或者丢失了对方发回的ACK段,经过等待超时后TCP协议自动将发送缓冲区中的数据包重发。

四次挥手

所谓四次挥手(Four-Way-Wavehand)即终止TCP连接,就是指断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开。在socket编程中,这一过程由客户端或服务器任一方执行close来触发。好比两个人打完电话要挂断:

Client:“我要说的事情都说完了,我没事了。挂啦?”
Server:“等下,我还有一个事儿。Balabala…”
Server:“好了,我没事儿了。挂了啊。”
Client:“ok!拜拜”

关闭连接(四次握手)的过程:
由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个FIN来终止这个方向的连接。收到一个 FIN只意味着这一方向上没有数据流动,一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。

  1. 客户端发出段7,FIN位表示关闭连接的请求。
  2. 服务器发出段8,应答客户端的关闭连接请求。
  3. 服务器发出段9,其中也包含FIN位,向客户端发送关闭连接请求。
  4. 客户端发出段10,应答服务器的关闭连接请求。

建立连接的过程是三次握手,而关闭连接通常需要4个段,服务器的应答和关闭连接请求通常不合并在一个段中,因为有连接半关闭的情况,这种情况下客户端关闭连接之后就不能再发送数据给服务器了,但是服务器还可以发送数据给客户端,直到服务器也关闭连接为止。

2.7 UDP通信

在之前的案例中,我们一直使用的是TCP协议来编写Socket的客户端与服务端。其实也可以使用UDP协议来编写Socket的客户端与服务端

UDP服务器

由于UDP是“无连接”的,所以,服务器端不需要额外创建监听套接字,只需要指定好IP和port,然后监听该地址,等待客户端与之建立连接,即可通信。

创建监听地址:

func ResolveUDPAddr(network, address string) (*UDPAddr, error) 

创建监听连接:

func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error) 

接收udp数据:

func (c *UDPConn) ReadFromUDP(b []byte) (int, *UDPAddr, error)

写出数据到udp:

func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error)

服务端完整代码实现如下:

package main

import (
   "fmt"
   "net"
)

func main() {
   //创建监听的地址,并且指定udp协议
   udp_addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:8002")
   if err != nil {
      fmt.Println("ResolveUDPAddr err:", err)
      return
   }
   conn, err := net.ListenUDP("udp", udp_addr)    //创建监听链接
   if err != nil {
      fmt.Println("ListenUDP err:", err)
      return
   }
   defer conn.Close()

   buf := make([]byte, 1024)
   n, raddr, err := conn.ReadFromUDP(buf)        //接收客户端发送过来的数据,填充到切片buf中。
   if err != nil {
      return
   }
   fmt.Println("客户端发送:", string(buf[:n]))

   _, err = conn.WriteToUDP([]byte("nice to see u in udp"), raddr) //向客户端发送数据
   if err != nil {
      fmt.Println("WriteToUDP err:", err)
      return
   }
}

UDP客户端

udp客户端的编写与TCP客户端的编写,基本上是一样的,只是将协议换成udp.代码如下:

package main

import (
   "net"
   "fmt"
)

func main() {
   conn, err := net.Dial("udp", "127.0.0.1:8002") 
   if err != nil {
      fmt.Println("net.Dial err:", err)
      return
   }
   defer conn.Close()

   conn.Write([]byte("Hello! I'm client in UDP!"))

   buf := make([]byte, 1024)
   n, err1 := conn.Read(buf)
   if err1 != nil {
      return
   }
   fmt.Println("服务器发来:", string(buf[:n]))
}

并发

其实对于UDP而言,服务器不需要并发,只要循环处理客户端数据即可。客户端也等同于TCP通信并发的客户端。

服务器:

package main

import (
   "net"
   "fmt"
)

func main() {
   // 创建 服务器 UDP 地址结构。指定 IP + port
   laddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:8003")
   if err != nil {
      fmt.Println("ResolveUDPAddr err:", err)
      return
   }
   // 监听 客户端连接
   conn, err := net.ListenUDP("udp", laddr)
   if err != nil {
      fmt.Println("net.ListenUDP err:", err)
      return
   }
   defer conn.Close()

   for {
      buf := make([]byte, 1024)
      n, raddr, err := conn.ReadFromUDP(buf)
      if err != nil {
         fmt.Println("conn.ReadFromUDP err:", err)
         return
      }
      fmt.Printf("接收到客户端[%s]:%s", raddr, string(buf[:n]))

      conn.WriteToUDP([]byte("I-AM-SERVER"), raddr) // 简单回写数据给客户端
   }
}

客户端:

package main

import (
   "net"
   "os"
   "fmt"
)

func main() {
   conn, err := net.Dial("udp", "127.0.0.1:8003")
   if err != nil {
      fmt.Println("net.Dial err:", err)
      return
   }
   defer conn.Close()
   go func() {
      str := make([]byte, 1024)
      for {
         n, err := os.Stdin.Read(str) //从键盘读取内容, 放在str
         if err != nil {
            fmt.Println("os.Stdin. err1 = ", err)
            return
         }
         conn.Write(str[:n])       // 给服务器发送
      }
   }()
   buf := make([]byte, 1024)
   for {
      n, err := conn.Read(buf)
      if err != nil {
         fmt.Println("conn.Read err:", err)
         return
      }
      fmt.Println("服务器写来:", string(buf[:n]))
   }
}

2.8 UDP与TCP的差异

TCP UDP
面向连接 面向无连接
要求系统资源较多 要求系统资源较少
TCP程序结构较复杂 UDP程序结构较简单
使用流式 使用数据包式
保证数据准确性 不保证数据准确性
保证数据顺序 不保证数据顺序
通讯速度较慢 通讯速度较快

2.9 文件传输

流程简析

借助TCP完成文件的传输,基本思路如下:
1:发送方(客户端)向服务端发送文件名,服务端保存该文件名。
2:接收方(服务端)向客户端返回一个消息ok,确认文件名保存成功。
3:发送方(客户端)收到消息后,开始向服务端发送文件数据。
4:接收方(服务端)读取文件内容,写入到之前保存好的文件中。

在这里插入图片描述

首先获取文件名。借助os包中的stat()函数来获取文件属性信息。在函数返回的文件属性中包含文件名和文件大小。Stat参数name传入的是文件访问的绝对路径。FileInfo中的Name()函数可以将文件名单独提取出来。

func Stat(name string) (FileInfo, error) 
type FileInfo interface {
   Name() string       
   Size() int64        
   Mode() FileMode     
   ModTime() time.Time 
   IsDir() bool        
   Sys() interface{}   
}

获取文件属性示例:

package main

import (
   "os"
   "fmt"
)

func main()  {
   list := os.Args                        // 获取命令行参数,存入list中
   if len(list) != 2 {			// 确保用户输入了一个命令行参数
      fmt.Println("格式为:xxx.go 文件名")
      return
   }
   fileName := list[1]                   // 从命令行保存文件名(含路径)

   fileInfo, err := os.Stat(fileName)    //根据文件名获取文件属性信息 fileInfo
   if err != nil {
      fmt.Println("os.Stat err:", err)
      return
   }
   fmt.Println("文件name为:", fileInfo.Name())   // 得到文件名(不含路径)
   fmt.Println("文件size为:", fileInfo.Size())   // 得到文件大小。单位字节
}

客户端实现

实现流程大致如下:

  1. 提示用户输入文件名。接收文件名path(含访问路径)
  2. 使用os.Stat()获取文件属性,得到纯文件名(去除访问路径)
  3. 主动连接服务器,结束时关闭连接
  4. 给接收端(服务器)发送文件名conn.Write()
  5. 读取接收端回发的确认数据conn.Read()
  6. 判断是否为“ok”。如果是,封装函数SendFile() 发送文件内容。传参path和conn
  7. 只读Open文件, 结束时Close文件
  8. 循环读文件,读到EOF终止文件读取
  9. 将读到的内容原封不动Write给接收端(服务器)

代码实现:

package main

import (
   "fmt"
   "os"
   "net"
   "io"
)

func SendFile(path string, conn net.Conn)  {
   // 以只读方式打开文件
   f, err := os.Open(path)
   if err != nil {
      fmt.Println("os.Open err:", err)
      return
   }
   defer f.Close()                   // 发送结束关闭文件。

   // 循环读取文件,原封不动的写给服务器
   buf := make([]byte, 4096)
   for {
      n, err := f.Read(buf)        // 读取文件内容到切片缓冲中
      if err != nil {
         if err == io.EOF {
            fmt.Println("文件发送完毕")
         } else {
            fmt.Println("f.Read err:", err)
         }
         return
      }
      conn.Write(buf[:n])  // 原封不动写给服务器
   }
}

func main()  {
   // 提示输入文件名
   fmt.Println("请输入需要传输的文件:")
   var path string
   fmt.Scan(&path)

   // 获取文件名   fileInfo.Name()
   fileInfo, err := os.Stat(path)
   if err != nil {
      fmt.Println("os.Stat err:", err)
      return
   }

   // 主动连接服务器
   conn, err := net.Dial("tcp", "127.0.0.1:8005")
   if err != nil {
      fmt.Println("net.Dial err:", err)
      return
   }
   defer conn.Close()

   // 给接收端,先发送文件名
   _, err = conn.Write([]byte(fileInfo.Name()))
   if err != nil {
      fmt.Println("conn.Write err:", err)
      return
   }

   // 读取接收端回发确认数据 —— ok
   buf := make([]byte, 1024)
   n, err := conn.Read(buf)
   if err != nil {
      fmt.Println("conn.Read err:", err)
      return
   }

   // 判断如果是ok,则发送文件内容
   if "ok" == string(buf[:n]) {
      SendFile(path, conn)   // 封装函数读文件,发送给服务器,需要path、conn
   }
}

服务端实现

实现流程大致如下:

  1. 创建监听listener,程序结束时关闭。
  2. 阻塞等待客户端连接,程序结束时关闭conn。
  3. 读取客户端发送文件名。保存fileName。
  4. 回发“ok”给客户端做应答
  5. 封装函数 RecvFile接收客户端发送的文件内容。传参fileName 和conn
  6. 按文件名Create文件,结束时Close
  7. 循环Read客户端发送的文件内容,当读到EOF说明文件读取完毕。
  8. 将读到的内容原封不动Write到创建的文件中

代码实现:

package main

import (
   "net"
   "fmt"
   "os"
   "io"
)

func RecvFile(fileName string, conn net.Conn)  {
   // 创建新文件
   f, err := os.Create(fileName)
   if err != nil {
      fmt.Println("Create err:", err)
      return
   }
   defer f.Close()

   // 接收客户端发送文件内容,原封不动写入文件
   buf := make([]byte, 4096)
   for {
      n, err := conn.Read(buf)
      if err != nil {
         if err == io.EOF {
            fmt.Println("文件接收完毕")
         } else {
            fmt.Println("Read err:", err)
         }
         return
      }
      f.Write(buf[:n])   // 写入文件,读多少写多少
   }
}

func main()  {
   // 创建监听
   listener, err := net.Listen("tcp", "127.0.0.1:8005")
   if err != nil {
      fmt.Println("Listen err:", err)
      return
   }
   defer listener.Close()

   // 阻塞等待客户端连接
   conn, err := listener.Accept()
   if err != nil {
      fmt.Println("Accept err:", err)
      return
   }
   defer conn.Close()

   // 读取客户端发送的文件名
   buf := make([]byte, 1024)
   n, err := conn.Read(buf)
   if err != nil {
      fmt.Println("Read err:", err)
      return
   }
   fileName := string(buf[:n])       // 保存文件名

   // 回复 0k 给发送端
   conn.Write([]byte("ok"))

   // 接收文件内容
   RecvFile(fileName, conn)      // 封装函数接收文件内容, 传fileName 和 conn
}

三 并发聊天室

并发编程和网络编程是现今行业开发中常用的技术。Go语言强大的语法设定使得并发和网络编程都变的简洁而高效。

下面我们利用前面学到的知识,使用并发和网络实现一个简单的网络在线聊天室。体会下这两种技术的实际应用。在整个聊天室的项目中,充分利用了协程并发,处理不同任务。

3.1 模块简述

整个聊天室程序可简单划分为如下模块,都分别使用协程来实现:

主协程(服务器):
负责监听、接收用户(客户端)连接请求,建立通信关系。同时启动相应的协程处理任务。

处理用户连接协程:HandleConnect
负责新上线用户的存储,用户消息读取、发送,用户改名、下线处理及超时处理。
为了提高并发效率,同时给一个用户维护多个协程来并行处理上述任务。

用户消息广播协程:Manager
负责在线用户遍历,用户消息广播发送。需要与HandleConnect协程及用户子协程协作完成。

协程间应用数据及通信:
map:存储所有登录聊天室的用户信息, key:用户的ip+port。Value:Client结构体。
Client结构体:包含成员:用户名Name,网络地址Addr(ip+port),发送消息的通道C(channel)
通道message:协调并发协程间消息的传递。

3.2 广播用户上线

首先,服务器启动,等待用户建立通信连接。当有用户连接上来,将其存储到map中,这样就维护了一个“在线用户”的列表。当再有新用户连接上来时,应向该列表中所有用户进行广播通知,提示xxx用户上线。

当然,简单实现手法可以循环读取列表中的用户,依次向其发送消息通知新用户上线。但这种方式无疑是一种串行的通信手段,实现简单,但执行效率较低。

在go语言中,我们利用协程轻便、高效、并发性好的特性,给每个登录用户维护多个协程来进行数据通信,借助channel不需要使用同步锁,就可以实现高效的并发通信。

下图充分利用goroutine和channel实现了新用户登录,向所有在线用户进行广播通知:

在这里插入图片描述

分析上图,主要分为几大模块。

全局位置定义用户结构体类型 Client,存储登录用户信息。成员包含channel、Name、

Addr
type Client struct {
	C chan string
	Name string
	Addr string
}

定义全局通道message处理消息。
定义全局map 存储在线用户信息。Key为用户网络地址。Value为用户结构体。

主协程,监听客户端连接请求,当有新的客户端连接,创建新协程handleConnet处理用户连接。

handleConnet协程,获取用户网络地址(Ip+port),创建新用户结构体,包含成员C、Name、Addr。新用户的Name和Addr初值都是用户网络地址(Ip+port)。将用户结构体存入map中。并创建WriteMsgToClient协程,专门负责给当前用户发送消息。组织新用户上线广播消息内容,写入全局通道message中。

WriteMsgToClient协程,读取用户结构体C中的数据,没有则阻塞等待,有数据写出给登录用户。

Manager协程,给map分配空间。循环读取 message 通道中是否有数据。没有,阻塞等待。有则解除阻塞,将message通道中读到的数据写到用户结构体中的C通道。

代码实现:

package main

import (
   "net"
   "fmt"
)

// 定义用户结构体类型
type Client struct {
   C chan string
   Name string
   Addr string
}
// 定义全局 map 存储在线用户 key:IP+port, value:Client
var onlineMap map[string]Client

// 定义全局 channel 处理消息
var message = make(chan string)

func WriteMsgToClient(clnt Client, conn net.Conn)  {
   // 循环跟踪 clnt.C,有消息则读走,Write 给客户端
   for msg := range clnt.C {
      conn.Write([]byte(msg + "\n"))    // 发送消息 给客户端
   }
}

func MakeMsg(clnt Client, msg string) (buf string) {
   buf =  "[" + clnt.Addr + "]" + clnt.Name + ": " + msg
   return
}

func HandleConnect(conn net.Conn)  {
   defer conn.Close()
   // 获取新连接上来的用户的网络地址(IP+port)
   netAddr := conn.RemoteAddr().String()
   // 给新用户创建结构体。用户名、网络地址一样
   clnt := Client{make(chan string), netAddr, netAddr}
   // 将新创建的结构体,添加到 map 中,key值为获取到的网络地址(IP+port)
   onlineMap[netAddr] = clnt

   // 新创建一个协程,专门给当前客户端发送消息。
   go WriteMsgToClient(clnt, conn)

   // 广播新用户上线
   //message <- "[" + clnt.Addr + "]" + clnt.Name + ": login"
   message <- MakeMsg(clnt, "login")

   for {     // 不能让当前协程结束。
      ;
   }
}

func Manager()  {
   // 给map分配空间
   onlineMap = make(map[string]Client)

   // 循环读取 message 通道中的数据
   for {
      // 通道 message 中有数据读到 msg 中。 没有,则阻塞
      msg := <-message

      // 一旦执行到这里,说明message中有数据了,解除阻塞。 遍历 map
      for _, clnt := range onlineMap {
         clnt.C <- msg  // 把从Message通道中读到的数据,写到 client 的 C 通道中。
      }
   }
}

func main()  {
   // 创建监听 socket
   listener, err := net.Listen("tcp", "127.0.0.1: 8000")
   if err != nil {
      fmt.Println("Listen err:", err)
      return
   }
   defer listener.Close()

   // 创建协程 处理消息
   go Manager()

   // 循环接收客户端连接请求
   for {
      conn, err := listener.Accept()
      if err != nil {
         fmt.Println("Accept err:", err)
         continue   // 失败,监听其他客户端连接
      }
      defer conn.Close()

      // 给新连接的客户端,单独创建一个协程,处理客户端连接请求
      go HandleConnect(conn)
   }
}

3.3 广播用户消息

当某个客户端向服务端发送消息后,服务端应将该消息广播给其它的客户端,达到聊天室的群聊效果。
开启一个新的协程,为方便传参,可以选择匿名协程。专门负责接收从客户端传递过来的数据,然后将接收到的数据写到messaage通道中。
在实现“广播用户上线”时,我已经完成:Manager协程会阻塞读message通道,一旦有数据,则遍历map中的在线用户。将数据写到结构体成员的C通道中。WriteMsgToClient协程会迭代C这个channel,最终将数据发送给客户端。
综上,实际上我们想完成“广播用户消息”给所有在线用户的功能,只需要将读到的数据写到message通道即可达到目的。

相关代码:


func HandleConnect(conn net.Conn)  {
……
……
   // 广播新用户上线
   message <- MakeMsg(clnt, "login")

   // 创建一个新协程,循环读取用户发送的消息,广播给在线用户
   go func() {
      buf := make([]byte, 2048)	 // 定义切片缓冲区,存储读到的用户消息
      for {
         n, err := conn.Read(buf)
         if n == 0 {             	// 用户退出登录
            fmt.Printf("用户%s退出登录\n", clnt.Name)
            return
         }
         if err != nil {
            fmt.Println("Read err:", err)
            return
         }
         msg := string(buf[:n])         // 保存用户写来的消息内容
         message <-MakeMsg(clnt, msg)   // 将消息广播给所有在线用户
      }
   }()

   for {     // 不能让当前协程结束。
      ;
   }
}

3.4 展示在线用户

因为nc工具默认会添加‘\n’, 所以conn.Read()读取用户消息后,修改保存用户消息内容实现语句:
msg := string(buf[:n-1]) 重新读取用户消息。
读到后,对消息内容进行判断:如果用户发送了“who”,则当成一个查询指令处理。遍历map中所有在线用户,取出每个用户的相关描述信息,组成提示消息,写给当前用户即可。

由于这里客户端我们使用nc工具模拟,该工具对中文支持较差,所以我们组织的用户描述信息中不要包含中文字符。

代码片段如下:

msg := string(buf[:n-1])       // 保存用户写来的消息内容, nc 工具默认添加‘\n’
if msg == "who" && len(msg) == 3 {       // 判断用户发送了 who 指令
   conn.Write([]byte("user list:\n"))
   for _, user := range onlineMap {      // 遍历map获取在线用户
      userInfo := user.Addr + ":" + user.Name + "\n" // 组织在线用户信息
      conn.Write([]byte(userInfo))      // 写给当前用户
   }
} else {
   message <-MakeMsg(clnt, msg)         // 将消息广播给所有在线用户
}

3.5 修改用户名

前面我们查看用户信息时,用户名都是与用户网络地址相同的内容。主要由于用户登录时,创建该用户名不是用户自己完成的。无法洞悉用户的意图。当用户成功登录上来可以通过给服务器发送消息,来修改自己的用户名。

设定,如果用户发送“rename | Iron man”指令,既是想修改自己的用户名为“Iron man”。判断用户消息,是否包含“rename|”关键字:if len(msg) >= 8 && msg[:6] == "rename" 。如果是,那么拆分用户意欲修改的用户名保存。strings.Split()函数可以完成拆分字符串操作。

将该用户名替换当前用户的Name。使用用户的Addr作为key,找到map中当前用户,覆盖即可达到改名的目的。操作结束提示用户改名成功。
代码片段如下:

msg := string(buf[:n-1])
if msg == "who" && len(msg) == 3 {
   conn.Write([]byte("user list:\n"))
   for _, user := range onlineMap {
      userInfo := user.Addr + ":" + user.Name + "\n"
      conn.Write([]byte(userInfo))
   }
   // 判断用户输入的前6个字符是否为 rename
} else if len(msg) >= 8 && msg[:6] == "rename" {   	// rename | Iron man
   newName := strings.Split(msg, "|")[1]     		// 按"|"拆分,rename为[0], Iron man为[1]
   clnt.Name = newName             			// 替换掉当前用户原始Name
   onlineMap[netAddr] = clnt    			// 使用netAddr为key找到map中当前用户。覆盖
   conn.Write([]byte("rename successful\n"))
} else {
   message <- MakeMsg(clnt, msg)
}

3.6 用户退出

前面在“广播用户消息”时,当conn.Read() 读到0时,我们在服务器端,简单打印了“用户xxx退出登录”的提示。

但实际上,在聊天室中,有在线用户离开,我们应该将这一事件广播给所有用户知晓,并且将该用户从map在线用列表中移除。需要实时的监看在线用户的状态。可以创建channel来检测用户退出状态,并使用select来监听channel上的数据流动。

当channel上有数据时,select对应阻塞case语句得以执行。将用户从map中移除。同时通知所有在线用户。

代码片段:

func HandleConnect(conn net.Conn) {

……
   message <- MakeMsg(clnt, "login")

   isQuit := make(chan bool)	// 检测用户主动退出

   go func() {
      buf := make([]byte, 2048)
      for {
         n, err := conn.Read(buf)
         if n == 0 {
            isQuit <- true    	// 用户主动退出登录
            fmt.Printf("用户%s退出登录\n", clnt.Name)
            return
         }
         ……
	}
   }()

   for { 
      select {
         case <-isQuit:                            // 用户不主动退出,阻塞
            delete(onlineMap, netAddr)             // 将当前用户从map中移除
            message <- MakeMsg(clnt, "logout") 	// 广播给在线用户,谁退出了
            return                                // 结束当前退出用户对应协程
      }
   }
}

3.7 超时处理

如果客户端没有主动退出,并且长时间没有发送消息,会一直占用服务端的资源。服务器通常针对这种用户添加“超时强踢”机制,强制将该客户端与服务器连接断开。

可以借助并发编程时我们所学的select超时机制来实现。Select监听time.After(60 * time.Second) 通道上的数据流动。如果在计时期间一直没有数据,通道中会被写入当前系统时间,select 的case满足读条件,不再阻塞。但,有一个问题,用户如果持续在输入数据,这个计时器依然在计时,时间到,依然会强制踢出用户。

因此,我们另外创建一个通道hasData来检测用户是否有数据发送,让Select也来监听这个channel。这样,当用户有数据输入时,select监听的这个hasData通道会满足case条件得以执行,但我们不做任何处理。目的是使得监听在select中的计时器被重新计时。
只有当真正持续60s没有数据发送时,select 中用于计时的case才满足条件,将用户与服务器连接断开。

代码片段:

func HandleConnect(conn net.Conn) {
  ……
……
   isQuit := make(chan bool)
   
   hasData := make(chan bool)	// 检测用户是否有消息发送
   
   go func() {
      buf := make([]byte, 2048)
      for {
         n, err := conn.Read(buf)
         ……
         msg := string(buf[:n-1])
         if {
	……
	} else if {
	……
	} else {
	……
	}
         hasData <- true   		// 只要执行到这里,就说明用户有数据发送
      }
   }()

   for { 
      select {
         case <-isQuit:                        
            delete(onlineMap, netAddr)         
            message <- MakeMsg(clnt, "logout") 
            return                             
         case <-hasData:
                                             		// 什么都不做,目的是让计时器归零
         case <-time.After(60*time.Second):
            delete(onlineMap, netAddr)             	// 将当前用户从map中移除
            message <- MakeMsg(clnt, "time out leave") // 广播给在线用户,超时退出
            return                                	// 结束当前退出用户对应协程
      }
   }
}

这里需要注意的是,每循环一次,第三个case后面的时间都会重新计算。(例如:执行完case<-hasData后,紧跟着执行第三个case,发现时间是10秒,不到60秒,条件不成立,不会执行该case后面的代码,进入下次循环,这时时间重新计算)

当hasData没有数据,isQuit没有数据,60s时间没有到,这时三个case都阻塞等待。直到60秒后,前两个case条件依然不成立,第三个case满足,执行后面代码,断开客户端连接,踢下线。

四 HTTP编程

4.1 概述

4.1.1 Web工作方式

我们平时浏览网页的时候,会打开浏览器,输入网址后按下回车键,然后就会显示出你想要浏览的内容。在这个看似简单的用户行为背后,到底隐藏了些什么呢?

对于普通的上网过程,系统其实是这样做的:浏览器本身是一个客户端,当你输入URL的时候,首先浏览器会去请求DNS服务器,通过DNS获取相应的域名对应的IP,然后通过IP地址找到IP对应的服务器后,要求建立TCP连接,等浏览器发送完HTTP Request(请求)包后,服务器接收到请求包之后才开始处理请求包,服务器调用自身服务,返回HTTP Response(响应)包;客户端收到来自服务器的响应后开始渲染这个Response包里的主体(body),等收到全部的内容随后断开与该服务器之间的TCP连接。

在这里插入图片描述

DNS域名服务器(Domain Name Server)是进行域名(domain name)和与之相对应的IP地址转换的服务器。DNS中保存了一张域名解析表,解析消息的域名。

一个Web服务器也被称为HTTP服务器,它通过HTTP (HyperText Transfer Protocol 超文本传输协议)协议与客户端通信。这个客户端通常指的是Web浏览器(其实手机端客户端内部也是浏览器实现的)。

Web服务器的工作原理可以简单地归纳为:

  • 客户机通过TCP/IP协议建立到服务器的TCP连接
  • 客户端向服务器发送HTTP协议请求包,请求服务器里的资源文档
  • 服务器向客户机发送HTTP协议应答包,如果请求的资源包含有动态语言的内容,那么服务器会调用动态语言的解释引擎负责处理“动态内容”,并将处理得到的数据返回给客户端
  • 客户机与服务器断开。由客户端解释HTML文档,在客户端屏幕上渲染图形结果

4.1.2 HTTP协议

超文本传输协议(HTTP,HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议,它详细规定了浏览器和万维网服务器之间互相通信的规则,通过因特网传送万维网文档的数据传送协议。

HTTP协议通常承载于TCP协议之上,有时也承载于TLS或SSL协议层之上,这个时候,就成了我们常说的HTTPS。如下图所示:

在这里插入图片描述

4.1.3 地址(URL)

URL全称为Unique Resource Location,用来表示网络资源,可以理解为网络文件路径。
基本URL的结构包含模式(协议)、服务器名称(IP地址)、路径和文件名。常见的协议/模式如http、https、ftp等。服务器的名称或IP地址后面有时还跟一个冒号和一个端口号。再后面是到达这个文件的路径和文件本身的名称。如:

http://localhost[":"port][abs_path]
http://192.168.31.1/html/index
https://pan.baidu.com/

URL的长度有限制,不同的服务器的限制值不太相同,但是不能无限长。

4.2 HTTP报文解析

在这里插入图片描述

4.2.1 请求报文格式

获取请求报文

为了更直观的看到浏览器发送的请求包,我们借助前面学习的TCP通信模型,编写一个简单的web服务器,只接收浏览器发送的内容,打印查看。

服务器测试代码:

package main

import (
   "net"
   "fmt"
)

func main() {
   //创建、监听socket
   listenner, err := net.Listen("tcp", "127.0.0.1:8000")
   if err != nil {
      fmt.Println("Listen err:", err)
      return
   }
   defer listenner.Close()

   //阻塞等待客户端连接
   conn, err := listenner.Accept()
   if err != nil {
      fmt.Println("Accept err:", err)
      return
   }
   defer conn.Close()

   fmt.Println(conn.RemoteAddr().String(), "连接成功")       //连接客户端的网络地址

   buf := make([]byte, 4096)  //切片缓冲区,接收客户端发送数据
   n, err := conn.Read(buf)   //n 接收数据的长度
   if err != nil {
      fmt.Println("Read err:", err)
      return
   }
   result := buf[:n]        //切片截取

   fmt.Printf("#\n%s#", string(result))
}

在浏览器中输入url地址: 127.0.0.1:8000

服务器端运行打印结果如下:

在这里插入图片描述

请求报文格式说明

HTTP 请求报文由请求行、请求头部、空行、请求包体4个部分组成,如下图所示:

在这里插入图片描述

请求行

请求行由方法字段、URL 字段 和HTTP 协议版本字段 3个部分组成,他们之间使用空格隔开。常用的 HTTP 请求方法有 GET、POST。
GET:

  • 当客户端要从服务器中读取某个资源时,使用GET 方法。GET 方法要求服务器将URL 定位的资源放在响应报文的数据部分,回送给客户端,即向服务器请求某个资源。
  • 使用GET方法时,请求参数和对应的值附加在 URL 后面,利用一个问号(“?”)代表URL 的结尾与请求参数的开始,传递参数长度受限制,因此GET方法不适合用于上传数据。
  • 通过GET方法来获取网页时,参数会显示在浏览器地址栏上,因此保密性很差。
    POST:
  • 当客户端给服务器提供信息较多时可以使用POST 方法,POST 方法向服务器提交数据,比如完成表单数据的提交,将数据提交给服务器处理。
  • GET 一般用于获取/查询资源信息,POST 会附带用户数据,一般用于更新资源信息。POST 方法将请求参数封装在HTTP 请求数据中,而且长度没有限制,因为POST携带的数据,在HTTP的请求正文中,以名称/值的形式出现,可以传输大量数据。

请求头部

请求头部为请求报文添加了一些附加信息,由“名/值”对组成,每行一对,名和值之间使用冒号分隔。请求头部通知服务器有关于客户端请求的信息,典型的请求头有:

请求头 含义
User-Agent 请求的浏览器类型
Accept 客户端可识别的响应内容类型列表,星号“ * ”用于按范围将类型分组,用“ */* ”指示可接受全部类型,用“ type/* ”指示可接受 type 类型的所有子类型
Accept-Language 客户端可接受的自然语言
Accept-Encoding 客户端可接受的编码压缩格式
Accept-Charset 可接受的应答的字符集
Host 请求的主机名,允许多个域名同处一个IP 地址,即虚拟主机
connection 连接方式(close或keepalive)
Cookie 存储于客户端扩展字段,向同一域名的服务端发送属于该域的cookie

空行

最后一个请求头之后是一个空行,发送回车符和换行符,通知服务器以下不再有请求头。

请求包体

请求包体不在GET方法中使用,而在POST方法中使用。POST方法适用于需要客户填写表单的场合。与请求包体相关的最常使用的是包体类型Content-Type和包体长度Content-Length。

4.2.2 响应报文格式

要想获取响应报文,必须先发送请求报文给web服务器。服务器收到并解析浏览器(客户端)发送的请求报文后,借助http协议,回复相对应的响应报文。

下面我们借助net/http包,创建一个最简单的服务器,给浏览器回发送响应包。首先注册处理函数http.HandleFunc(),设置回调函数handler。而后绑定服务器的监听地址http.ListenAndserve()。

这个服务器启动后,当有浏览器发送请求,回调函数被调用,会向浏览器回复“hello world”作为网页内容。当然,是按照http协议的格式进行回复。

创建简单的响应服务器

服务器示例代码:

package main

import "net/http"

// 浏览器访问时,该函数被回调
func handler(w http.ResponseWriter, r *http.Request) {
   w.Write([]byte("hello http"))
}

func main()  {
   // 注册处理函数
   http.HandleFunc("/hello", handler)

   // 绑定服务器监听地址
   http.ListenAndServe("127.0.0.1:8000", nil)
}

测试:启动服务器。打开浏览器,在URL中写入127.0.0.1:8000/hello,向服务器发送请求。会在浏览器中看到服务器回发的“hello http”。

回调函数handler会在浏览器访问服务器时被调用。服务器使用w.Write向浏览器写了“hello http”。

但服务器是怎样借助http协议将“hello http”字符串写回来的呢,服务器响应报文的具体格式是什么样的呢?

接下来我们编写一个客户端,模拟浏览器给服务器发送“请求报文”的行为,然后将服务器回发的响应报文打印出来就可以看到了。

客户端测试示例代码:

package main

import (
   "net"
   "fmt"
)

func main()  {
   // 客户端主动连接服务器
   conn, err := net.Dial("tcp", "127.0.0.1:8000")
   if err != nil {
      fmt.Println("Dial err:", err)
      return
   }
   defer conn.Close()

   // 模拟浏览器,组织一个最简单的请求报文。包含请求行,请求头,空行即可。
   requestHttpHeader := "GET /hello HTTP/1.1\r\nHost:127.0.0.1:8000\r\n\r\n"

   // 给服务器发送请求报文
   conn.Write([]byte(requestHttpHeader))

   buf := make([]byte, 4096)
   // 读取服务器回复 响应报文
   n, err := conn.Read(buf)
   if err != nil {
      fmt.Println("Read err:", err)
      return
   }
   // 打印观察
   fmt.Printf("#\n%s#", string(buf[:n]))
}

启动程序,测试http的成功响应报文:

在这里插入图片描述
启动程序,测试http的失败响应报文:

在这里插入图片描述

响应报文格式说明

HTTP 响应报文由状态行、响应头部、空行、响应包体4个部分组成,如下图所示:

在这里插入图片描述

状态行

状态行由 HTTP 协议版本字段、状态码和状态码的描述文本,3个部分组成,他们之间使用空格隔开。

状态码:状态码由三位数字组成,第一位数字表示响应的类型,常用的状态码有五大类如下所示:

状态码 含义
1xx 表示服务器已接收了客户端请求,客户端可继续发送请求
2xx 表示服务器已成功接收到请求并进行处理
3xx 表示服务器要求客户端重定向
4xx 表示客户端的请求有非法内容
5xx 表示服务器未能正常处理客户端的请求而出现意外错误

常见的状态码举例:

状态码 含义
200 OK 客户端请求成功
400 Bad Request 请求报文有语法错误
401 Unauthorized 未授权
403 Forbidden 服务器拒绝服务
404 Not Found 请求的资源不存在
500 Internal Server Error 服务器内部错误
503 Server Unavailable 服务器临时不能处理客户端请求(稍后可能可以)

响应头部

响应头可能包括:

响应头 含义
Location Location响应报头域用于重定向接受者到一个新的位置
Server Server 响应报头域包含了服务器用来处理请求的软件信息及其版本
Vary 指示不可缓存的请求头列表
Connection 连接方式

空行

最后一个响应头部之后是一个空行,发送回车符和换行符,通知服务器以下不再有响应头部。

响应包体

服务器返回给客户端的文本信息。

4.3 Go语言HTTP编程

Go语言标准库内建提供了net/http包,涵盖了HTTP客户端和服务端的具体实现。使用net/http包,我们可以很方便地编写HTTP客户端或服务端的程序。

4.3.1 HTTP服务端

示例代码:

package main

import (
    "fmt"
    "net/http"
)

//服务端编写的业务逻辑处理程序 —— 回调函数
func myHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Println("method = ", r.Method) 	//请求方法
    fmt.Println("URL = ", r.URL)		// 浏览器发送请求文件路径
    fmt.Println("header = ", r.Header)		// 请求头
    fmt.Println("body = ", r.Body)		// 请求包体
    fmt.Println(r.RemoteAddr, "连接成功")  	//客户端网络地址

    w.Write([]byte("hello http")) 	//给客户端回复数据
}

func main() {
    http.HandleFunc("/hello", myHandler)	// 注册处理函数

    //该方法用于在指定的 TCP 网络地址 addr 进行监听,然后调用服务端处理程序来处理传入的连接请求。
    //该方法有两个参数:第一个参数 addr 即监听地址;第二个参数表示服务端处理程序,通常为nil
    //当参2为nil时,服务端调用 http.DefaultServeMux 进行处理
    http.ListenAndServe("127.0.0.1:8000", nil)
}

浏览器输入url地址:127.0.0.1:8000/hello

回调函数myHandler的函数原型固定。func myHandler(w http.ResponseWriter, r *http.Request) 有两个参数:
w http.ResponseWriter 和 r *http.Request。w用来“给客户端回发数据”。它是一个interface:

type ResponseWriter interface {
   Header() Header			
   Write([]byte) (int, error)	
   WriteHeader(int)			
}

r 用来“接收客户端发送的数据”。浏览器发送给服务器的http请求包的内容可以借助r来查看。它对应一个结构体:

type Request struct {
	Method string		// 浏览器请求方法 GET、POST…
	URL *url.URL		// 浏览器请求的访问路径
	……
	Header Header		// 请求头部
	Body io.ReadCloser	// 请求包体
	RemoteAddr string	// 浏览器地址
	……
    	ctx context.Context
}

查看一下结构体成员:

fmt.Println("Method = ", r.Method)
fmt.Println("URL = ", r.URL)
fmt.Println("Header = ", r.Header)
fmt.Println("Body = ", r.Body)
fmt.Println(r.RemoteAddr, "连接成功")

查看到如下内容:

在这里插入图片描述

4.3.2 HTTP客户端

客户端访问web服务器数据,主要使用func Get(url string) (resp *Response, err error)函数来完成。读到的响应报文数据被保存在 Response 结构体中。

type Response struct {
   Status     string // e.g. "200 OK"
   StatusCode int    // e.g.  200
   Proto      string // e.g. "HTTP/1.0"
   ……
   Header Header
   Body io.ReadCloser
   ……
}

服务器发送的响应包体被保存在Body中。可以使用它提供的Read方法来获取数据内容。保存至切片缓冲区中,拼接成一个完整的字符串来查看。

结束的时候,需要调用Body中的Close()方法关闭io。

示例代码:

package main

import (
   "net/http"
   "fmt"
)

func main()  {
   // 使用Get方法获取服务器响应包数据
   //resp, err := http.Get("http://www.baidu.com")
   resp, err := http.Get("http://127.0.0.1:8000/hello")
   if err != nil {
      fmt.Println("Get err:", err)
      return
   }
   defer resp.Body.Close()

   // 获取服务器端读到的数据
   fmt.Println("Status = ", resp.Status)           // 状态
   fmt.Println("StatusCode = ", resp.StatusCode)   // 状态码
   fmt.Println("Header = ", resp.Header)           // 响应头部
   fmt.Println("Body = ", resp.Body)               // 响应包体

   buf := make([]byte, 4096)         // 定义切片缓冲区,存读到的内容
   var result string
   // 获取服务器发送的数据包内容
   for {
      n, err := resp.Body.Read(buf)  // 读body中的内容。
      if n == 0 {
         fmt.Println("Body.Read err:", err)
         break
      }
      result += string(buf[:n])     // 累加读到的数据内容
   }
   // 打印从body中读到的所有内容
   fmt.Println("result = ", result)
}
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐