Khó hiểu nhưng gọn nhẹ!

Protobuf hay Protocol buffer là một cách thức pack và unpack dữ liệu do các kỹ sư của Google sáng chế ra, mục tiêu của nó là để giảm số lượng byte cần thiết để biểu diễn một gói tin, từ đó tăng tốc độ gửi nhận qua network vốn dĩ vẫn chậm chạp. Và cho đến thời điểm này, protobuf vẫn là một trong những giao thức nhẹ nhàng và tiết kiệm được nhiều byte nhất. Mục tiêu thì đơn giản nhưng việc hiểu được cơ chế bên trong của Protobuf lại không hề đơn giản, nhất là khi chúng ta vẫn thường chỉ tiếp xúc với các file .proto, các công cụ gen ra file code và các lớp builder. Trong bài này, chúng ta sẽ cùng đào sâu vào lõi của protobuf nhé.

Bài toán thực tế

Hãy tưởng tưởng, à không cần tưởng tượng, chúng ta đang cần gửi con số int: 300 qua socket, ồ thật đơn giản chỉ cần gửi 4 bytes:

[0, 0, 1, 44]

Như thế này đi thôi đúng không? Nhưng nhìn lại 1 chút, có cái gì đó không ổn ở đây? hình như thừa 2 số 0 ở đầu? Đúng vậy, chúng ta không cần thiết phải dùng đến 4 bytes để mô tả số 300, chúng ta chỉ cần dùng tối đa 2 bytes là 1 và 44 là đủ.

Ồ, nhưng trong thực tế chẳng bao giờ chúng ta lại đi gửi 1 con số 300 vô nghĩa qua socket cả, thường thì chúng ta sẽ gửi một message kiểu:

[300, 100]

Ý nghĩa ở đây là: trừ 100đ trong tài khoản của user có id là 300. Vậy thì phải mô tả cái message này kiểu gì giờ? Nếu mà để kiểu

[1, 44, 100]

Lúc đọc chúng ta sẽ hiểu nhầm là số 76900, phải cần một cái gì đó thể phân tách ra được thành 2 số 300 và 100. Lúc này một kỹ sư đã có ý tưởng và Protobuf ra đời.

Ý tưởng ban đầu

Ý tưởng của protobuf rất đơn giản, đó là sử dụng 1 bit đầu tiên của 1 byte để đánh dấu rằng có dữ liệu ở byte tiếp theo không, nghĩa là cứ 1 byte, chúng ta lại mất 1 bit để để đánh dấu và chỉ có 7 bit cho phần dữ liệu mà thôi. Ồ vậy đối với kiểu dữ liệu string thì sao, cứ mỗi 1 byte cũng lại mất 1 bit à? Không phải, đối với từng kiểu dữ liệu lại có đối ứng khác nhau:

  1. Đối với kiểu dữ liệu số nguyên (int, long): có thể mỗi byte sẽ cần mất 1 bit
  2. Đối với kiểu dữ liệu kiểu string: phần độ dài của string (int) có thể mỗi byte sẽ cần mất 1 bit, còn phần nội dung sẽ được dữ nguyên
  3. Đối với kiểu dữ liệu mảng (array, list) hay map: phần size int) có thể mỗi byte sẽ cần mất 1 bit, còn phần nội dung sẽ tuần theo 1 và 2

Cơ chế hoạt động

Ví dụ con số 300 sẽ có dãy bit là: 100101100, viết ở dạng 2 byte sẽ là: 

00000001 00101100

Viết ở dạng 7 bit chúng ta có:

0000010 0101100

Đảo ngược lại chúng ta có:

0101100 0000010

Chúng ta có thể thấy dãy bit có 2 phần, vậy ta sẽ bổ sung bit 1 vào 7 bit đầu (đánh dấu là có dữ liệu ở phía sau), và bit 0 vào 7 bit cuối (không còn dữ liệu ở phía sau nữa), và chúng ta có

10101100 00000010

file.proto

Chúng ta có thể thấy, protobuf chỉ đơn thuần là các byte[] nằm cạnh nhau, và nó chẳng thể nào biết kiểu dữ liệu là gì để mà parse ra, chính vì vậy mà nó cần file.proto để định nghĩa kiểu. Ví dụ chúng ta có file proto:

// UpdateUserMoney.proto 

int32 userId = 1; // sẽ đọc tối đa 4 bytes
int32 balance = 2; // sẽ đọc tối đa 4 bytes

Để đọc được mảng byte [2, 172, 2, 100] ra thành đối tượng:

class UpdateUserMoney {
    int userId;
    int balance;
}
  • Bước 1: khởi tạo đối tượng UpdateUserMoney obj = new UpdateUserMoney()
  • Bước 1: đọc byte đầu tiên là 2 chúng ta thấy 2 được biểu diễn dưới dạng bit là 00000010 vậy không có thêm byte nào tiếp theo, giá trị chúng ta nhận được sẽ là 2, vậy đối tượng của chúng ta sẽ có 2 trường
  • Bước 2: đọc byte 172 dưới dạng bit 10101100 chúng ta thấy có bit 1 ở đầu, vậy cần phải đọc tiếp, trước khi đọc tiếp bỏ số 1 đi, lưu lại 7 bit 0101100
  • Bước 3: đọc bye 2 dưới dạng bit 00000010 chúng không thấy có bit 1 ở đầu, vậy dừng lại việc đọc
  • Bước 4: ghép 2 dãy bít ta được 00000010 0101100, đọc thành số ta được 300
  • Bước 5: gán 300 vào userId: `obj.setUserId(300)
  • Bước 6: Đọc 100 thành bit chúng ta có 01100100, không có bit 1 ở đầu vậy chúng ta có giá trị 100
  • Bước 7: set 100 vào balance: obj.setBalance(100)
  • Bước 8: không còn byte nào nữa kết thục tại đây và trả về obj

Protobuf cũng cho phép chúng ta chưa cần thiết phải parse ra kiểu cụ thể, chúng ta có thể đọc ra giá trị ở runtime, bằng cách thêm optional khi khai báo trường dữ liệu:

// UpdateUserMoney.proto 

optional int32 userId = 1; // sẽ đọc tối đa 4 bytes
optional int32 balance = 2; // sẽ đọc tối đa 4 bytes

Ví dụ

Ở đây mình chỉ làm ví dụ đơn giản để serialize và deserialize 1 số int thành byte array và ngược lại thôi nhé:

import java.nio.ByteBuffer;

public class ProtobufExample {
    public static void main(String[] args) {
        final int value = 300;
        final byte[] bytes = serialize(300);
        System.out.println("serialize of " + value + ": " + toString(bytes));
        System.out.println("deserialize: " + deserialize(bytes));
    }

    private static int deserialize(byte[] bytes) {
        int answer = 0;
        for(int i = 0 ; i < bytes.length ; ++i) {
            answer += (bytes[i] & 0x7F) << i * 7;
        }
        return answer;
    }

    private static byte[] serialize(int value) {
        final byte[] valueBytes = ByteBuffer.allocate(4).putInt(value).array();
        int byteCount = 4;
        for (byte valueByte : valueBytes) {
            if ((valueByte & 0xFF) != 0) {
                break;
            }
            --byteCount;
        }
        final ByteBuffer byteBuffer = ByteBuffer.allocate(byteCount);
        int remainValue = value;
        for(int i = 0 ; i < byteCount - 1 ; ++i) {
        // 0x80 = 10000000
            byteBuffer.put((byte)(0x80 | (0x7F & remainValue)));
            remainValue >>= 7;
        }
        byteBuffer.put((byte)remainValue);
        return byteBuffer.array();
    }

    private static String toString(byte[] array) {
        final StringBuilder builder = new StringBuilder("[");
        for(int i = 0 ; i < array.length ; ++i) {
            final ByteBuffer buffer = ByteBuffer.allocate(4).put(new byte[] { 0, 0, 0, array[i]});
            buffer.flip();
            builder.append(buffer.getInt());
            if(i < array.length - 1) {
                builder.append(", ");
            }
        }
        builder.append("]");
        return builder.toString();
    }
}

Kết quả chúng ta nhận được sẽ là:

serialize of 300: [172, 2]
deserialize: 300

Tham khảo

  1. Google offical document