+2

[JavaNet p5] Network Protocol & WebServer

Bài viết được lấy từ https://truongphuoc.wordpress.com/2024/07/18/javanet-p5-network-protocol/

Bài viết này gồm 4000 chữ có hơn, bạn sẽ muốn đọc nó trên máy tính đó. Nếu đang sử dụng điện thoại để đọc thì hãy note lại, sau đó mở máy tính lên để tiếp tục nha.

Mở bài

Trong các bài trước chúng ta đã tìm hiểu để tạo ra Client và Server. Chúng ta cũng đã thực hành với bài toán tính bình phương một số. Trong phần này, chúng ta hãy cùng đi qua giao thức mạng nhé.

Ngôn ngữ/Giao thức

Nhắc lại bài cũ: Trong những phần trước, chúng ta đã làm Server và Client để giải bài toán bình phương một số. Client sẽ nhập một số, sau đó Server sẽ trả về bình phương của số đó. Nếu Client nhập vào 0 thì sẽ đóng kết nối.

Bài toán phía trên rất đơn giản và chỉ có một phép tính, trong thực tế chúng ta không chỉ có một cách giao tiếp như thế, ta sẽ có phép cộng, phép trừ, phép nhân, phép chia,... Ngoài ra nếu trong đời sống thì sẽ có những cách giao tiếp phức tạp hơn như chào hỏi, tâm sự,... Để làm được những thứ đó con người đã phát minh ra ngôn ngữ để giao tiếp với nhau.

Trong mạng máy tính cũng thế, khi hai máy tính trao đổi thông tin với nhau, chúng phải sử dụng ngôn ngữ. Ngôn ngữ trong mạng máy tính gọi là giao thức và có rất nhiều giao thức khác nhau trong mạng máy tính.

Socket và chiếc điện thoại

Như các bài trước mình đã nêu, Socket giống như chiếc điện thoại vậy, chúng ta sử dụng điện thoại để liên lạc giữa con người với con người thì máy tính cũng sử dụng Socket để liên lạc giữa máy tính với máy tính.

Ta là người sử dụng, ta sử dụng điện thoại và ta nói chuyện qua điện thoại đó, chúng ta sử dụng ngôn ngữ nào đó (tiếng Việt, tiếng Anh,...) để giao tiếp qua điện thoại, ta không quan tâm hai chiếc điện thoại kết nối với nhau, truyền tải dữ liệu kiểu gì. Ta cứ sử dụng thôi.

Trong thế giới mạng máy tính cũng thế, ứng dụng của chúng ta là người sử dụng, ứng dụng sử dụng một phương thức nào đó để giao tiếp qua "chiếc điện thoại" Socket, ứng dụng không quan tâm Socket kết nối với nhau, truyền tải dữ liệu kiểu gì. Ứng dụng cứ sử dụng thôi.

Trong đời sống có rất nhiều ngôn ngữ: Tiếng Việt, tiếng Anh,... Và khi hai người nói chung một ngôn ngữ thì sẽ hiểu nhau.

Trong mạng máy tính cũng có rất nhiều giao thức, và khi hai ứng dụng sử dụng chung một giao thức thì sẽ hiểu nhau.

Trong bài này chúng ta sẽ cùng học một giao thức rất quen thuộc: HTTP.

Note: Về các giao thức khác, các mô hình mạng... là một chủ để khá hay, có lẽ mình sẽ phải làm một (vài) bài chi tiết (Hi vọng có sức làm 😂). Các bạn đón đọc nha.

HTTP

Nói một chút về lịch sử, vào khoảng thập nhiên 80-90 của thế kỷ trước, một nhà khoa học người Anh là Tim Berners-Lee đã có một sáng kiến là tạo ra một mạng lưới thông tin, nơi mà mọi người có thể truy cập thông tin trên mạng lưới đó thoải mái. Để làm được việc đó, ông đã tạo ra một ngôn ngữ để biểu đạt thông tin là HTML (HyperText Markup Language). Để hiển thị HTML trên máy tính, ông đã sáng chế ra một trình duyệt: WorldWideWeb, đây là trình duyệt đầu tiên trên thế giới. Và để truyền tải HTML giữa các máy tính với nhau, ông đã sáng tạo ra HTTP (HyperText Transport Protocol).

Thế là ban đầu HTTP được sử dụng như một giao thức để vận chuyển HTML giữa Server và Client, giống như tên gọi của nó vậy. Thực tế rằng HTTP không bị bó buộc trong HTML, nó có thể vận chuyển bất kỳ dạng dữ liệu nào tùy ý.

HTTP Version

Tới nay đã có nhiều phiên bản HTTP được phát hành:

  • HTTP/0.9 được phát hành năm 1991.
  • HTTP/1.0 được phát hành năm 1996 (RFC 1945).
  • HTTP/1.1 được phát hành năm 1997 (RFC 2068), cập nhật năm 1999 (RFC 2616), và 2014 (RFC 7230--7235).
  • HTTP/2 được phát hành năm 2015 (RFC 7540).
  • HTTP/3 được phát hành năm 2020.

Trong bài viết này chúng ta sẽ tìm hiểu về HTTP/1.1

Note: các bạn có thể tìm và đọc các bản Request for Comments (RFC) như viết ở trên để hiểu tường tận về HTTP nha.

Cách thức giao tiếp của HTTP

HTTP thực hiện giao tiếp theo Request-Response Communication, nghĩa là một người đưa ra một yêu cầu và một người đáp lại yêu cầu đó. Trong HTTP cặp Request và Response đi song hành với nhau, có Request thì sẽ có Response.

Cấu trúc của HTTP

Giống ngôn ngữ thường nhật phải có ngữ pháp, thì HTTP cũng có "ngữ pháp" của nó. HTTP gồm hai phần chính là Header và Body (được phân cách bởi dấu nét liền như hình), trong đó Header là phần bắt buộc, nó có hai thành phần là Start-Line và Request-Headers (được phân cách bởi dấu nét đứt như hình).

Dưới đây là Request và Response điển hình

HTTP Header

HTTP Header bao gồm các dòng dữ liệu. Dòng đầu tiên Start-Line được gọi là Request-Line đối với Request và Status-Line đối với Response. Các dòng tiếp theo được viết theo cấu trúc Key: Value dùng để biểu thị các thông số của Request/Response.

Request-Line

Request-Line có dạng như sau:

METHOD TARGET VERSION
  1. Method: HTTP Method dùng để biểu diễn hành động muốn thực hiện. Có hai method thường gặp là GET và POST. Method GET được dùng để yêu cầu lấy thông tin từ Server về Client còn method POST được dùng để yêu cầu đẩy thông tin từ Client lên Server.
  2. Target: Request Target thường là một URI, dùng để biểu thị vị trí, tài nguyên mong muốn.
  3. Version: HTTP Version dùng để biểu diễn phiên bản HTTP Client đang sử dụng.

Ví dụ về Request-Line như sau:

GET /hello.htm HTTP/1.1

Có nghĩa là Client muốn lấy tài nguyên là nội dung của file /hello.htm từ Server, và Client đang sử dụng HTTP phiên bản 1.1

Status-line

Còn đối với Response thì Status-Line có dạng như sau:

VERSION CODE TEXT
  1. Version: HTTP Version dùng để biểu diễn phiên bản HTTP Server đang sử dụng.
  2. Code: Status Code dùng để biểu diễn trạng thái của response, trạng thái này là một số. Có một vài Status Code phổ biến như: 200 - thành công, 404 = không tìm thấy, 500 - lỗi hệ thống,...
  3. Text: Đây là phần văn bản của CODE giúp con người dễ dàng hiểu được ý nghĩa của CODE. Một vài Status Text ứng với các Code phổ biến như: 200 - OK, 404 - Not Found, 500 - Internal Server Error, ...

Ví dụ về Status-Line như sau:

HTTP/1.1 200 OK

Có nghĩa là Server sử dụng phiên bản HTTP/1.1, trả về dữ liệu thành công.

Đã hết phần Start-Line, tiếp theo là phần Request/Response Headers.

rreques/Response headers

Dưới Start-Line là các cặp Key:Value để biểu diễn các thông số của request/response, mỗi dòng được gọi là một Header. Một vài header quen thuộc như:

Host: www.example.com                  // địa chỉ server
Connection: keep-alive                 // tùy chọn kết nối: Keep-Alive hoặc Close
Accept: text/html                      // loại định dạng mà client có thể xử lý
Accept-Encoding: gzip, deflate         // cơ chế mã hóa mà client có thể xử lý
Content-Type: application/json         // định của của body (nếu có)
Content-Length: 348                    // độ dài của body (nếu có)
Transfer-Encoding: chunked             // kiểu encode khi truyền dữ liệu

Kết hợp Start-Line và Request-Headers, ta có một ví dụ về HTTP Request như sau:

GET /hello.htm HTTP/1.1
Host: www.example.com
Connection: Keep-Alive
						// đây là dòng trống để nhận biết kết thúc HTTP Header

HTTP Response như sau:

HTTP/1.1 200 OK
Connection: Closed
					   // đây là dòng trống để nhận biết kết thúc HTTP Header

Header được đánh dấu kết thúc bởi một dòng trống.

HTTP Body

Khi Server trả về Response hoặc khi chúng ta muốn đẩy dữ liệu lên Server, dữ liệu được sẽ được biểu diễn bên dưới HTTP Request.

Dưới đây là ví dụ một HTTP Response có body:

HTTP/1.1 200 OK
Content-Length: 56
Content-Type: text/html
Connection: Closed

<html>
<body>
<h1>Hello, World!</h1>
</body>
</html>

Content-Type và Transfer-Encoding

HTTP Header được xác định kết thúc bởi một dòng trống, còn HTTP body thì không thể đánh dấu là kết thúc bởi một dòng trống được, lý do bởi có thể body thực sự được gửi là một dòng trống kiểu như:

HTTP/1.1 200 OK
Content-Length: 60
Content-Type: text/html
Connection: Closed

<html>
<body>

<h1>Hello, World!</h1>

</body>
</html>

Ta phải có cách khác để xác định lúc nào kết thúc HTTP Body. HTTP/1.1 có hai cách để xác định độ dài của Body, cách đầu tiên là sử dụng Header Content-Length để chỉ chính xác độ lớn của body như ví dụ ở trên. Hoặc nếu không biết độ lớn của body thế nào, chúng ta có thể sử dụng Transfer-Encoding để thông báo rằng body được chia thành các đoạn nhỏ như ví dụ dưới đây

HTTP/1.1 200 OK
Content-Type: text/html
Transfer-Encoding: chunked
Connection: Closed

6                         // thông báo chunk tiếp theo có 6 byte
<html>                    // nội dung chunk
6
<body>
16
<h1>Hello, World!</h1>
7
</body>
7
</html>
0  // thông báo chunk tiếp theo có 0 byte => đánh dấu kết thúc
   // kết thúc body

Nếu không có hai Request Header Content-LengthTransfer-Encoding, việc kết thúc body được chỉ định bằng cách đóng kết nối.

Việc tìm hiểu Method, URI, Status Code hay các Request Header đã có rất nhiều bài trên mạng đã viết rất hay rồi, mình xin phép không đi sâu vào các vấn đề này. Mình chỉ trình bày ý tưởng, cái tổng quát để mọi người hiểu và có hứng thú tìm hiểu sâu hơn thôi.

HTTP chỉ là những dòng text có quy ước

Như bạn thấy ở trên, HTTP cũng chẳng có gì to tát cả, nó chỉ là những quy ước đã được định sẵn. Chúng ta hoàn toàn có thể tùy biến HTTP theo ý của chúng ta, đại loại tôi không thích các method mặc định trong HTTP, tôi muốn có một method gọi là XXX, bạn hoàn toàn có thể làm như thế này: XXX /YuaMikami JAV/1.1 chung quy lại chúng chỉ là đoạn text thôi mà.

Nhưng tại sao chúng ta lại theo những quy ước định sẵn, tại vì những Server, những máy tính khác họ đã tuân theo quy ước HTTP rồi, nếu chúng ta không tuân theo quy ước này, chúng ta sẽ không giao tiếp được với họ, thế thôi. Giống việc bạn không nói tiếng Việt mà sáng tạo ra ngôn ngữ của riêng mình, thì mình bạn hiểu ngôn ngữ đó mà thôi, muốn giao tiếp với người khác thì hai bên phải sử dụng chung một ngôn ngữ phải không nào.

Text-based và Binary-based

Như ta thấy trên, HTTP sử dụng text để biểu diễn thông tin, nên có thể gọi HTTP là text-based protocol. Text-based protocol rất tường minh đối với con người, chúng ta có thể đọc, có thể debug một cách dễ dàng. Nhưng text-based protocol lại có một nhược điểm: nó tốn tài nguyên để biểu diễn.

Ví dụ một request đơn giản như sau:

GET /hello.htm HTTP/1.1
Host: www.example.com
Connection: Keep-Alive

Để truyền tin nhắn trên từ Client tới Server, chúng ta sẽ cần vận chuyển 74 byte.

Chúng ta cùng quy ước lại một chút, ta sẽ cố gắng chuyển đổi, biến hóa từ text sang binary xem có thể giảm số byte được không nhé.

Tại sao lại là binary? Tại vì biểu diễn bằng binary sẽ dễ dàng hơn đối với máy tính, hãy đọc tiếp rồi bạn sẽ thấy nhé

Dòng đầu TIên: GET /hello.htm HTTP/1.1
  • Method: mình quy ước có 1 byte để quy định method là gì, ví dụ 00000001 = 01 đại diện cho method GET, 00000010 = 02 đại diện cho method POST...
  • URI: mình giữ nguyên, chỉ đổi sang byte là thôi, biểu diễn dưới dạng hex thì
/hello.htm = 2F 68 65 6C 6C 6F 2E 68 74 6D
  • Version: mình quy ước 1 byte để quy định version là gì, ví dụ 01 = HTTP/0.9, 02 = HTTP/1.0, 03 = HTTP1.1,...
headers
  • Đối với cặp KEY:VAlUE, mình sẽ dành 1 byte để biểu diễn key, còn value thì tùy chọn. Ví dụ 01 = Host, 02 = Connection.
  • Với Key = Connection thì có hai giá trị VALUE là Keep-Alive và Close nên mình sẽ dành 1 byte để biểu diễn: Keep-Alive = 01 và Close = 02.
  • Còn đối với www.example.com thì mình chuyển sang byte như sau:
www.example.com = 77 77 77 2E 65 78 61 6D 70 6C 65 2E 63 6F 6D

Tất nhiên còn ký tự khoảng trắng ở dòng đầu tiên và ký tự xuống dòng nữa. Ký tự khoảng trắng hex: space = 20 và ký tự xuống dòng \r\n = 0D 0A

space = 20
/r/n = 0D 0A

Ghép nối tất cả Start-Line và Headers lại ta có như sau:

GET /hello.htm HTTP/1.1
Host: www.example.com
Connection: Keep-Alive

===========================================================

01 20 2F 68 65 6C 6C 6F 2E 68 74 6D 20 03 0D 0A
01 77 77 77 2E 65 78 61 6D 70 6C 65 2E 63 6F 6D 0D 0A
02 01 0D 0A
0D 0A

Thế là mình đã chuyển từ text-based sang binary-based một cách khá đơn giản, tổng số lượng byte cần vận chuyển với binay-based là 38 byte.

Với việc chuyển từ text-based thành binary-based như trên, mình đã giảm được khối lượng vận chuyển từ 74 xuống còn 38 byte, điều này làm tăng tốc độ truyền tải dữ liệu.

Binary-based protocol có ưu điểm là gọn nhẹ hơn rất nhiều so với text-based, dẫn tới tốc độ truyền tải nhanh hơn. Nó đồng thời cũng giúp parse dữ liệu nhanh hơn nữa, thay vì parse cả một đoạn text dài (tức nhiều byte) mới biết ý nghĩa, ta chỉ cần parse 1 byte dữ liệu là biết được ý nghĩa rồi (nghĩ tới việc chuyển từ binary sang text và ngược lại tốn thời gian thế nào).

Nhưng binary-based có một nhược điểm là khó đọc, khó debug đối với con người. Các bạn đọc một đoạn byte ở trên so với đọc text thì biết rồi đó 😄

Binary-based protocol thường được sử dụng trong các hệ thống microservice thay vì text-based protocol, khi các service gọi nhau thì cần phải sử dụng một protocol nhanh, nhẹ và Binary-based protocol rất phù hợp cho việc đó.

Web Server

Nếu Server của chúng ta hỗ trợ giao thức HTTP, ta có thể gọi nó là một Web Server. Giờ hãy tạo một Web Server tính bình phương của một số.

Web Server của chúng ta rất đơn giản: Nó chỉ hỗ trợ phương thức GET mà thôi.

Client sẽ gửi một request với method GET kiểu như:

GET /square?number=2 HTTP/1.1

Server của chúng ta sẽ trả về HTML dạng như

<html>
<body>
<h1>Square application</h1>
<p>2 square is 4!</p>
</body>
</html>

HTTP Parser

Để làm được việc đó, ta phải có một chương trình để đọc hiểu thông tin từ HTTP, tức parse dữ liệu từ HTTP để hiểu Client muốn gì.

Parse Header

HTTP Header là các dòng dữ liệu và kết thúc là một dòng trống. Với dòng đầu tiên là Request-Line và các dòng còn lại là Request Header. Ta sẽ loop qua từng dòng và parse giá trị.

Parse Body

Tại vì request không có body nên ta không cần phải parse body làm gì.

Code cuối cùng của chúng ta như sau:

import java.io.IOException;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;

public class Server {
    public static void main(String[] args) throws IOException {
        System.out.println("Thread: " + Thread.currentThread().getName());
        InetAddress inetAddress = InetAddress.getByName("0.0.0.0");
        SocketAddress socketAddress = new InetSocketAddress(inetAddress, 8080);
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(socketAddress);
        serverSocketChannel.configureBlocking(false);
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("server is running on port 8080");
        while (true) {
            selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();

                if (key.isAcceptable()) {
                    handleAccept(key, selector);
                } else if (key.isReadable()) {
                    handleRead(key);
                }
                keyIterator.remove();
            }

        }
    }

    private static void handleAccept(SelectionKey key, Selector selector) throws IOException {
        System.out.println("Thread: " + Thread.currentThread().getName());
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(false);
        socketChannel.register(selector, SelectionKey.OP_READ);
        System.out.println("accepted " + serverSocketChannel);
    }

    private static void handleRead(SelectionKey key) throws IOException {
        System.out.println("Thread: " + Thread.currentThread().getName());
        SocketChannel clientSocketChannel = (SocketChannel) key.channel();

        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        int read;
        while ((read = clientSocketChannel.read(byteBuffer)) != -1) {
            if (read == 0) {
                break;
            }
            byteBuffer.flip();
            String clientInput = new String(byteBuffer.array(), 0, byteBuffer.limit()).trim();
            System.out.println(clientInput);
            String response;
            HttpRequest httpRequest = parseHttp(clientInput);
            URI uri = URI.create(httpRequest.uri);
            if ("GET".equals(httpRequest.method) && "/square".equals(uri.getPath())) {
                // uri.query: number=2
                int number = Integer.parseInt(uri.getQuery().split("=")[1]);
                int square = number * number;
                String responseBody = String.format("<html>\\r\\n" +
                        "<body>\\r\\n" +
                        "<h1>Square application</h1>\\r\\n" +
                        "<p>%d square is %d</p>\\r\\n" +
                        "</body>\\r\\n" +
                        "</html>\\r\\n", number, square);
                response = String.format("HTTP/1.1 200 OK\\r\\n" +
                        "Content-Length: %d\\r\\n" +
                        "Content-Type: text/html\\r\\n" +
                        "Connection: Closed\\r\\n" +
                        "\\r\\n" +
                        "%s", responseBody.getBytes().length, responseBody);
            } else {
                String responseBody = "<html>\\r\\n" +
                        "<body>\\r\\n" +
                        "<h1>Square application</h1>\\r\\n" +
                        "<p>Bad Request</p>\\r\\n" +
                        "</body>\\r\\n" +
                        "</html>\\r\\n";
                response = String.format("HTTP/1.1 400 Bad Request\\r\\n" +
                        "Content-Length: %d\\r\\n" +
                        "Content-Type: text/html\\r\\n" +
                        "Connection: Closed\\r\\n" +
                        "\\r\\n" +
                        "%s", responseBody.getBytes().length, responseBody);
            }

            byteBuffer.clear();
            byteBuffer.put(response.getBytes());

            byteBuffer.flip();
            clientSocketChannel.write(byteBuffer);

            byteBuffer.clear();
        }
        clientSocketChannel.close();
    }

    private static HttpRequest parseHttp(String request) {
        HttpRequest httpRequest = new HttpRequest();
        String[] requestLines = request.split("\\r\\n");

        // parse header
        // dòng đầu tiên là request-line
        String[] requestLine = requestLines[0].split(" ");
        httpRequest.method = requestLine[0];
        httpRequest.uri = requestLine[1];
        httpRequest.version = requestLine[2];

        // các dòng tiếp theo là request header
        int i = 1;
        while (i < requestLines.length) {
            String line = requestLines[i++];
            if (line.isEmpty()) {
                break;
            }
            String[] header = line.split(": "); // KEY: VALUE
            httpRequest.headers.put(header[0], header[1]);
        }
        return httpRequest;
    }

    private static final class HttpRequest {
        public String method;
        public String uri;
        public String version;
        public Map<String, String> headers = new HashMap<>();
        public String body;
    }
}

Code hơi dài, nhưng mình để đây các bạn sẽ dễ dàng hơn trong việc copy và chạy thử nha.

Các bạn có thể chạy thử code trên, sau đó nhấp vào đây: http://localhost:8080/square?number=1234 các bạn có thể thay đổi giá trị của number để xem kết quả thay đổi.

Bài tập về nhà: các bạn viết một Web Server tính bình phương một số có hỗ trợ method POST. như sau:

POST /square HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 24

{
    "number": 1234
}

Gợi ý: sử dụng Content-Length để đọc body và Postman để test.

Kết luận

Tổng kết lại là: HTTP được tạo ra trong quá trình phát minh ra World Wide Web. Nó là một text-based protocol sử dụng Request-Response communication. HTTP là một loạt những quy tắc để client và server có thể giao tiếp được với nhau. Binary-based protocol sẽ giúp giảm dung lượng vận chuyển dẫn tới giảm thời gian truyền tải dữ liệu, nó còn giúp parse dữ liệu nhanh hơn nữa. Cuối cùng chúng ta đã tạo một web server siêu đơn giản để tính bình phương một số rồi.

Link bài viết gốc https://truongphuoc.wordpress.com/2024/07/18/javanet-p5-network-protocol/


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí