Modern C++ binary RPC framework gọn nhẹ, không cần code generation

587

Bài viết sẽ bàn về một framework C++ RP, ko yêu cầu bước code generation để glue code. Trước khi đi vào chi tiết, hãy tìm hiểu một số tính năng cơ bản của công cụ:

  • Mã nguồn tại https://bitbucket.org/ruifig/czrpc
    • Đoạn mã nguồn trên vẫn chưa hoàn chỉnh. Mục tiêu của nó chỉ là để thể hiện nền tảng xây dựng framework. Hơn nữa, để rút ngắn, đoạn code là hỗn hợp của code từ repo lúc viết và custom sample code, nên có thể sẽ xảy ra lỗi.
    • Một số đoạn trong mã nguồn (không liên quan trực tiếp đến nội dung ta đang bàn đến) sẽ chỉ được xem xét nhanh mà không quá quan tâm đến hiệu năng. Bất cứ cải tiến nào sẽ được thêm vào repo mã nguồn sau.
  • Modern C++ (C++11/14)
    • Yêu cầu ít nhất Visual Studio 2015. Clang/GCC cũng tạm ổn.
  • Type-safe
    • Framework xác định những RPC call không hợp lệ trong lúc compile, như tên RPC không rõ, số thông số sai, hoặc kiểu thông số sai.
  • API tương đối nhỏ, không dài dòng
  • Nhiều cách xử lý RPC reply
    • Bộ xử lý không đồng bộ (Asynchronous handler)
    • Futures
    • Client có thể xác định nếu một RPC gây exception bên phía server
  • Cho phép sử dụng (gần như) bất kỳ kiểu RPC parameter nào
    • Giả sử nguời dùng chạy đúng hàm để xử lý kiểu parameter đó
  • RPC hai chiều (server có thể call RPC trên client)
    • Thông thường, client code không thể tin tưởng được, nhưng vì framework nhằm sử dụng giữa các bên uy tín, nên đây không phải là vấn đề quá lớn
  • Không xâm nhập
    • Một object dùng cho RPC call không cần biết bất cứ thứ gì về RPC hoặc network cả.
    • Từ đó có thể bọc các class bên thứ ba cho RPC call.
  • Chi phí băng thông tối thiểu mỗi RPC call
  • Không phụ thuộc bên ngoài
    • Dù supplied transport (trong repo mã nguồn) dùng Asio/Boost Asio, nhưng bản thân framework không phụ thuộc vòa đó. Bạn có thể tự cắm transport của mình.
  • Không có tính năng bảo mật
    • Vì frame work được thiết kế sử dụng cho các bên tin tưởng lẫn nhau (như giữa server chẳng hạn).
    • Ứng dụng có thể tự chọn transport riêng, nên sẽ có cơ hội encrypt bất cứ thứ gì nếu cần thiết.

Có thể bạn muốn xem:

  Clean Code là cái của nợ gì?
  Tìm hiểu về nguyên lý "vàng" SOLID trong lập trình hướng đối tượng

Mặc dù mã nguồn vẫn chưa hoàn thiện, bài viết vẫn rất nặng về code. Code được tách thành nhiều phần nhỏ và một phần sau sẽ dựa theo phần trước. Để bạn hình dung được, sau đây là một mẫu code hoạt động hoàn chỉnh sử dụng repo mã nguồn tại thời điểm viết bài:

Đây đa phần là setup code, vì transport được nhắc đến sử dụng Asio. Bản thân RPC có thể đơn giản như:

Lý do tôi viết công cụ này

Tôi đang làm việc với một game có code named G4 cũng được vài năm rồi, trò chơi cung cấp cho player những chiếc máy tính mô phỏng nhỏ in-game mà thậm chí có thể code được. Điều này yêu cầu tôi phải cho nhiều server cùng chạy song song:

  • Gameplay Server(s)
  • VM Server(s) (mô phỏng máy tính ingame)
    • Để vẫn có thể mô phỏng máy tính ngay cả khi người chơi hiện không online
  • VM Disk Server(s)
    • Xử lý lưu trữ của máy tính ingame, như đĩa mềm hoặc ổ cứng.
  • Database server(s)
  • Login server(s)

Tất cả những server này cần trao đổi dữ liệu, vì vậy ta cần một RPC framework linh hoạt.

Ban đầu, giải pháp tôi tìm đến là tag method của một class với attribute cụ thể, rồi từ parser gốc Clang (clReflect) tạo bất cứ serialization cần thiết nào, và glue code.

Dù cách này vẫn áp dụng được, nhưng nhiều năm qua tôi vẫn luôn canh cánh làm cách nào tôi có thể dùng tính năng C++11/14 mới để tạo một minimal type safe C++ RPC framework, một phương pháp không cần bước code generation cho glue code, mà vẫn giữa một API chấp nhận được.

Với serialization của non-fundamental type, code genaration vẫn còn hữu dụng, vậy nên tôi không cần phải xác định thủ công làm sao để serialize tất cả các field của một struct/class cho trước. (dù khâu xác định thủ công này cũng chả phiền phức cho lắm).

RPC Parameters

Xét một function, để có type safe RPC call, ta phải làm được:

  • Xác định lúc compile nếu function là một RPC function hợp lệ (đúng số thông ố, đúng kiểu parameter,…)
  • Kiểm tra liệu các parameter được supply khớp (hoặc convert được) sang thứ mà function signature chỉ ra.
  • Serialize tất cả parameter.
  • Deserialize tất cả parameter.
  • Call những function mong muốn

Parameter Traits

Vấn đề đầu tiên bạn đối mặt là việc xác định kiểu parameter nào được chấp nhập. Một số RPC framework chỉ chấp nhật một số kiểu giới hạn, như Thrift. Hãy xem thử vấn đề.

Xét các function signature sau:

Vậy làm cách nào ta bắt compile kiểm tra theo parameter? Những kiểu fundamental type khá dễ và chắc chắn phải được framework hỗ trợ. Một bản copy dumb memory là đủ cho trường hợp như thế này, trừ khi bạn muốn cắt giảm số bit cần thiết để hy sinh một ít hiệu năng lấy bandwidth. Vậy chòn với các kiểu phức tạp như std::string, std::vector, hoặc chính class của mình? Còn pointer, reference, const reference, rvalue thì sao?

Ta có thể lấy một số ý tưởng từ những thứ C++ Standard Library đang làm trong type_traits header. Ta cần truy vấn một kiểu cho trước dựa theo tính chất RPC của nó. Hãy thử biến ý tưởng trên thành một template class ParamTraits<T>, với layout như sau:

Member constants
valid true if T is valid for RPC parameters, false otherwise
Member types
store_type Type dùng để giữ copy tạm thời cần cho deserializing
Member functions
write Ghi parameter đến một stream
read Đọc parameter vào một store_type
get Xét một store_type parameter, kết quả nó trả có thể được chuyển đến RPC function làm parameter

Trong ví dụ này,hãy thực thi ParamTraits<T> cho kiểu thuật toán, trong trường hợp ta có stream class với method writeread:

Và một ví dụ đơn giản:

ParamTraits<T> còn được sử dụng để kiểm tra nếu return type hợp lệ, và vì một void function hợp lệ, ta cần phải chuyên hóa ParamTraits cho void nữa.

Một điều khá kỳ lạ là khi chuyên hóa cho void là còn chỉ định một store_type nữa. Chúng ta không thể sử dụng nó để lưu trữ bất cư thứ gì, nhưng sẽ làm một vìa đoạn template code về sau dễ dàng hơn.

Với những ví dụ ParamTraits, reference không phải là RPC parameter hợp lệ. Trong thực tế, bạn ít nhất sẽ muốn cho phép const reference, đặc biệt với fundamental type. Bạn có thể thêm một tweak để kích hoạt hỗ trợ cho const T& cho bất kỳ T hợp lệ nào khi ứng dụng của bạn cần đến.

Bạn cũng có thể thực hiện những tweak tương tự để kích hoạt hỗ trợ T& hoặc T&& nếu cần thiết, mặc dù nếu function thay đổi đến những parameter này, những thay đổi đó sẽ mất đi.

Hãy thử thêm hỗ trợ cho các kiểu phức tạp như std::vector<T>. Để hỗ trợ std::vector<T>, ta buộc phải hỗ trợ thêm cả T:

Để tiện hơn, chúng ta có thể dùng toán tử <<>>  với class stream (không thế hiện ở đây). Với những toán tử này, hãy đơn thuần call các function ParamTraits<T> readParamTraits<T> write tương ứng.

Giờ đây ta đã có thể kiểm tra xem một type nhất định có được phép cho RPC parameter hay không, chúng ta có thể build trên đó kiểm tra xem một function có được dùng cho RPC hay không. Cách này được thực thi với variadic template.

Trước hết hãy tạo một template cho ta biết nếu nhóm parameter có hợp lệ hay không.

FunctionTraits cho ta một vài tính chất sẽ được sử dụng sau. Lưu ý với ví dụ rằng FunctionTraits::param_tuple build trên ParamTraits<T>::store_type. Việc này cần thiết, vì ở một thời điểm nào đó, chung ta cần cách ể deserialize tất cả parameter thành một tuple trước khi call function.

Serialization

Vì chúng ta giờ đây có code cần thiết để truy vấn parameter, return type và validating function, chúng ta có thể gộp code lại để serialize một function call. Hơn nữa, đây chính là type safe. Nó sẽ không complie nếu nhập sai số hoặc kiểu parameter, hoặc nếu bản thân function không hợp lệ cho RPC (không hỗ trợ các return/parameter type).

Deserialization

Như đã đề cập ở trên, FunctionTraits<F>::param_tuple là type std::tuple ta dùng được để giữ tất cả parameter của function. Để có thể sử dụng tuple này để deserialize parameter, chúng ta phải chuyên hóa ParamTraits cho các tuple. Bên cạnh đó, ta còn có thể sử dụng std::tuple cho RPC parameter.

Từ tuple đến function parameters

Sau khi deserialize tất cả parameter thành tuple, chúng ta giờ đâu phải tìm cách tháo tuble để call matching function. Một lần nữa, ta có thể dùng variadic template.

Vậy, giờ đây ta đã biết cách xác thực một function, serialize, deserialize, và call function đó. Vậy là đã xong bước code “level thấp” rồi. Lớp tiếp theo chúng ta sắp sửa phủ lên code này sẽ là RPC API thực sự.

The RPC API

Header

Header có chứa những thông tin sau:

Field
size Tổng dung lượng (byte) của RPC. Có dung lượng là một phần của header sẽ đơn giản hóa mạnh mẽ, vì chúng ta có thể kiểm tra nếu ta nhận tất cả dữ liệu trước khi thử xử lý RPC.
counter Số call. Mỗi lúc ta gọi RPC, một counter sẽ tăng và được chỉ định đến RPC call đó.
rpcid The function to call
isReply Nếu true, đây là reply đến RPC. Nếu false, đây là RPC call.
success Chỉ áp dụng cho reply (isReply==true). Nếu true, call thành công và data là reply. Nấu false, data là exception information

Counter và rpcid hình thành một key xác định RPC call instance, cần khi ghép một incoming RPC reply đế RPC call gây ra nó.

// Small utility struct to make it easier to work with the RPC headers
struct Header {
enum {
kSizeBits = 32,
kRPCIdBits = 8,
kCounterBits = 22,
};
explicit Header() {
static_assert(sizeof(*this) == sizeof(uint64_t),
“Invalid size. Check the bitfields”);
all_ = 0;
}

struct Bits {
uint32_t size : kSizeBits;
unsigned counter : kCounterBits;
unsigned rpcid : kRPCIdBits;
unsigned isReply : 1;  // Is it a reply to a RPC call ?
unsigned success : 1;  // Was the RPC call a success ?
};

uint32_t key() const {
return (bits.counter << kRPCIdBits) | bits.rpcid;
}
union {
Bits bits;
uint64_t all_;
};
};

inline Stream& operator<<(Stream& s, const Header& v) {
s << v.all_;
return s;
}

inline Stream& operator>>(Stream& s, Header& v) {
s >> v.all_;
return s;
}

}  // namespace rpc
}  // namespace cz

Table

Chúng ta đã serialize và deserialize một RPC, nhưng không phải là cách map một RPC đã serialize đến đúng function bên phía server. Để giải quyết vấn đề này, chúng ta cần chỉ định một ID đến mỗi function. Client sẽ biết nó muốn call function nào, và điền đúng ID vào header. Server kiểm tra header, và khi biết được ID, nó sẽ gửi đến đúng handler. Hãy tạo một số ID cơ bản để xác định bảng phân phối tương tự.

The Table template cần phải được chuyên hóa cho class mà ta muốn dùng cho RPC call. Giả sử ta có một Calculator class muốn dùng cho RPC calls:

Ta có thể chuyên hóa Table template cho Calculator, để cả client và server đều có thể tham gia:

Với một ID,function get sẽ trả dispatcher về đúng method Calculator. Rồi ta có thể chuyển Calculator instance,  input và output streams sang dispatcher để xử lý mọi thứ còn lại.

Quá trình chuyên hóa khá dài dòng và không thể tránh khỏi sai sót, vì enum và registerRPC call phải khớp. Nhưng chúng ta có thể rút ngắn rõ rệt quá trình với một vài macro. Đầu tiên, hãy xem thử một ví dụ dài dòng để thấy cách dùng bảng này:

Một lần nữa, ví dụ này khá dài dòng, chỉ để thể hiện code flow. Chúng ta sẽ có ví dụ tối ưu hơn sau.

Vậy, chúng ta đơn giản quá trình chuyên hóa bảng như thế nào? Nếu ta đặt gist của chuyên hóa bảng trong một header không được guard, bạn chỉ cần một vài define theo sau là một include của header (không guard) đó để tạo tương tự.

Ví dụ:

Quá đơn giản, đúng không?

RPCGenerate.h là một header không được guard, trông như sau:

Thêm vào đó, việc chuyên hóa bảng thế này giúp ta hỗ trợ inheritance dễ dàng hơn. Hãy tưởng tượng chúng ta có một ScientificCalculator kế thừa từ Calculator:

Khi đã define riêng biệt các content của Calculator, chúng ta có thể tái sử dụng define này:

Transport

Chúng ta phải xác định dữ liệu được chuyển đổi như thế nào giữa client và server. Hãy đặt dữ liệu đó vào Transport interface class. Interface bị cố ý để rất đơn giản để ứng dụng xác định một custom transport. Tất cả chúng ta cần là method để gửi, nhận, và đóng.

Result

Kết quả RPC phải trông như thế nào? Mỗi khi chúng ta tạo một RPC call, kết quả có thể đi theo 3 hình thức.

Form Meaning
Valid Nhận reply từ server với giá trị kết quả của RPC call
Aborted Connection failed hoặc bị đóng, và chúng ta không nhận được giá trị kết quả
Exception Nhận reply từ server với exception string (RPC call gây exception bên server side)

Chuyên hóa Result<void> rất cần thiết, vì trong trường hợp đó không có kết quả, nhưng caller vẫn muốn biết neus RPC call có được xử lý đúng cách hay không. Mới đầu, tôi từng xem xét sử dụng Expected<T> cho RPC reply. Nhưng Expected<T> về cơ bản có hai trạng thái (Value hoặc Exception), mà chúng ta lại cần tới 3 (Value, Exception, và Aborted). Ta hay cho rằng Aborted có thể được xem là exceptiom, nhưng từ góc nhìn của client, điều này không phải lúc nào cũng đúng. Trong một số trường hợp bạn sẽ muốn biết một RPC fail vì đóng kết nối, mà không phải vì server phản hồi exception.

Video: Sendo.vn xây dựng kiến trúc hệ thống mở rộng để đáp ứng tăng trưởng 10x mỗi năm như thế nào?

OutProcessor

Chúng ta cần phải theo dõi những RPC call đang diễn ra để user code nhận kết quả khi chúng đến. Ta có thể handle một kết quả theo hai cách. Thông qua handle không đồng bộ (tương tự Asio), hoặc với một future.

Ta cần hai class cho việc này: một outgoing processor và một wrapper cho một RPC call duy nhất. Một class khác (Connection) để buộc các outgoing processor và incoming processor với nhau. Class này sẽ được giới thiệu sau.

Nên nhớ OutProcessor<T> không cần một reference/pointer đến object của T. Nó chỉ cần biết type mà ta gửi RPC đến, để biết được phải dùng Table<T>.

Đây là ví dụ sử dụng OutProcessor:

Một lần nữa, hơi quá dài dòng, vì tôi chưa giới thiệu tất cả code để áp dụng tối ưu. Nhưng chí ít, ta đã thấy được interface OutProcessor<T>Call làm việc như thế nào. Thực thi std::future build đơn giản trên thực thi không đồng bộ.

InProcessor

Giờ đây ta có thể gửi một RPC và đợi kết quả, hãy xem thử ta cần gì ở bên kia. Phải làm gì khi server nhận được một RPC call.

Hãy tạo class InProcessor<T>. Trái với OutProcessor<T>, InProcessor<T> cần phải giữ một reference tới một object thuộc kiểu T. Điều này sảy ra khi nhận được RPC, nó có thể gọi requested method lên object đó, và gửi kết quả trở lại client.

Define CZRPC_CATCH_EXCEPTIONS cho phép chúng ta tweak nếu ta muốn exception phía server được chuyển sang phía client.

Nếu việc sử dụng InProcessor<T> (và Table<T>) cho phép call RPC lên object (không biết gì về RPC hoặc netwrk). Ví dụ như, hay xem xét giả dụ sau:

Object Calculator được dùng cho RPC không biết gì về RPC. InProcessor<Calculator> sẽ đảm nhiệm mọi nhiệm vụ liên quan. Từ đó ta không thể sử dụng các class bên thức ba cho PRC. Trong một số trường hợp, chung ta muốn class dùng cho RPC biết biết về RPC và/hoặc network. Ví dụ như, nếu bạn đang tạo một chat system, bạn sẽ khiến client gửi message (RPC call) đến server. Server cần biết client được kết nối đến thứ gì, để có thể truyền phát message.

Connection

Ta giờ đây đã có thể gửi nhận RPC. dù API có hơi dài một chút. Những template class OutProcessor<T>InProcessor<T> sẽ xử lý những sự kiện sảy ra với data tại cả hai đầu kết nối. Vậy, hiện ta cần chính điều này. Một Connection để buộc mọi thức cần để gửi nhận dữ liệu, và đơn giản là API, về một chỗ.

Từ đây ta có output processor, the input processor, và the transport. Để user code có thể xác định liệu mó có đang phục vụ RPC không. nó sử dụng một class Callstack. Class cho phép tạo RPC/network aware code nếu cần thiết, giống server class.

Vậy, ta đơn giản hóa API bằng cách nào? Vì Connection<T> có mọi thứ ta cần, một macro dưới dạng paramether của connection object, một function name và parameter, xử lý mọi thứ, bao gồm cả type check để nó không compile nếu là call không hợp lệ.

Khi dùng marcro này, cú pháp RPC call sẽ trở nên cực kỳ đơn giản. Hãy xe, thử client code ví dụ dưới đây:

Bạn có để ý là voidnullptr được sử dụng khi tạo kết nối với Connection<void, MagicSauce> con(nullptr, trp);? Thao tác này sẽ điều tiết bidirectional RPC (server cũng có thể call RPC lên một client). Trong trường hợp này, chúng ta không trong đợi vào client side RPC, vậy nên client side Connection object không có một local object để call RPC.

Một ví dụ đơn giản (nhưng không hoạt động) của bidirectional RPC:

Các parameter của Connection template trên server và client được đảo ngược. Khi nhận được dữ liệu, object Connection sẽ forward xử lý đến InProcessor nếu là một incoming RPC call, hoặc đến OutProcessor nếu đó là reply đến outgoing RPC call trước đó. Data flow của bidirectional RPC trông như thế này:

Improvements

Có một số phần được cố ý không đưa vào framework, để từ đó ứng dụng có thể quyết định thế nào là tốt nhất. Ví dụ:

  • Transport initialization
    • Giao diện transport rất đơn giản, nên nó không quá chú trọng vào hướng khởi tạo cụ thể hoặc xác định incoming data. Việc cung cấp một transport hiệu quả đến class Connecttion hoàn toàn phụ thuộc vào ứng dụng. Đây cũng là lý do tại sao tôi tránh hiển thị transport initialization, vì tôi phải trình bày một transport implementation hoạt động hoàn chỉnh.
    • Tại thời điểm viết bài, repo mã nguồn có một transport implementation dùng Boost Asio (hoặc Asio biệt lập)
  • Xác định disconnection
    • Như với initialization, có những đoạn code để xử lý và xác định shutdown.Ứng dụng sẽ có nhiệm vụ quyết định thao tác như thế nào với bất cứ custom implementation nó cung cấp.
  • Và nhiều hơn nữa

TopDev via gamasutra