golang socket編程

在大學的時候,曾經修過一門課《網絡原理》,其中就花很大的篇幅講過TCP/IP四層網絡協議(OSI的七層網絡協議可以映射到這個四層協議上來),也講過HTTP協議、socket編程。但是講的東西多了,總有一種雲裏霧裏的感覺,而且也沒能很好的瞭解其中的關聯。這裏就和大家一起梳理一下,上述幾個概念之間的關係,並通過golang實現socket編程。

1. 基礎概念

1.1 TCP/IP協議

TCP/IP協議(傳輸控制協議/互聯網協議)不是簡單的一個協議,而是一組特別的協議,包括:TCP,IP,UDP,ARP等,這些被稱爲子協議。在這些協議中,最重要、最著名的就是TCP和IP。因此,大部分網絡管理員稱整個協議族爲“TCP/IP”。

上面就是百度百科給出的概念,我們需要了解的就是TCP/IP 協議棧是一系列網絡協議的總和,是構成網絡通信的核心骨架,它定義了電子設備如何連入因特網,以及數據如何在它們之間進行傳輸。通俗一點講就是,一個主機的數據要經過哪些過程才能發送到對方的主機上

所謂IP就是網絡層IP協議,負責唯一標識網絡中的主機,將數據分組從一臺主機傳送到另一臺主機,並且這個傳動過程並不是可靠的,會發生丟包、重複、失序等問題,只能說是“盡力而爲”。

TCP就是指傳輸層TCP協議,保證兩臺主機進程之間的可靠通信。

1.2 HTTP協議

HTTP協議(超文本傳輸協議),通俗的講就是一個定義了超文本(HTML)傳輸規則的一個協議,可以保證超文本的可靠傳輸,是TCP/IP四層模型的應用層協議。HTTP協議其實就是基於傳輸層TCP協議和網絡層IP協議實現的一個協議,所以它可以保證超文本的可靠傳輸

1.3 Socket

從我們之前學習的一些概念,可以知道TCP要保證可靠傳輸,要通過三次握手建立連接,傳輸數據時,要有滑動窗口、累積確認、分組緩存、流量控制等約束,斷開連接時要通過四次揮手斷開連接,是一個非常麻煩的協議。如果有個需求,使用TCP協議編程,設計一個客戶端和服務端的通信系統,代價將是特別大的。這時候我們肯定想到一個概念——抽象。因爲TCP協議非常複雜,不能要求每個程序員都去實現建立連接的三次握手、累計確認、分組緩存,這些應該屬於操作系統內核部分,沒必要重複開發。但是對於程序員來講,操作系統需要抽象出一個概念,讓上層應用可以使用抽象概念去編程,而這個抽象的概念就是Socket(Socket是操作系統抽象出來出我們更方便使用)。

從web開發者的角度而言,一切編程都是Socket,只不過因爲我們日常開發都是基於應用層開發,所以掩蓋了Socket的細節。考慮一些場景,我們每天打開瀏覽器瀏覽網頁時,瀏覽器進程怎麼和 Web 服務器進行通信的呢?使用QQ聊天時,QQ進程怎麼和服務器或者是你的好友所在的 QQ 進程進行通信的呢?如此種種,底層都是靠 Socket來進行通信的。

常用的Socket類型有兩種:流式Socket(SOCK_STREAM)和數據報式Socket(SOCK_DGRAM)。流式是一種面向連接的Socket,針對於面向連接的 TCP 服務應用;數據報式 Socket 是一種無連接的Socket,對應於無連接的UDP服務應用。

使用socket實現Tcp客戶端和服務端交互的流程如下:

2. Socket編程

2.1 Socket編程簡述

通過上面的基礎概念,我們知道其實Socket就是操作系統抽象出來的,用於方便我們使用TCP/UDP協議進行網絡中多個主機進程之間進行通訊的一種方式。既然要實現多個主機進程之間通信,肯定要能唯一標誌每個進程。而Socket是通過IP:Port來唯一標誌進程的。通俗的講,每一個IP:Port組合都是一個Socket。比如常見的80、443端口,就是這裏講的用來進行Socket連接的port,也可講是服務端對外提供服務的端口號。

有了抽象的Socket後,當要使用TCP(或UDP)協議進行web編程時,就可以通過如下方式進行:

  • client端僞代碼:
clientfd = socket(……)
connect(clientfd, serverIp:Port, ……)
send(clientfd, data)
receive(clientfd, ……)
close(clientfd)

使用socket後,就不用理會TCP/UDP那些煩人的細節了,只剩下一些概念性的東西。比如connect方法,就是在和服務端進行三次握手建立連接。

  • server端僞代碼:
listenfd = socket(……)
bind(listenfd, ServerIp:Port, ……)
listen(listenfd, ……)
while(true) {
  conn = accept(listenfd, ……)
  receive(conn, ……)
  send(conn, ……)
}

作爲Socket服務端,要滿足兩個條件:

  1. 服務端是被動的,所以服務端啓動之後,需要監聽客戶端發起的連接
  2. 服務端要應付很多客戶端發起的連接,所以要把各個連接區分開來,不能混淆

上述僞代碼中,listenfd就是爲了實現服務端監聽創建的Socket描述符,而bind方法就是服務端進程佔用端口,避免其它端口被其它進程使用,listen方法開始對端口進行監聽。下面的while循環用來處理客戶端源源不斷的請求,accept方法返回一個conn,其實就是用來區分各個客戶端的連接的,之後的接受和發送動作都是基於這個conn來實現的。其實accept就是和客戶端的connect一起完成了TCP的三次握手。

2.2 golang實現Socket編程

2.2.1 TCP Socket

這裏我們通過TCP協議,實現一個功能,客戶端向服務端發送一個字符串,服務端獲取客戶端發送的字符串後,並在字符串後添加一個當前的時間戳返回給客戶端。這裏我們使用golang中內置的net包來實現。

在Go語言的net包中有一個類型TCPConn,這個類型可以用來作爲客戶端和服務器端交互的通道,他有兩個主要的函數:

func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Read(b []byte) (int, error)

TcpConn可以用在客戶端和服務器端來讀寫數據

還需要了解一個TCPAddr 類型,他表示一個TCP的地址信息,定義如下:

type TCPAddr struct {
    IP IP
    Port int
    Zone string // IPv6 scoped addressing zone
}

在Go語言中通過ResolveTCPAddr可以獲取一個TCPAddr,如下:

func ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)
  • net 參數是 “tcp4″、”tcp6″、”tcp” 中的任意一個,分別表示TCP (IPv4-only),TCP (IPv6-only) 或者TCP (IPv4, IPv6 的任意一個)
  • addr 表示域名或者 IP 地址,例如 “www.baidu.com:80” 或者 “127.0.0.1:7777”

2.1.1.1 client端

Go語言中通過net包中的DialTCP函數來建立一個TCP連接,並返回一個TCPConn類型的對象,當連接建立時服務器端也創建一個同類型的對象,此時客戶端和服務器段通過各自擁有的TCPConn對象來進行數據交換。一般而言,客戶端通過TCPConn對象將請求信息發送到服務器端,讀取服務器端響應的信息。服務器端讀取並解析來自客戶端的請求,並返回應答信息,這個連接只有當任一端關閉了連接之後才失效,不然這連接可以一直在使用。建立連接的函數定義如下:

func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
  • net 參數是 “tcp4″、”tcp6″、”tcp” 中的任意一個,分別表示TCP (IPv4-only)、TCP (IPv6-only) 或者TCP (IPv4, IPv6 的任意一個)
  • laddr 表示本機地址,一般設置爲 nil
  • raddr 表示遠程的服務地址

接下來我們來實現功能,client端代碼如下:

package main

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

func main() {
	if len(os.Args) != 3 {
		fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
		os.Exit(1)
	}
	service := os.Args[1]
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
	checkError(err)
	conn, err := net.DialTCP("tcp", nil, tcpAddr)
	checkError(err)
	_, err = conn.Write([]byte(os.Args[2]))
	checkError(err)

	result, err := ioutil.ReadAll(conn)
	checkError(err)
	fmt.Println(string(result))
}

func checkError(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
		os.Exit(1)
	}
}
  • Go語言中,os.Args可以用於獲取程序運行時的參數,其中os.Args[0]默認是可執行文件地址,其他運行參數從os.Args[1]開始
  • 客戶端程序將用戶的第一個輸入參數作爲參數傳入net.ResolveTCPAddr獲取一個tcpAddr
  • tcpAddr傳入DialTCP後創建了一個TCP連接conn
  • 通過conn來發送請求信息,最後通過ioutil.ReadAll從conn中讀取全部的文本

2.1.1.2 server端

上面我們編寫了一個TCP的客戶端程序,也可以通過net包來創建一個服務器端程序,在服務器端我們需要綁定服務到指定的非激活端口,並監聽此端口,當有客戶端請求到達的時候可以接收到來自客戶端連接的請求。net包中有相應功能的函數,函數定義如下:

func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
func (l *TCPListener) Accept() (Conn, error)

上述參數跟客戶端相同,下面我們來實現服務端功能:

package main

import (
	"fmt"
	"net"
	"os"
	"strconv"
	"time"
)

func main() {
	service := ":7777"
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
	checkError1(err)
	listener, err := net.ListenTCP("tcp", tcpAddr)
	checkError1(err)
	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		b := make([]byte, 1024)
		conn.Read(b)
		conn.Write([]byte(string(b) + ":" + strconv.FormatInt(time.Now().Unix(), 10)))
		conn.Close()
	}
}

func checkError1(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
		os.Exit(1)
	}
}

上面的服務跑起來之後,它將會一直在那裏等待,直到有新的客戶端請求到達。當有新的客戶端請求到達並同意接受Accept該請求的時候服務端也會創建一個TcpConn對象來與服務端進行交互,讀取客戶端請求內容,並在請求內容後面拼一個當前的時間戳。

上面的代碼有個缺點,執行的時候是單任務的,不能同時接收多個請求,那麼該如何改造以使它支持多併發呢?Go裏面有一個goroutine機制,請看下面改造後的代碼:

package main

import (
	"fmt"
	"net"
	"os"
	"strconv"
	"time"
)

func main() {
	service := ":7777"
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
	checkError1(err)
	listener, err := net.ListenTCP("tcp", tcpAddr)
	checkError1(err)
	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		go handleClient(conn)
	}
}

func handleClient(conn net.Conn) {
	defer conn.Close()
	b := make([]byte, 1024)
	conn.Read(b)
	conn.Write([]byte(string(b) + ":" + strconv.FormatInt(time.Now().Unix(), 10)))

}

func checkError1(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
		os.Exit(1)
	}
}

2.1.1.3 運行結果

將服務端程序運行起來,執行客戶端代碼如下:

./tcp_socket_client localhost:7777 hello

運行結果:

hello:1562116434

2.1.1.4 實現TCP長連接

上述代碼,在客戶端請求一次,服務端處理之後,就把conn關閉了,如何來實現Tcp長連接?

package main

import (
	"fmt"
	"net"
	"os"
	"strconv"
	"time"
)

func main() {
	service := ":7777"
	tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
	checkError1(err)
	listener, err := net.ListenTCP("tcp", tcpAddr)
	checkError1(err)
	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}

		go handleClient(conn)
	}
}

func handleClient(conn net.Conn) {
	conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) // set 2 minutes timeout
	request := make([]byte, 1024)
	defer conn.Close()  // close connection before exit
	for {
		read_len, err := conn.Read(request)

		if err != nil {
			fmt.Println(err)
			break
		}

		if read_len == 0 {
			break // connection already closed by client
		}

		conn.Write([]byte(string(request) + ":" + strconv.FormatInt(time.Now().Unix(), 10)))

		request = make([]byte, 128) // clear last read content
	}

}

func checkError1(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
		os.Exit(1)
	}
}
  • 使用conn.Read()不斷讀取客戶端發來的請求
  • 由於需要保持與客戶端的長連接,所以不能在讀取完一次請求後就關閉連接
  • conn.SetReadDeadline()設置了超時,當一定時間內客戶端無請求發送,conn 便會自動關閉,下面的 for 循環即會因爲連接已關閉而跳出
  • request在創建時需要指定一個最大長度以防止flood attack
  • 每次讀取到請求處理完畢後,需要清理request,因爲conn.Read() 會將新讀取到的內容append到原內容之後

2.1.1.2 控制TCP連接

TCP有很多連接控制函數,我們平常用到比較多的有如下幾個函數:

func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)

設置建立連接的超時時間,客戶端和服務器端都適用,當超過設置時間時,連接自動關閉。

func (c *TCPConn) SetReadDeadline(t time.Time) error
func (c *TCPConn) SetWriteDeadline(t time.Time) error

用來設置寫入/讀取一個連接的超時時間,當超過設置時間時,連接自動關閉。

func (c *TCPConn) SetKeepAlive(keepalive bool) os.Error

設置keepAlive屬性,是操作系統層在tcp上沒有數據和ACK的時候,會間隔性的發送keepalive包,操作系統可以通過該包來判斷一個tcp連接是否已經斷開,在 windows上默認2個小時沒有收到數據和keepalive包的時候人爲tcp連接已經斷開,這個功能和我們通常在應用層加的心跳包的功能類似。

2.2.2 UDP Socket

Go語言包中處理UDP Socket和TCP Socket不同的地方就是在服務器端處理多個客戶端請求數據包的方式不同,UDP缺少了對客戶端連接請求的Accept函數。其他基本幾乎一模一樣,只有TCP換成了UDP而已。UDP的幾個主要函數如下所示:

func ResolveUDPAddr(net, addr string) (*UDPAddr, os.Error)
func DialUDP(net string, laddr, raddr *UDPAddr) (c *UDPConn, err os.Error)
func ListenUDP(net string, laddr *UDPAddr) (c *UDPConn, err os.Error)
func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, err os.Error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (n int, err os.Error)

2.2.2.1 client端

package main

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

func main() {
	if len(os.Args) != 3 {
		fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
		os.Exit(1)
	}
	service := os.Args[1]
	udpAddr, err := net.ResolveUDPAddr("udp4", service)
	checkError(err)

	conn, err := net.DialUDP("udp", nil, udpAddr)
	checkError(err)

	_, err = conn.Write([]byte(os.Args[2]))
	checkError(err)

	response, err :=ioutil.ReadAll(conn)
	fmt.Println(string(response))

	os.Exit(0)
}
func checkError(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
		os.Exit(1)
	}
}

2.2.2.2 server端

package main

import (
	"fmt"
	"net"
	"os"
	"strconv"
	"time"
)

func main() {
	service := ":8888"
	udpAddr, err := net.ResolveUDPAddr("udp4", service)
	checkError(err)

	conn, err := net.ListenUDP("udp", udpAddr)
	checkError(err)
	for {
		handleClient(conn)
	}
}

func handleClient(conn *net.UDPConn) {
	request := make([]byte, 1024)
	_, addr, err := conn.ReadFromUDP(request)
	if err != nil {
		return
	}

	conn.WriteToUDP([]byte(string(request) + ":" + strconv.FormatInt(time.Now().Unix(), 10)), addr)
}

func checkError(err error) {
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
		os.Exit(1)
	}
}

以上就是使用golang實現Socket編程的簡單示例,比之前的文章 Netty是什麼中介紹的使用Java的實現要簡單。另外go提供的goroutine機制,跟Java的線程相比,更輕量(協程),所以處理效率上也會比Java高一些。

參考鏈接:

1. 《碼農翻身——搞清楚socket》

2. 《Go Web編程》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章