Быстрый Variable Length String View в бинарных протоколах
Sehnsucht (https://t.me/cxx95)
При работе с сетью нужно читать сообщения бинарного протокола поверх UDP или TCP. В большинстве случаев сообщения имеют жестко фиксированный размер, и можно использовать просто reinterpret_cast<const Message*>(address) в C++ структуру Message чтобы получить доступ к полям сообщения. Изредка присутствуют массивы из "вспомогательных" месседжей после "основного" месседжа, но их все равно несложно поддержать.
Но иногда встречаются надмозговые протоколы, где в месседжах некоторые поля имеют варьирующуюся длину - какие-то строки рандомной длины. На физическом уровне это выглядит как что например сначала идет число длины N байт, где лежит значение Len, и следующие Len байт значение поля. Потом идут следующие поля месседжа. Чтобы справиться с такими месседжами, можно руками перегонять данные в промежуточную структуру, но это не по пацански.
Создадим что-то вроде аналога std::string_view для динамических полей, упрощенно выглядит так:
template<IsEndianInteger T>
class VariableLengthStringView {
public:
using length_type = T;
VariableLengthStringView(std::string_view data): data_{data} {
}
[[nodiscard]] std::string_view view() const {
return data_;
}
[[nodiscard]] size_t full_size() const {
return sizeof(T) + data_.size();
}
static VariableLengthStringView parse(const std::byte* raw_data) {
auto length = reinterpret_cast<const T*>(raw_data)->get();
auto value = std::string_view{reinterpret_cast<const char*>(raw_data + sizeof(T)), length};
return VariableLengthStringView{value};
}
private:
std::string_view data_;
};
Таким образом этот класс - обертка над std::string_view для динамического поля. Объекты этого класса должны стоять в C++ структуре условного Message наравне со статическими полями.
Далее, нам потребуется чтобы мы могли "итерироваться" по полям Message. Для этого можно либо дождаться пока наконец довезут рефлексию из C++26 в компиляторы, или иметь свою реализацию рефлексии (стащить откуда-то) - с большой вероятностью это есть во всех крупных проектах, потому что довольно часто надо парсить json-конфиги не "руками", и так далее.
Попробуем реализовать чтение месседжа, для этого имеем условно такую верхнеуровневую функцию:
template<reflect::reflectable T>
[[nodiscard]] std::pair<T, size_t> parse_with_variable_length_strings(const std::byte* raw_data) {
T result;
const std::byte* cursor = raw_data;
detail::parse_dispatch<0>(result, cursor, reflect::members_of<T>());
return {result, static_cast<size_t>(cursor - raw_data)};
}
Далее заведем хелпер, который указывает, является ли поле статическим или динамическим:
template<typename T>
concept IsVariableLengthStringView = IsEndianInteger<typename std::remove_cvref_t<T>::length_type> &&
std::same_as<std::remove_cvref_t<T>, VariableLengthStringView<typename std::remove_cvref_t<T>::length_type>>;
template<typename Member>
consteval bool is_fixed_field() {
return !IsVariableLengthStringView<typename Member::value_type>;
}
Далее для "быстрой итерации" нужны функции, которые находят индекс следующего динамического поля, начиная с индекса I, и длину полей от I до E:
template<size_t I, size_t N, typename Members>
consteval size_t next_vls_or_end() {
if constexpr (I >= N) {
return N;
} else if constexpr (!is_fixed_field<std::tuple_element_t<I, Members>>()) {
return I;
} else {
return next_vls_or_end<I + 1, N, Members>();
}
}
template<size_t I, size_t End, typename Members>
consteval size_t wire_size_of_run() {
if constexpr (I >= End) {
return 0;
} else {
return sizeof(typename std::tuple_element_t<I, Members>::value_type) + wire_size_of_run<I + 1, End, Members>();
}
}
Как видно по consteval, эти подсчеты идут в compile-time.
Далее реализуем саму логику чтения - копируем кусками последовательные статические поля, потом отдельно парсим динамическое поле, потом снова кусок статических полей, и так до конца:
template<size_t I, typename T, typename Members>
void parse_dispatch(T& result, const std::byte*& cursor, const Members& members) {
constexpr auto N = std::tuple_size_v<Members>;
if constexpr (I >= N) {
return;
} else if constexpr (!is_fixed_field<std::tuple_element_t<I, Members>>()) {
auto member = get<I>(members);
using FieldType = typename decltype(member)::value_type;
auto parsed = FieldType::parse(cursor);
member(result) = parsed;
cursor += parsed.full_size();
parse_dispatch<I + 1>(result, cursor, members);
} else {
constexpr size_t run_end = next_vls_or_end<I, N, Members>();
constexpr size_t run_wire_size = wire_size_of_run<I, run_end, Members>();
std::memcpy(static_cast<void*>(&get<I>(members)(result)), cursor, run_wire_size);
cursor += run_wire_size;
parse_dispatch<run_end>(result, cursor, members);
}
}
Такой подход дает максимально быстрый парсинг всего месседжа, где все оффсеты подсчетаны в compile-time, и весь parse_dispatch на уровне компилятора разбивается на парсинг месседжа кусками не хуже, чем если бы эти куски писались "руками".
Отдельно можно реализовать запись месседжа, по такому же принципу с write_with_variable_length_strings и write_dispatch.