Commit d3cfa1fe authored by Dominik Charousset's avatar Dominik Charousset Committed by Dominik Charousset

Implement better utilities for timestamp parsing

(cherry picked from commit e576f54e)
parent 571c4d19
...@@ -93,6 +93,7 @@ caf_add_component( ...@@ -93,6 +93,7 @@ caf_add_component(
src/binary_deserializer.cpp src/binary_deserializer.cpp
src/binary_serializer.cpp src/binary_serializer.cpp
src/blocking_actor.cpp src/blocking_actor.cpp
src/chrono.cpp
src/config_option.cpp src/config_option.cpp
src/config_option_adder.cpp src/config_option_adder.cpp
src/config_option_set.cpp src/config_option_set.cpp
...@@ -234,8 +235,7 @@ caf_add_component( ...@@ -234,8 +235,7 @@ caf_add_component(
binary_deserializer binary_deserializer
binary_serializer binary_serializer
blocking_actor blocking_actor
byte chrono
composition
config_option config_option
config_option_set config_option_set
config_value config_value
......
// This file is part of CAF, the C++ Actor Framework. See the file LICENSE in
// the main distribution directory for license terms and copyright or visit
// https://github.com/actor-framework/actor-framework/blob/master/LICENSE.
#pragma once
#include "caf/detail/core_export.hpp"
#include "caf/fwd.hpp"
#include "caf/parser_state.hpp"
#include "caf/string_view.hpp"
#include <chrono>
#include <ctime>
#include <optional>
#include <string>
namespace caf::detail {
/// The size of the buffer used for formatting.
constexpr size_t format_buffer_size = 40;
/// Splits the time point into seconds and nanoseconds since epoch.
template <class TimePoint>
std::pair<time_t, int> split_time_point(TimePoint ts) {
namespace sc = std::chrono;
using clock_type = sc::system_clock;
using clock_timestamp = typename clock_type::time_point;
using clock_duration = typename clock_type::duration;
auto tse = ts.time_since_epoch();
clock_timestamp as_sys_time{sc::duration_cast<clock_duration>(tse)};
auto secs = clock_type::to_time_t(as_sys_time);
auto ns = sc::duration_cast<sc::nanoseconds>(tse).count() % 1'000'000'000;
return {secs, static_cast<int>(ns)};
}
/// Prints the date and time to a buffer.
/// @param buf The buffer to write to.
/// @param buf_size The size of `buf` in bytes.
/// @param secs The number of seconds since epoch.
/// @param nsecs The number of nanoseconds since the last full second.
/// @returns A pointer to the null terminator of `buf`.
/// @pre `buf_size >= 36`.
CAF_CORE_EXPORT
char* print_localtime(char* buf, size_t buf_size, time_t secs,
int nsecs) noexcept;
} // namespace caf::detail
namespace caf::chrono {
// TODO: becomes obsolete when switching to C++20.
template <class Duration>
using sys_time = std::chrono::time_point<std::chrono::system_clock, Duration>;
/// Formats `ts` in ISO 8601 format.
/// @param ts The time point to format.
/// @returns The formatted string.
template <class Duration>
std::string to_string(sys_time<Duration> ts) {
char buf[detail::format_buffer_size];
auto [secs, nsecs] = detail::split_time_point(ts);
detail::print_localtime(buf, sizeof(buf), secs, nsecs);
return buf;
}
/// Prints `ts` to `out` in ISO 8601 format.
/// @param out The buffer to write to.
/// @param ts The time point to print.
template <class Buffer, class Duration>
void print(Buffer& out, sys_time<Duration> ts) {
char buf[detail::format_buffer_size];
auto [secs, nsecs] = detail::split_time_point(ts);
auto end = detail::print_localtime(buf, sizeof(buf), secs, nsecs);
out.insert(out.end(), buf, end);
}
/// Represents a point in time, expressed as a date and time of day. Also
/// provides formatting and parsing functionality for ISO 8601.
class CAF_CORE_EXPORT datetime {
public:
/// The year.
int year = 0;
/// The month of the year, starting with 1 for January.
int month = 0;
/// The day of the month, starting with 1.
int day = 0;
/// The hour of the day, starting with 0.
int hour = 0;
/// The minute of the hour, starting with 0.
int minute = 0;
/// The second of the minute, starting with 0.
int second = 0;
/// The nanosecond of the second, starting with 0.
int nanosecond = 0;
/// The offset from UTC in seconds.
std::optional<int> utc_offset;
/// Returns whether this object contains a valid date and time. A default
/// constructed object is invalid.
bool valid() const noexcept;
/// Returns whether this object is equal to `other`.
bool equals(const datetime& other) const noexcept;
/// Parses a date and time string in ISO 8601 format.
/// @param str The string to parse.
/// @returns The parsed date and time.
error parse(string_view str);
/// Convenience function for converting a string to a `datetime` object.
static expected<datetime> from_string(string_view str);
/// Parses a date and time string in ISO 8601 format.
/// @param ps The parser state to use.
void parse(string_parser_state& ps);
/// Overrides the current date and time with the values from `x`.
void value(const datetime& x) noexcept {
*this = x;
}
/// Converts this object to a time_point object with given Duration type.
template <class Duration = std::chrono::system_clock::duration>
auto to_local_time() const noexcept {
// TODO: consider using year_month_day once we have C++20.
namespace sc = std::chrono;
auto converted = sc::system_clock::from_time_t(to_time_t());
auto result = sc::time_point_cast<Duration>(converted);
auto ns = sc::nanoseconds{nanosecond};
result += sc::duration_cast<Duration>(ns);
return sc::time_point_cast<Duration>(result);
}
private:
/// Converts this object to a time_t value.
time_t to_time_t() const noexcept;
};
/// @relates datetime
inline bool operator==(const datetime& x, const datetime& y) noexcept {
return x.equals(y);
}
/// @relates datetime
inline bool operator!=(const datetime& x, const datetime& y) noexcept {
return !(x == y);
}
/// @relates datetime
std::string to_string(const datetime& x);
} // namespace caf::chrono
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
#include <optional> #include <optional>
#include <type_traits> #include <type_traits>
#include <utility> #include <utility>
#include <variant>
namespace caf::detail { namespace caf::detail {
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "caf/chrono.hpp"
#include "caf/detail/core_export.hpp" #include "caf/detail/core_export.hpp"
#include "caf/detail/squashed_int.hpp" #include "caf/detail/squashed_int.hpp"
#include "caf/detail/type_traits.hpp" #include "caf/detail/type_traits.hpp"
...@@ -186,45 +187,11 @@ void parse(string_parser_state& ps, std::chrono::duration<Rep, Period>& x) { ...@@ -186,45 +187,11 @@ void parse(string_parser_state& ps, std::chrono::duration<Rep, Period>& x) {
template <class Duration> template <class Duration>
void parse(string_parser_state& ps, void parse(string_parser_state& ps,
std::chrono::time_point<std::chrono::system_clock, Duration>& x) { std::chrono::time_point<std::chrono::system_clock, Duration>& x) {
namespace sc = std::chrono; chrono::datetime dt;
using value_type = sc::time_point<sc::system_clock, Duration>; dt.parse(ps);
// Parse ISO 8601 as generated by detail::print.
using int_wrapper = zero_padded_integer<int32_t>;
auto year = int_wrapper{};
auto month = int_wrapper{};
auto day = int_wrapper{};
auto hour = int_wrapper{};
auto minute = int_wrapper{};
auto second = int_wrapper{};
auto milliseconds = int_wrapper{};
if (!parse_sequence(ps,
// YYYY-MM-DD
year, literal{{"-"}}, month, literal{{"-"}}, day,
// "T"
literal{{"T"}},
// hh:mm:ss
hour, literal{{":"}}, minute, literal{{":"}}, second,
// "." xxx
literal{{"."}}, milliseconds))
return;
if (ps.code != pec::success) if (ps.code != pec::success)
return; return;
// Fill tm record. x = dt.to_local_time<Duration>();
tm record;
record.tm_sec = second.val;
record.tm_min = minute.val;
record.tm_hour = hour.val;
record.tm_mday = day.val;
record.tm_mon = month.val - 1; // months since January
record.tm_year = year.val - 1900; // years since 1900
record.tm_wday = -1; // n/a
record.tm_yday = -1; // n/a
record.tm_isdst = -1; // n/a
// Convert to chrono time and add the milliseconds.
auto tstamp = sc::system_clock::from_time_t(mktime(&record));
auto since_epoch = sc::duration_cast<Duration>(tstamp.time_since_epoch());
since_epoch += sc::milliseconds{milliseconds.val};
x = value_type{since_epoch};
} }
// -- convenience functions ---------------------------------------------------- // -- convenience functions ----------------------------------------------------
......
// This file is part of CAF, the C++ Actor Framework. See the file LICENSE in
// the main distribution directory for license terms and copyright or visit
// https://github.com/actor-framework/actor-framework/blob/master/LICENSE.
#pragma once
#include "caf/chrono.hpp"
#include "caf/config.hpp"
#include "caf/detail/consumer.hpp"
#include "caf/detail/parser/add_ascii.hpp"
#include "caf/detail/parser/chars.hpp"
#include "caf/detail/parser/read_signed_integer.hpp"
#include "caf/detail/scope_guard.hpp"
CAF_PUSH_UNUSED_LABEL_WARNING
#include "caf/detail/parser/fsm.hpp"
namespace caf::detail::parser {
// Parses an integer in the form "00". We can't use `read_int` here because
// it will interpret the leading zero as an octal prefix.
template <class State, class Consumer>
void read_two_digit_int(State& ps, Consumer&& consumer) {
auto result = 0;
auto g = make_scope_guard([&] {
if (ps.code <= pec::trailing_character) {
consumer.value(result);
}
});
// clang-format off
start();
state(init) {
transition(at_digit2, decimal_chars, add_ascii<10>(result, ch))
}
state(at_digit2) {
transition(done, decimal_chars, add_ascii<10>(result, ch))
}
term_state(done) {
// nop
}
fin();
// clang-format on
}
/// Reads a UTC offset in ISO 8601 format, i.e., a string of the form
/// `[+-]HH:MM`, `[+-]HHMM`, or `[+-]HH`.
template <class State, class Consumer>
void read_utc_offset(State& ps, Consumer&& consumer) {
bool negative = false;
auto hh = 0;
auto mm = 0;
auto g = make_scope_guard([&] {
if (ps.code <= pec::trailing_character) {
auto result = hh * 3600 + mm * 60;
consumer.value(negative ? -result : result);
}
});
// clang-format off
start();
state(init) {
fsm_transition(read_two_digit_int(ps, make_consumer(hh)), after_hh, '+')
fsm_transition(read_two_digit_int(ps, make_consumer(hh)), after_hh, '-',
negative = true)
}
term_state(after_hh) {
fsm_transition(read_two_digit_int(ps, make_consumer(mm)), done, ':')
fsm_epsilon(read_two_digit_int(ps, make_consumer(mm)), done, decimal_chars)
}
term_state(done) {
// nop
}
fin();
// clang-format on
}
/// Reads a date and time in ISO 8601 format.
template <class State, class Consumer>
void read_timestamp(State& ps, Consumer&& consumer) {
auto fractional_seconds = 0;
auto fractional_seconds_decimals = 0;
auto add_fractional = [&](char ch) {
++fractional_seconds_decimals;
return add_ascii<10>(fractional_seconds, ch);
};
chrono::datetime result;
auto g = make_scope_guard([&] {
if (ps.code <= pec::trailing_character) {
if (fractional_seconds_decimals > 0) {
auto divisor = 1;
for (auto i = 0; i < fractional_seconds_decimals; ++i)
divisor *= 10;
result.nanosecond = fractional_seconds * (1'000'000'000 / divisor);
}
if (result.valid())
consumer.value(result);
else
ps.code = pec::invalid_argument;
}
});
// clang-format off
start();
state(init) {
fsm_epsilon(read_signed_integer(ps, make_consumer(result.year)),
has_year, decimal_chars);
}
state(has_year) {
fsm_transition(read_two_digit_int(ps, make_consumer(result.month)),
has_month, '-')
}
state(has_month) {
fsm_transition(read_two_digit_int(ps, make_consumer(result.day)),
has_day, '-')
}
state(has_day) {
transition(at_hour, 'T')
}
state(at_hour) {
fsm_epsilon(read_two_digit_int(ps, make_consumer(result.hour)),
has_hour, decimal_chars);
}
state(has_hour) {
transition(at_minute, ':')
}
state(at_minute) {
fsm_epsilon(read_two_digit_int(ps, make_consumer(result.minute)),
has_minute, decimal_chars);
}
state(has_minute) {
transition(at_second, ':')
}
state(at_second) {
fsm_epsilon(read_two_digit_int(ps, make_consumer(result.second)),
has_second, decimal_chars);
}
term_state(has_second) {
transition(has_fractional, '.')
transition(done, 'Z', result.utc_offset = 0)
fsm_epsilon(read_utc_offset(ps, make_consumer(result.utc_offset.emplace(0))),
done, "+-")
}
term_state(has_fractional) {
transition_if(fractional_seconds_decimals < 9, has_fractional, decimal_chars,
add_fractional(ch))
epsilon_if(fractional_seconds_decimals > 0, has_utc_offset)
}
term_state(has_utc_offset) {
transition(done, 'Z', result.utc_offset = 0)
fsm_epsilon(read_utc_offset(ps, make_consumer(result.utc_offset.emplace(0))),
done, "+-")
}
term_state(done) {
// nop
}
fin();
// clang-format on
}
} // namespace caf::detail::parser
#include "caf/detail/parser/fsm_undef.hpp"
CAF_POP_WARNINGS
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
#pragma once #pragma once
#include "caf/chrono.hpp"
#include "caf/detail/core_export.hpp"
#include "caf/none.hpp" #include "caf/none.hpp"
#include "caf/string_view.hpp" #include "caf/string_view.hpp"
...@@ -15,6 +17,7 @@ ...@@ -15,6 +17,7 @@
namespace caf::detail { namespace caf::detail {
// TODO: this function is obsolete. Deprecate/remove with future versions.
CAF_CORE_EXPORT CAF_CORE_EXPORT
size_t print_timestamp(char* buf, size_t buf_size, time_t ts, size_t ms); size_t print_timestamp(char* buf, size_t buf_size, time_t ts, size_t ms);
...@@ -220,20 +223,7 @@ void print(Buffer& buf, std::chrono::duration<Rep, Period> x) { ...@@ -220,20 +223,7 @@ void print(Buffer& buf, std::chrono::duration<Rep, Period> x) {
template <class Buffer, class Duration> template <class Buffer, class Duration>
void print(Buffer& buf, void print(Buffer& buf,
std::chrono::time_point<std::chrono::system_clock, Duration> x) { std::chrono::time_point<std::chrono::system_clock, Duration> x) {
namespace sc = std::chrono; chrono::print(buf, x);
using clock_type = sc::system_clock;
using clock_timestamp = typename clock_type::time_point;
using clock_duration = typename clock_type::duration;
auto tse = x.time_since_epoch();
clock_timestamp as_sys_time{sc::duration_cast<clock_duration>(tse)};
auto secs = clock_type::to_time_t(as_sys_time);
auto msecs = sc::duration_cast<sc::milliseconds>(tse).count() % 1000;
// We print in ISO 8601 format, e.g., "2020-09-01T15:58:42.372". 32-Bytes are
// more than enough space.
char stack_buffer[32];
auto pos = print_timestamp(stack_buffer, 32, secs,
static_cast<size_t>(msecs));
buf.insert(buf.end(), stack_buffer, stack_buffer + pos);
} }
} // namespace caf::detail } // namespace caf::detail
// This file is part of CAF, the C++ Actor Framework. See the file LICENSE in
// the main distribution directory for license terms and copyright or visit
// https://github.com/actor-framework/actor-framework/blob/master/LICENSE.
#include "caf/config.hpp"
#ifdef CAF_WINDOWS
# include <time.h>
namespace {
int utc_offset(const tm& time_buf) noexcept {
# if !defined(WINAPI_FAMILY) || (WINAPI_FAMILY == WINAPI_FAMILY_DESKTOP_APP)
// Call _tzset once to initialize the timezone information.
static bool unused = [] {
_tzset();
return true;
}();
CAF_IGNORE_UNUSED(unused);
# endif
// Get the timezone offset in seconds.
long offset = 0;
_get_timezone(&offset);
// Add the DST bias if DST is active.
if (time_buf.tm_isdst) {
long dstbias = 0;
_get_dstbias(&dstbias);
offset += dstbias;
}
return static_cast<int>(-offset);
}
void to_localtime(time_t ts, tm& time_buf) noexcept {
localtime_s(&time_buf, &ts);
}
time_t tm_to_time_t(tm& time_buf) noexcept {
return _mkgmtime(&time_buf);
}
} // namespace
#else
# ifndef _GNU_SOURCE
# define _GNU_SOURCE
# endif
# include <ctime>
# include <ratio>
namespace {
int utc_offset(const tm& time_buf) noexcept {
return time_buf.tm_gmtoff;
}
void to_localtime(time_t ts, tm& time_buf) noexcept {
localtime_r(&ts, &time_buf);
}
time_t tm_to_time_t(tm& time_buf) noexcept {
return timegm(&time_buf);
}
} // namespace
#endif
#include "caf/chrono.hpp"
#include "caf/detail/parser/read_timestamp.hpp"
#include "caf/detail/print.hpp"
#include "caf/error.hpp"
#include "caf/expected.hpp"
#include "caf/parser_state.hpp"
namespace {
// Prints a fractional part of a timestamp, i.e., a value between 0 and 999.
// Adds leading zeros.
char* print_localtime_fraction(char* pos, int val) noexcept {
CAF_ASSERT(val < 1000);
*pos++ = static_cast<char>((val / 100) + '0');
*pos++ = static_cast<char>(((val % 100) / 10) + '0');
*pos++ = static_cast<char>((val % 10) + '0');
return pos;
}
char* print_localtime_impl(char* buf, size_t buf_size, time_t ts,
int ns) noexcept {
// Max size of a timestamp is 29 bytes: "2019-12-31T23:59:59.111222333+01:00".
// We need at least 36 bytes to store the timestamp and the null terminator.
CAF_ASSERT(buf_size >= 36);
// Read the time into a tm struct and print year plus time using strftime.
auto time_buf = tm{};
to_localtime(ts, time_buf);
auto index = strftime(buf, buf_size, "%FT%T", &time_buf);
auto pos = buf + index;
// Add the fractional component.
if (ns > 0) {
*pos++ = '.';
auto ms_val = ns / 1'000'000;
auto us_val = (ns / 1'000) % 1'000;
auto ns_val = ns % 1'000;
pos = print_localtime_fraction(pos, ms_val);
if (ns_val > 0) {
pos = print_localtime_fraction(pos, us_val);
pos = print_localtime_fraction(pos, ns_val);
} else if (us_val > 0) {
pos = print_localtime_fraction(pos, us_val);
}
}
// Add the timezone part.
auto offset = utc_offset(time_buf);
if (offset == 0) {
*pos++ = 'Z';
*pos = '\0';
return pos;
}
if (offset > 0) {
*pos++ = '+';
} else {
*pos++ = '-';
offset = -offset;
}
auto hours = offset / 3600;
auto minutes = (offset % 3600) / 60;
*pos++ = static_cast<char>(hours / 10 + '0');
*pos++ = static_cast<char>(hours % 10 + '0');
*pos++ = ':';
*pos++ = static_cast<char>(minutes / 10 + '0');
*pos++ = static_cast<char>(minutes % 10 + '0');
*pos = '\0';
return pos;
}
} // namespace
namespace caf::detail {
char* print_localtime(char* buf, size_t buf_size, time_t secs,
int nsecs) noexcept {
return print_localtime_impl(buf, buf_size, secs, nsecs);
}
} // namespace caf::detail
namespace caf::chrono {
namespace {
constexpr bool in_range(int x, int lower, int upper) noexcept {
return lower <= x && x <= upper;
}
constexpr bool is_leap_year(int year) noexcept {
// A leap year can be divided by 4 (2020, 2024 ...)
// -> but it's *not* if it can be divided by 100 (2100, 2200, ...)
// -> but it's *always* if it can be divided by 400 (2400, 2800, ...)
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0);
}
constexpr bool is_valid_day(int day, int month, int year) noexcept {
if (day < 1)
return false;
switch (month) {
default:
return false;
case 1: // January
case 3: // March
case 5: // May
case 7: // July
case 8: // August
case 10: // October
case 12: // December
return day <= 31;
case 4: // April
case 6: // June
case 9: // September
case 11: // November
return day <= 30;
case 2: // February
return is_leap_year(year) ? day <= 29 : day <= 28;
}
}
} // namespace
bool datetime::valid() const noexcept {
return in_range(month, 1, 12) // Month 1 is January.
&& is_valid_day(day, month, year) // Valid days depend on month & year.
&& in_range(hour, 0, 23) // Hour: 00 to 23.
&& in_range(minute, 0, 59) // Minute: 00 to 59.
&& in_range(second, 0, 59) // Second: 00 to 59.
&& in_range(nanosecond, 0, 999'999'999);
}
bool datetime::equals(const datetime& other) const noexcept {
// Two timestamps can be equal even if they have different UTC offsets. Hence,
// we need to normalize them before comparing.
return to_time_t() == other.to_time_t() && nanosecond == other.nanosecond;
}
time_t datetime::to_time_t() const noexcept {
auto time_buf = tm{};
time_buf.tm_year = year - 1900;
time_buf.tm_mon = month - 1;
time_buf.tm_mday = day;
time_buf.tm_hour = hour;
time_buf.tm_min = minute;
time_buf.tm_sec = second;
time_buf.tm_isdst = -1;
return tm_to_time_t(time_buf) - utc_offset.value_or(0);
}
error datetime::parse(string_view str) {
string_parser_state ps{str.begin(), str.end()};
detail::parser::read_timestamp(ps, *this);
return ps.code;
}
void datetime::parse(string_parser_state& ps) {
detail::parser::read_timestamp(ps, *this);
}
expected<datetime> datetime::from_string(string_view str) {
datetime tmp;
if (auto err = tmp.parse(str))
return err;
return tmp;
}
std::string to_string(const datetime& x) {
std::string result;
result.reserve(32);
auto add_two_digits = [&](int x) {
if (x < 10)
result.push_back('0');
detail::print(result, x);
};
auto add_three_digits = [&](int x) {
if (x < 10)
result += "00";
else if (x < 100)
result.push_back('0');
detail::print(result, x);
};
detail::print(result, x.year);
result.push_back('-');
add_two_digits(x.month);
result.push_back('-');
add_two_digits(x.day);
result.push_back('T');
add_two_digits(x.hour);
result.push_back(':');
add_two_digits(x.minute);
result.push_back(':');
add_two_digits(x.second);
if (x.nanosecond > 0) {
result.push_back('.');
auto ms = x.nanosecond / 1'000'000;
add_three_digits(ms);
if ((x.nanosecond % 1'000'000) > 0) {
auto us = (x.nanosecond % 1'000'000) / 1'000;
add_three_digits(us);
if ((x.nanosecond % 1'000) > 0) {
auto ns = x.nanosecond % 1'000;
add_three_digits(ns);
}
}
}
if (!x.utc_offset)
return result;
auto utc_offset = *x.utc_offset;
if (utc_offset == 0) {
result.push_back('Z');
return result;
}
if (utc_offset > 0) {
result.push_back('+');
} else {
result.push_back('-');
utc_offset = -utc_offset;
}
add_two_digits(utc_offset / 3600); // hours
result.push_back(':');
add_two_digits((utc_offset % 3600) / 60); // minutes
return result;
}
} // namespace caf::chrono
This diff is collapsed.
...@@ -157,9 +157,9 @@ CAF_TEST(rendering) { ...@@ -157,9 +157,9 @@ CAF_TEST(rendering) {
timestamp t0; timestamp t0;
time_t t0_t = 0; time_t t0_t = 0;
char t0_buf[50]; char t0_buf[50];
CAF_REQUIRE(strftime(t0_buf, sizeof(t0_buf), strftime(t0_buf, sizeof(t0_buf), "%Y-%m-%dT%H:%M:%S", localtime(&t0_t));
"%Y-%m-%dT%H:%M:%S.000", localtime(&t0_t))); // Note: we use starts_with because we cannot predict the exact time zone.
CAF_CHECK_EQUAL(render(logger::render_date, t0), t0_buf); CHECK(starts_with(render(logger::render_date, t0), t0_buf));
// Rendering of events. // Rendering of events.
logger::event e{ logger::event e{
CAF_LOG_LEVEL_WARNING, CAF_LOG_LEVEL_WARNING,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment