Быстрый Variable Length String View в бинарных протоколах

Быстрый 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.

Report Page