Sunday, November 6, 2016

Bài 34: Các dịch vụ web

Dịch vụ web (web service) là dịch vụ cung cấp truy xuất và trao đổi dữ liệu qua môi trường web giữa các thiết bị mà không bị bó buộc bởi phần cứng hay hệ điều hành sử dụng. Điểm khác biệt giữa dịch vụ web và ứng dụng web là dịch vụ web phục vụ cho các thiết bị còn ứng dụng web phục vụ cho người dùng. Do đó ứng dụng web cần thêm giao diện đẹp, dễ dùng còn dịch vụ web chỉ cần cung cấp các API cho các thiết bị khác truy cập truy xuất thông tin. Trong bài này chúng ta sẽ tìm hiểu các phương thức trao đổi dữ liệu để xây dựng các loại dịch vụ web khác nhau: socket, websocket, REST và RPC.


Socket


Socket là kết nối mạng cấp thấp nhưng đóng vai trò vô cùng quan trọng. Bạn có biết trình duyệt dùng nó để kết nối web server? Hầu hết các ứng dụng chat như Yahoo Messenger, MSN, Skype, v.v... sử dụng socket trong trao đổi dữ liệu chat. Vậy socket là gì?

Socket bắt nguồn từ Unix, dựa trên nguyên lý "mọi thứ đều là file" và đều có thể "mở->đọc/ghi->đóng". Socket sẽ được mở như mở file, trả về một đối tượng mà từ đó có thể tạo kết nối, truyền nhận dữ liệu qua đọc/ghi thông tin vào socket. Có 2 loại socket: stream socket (SOCK_STREAM) và datagram sockets (SOCK_DGRAM). Socket loại stream là loại hướng kết nối giống TCP, trong khi datagram không thiết lập kết nối giống UDP.

Trên cùng 1 thiết bị có thể tạo cùng lúc nhiều socket khác nhau miễn là khác cổng kết nối. Các socket giữa các thiết bị nói chuyện được với nhau nhờ IP của thiết bị và cổng kết nối. IP và cổng kết hợp sẽ cho một địa chỉ socket riêng biệt, tương tự như địa chỉ ứng dụng web ta đã tìm hiểu ở bài 25. Package net của Go khai báo cấu trúc địa chỉ socket cho TCP và UDP tựa như nhau:
type TCPAddr struct {
IP IP
Port int
Zone string // vùng phạm vi địa chỉ IPv6
}

type UDPAddr struct {
IP IP
Port int
Zone string // vùng phạm vi địa chỉ IPv6
}

Go cung cấp hàm ParseIP trong package net để chuyển 1 chuỗi có dạng IPv4 hoặc IPv6 về dạng lưu trữ IP là một slice kiểu byte:
func ParseIP(s string) IP

type IP []byte


Socket TCP

Kết nối socket gồm 2 phần trên client và trên server:
- Trên client đơn giản tạo một đối tượng kết nối TCP nhờ hàm DialTCP. Sau đó sử dụng đối tượng kiểu TCPConn này để gửi nhận dữ liệu.
func DialTCP(net string, laddr, raddr *TCPAddr) (*TCPConn, error)
Thay vì dùng DialTCP, ta có thể dùng DialTimeout để xác định sẽ đóng kết nối sau bao lâu nếu không kết nối thành công:
func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)
- Trên server, quy trình phức tạp hơn chút. Chúng ta cần khai báo cổng kết nối và đăng ký lắng nghe kết nối trên cổng này qua hàm ListenTCP. Khi có kết nối, nó sẽ tạo đối tượng kết nối TCPConn để trao đổi dữ liệu với client thông qua phương thức Accept:
func ListenTCP(net string, laddr *TCPAddr) (*TCPListener, error)
func (l *TCPListener) AcceptTCP() (*TCPConn, error)
 - Lúc này khi kết nối được thiết lập. Hai bên có thể trao đổi dữ liệu với nhau thông qua 2 đối tượng TCPConn của mỗi bên:
func (c *TCPConn) Read(b []byte) (int, error)
func (c *TCPConn) Write(b []byte) (int, error)
Hay phương thức này khi thực thi sẽ khóa ứng dụng lại cho đến khi hoàn tất hoặc đến khi hết thời gian cho phép. Để thiết lập khoản thời gian này, TCPCon cung cấp một số phương thức như: SetDeadline thiết lập thời gian chung cho cả đọc và ghi, SetWriteDeadline cho riêng phần ghi còn SetReadDeadline cho phần đọc. Giá trị tham số là 0 nghĩa là không có thời hạn
func (c *TCPConn) SetDeadline(t time.Time) error
func (c *TCPConn) SetWriteDeadline(t time.Time) error
func (c *TCPConn) SetReadDeadline(t time.Time) error
- Việc trao đổi dữ liệu giữa server và client chỉ kết thúc khi một trong 2 bên đóng kết nối bằng phương thức Close của đối tượng TCPConn:
func (c *TCPConn) Close() error

Bây giờ chúng ta cùng mô phỏng trao đổi dữ liệu đơn giản qua socket giữa client và server. Đây là dịch vụ hỏi giờ, client hỏi và server trả về ngày giờ hiện tại trên server:
Client:
- Package sử dụng:
import (
    "fmt"
    "log"
    "net"
)
- Đầu tiên tạo địa chỉ TCP từ chuỗi URL của server: localhost:8888
tcpAddr, err := net.ResolveTCPAddr("tcp4", "localhost:8888")
if err != nil {
    log.Fatal("Lỗi phân giải địa chỉ: " + err.Error())
}
- Tiếp theo tạo kết nối đến server:
conn, err := net.DialTCP("tcp", nil, tcpAddr)
if err != nil {
    log.Fatal("Lỗi tạo socket: " + err.Error())
}
- Lúc này đã có thể gửi dữ liệu lên server:
_, err = conn.Write([]byte("Mấy giờ?"))
if err != nil {
    log.Fatal("Lỗi gửi dữ liệu: " + err.Error())
}
- Nhận phản hồi từ server:
message := make([]byte, 512)
n, err := conn.Read(message)
if err != nil {
    log.Fatal("Lỗi nhận dữ liệu: " + err.Error())
}
fmt.Printf("Nhận %d byte từ server: %s", n, string(message[:n]))
Biến message nhận dữ liệu được ấn định chiều dài là 512 byte. Cũng vì vậy chuỗi in ra có thể in luôn phần trống nên cần cắt lại dựa trên số byte nhận được message[:n].

Server:
- Package sử dụng:
import (
    "fmt"
    "log"
    "net"
    "time"
)
- Đầu tiên cũng tạo địa chỉ như ở client:
tcpAddr, err := net.ResolveTCPAddr("tcp4", "localhost:8888")
if err != nil {
    log.Fatal("Lỗi phân giải địa chỉ: " + err.Error())
}
- Tiếp theo là phần đăng ký và lắng nghe kết nối từ client:
listener, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
    log.Fatal("Lỗi đăng kết nối: " + err.Error())
}
- Lúc này server sẽ chạy vòng lặp không điều kiện để đón nhận kết nối và xử lý kết nối từ client:
for {
    conn, err := listener.AcceptTCP()
    if err != nil {
        log.Println("Lỗi mở kết nối: " + err.Error())
        continue
    }
    message := make([]byte, 512)
    n, err := conn.Read(message)
    if err != nil {
        log.Fatal("Lỗi nhận dữ liệu: " + err.Error())
    }
    fmt.Printf("Nhận %d byte từ client: %s\n", n, string(message[:n]))

    daytime := time.Now().String()
    conn.Write([]byte(daytime))
    conn.Close()
}
Chúng ta có thể thấy là nếu có gặp lỗi mở kết nối với 1 client, server chỉ ghi nhận và tiếp tục hoạt động. Phần xử lý kết nối trên server ở trên chạy tạm ổn vì xử lý đơn giản nhưng nếu trao đổi với client phức tạp thì không thể sử dụng vì danh sách client đợi kết nối ngày càng tăng do mọi việc xử lý tuần tự. Ta có thể áp dụng goroutine vào đây để cải thiện hiệu năng như sau:
for {
    conn, err := listener.Accept()
    if err != nil {
        log.Println("Lỗi mở kết nối: " + err.Error())
        continue
    }
    go handleClient(conn)
}
Hàm handleClient được hiện thực như sau:
func handleClient(conn *net.TCPConn) {
    message := make([]byte, 512)
    n, err := conn.Read(message)
    if err != nil {
        log.Fatal("Lỗi nhận dữ liệu: " + err.Error())
    }
    fmt.Printf("Nhận %d byte từ client: %s\n", n, string(message[:n]))

    daytime := time.Now().String()
    conn.Write([]byte(daytime))
}

Socket UDP

Điểm khác biệt lớn nhất giữa socket TCP và UDP ở Go là phần xử lý quản lý kết nối từ client trên server. Không hề có phương thức Accept. Ngoài ra các hàm, phương thức khác khá tương tự chỉ thay tên từ TCP thành UDP.

Client:
udpAddr, err := net.ResolveUDPAddr("udp4", "localhost:8888")
if err != nil {
    log.Fatal("Lỗi phân giải địa chỉ: " + err.Error())
}
conn, err := net.DialUDP("udp", nil, udpAddr)
if err != nil {
    log.Fatal("Lỗi tạo socket: " + err.Error())
}
_, err = conn.Write([]byte("Mấy giờ rồi?"))
if err != nil {
    log.Fatal("Lỗi gửi dữ liệu: " + err.Error())
}
message := make([]byte, 512)
size, err := conn.Read(message)
if err != nil {
    log.Fatal("Lỗi nhận dữ liệu: " + err.Error())
}
fmt.Printf("Nhận %d byte từ server: %s", size, string(message[:size]))

Server:
udpAddr, err := net.ResolveUDPAddr("udp4", "localhost:8888")
if err != nil {
    log.Fatal("Lỗi phân giải địa chỉ: " + err.Error())
}
conn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
    log.Fatal("Lỗi đăng kết nối: " + err.Error())
}
for {
    handleClient(conn)
}
Hàm handleClient: 
func handleClient(conn *net.UDPConn) {
    message := make([]byte, 512)
    n, addr, err := conn.ReadFromUDP(message)
    if err != nil {
        log.Fatal("Lỗi nhận dữ liệu: " + err.Error())
    }
    fmt.Printf("Nhận %d byte từ client: %s\n", n, string(message[:n]))

    daytime := time.Now().String()
    conn.WriteToUDP([]byte(daytime), addr)
}
Phương thức ReadFromUDP đọc dữ liệu từ conn đổ vào message đồng thời trả về số byte đọc được và địa chỉ client để sử dụng khi muốn phản hồi:
func (c *UDPConn) ReadFromUDP(b []byte) (int, *UDPAddr, error)
Phương thức WriteToUDP đơn giản ghi 1 chuỗi byte vào conn và trả về địa chỉ client có ở addr:
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error)

Websocket


Trao đổi 2 chiều giữa client và server với socket như trên là mơ ước của các bạn lập trình ứng dụng web bởi lẽ trao đổi giữa client (cụ thể là trình duyệt) và server là trao đổi kiểu yêu cầu - phản hồi. Client (trình duyệt) yêu cầu, server nhận yêu cầu, xử lý rồi phản hồi về cho client. Server chờ yêu cầu mới chứ không thể chủ động gửi về cho client được. Việc làm sao để server có thể gửi thông tin mới về client mà không cần chờ client yêu cầu diễn ra trong thời gian dài với nhiều kỹ thuật khác nhau:
- Đầu tiên là sự xuất hiện của AJAX (Asynchronous JavaScript And XML) cho phép client có thể yêu cầu thông tin mới từ server mà không cần nạp lại trang. AJAX polling là kỹ thuật đáp ứng nhu cầu cập nhật thông tin từ server bằng cách cứ định kỳ, trang web trên client dùng AJAX gọi lên server lấy thông tin mới nếu có. Cách này có điểm yếu là không có dữ liệu thời gian thực bởi chỉ có dữ liệu sau những lần gọi định kỳ. Muốn gần thời gian thực thì phải rút ngắn chu kỳ gọi mà yêu cầu liên tục thì làm tăng băng thông.

- Tiếp theo là AJAX long-polling. Nó cải tiến so với AJAX polling là client không yêu cầu định kỳ mà sẽ hoạt động theo nguyên tắt sau: khi client dùng AJAX yêu cầu server cung cấp thông tin mới, server xử lý nhưng chỉ phản hồi ngay nếu có thông tin mới, còn không nó sẽ chờ đến khi có thông tin mới thì gửi phản hồi. Sau khi nhận phản hồi, client lại gửi yêu cầu mới. Cứ như vậy thì mọi thông tin từ server được cập nhật về cho client ngay. Kỹ thuật này tốt hơn AJAX polling nhưng client phải request nhiều lần để đảm bảo luôn có dữ liệu mới còn server thì phải duy trì kết nối chờ có dữ liệu mới để phản hồi. AJAX long-polling đôi khi được biết đến với tên là Comet long-polling.

- Tiếp theo là HTML5 SSE (Server Sent Events) ra đời. Nó cho phép client mở kết nối đến server. Khi có thông tin mới, server sẽ gửi về thông qua kết nối này. Như vậy đây đúng là cái mà giới lập trình web trước đó mong đợi: server có thể cập nhật thông tin thời gian thực một cách chủ động. Điểm yếu của nó là client không thể dùng kết nối này để gửi thông tin cho server và client sẽ không thể mở kết nối đến các server khác domain nữa.

- Điểm yếu của HTML5 SSE đưa đến sự ra đời của HTML Websocket mà thường gọi là websocket. Nó xây dựng một socket ở trình duyệt, giúp trình duyệt trao đổi 2 chiều với server. Ngoài ra dùng websocket dễ dàng mở kết nối với bất kỳ server nào. Kết nối này được duy trì hoạt động trong suốt thời gian kết nối. Websocket giải quyết nhiều vấn đề đau đầu trong phát triển web thời gian thực. Google Chrome, Microsoft Edge, Internet Explorer, Firefox, Safari hay Opera đều hỗ trợ websocket.


WebSocket sử dụng một loại header đặc biệt để giảm số lần trao đổi bắt tay (handshake) giữa trình duyệt và server xuống còn 1 khi kết nối websocket:
- Yêu cầu mở websocket từ client: 
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
 Chúng ta thấy ở đây loại kết nối là Upgrade và có trường Upgrade với giá trị là websocket. Client gửi khóa ở trường Sec-WebSocket-Key.
- Server đồng ý kết nối và phản hồi như sau:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Server gửi lại khóa cho client bằng cách dùng khóa client gửi lên thêm đằng sau chuỗi 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 rồi mã hóa SHA1, sau đó base64.

Phía client sử dụng JavaScript để đọc, ghi dữ liệu trên kết nối này, giống như kết nối socket loại TCP:
- Khởi tạo socket thông qua hàm WebSocket(<địa chỉ URL>) với URL có dạng như sau: ws://<địa chỉ IP hoặc tên miền server>:<cổng kết nối>/<đường dẫn tài nguyên>/. Giá trị trả về là đối tượng websocket.
- Gửi dữ liệu thông qua phương thức send(). Tham số của nó có thể dạng văn bản hoặc dạng nhị phân. Websocket hỗ trợ 2 kiểu nhị phân là blobs và ArrayBuffers. Sau khi gửi có thể kiểm tra việc gửi đã hoàn tất chưa qua thuộc tính bufferedAmount, nó cho biết số byte còn trong bộ đệm. Ví dụ sau thực hiện việc gửi nội dung và kiểm tra:
var data = new ArrayBuffer(10000000);

socket.send(data); // gửi dữ liệu với ArrayBuffer
if (socket.bufferedAmount === 0) {
// Gửi thành công
}
else {
// Còn dữ liệu chưa gửi được
}
- Đóng websocket với phương thức close(). Nó có tham số là mã hoặc nguyên nhân đóng nhưng rất ít khi xài.
- Kiểm tra trạng thái websocket: có 4 trạng thái, được kiểm tra thông qua thuộc tính readyState của đối tượng websocket được tạo ở trên:

  • Connecting: Khi client khởi tạo websocket, nó sẽ có giá trị WebSocket.CONNECTING (giá trị 0).
  • Open: Khi server phản hồi đồng ý, kết nối thông thì nó có giá trị WebSocket.OPEN (1). Lúc này việc gửi nhận dữ liệu có thể thực hiện được.
  • Closing: Khi websocket được đóng lại, nó sẽ có trạng thái này với giá trị WebSocket.CLOSING (2).
  • Closed: Khi websocket đóng hoàn toàn, nó là WebSocket.CLOSED (3).
- Quản lý sự kiện: có 4 sự kiện liên quan đến websocket:
 + Onopen: Khi websocket chuyển sang trạng thái mở, sự kiện này sẽ được gọi:
socket.onopen = function(event) {
    // Xử sự kiện mở websocket
};
 + Onmessage: Khi nhận dữ liệu từ server, sự kiện này sẽ được gọi.
socket.onmessage = function(event) {
    var data = event.data;
    // xử dữ liệu loại string, blob, hay ArrayBuffer
};
 + Onclose: Khi socket được đóng lại, sự kiện này được gọi: nó có trả về 3 tham số: code là mã server gửi, reason mô tả lý do và wasClean thường là true
socket.onclose = function(event) {
    var code = event.code;
    var reason = event.reason;
    var wasClean = event.wasClean;
    // Xử sự kiện đóng websocket
};
 + onerror: Khi xảy ra lỗi, sự kiện này được gọi, dữ liệu trả về là một đối tượng error gồm tên và nội dung:
socket.onerror = function(event) {
    // handle error event
};
Tất cả các sự kiện trên có thể được tạo thông qua phương thức addEventListener() như sau:
socket.addEventListener("message", function(event) {
    var data = event.data;
    // Xử dữ liệu loại string, blob, hay ArrayBuffer
});
Thay "message" với "open", "close" và "error" để tạo phần quản lý sự kiện tương ứng.

Sau đây là ví dụ:
Phần client:
<html>
<head></head>
<body>
    <script type="text/javascript">
        var sock = null;
        var wsuri = "ws://127.0.0.1:1234";

        window.onload = function() {
            console.log("onload");
            sock = new WebSocket(wsuri);

            sock.onopen = function() {
                console.log("Kết nối: " + wsuri);
            }

            sock.onclose = function(e) {
                console.log("Đóng kết nối (" + e.code + ")");
            }

            sock.onmessage = function(e) {
                console.log("Nhận dữ liệu: " + e.data);
            }
        };

        function send() {
            var msg = document.getElementById('message').value;
            sock.send(msg);
        };
    </script>
    <h1>WebSocket Echo</h1>
    <form>
        <p>
            Nội dung: <input id="message" type="text" value="Xin chào!">
        </p>
    </form>
    <button onclick="send();">Gửi</button>
</body>
</html>

Phần server: chúng ta xử lý gần như xử lý một web server. Go không hỗ trợ websocket ở thư viện chuẩn mà hỗ trợ package websocket ở thư viện mở rộng của nó:
- Các package sử dụng:
import (
    "fmt"
    "log"
    "net/http"

    "golang.org/x/net/websocket"
)
- Xử lý web server:
http.Handle("/", websocket.Handler(Echo))

if err := http.ListenAndServe(":1234", nil); err != nil {
    log.Fatal("ListenAndServe:", err)
}
Để xử lý handler cho websocket chúng ta cần sử dụng websocket.Handler với Handler là kiểu hàm:
type Handler func(*websocket.Conn)
- Hàm Echo xử lý handler:
func Echo(ws *websocket.Conn) {
    var err error

    for {
        var reply string

        if err = websocket.Message.Receive(ws, &reply); err != nil {
            log.Println("Không thể nhận!")
            break
        }

        fmt.Println("Nhận từ client: " + reply)

        msg := "Đã nhận: " + reply
        fmt.Println("Gửi cho client: " + msg)

        if err = websocket.Message.Send(ws, msg); err != nil {
            fmt.Println("Không thể gửi!")
            break
        }
    }
}
Ở trên chúng ta thấy việc gửi nhận dữ liệu thực hiện qua websocket.Message.Send và websocket.Message.Receive. Thực ra package websocket có biến Message kiểu cấu trúc Codec để lưu trữ và xử lý gửi nhận dữ liệu:
type Codec struct {
    Marshal func(v interface{}) (data []byte, payloadType byte, err error)
    Unmarshal func(data []byte, payloadType byte, v interface{}) (err error)
}
Codec có 2 phương thức để gửi nhận dữ liệu:
func (cd Codec) Send(ws *Conn, v interface{}) (err error)
func (cd Codec) Receive(ws *Conn, v interface{}) (err error)

Qua ví dụ trên chúng ta có thể thấy là xây dựng websocket khá đơn giản. Phần xử lý trên server với Go cũng nhẹ nhàng. Với tính năng vượt trội và dễ cài đặt cộng với nhu cầu tương tác thời gian thực ngày một nhiều thì websocket sẽ ngày càng được ưa chuộng.

Trong bài tới chúng ta sẽ tìm hiểu về các phương thức xây dựng dịch vụ web khác như là REST và RPC.
Tóm tắt:
- Dịch vụ web phục vụ cho thiết bị còn ứng dụng web phục vụ cho con người nên dịch vụ web chỉ cần cung cấp API thay vì cung cấp giao diện. Các phương thức tạo kết nối trao đổi dữ liệu cho dịch vụ web ở Go: socket, websocket, REST và RPC.
- Socket: kết nối mạng cấp thấp gồm 2 loại TCP hướng kết nối và UDP không kết nối.
- Websocket: là một tính năng của HTML5, cho phép tạo socket trên trình duyệt để client và server có thể trao đổi dữ liệu qua lại thay vì theo mô hình client hỏi server trả lời như trước kia.

No comments:

Post a Comment