Cải Tiến Dịch Vụ Gate Trên Skynet
Do những lý do lịch sử, dịch vụ gate trong skynet ban đầu được viết bằng C như một dịch vụ độc lập. Khi skynet tích hợp mô-đun quản lý socket vào nhân hệ thống, gate đã trải qua một lần tái cấu trúc sử dụng API socket mới. Hiện nay, khi nền tảng skynet ngày càng hoàn thiện và định hình phát triển theo hướng ngôn ngữ Lua làm chủ đạo, đã đến lúc tiến hành viết lại hoàn toàn dịch vụ này bằng Lua.
Đối với những trường hợp có ít kết nối và không yêu cầu cao về hiệu năng, có thể trực tiếp sử dụng thư viện socket Lua của skynet. Ví dụ mẫu có thể tham khảo tại đây. Tuy nhiên gate được định vị để quản lý hiệu quả hàng loạt kết nối TCP dài hạn từ phía ngoài hệ thống. Mặc dù không phải thành phần lõi của skynet, nhưng gate lại đóng vai trò thiết yếu trong các ứng dụng game mạng.
Nhân skynet hiện tại đã tích hợp epoll/kqueue để xử lý hiệu quả các sự kiện socket. Tuy nhiên để xử lý kết nối dài hạn, vẫn còn thiếu một bước quan trọng: phân mảnh luồng dữ liệu. API socket hiện tại của skynet sử dụng cơ chế callback để nhận luồng dữ liệu socket. Trên mỗi kết nối TCP, bất kể kích thước dữ liệu nhận được mỗi lần là bao nhiêu, hệ thống sẽ chuyển tiếp toàn bộ qua kênh PTYPE_SOCKET
đến dịch vụ skynet liên kết với kết nối đó, mà không quan tâm đến cách tổ chức dữ liệu.
Thông thường, trong các giao thức game mạng, TCP luồng dữ liệu thường được chia nhỏ theo cấu trúc: độ dài + nội dung dữ liệu. Đương nhiên cũng có thể dùng các phương pháp khác như phân tách theo ký tự xuống dòng/carriage return như HTTP hoặc các giao thức văn bản khác (POP3/IMAP), tuy nhiên điều này sẽ làm gia tăng độ phức tạp của thuật toán phân mảnh mà không mang lại nhiều lợi ích thực tế.
Hiện tại, gate trong skynet sử dụng giao thức mã hóa độ dài 2 byte (big-endian) để biểu diễn gói tin tối đa 64KB, theo sau là số byte tương ứng với độ dài đã chỉ định. Trước đây tôi từng cân nhắc sử dụng độ dài 4 byte hoặc mã hóa varint như Google Protocol Buffer, nhưng cuối cùng đều từ bỏ. Lý do là việc cấp phát bộ đệm theo độ dài nhận được tiềm ẩn lỗ hổng bảo mật nghiêm trọng: nếu có nhiều kẻ tấn công gửi các gói tin có độ dài bất thường, sẽ khiến hệ thống tiêu thụ lượng lớn tài nguyên bộ nhớ trong thời gian ngắn. Lưu ý rằng phiên bản gate đầu tiên của skynet lại không gặp vấn đề này nhờ sử dụng cơ chế ringbuffer, các kết nối chỉ gửi gói tiêu đề mà không có dữ liệu sẽ bị ngắt khi ringbuffer đầy.
Trong trường hợp game server chỉ duy trì một kết nối TCP duy nhất, việc truyền gói tin quá lớn (>64KB) cũng không thích hợp. Gói tin lớn sẽ gây nghẽn luồng xử lý ứng dụng (do thời gian nhận/gửi kéo dài), thậm chí dễ gây timeout cho gói tin heart-beat ở tầng ứng dụng. Vì vậy thông thường ở tầng ứng dụng sẽ có cơ chế chia nhỏ các gói tin lớn thành các gói nhỏ hơn. Vấn đề này sẽ được thảo luận kỹ hơn ở phần cuối bài viết.
Nhiệm vụ chính của gate là duy trì hàng loạt kết nối TCP dài hạn, thực hiện phân mảnh dữ liệu theo giao thức đã định. Với các gói tin chưa hoàn chỉnh, cần lưu trữ tạm thời theo từng kết nối riêng biệt. Khi nhận được gói tin hoàn chỉnh, sẽ chuyển tiếp đến dịch vụ cần xử lý tương ứng.
Công việc này gồm hai phần chính: phân mảnh dữ liệu và chuyển tiếp. Việc phân mảnh và lưu trữ dữ liệu chưa hoàn chỉnh đòi hỏi tính chính xác cao, phù hợp để xử lý bằng C. Trong khi đó phần điều khiển chuyển tiếp có logic phức tạp hơn, nên dùng Lua để thực hiện. Đây chính là kim chỉ nam cho lần tái cấu trúc này.
Tôi đã tách riêng chức năng phân mảnh vào thư viện mở rộng Lua có tên netpack (xem chi tiết tại lua-netpack.c). Phần logic điều phối gate được đặt trong gate.lua phiên bản Lua. So với phiên bản trước toàn bộ bằng C, cách làm này có thể gây chút suy giảm hiệu năng nhưng lại cải thiện đáng kể khả năng mở rộng và bảo trì.
Khi thiết kế gate ban đầu, tôi mong muốn có thể chuyển tiếp dữ liệu từ nhiều kết nối đến một dịch vụ xử lý chung. Ví dụ như dùng một dịch vụ xác thực duy nhất để xử lý quy trình đăng nhập của tất cả kết nối. Để tránh phải đóng gói lại dữ liệu mạng (thêm số hiệu kết nối), gây phát sinh chi phí không cần thiết, tôi đã tạo ra một dịch vụ proxy tên là service_client
. Khi chuyển tiếp dữ liệu, hệ thống sẽ giả lập proxy này làm nguồn dữ liệu (trong khi nguồn thực tế từ kết nối TCP). Nhờ vậy, dịch vụ xử lý chỉ cần phản hồi dữ liệu theo nguồn nhận là tự động chuyển đúng về kết nối mạng tương ứng.
Hơn nữa, cách làm này cho phép đóng gói dữ liệu mạng thành tin nhắn nội bộ skynet thông qua skynet.filter. Khi nhận gói tin mạng (PTYPE_CLIENT
), hệ thống sẽ tách session ID khỏi dữ liệu, sau đó chuyển riêng session và nội dung đến các dịch vụ downstream tương ứng qua cơ chế filter. Điều này giúp che giấu tầng kết nối mạng khỏi logic nghiệp vụ.
Đây chính là lý do tại sao giao thức chuyển tiếp của gate cần cung cấp hai địa chỉ dịch vụ. Phiên bản gate mới này kế thừa cơ chế này, tuy nhiên trong thực tế triển khai trước đây, cơ chế này được đánh giá là hơi phức tạp. Với các ứng dụng đơn giản, có thể bỏ qua các dịch vụ trung gian và trực tiếp giao socket fd cho dịch vụ xử lý nghiệp vụ. Dịch vụ này có thể trực tiếp gửi dữ liệu qua socket mà không cần proxy.
Ví dụ về agent trong thư mục examples mới được tái cấu trúc theo tư tưởng này. Ví dụ minh họa cách đóng gói giao thức truyền thông đơn giản giữa client-server bằng JSON. Trước phần nội dung tin nhắn sẽ có một chuỗi văn bản chứa session ID và ký hiệu + hoặc -, trong đó + chỉ tin nhắn yêu cầu và - chỉ phản hồi cho session tương ứng.
Nhờ trực tiếp gọi API socket để gửi dữ liệu, agent mới không còn phụ thuộc vào service_client
. Chúng tôi đã xóa demo client C cũ và thay thế bằng phiên bản Lua sử dụng thư viện socket đơn giản. Với dự án thực tế, thư viện này cần tiếp tục cải thiện (và client không nhất thiết phải viết bằng Lua).
Ví dụ mới này chỉ đơn thuần tái cấu trúc lại code, chưa phải demo đầy đủ các tính năng phức tạp. Hiện tôi đang suy nghĩ cách xây dựng một ví dụ điển hình thể hiện rõ các ưu điểm của skynet. Hiện tại vẫn chưa có phương án tối ưu.
Với các dự án đã và sắp triển khai, cấu trúc phức tạp hơn nhiều so với ví dụ minh họa:
Thứ nhất, chúng tôi sử dụng giao thức Google Protocol Buffer làm định dạng chính, được bao bọc bởi các lớp giao thức bên ngoài. Mỗi gói tin gồm loại tin nhắn, session ID và nội dung. Loại tin nhắn được mô tả bằng ngôn ngữ đặc