2024年2月

如何基于 spdlog 在编译期提供类 logrus 的日志接口

实现见
Github
,代码简单,只有一个头文件。

前提

几年前看到戈君在知乎上的一篇
文章
,关于打印日志的一些经验总结;

实践下来很受用,在 golang 里结构化日志和
logrus
非常契合,最常见的使用方式如下。

logrus.WithField("addr", "127.0.0.1:80").Info("New conn")
logrus.WithFields(logrus.Fields{"ip": "127.0.0.1", "port": 80}).Info("New conn")

// 复用 task_id
l := logrus.WithField("task_id", 2)
l.WithField("progress", "20%").Info("Uploading os image")
l.WithFields(logrus.Fields{"err_msg": "Success", "err_code": 0}).Info("Completed")

最近在使用 C++ 写一些东西,日志库是 spdlog,综合体验最好的日志库了。在结构化输出一些多字段的情况下,有一个体验不佳的地方(相对 logrus)

spdlog::info("Closing TCP id={} listener={} addr={} ns={}", id, fmt::ptr(listener), addr.format(), netns);

字段多了容易造成 key-value 距离较远,修改起来容易张冠李戴。

期望

对 spdlog 进行简单的封装,提供类似 logrus 的接口

  1. key/value 不分离,代码清晰能够看到对应关系
  2. 编译期搞定,不分配内存
  3. 日志的 msg 及 key 只支持字面量字符串(这两个信息在打日志的时候就应该清晰)
// 纯消息的日志
logrus::info("hello world!");

// 携带一个 key/value 的日志
logrus::with_field("addr", "127.0.0.1:80").info("New conn");

// 携带两个 key/value 的日志
logrus::with_field("ip", "127.0.0.1").with_field("port", 80).info("New conn2");

// 携带多个 key/value 的日志, logrus::Field 为一个 key/value 结构
logrus::with_fields(logrus::Field("ip", "127.0.0.1"), logrus::Field("port", 80)).info("New conn3");

// 复用 task_id 日志对象,在不同条件下的日志
auto l = logrus::with_field("task_id", 1);
if (true)
  l.with_fields(logrus::Field("ip", "127.0.0.1"), logrus::Field("port", 80)).info("Listen on");
else
  l.with_field("path", "xx.sock").info("Listen on");

额外提供一些宏

  1. 减少日志代码长度
  2. 提升日志代码的区分度
  3. 获取
    __FILE__, __FUNCTION__, __LINE__
    (优先级低)
LOG_INFO("New conn", KV("addr", "127.0.0.1:80"));
LOG_INFO("Updated version", KV("from", "1.6.1"), KV("to", "2.0.0"), KV("task_id", 2));

实现

不重复造轮子,实现的终点为调用
spdlog::log(level, fmt, args)
,一行日志包括

  1. fields,包括零或者多个
    key/value

    with_field
    产生一个
    key/value
  2. msg,特化的 field,在所有的 fields 第一个位置,具体为
    "msg"=msg

分解一下参数实现

  • fmt
    由所有的 key 组合而成,可能出现多个如
    key1={} key2={}
    ,这里为了增加区分度实现为
    key1='{}' key2='{}'
  • args
    由所有的 value 组合而成,按顺序展开即可

实现所需

  1. 构造
    fmt
    ,需要在编译期对字符串常量进行拼接

  2. key/value
    抽象为
    Field
    进行管理,并把所有的
    Field
    存在
    std::tuple
  3. 在所有的
    Field
    都进入
    std::tuple
    后,构造出
    spdlog
    需要的参数

实现字面量字符串相加

所有的 key 都是字面量的字符串,期望是实现任意个字面量字符串进行相加。

key 的类型为
const char[N]
,要实现编译期相加,根据 N 来实现一个结构体/类,因为类型一定会在编译期确定。

结合 N 和 C++14 的特性
std::index_sequence
,实现一个最重要的构造函数,包含了两个字面量字符串及下标列表参数。

template <size_t N> struct Literal {
  constexpr Literal(const char (&literal)[N])
      : Literal(literal, std::make_index_sequence<N>{}) {}

  constexpr Literal(const Literal<N> &literal) : Literal(literal.s) {}

  template <size_t N1, size_t... I1, size_t N2, size_t... I2>
  constexpr Literal(const char (&str1)[N1], std::index_sequence<I1...>,
                    const char (&str2)[N2], std::index_sequence<I2...>)
      : s{str1[I1]..., str2[I2]..., '\0'} {}

  template <size_t... I>
  constexpr Literal(const char (&str)[N], std::index_sequence<I...>)
      : s{str[I]...} {}

  char s[N];
};

如果两个字面量字符串长度(包括
\0
结尾)分别为
N1

N2
,那么相加的长度为
N1+N2-1
,可以增加一个推导指引来实现构造函数

template <size_t N1, size_t N2>
Literal(const char (&)[N1], const char (&)[N2]) -> Literal<N1 + N2 - 1>;

// 有了推导指引后,可以直接实现两个相加的构造函数
template <size_t N1, size_t N2>
constexpr Literal(const char (&str1)[N1], const char (&str2)[N2])
    : Literal(str1, std::make_index_sequence<N1 - 1>{}, str2,
              std::make_index_sequence<N2 - 1>{}) {}

// 反之如果没有推导指引,可以通过一个函数来指定这个 N
template <size_t N1, size_t N2>
constexpr auto make_literal(const char (&str1)[N1], const char (&str2)[N2]) {
  return Literal<N1 + N2 - 1>(str1, std::make_index_sequence<N1 - 1>{}, str2,
                              std::make_index_sequence<N2 - 1>{});
}

为了降低复杂度(可变参数的字面量字符串相加的
N
需要增加额外函数来计算),类
Literal
只提供基本的构造函数,相加的过程放在外部的函数中进行;

template <size_t N> constexpr auto make_literal(const char (&str)[N]) {
  return Literal(str);
}

template <size_t N> constexpr auto make_literal(const Literal<N> &literal) {
  return Literal(literal);
}

template <size_t N1, size_t N2>
constexpr auto make_literal(const char (&str1)[N1], const char (&str2)[N2]) {
  return Literal<N1 + N2 - 1>(str1, std::make_index_sequence<N1 - 1>{}, str2,
                              std::make_index_sequence<N2 - 1>{});
}

template <size_t N1, size_t N2>
constexpr auto make_literal(const Literal<N1> &literal1,
                            const Literal<N2> &literal2) {
  return make_literal(literal1.s, literal2.s);
}

template <size_t N1, size_t N2>
constexpr auto make_literal(const char (&str)[N1], const Literal<N2> &literal) {
  return make_literal(str, literal.s);
}

template <size_t N1, size_t N2>
constexpr auto make_literal(const Literal<N1> &literal, const char (&str)[N2]) {
  return make_literal(literal.s, str);
}

template <size_t N1, typename... Args>
constexpr auto make_literal(const char (&str)[N1], const Args &...args) {
  return make_literal(str, make_literal(args...));
}

template <size_t N1, typename... Args>
constexpr auto make_literal(const Literal<N1> &literal, const Args &...args) {
  return make_literal(literal, make_literal(args...));
}

通过重载
make_literal
来达到使用各种参数相同调用的效果

auto l1 = logrus::make_literal("123");            // logrus::Literal<4>
auto l2 = logrus::make_literal("a", "b", l1);     // logrus::Literal<6>
auto l3 = logrus::make_literal(l1, " ", l2, " "); // logrus::Literal<11>

构造 spdlog 所需参数

抽象 key/value

单个 key/value 为一个 Field,功能实现简单只提供构造函数,作为字段的最小单位提供给其它模块使用。

template <size_t N, typename T> struct Field {
  Literal<N> key;
  T value;

  constexpr Field(const char (&k)[N], T &&v)
      : key(k), value(std::forward<T>(v)) {}

  constexpr Field(const Literal<N> &k, T &&v)
      : key(k), value(std::forward<T>(v)) {}

  constexpr Field(const char (&k)[N], const T &v) : key(k), value(v) {}

  constexpr Field(const Literal<N> k, const T &v) : key(k), value(v) {}
};

template <size_t N, typename T> Field(const char (&)[N], T) -> Field<N, T>;

Field 的构造推导指引函数非常重要,不可缺少,否则构造函数及后续的 tuple 会出现错误。

char[N]
在函数调用的情况下,类型会被转换为
char *

auto x = logrus::Field("hello", "world");
  • 没有推导指引函数的情况下 x 被推导为
    logrus::Field<6, char[6]>
  • 有推导指引函数的情况下 x 被推导为
    logrus::Field<6UL, const char *>

定义日志行对象 logrus::Entry

作为一个日志行的对象,内部包含了所有的
logrus::Field
,在编译期确定类型。

  1. 提供对外调用的
    with_field(s)

    info
    接口

  2. info
    被调用的时候调用日志格式化函数进行参数构造,并且最终调用
    spdlog::log

with_field(s)
返回类型为
Entry<Fields...>
,为了足够简单,只接受
Field
类型的参数。

同样的,为
Entry(k, v)
增加一个构造函数的推导指引,否则类型就推导为
std::tuple<N, T>
了。

make_formatter
为格式化函数的一个辅助函数。

template <typename... Fields> struct Entry {
  std::tuple<Fields...> fields;

  template <size_t N, typename T>
  constexpr Entry(const Field<N, T> &field) : fields(std::make_tuple(field)) {}

  constexpr Entry(std::tuple<Fields...> &&fields) : fields(fields) {}

  constexpr Entry(const std::tuple<Fields...> &fields) : fields(fields) {}

  template <size_t N, typename T>
  constexpr auto with_field(const char (&k)[N], const T &v) {
    return with_fields(Field(k, v));
  }

  template <typename... Fields1>
  constexpr auto with_fields(const Fields1 &...fields1) {
    return Entry<Fields..., Fields1...>(
        std::tuple_cat(fields, std::tie(fields1...)));
  }

  template <size_t N1>
  void log(const char (&msg)[N1], spdlog::level::level_enum lvl) {
    make_formatter(std::tuple_cat(std::make_tuple(Field("msg", msg)), fields),
                   std::make_index_sequence<sizeof...(Fields) + 1>{})
        .log(lvl);
  }

  template <size_t N1> void info(const char (&msg)[N1]) {
    log(msg, spdlog::level::info);
  }
}

template <size_t N, typename T>
Entry(const Field<N, T> &field) -> Entry<Field<N, T>>;

将 key/value 转换为 spdlog 的入参

至此所有的数据都有了,现在需要对这些
key/value
进行修改及重组。还是那样,要在编译期确定类型,起手一个结构体。


Formatter
内就不再需要推导指引了,除构造函数和 log 之外,其它的功能全部交给外部函数进行驱动;

  • make_formatter
    , 输入
    std::tuple<Fields...>
    来展开所有的
    logrus::Field
  • make_format_args
    ,写了三个重载函数进行展开调用(1个参数为终止函数,2个参数为过渡函数,多个参数为驱动函数)
    • 构造 fmt
      • 单个
        Field
        直接为
        key='{}'
      • 多个
        Field
        通过递归的从后向前进行构造,所以第一个参数为
        Field
        ,随后的参数为
        Formatter
    • 收集 args,使用
      std::tuple_cat
      追加即可
  • Formatter::log
    , 展开
    std::tuple<Args...> args
    ,为了减少工作量直接使用 C++17 中的
    std::apply
    ,在lambda内部进行调用真正的
    spdlog::log
template <size_t N, typename... Args> struct Formatter {
  Literal<N> fmt;
  std::tuple<Args...> args;

  Formatter(const Literal<N> &fmt, const std::tuple<Args...> &args)
      : fmt(fmt), args(args) {}

  Formatter(const Literal<N> &fmt, std::tuple<Args...> &&args)
      : fmt(fmt), args(std::forward<std::tuple<Args...>>(args)) {}

  void log(spdlog::level::level_enum level) {
    std::apply(
        [&](Args &&...args) {
          spdlog::log(level, fmt.s, std::forward<Args>(args)...);
        },
        std::forward<std::tuple<Args...>>(args));
  }
};

template <size_t N, typename T>
constexpr auto make_format_args(const Field<N, T> &field) {
  return Formatter<N + 5, T>(make_literal(field.key, "='{}'"), field.value);
}

template <size_t N1, typename T1, size_t N2, typename... Args>
constexpr auto make_format_args(const Field<N1, T1> &field,
                                const Formatter<N2, Args...> &formatter) {
  return Formatter<N1 + N2 + 5, T1, Args...>(
      make_literal(field.key, "='{}' ", formatter.fmt),
      std::tuple_cat(std::tie(field.value), formatter.args));
}

template <size_t N1, typename T1, size_t N2, typename... Args>
constexpr auto make_format_args(const Field<N1, T1> &field,
                                Formatter<N2, Args...> &&formatter) {
  return Formatter<N1 + N2 + 5, T1, Args...>(
      make_literal(field.key, "='{}' ", formatter.fmt),
      std::tuple_cat(std::tie(field.value), formatter.args));
}

template <size_t N1, typename T1, typename... Fields>
constexpr auto make_format_args(const Field<N1, T1> &field,
                                Fields &&...fileds) {
  return make_format_args(field,
                          make_format_args(std::forward<Fields>(fileds)...));
}

template <typename Tuple, size_t... Idx>
constexpr auto make_formatter(const Tuple &tpl, std::index_sequence<Idx...>) {
  return make_format_args(std::get<Idx>(tpl)...);
}

其它

类似 logrus,提供
with_field(s)
功能函数,不用调用
Entry
构造函数来初始化一条日志

template <size_t N, typename T>
constexpr auto with_field(const char (&k)[N], const T &v) {
  return Entry(Field(k, v));
}

template <size_t N, typename T, typename... Fields>
constexpr auto with_fields(const Field<N, T> &field, const Fields &...fields) {
  return Entry(std::make_tuple(field, fields...));
}

增强灵活性,有些日志可能有
key/value
,也有可能只有一个
msg
,通过可变参数进行实现。

template <size_t N, typename... Fields>
void trace(const char (&msg)[N], const Fields &...fields) {
  Entry(std::forward_as_tuple(fields...)).trace(msg);
}

至此,用宏进行封装一下也变得顺理成章了

#define LOG_TRACE(...) logrus::trace(__VA_ARGS__)

遇到的坑

实例化
logrus::Field("key", "value")
的时候,模版第二个参数推导为
char[N]
而不是
char *
,后面发现
std::pair
推导的类型没有问题,把
std::pair
的代码单独扒了看一遍才看到有推导指引这种东西

刚开始实现的时候,准备定一个
Fields
来完成现有的
Formatter

Entry
的功能,在类中需要写非常多的辅助函数来完成,还很容易推导失败,甚至经常进入死循环,直接把 clangd 干到 oom。所以做了一个转变

  1. 核心为
    key/value
    ,只要在编译期确定类型即可,这里用结构体封装,只实现构造函数,这样可以灵活调整模版类型
  2. Entry 和 Field 同理,只完成收集存储的功能
  3. 最后参数构造全部放在函数中进行,既可以修改 fmt 的值,还能够直接指定模版类型

TODO

  1. 提升
    Formatter
    的抽象程度,增加自定义
    Formatter
  2. 增加
    spdlog::logger
    可选项
  3. 完善
    const T &

    T &&
    的函数定义

参考

  1. 如何打印日志
  2. Structured, pluggable logging for Go.
  3. C++ 模板参数推导

设计模式是通用的、可复用的代码设计方案,也可以说是针对某类问题的解决方案,因此,掌握好设计模式,可以帮助我们编写更健壮的代码。

wiki中将
设计模式
分为四类,分别是:

  • 创建模式(creational patterns)
  • 结构模式(structural patterns)
  • 行为模式(behavioral patterns)
  • 并发模式(concurrency patterns)

适配器模式属于其中的结构型模式,结构型——从名称上就可以看出——与结构有关,应用这类模式不会影响对象的行为,但是会影响代码结构。那么适配器模式究竟是怎样的一种解决方案,适合什么场景呢,接下来我们就来探究一下。

适配

首先我们来先扣个字眼,适配是什么意思呢?我直接问ChatGPT,得到了以下的回答:

"适配"在计算机领域通常指的是使软件、程序或网站能够在不同的设备、平台或环境下正常工作和显示的过程。适配的主要目的是确保用户能够在各种设备上获得一致的用户体验,而不受设备类型、屏幕尺寸、操作系统或浏览器等因素的影响。

简单粗暴一点理解就是指的兼容性。

所以应用适配器模式的目的简单来理解,就是提高兼容性。

这类生活场景非常常见,比如公司给员工配了一台显示器,但是外接屏幕自带连接线的接头与电脑已有的接口不适配,两者无法连接上,这种情况是很常见的,随着技术升级、数码设备不断的升级换代,导致市面上存在很多类型的接口,于是转接头就应运而生了——转接头就是适配器模式的一种具象应用。

编程

适配在编程中也是一个常见需求。就比如
WIKI
中的描述:

It is often used to make existing classes work with others without modifying their source code.

翻译过来的意思是:它(适配器模式)通常用于在不修改源代码的情况下使现有类与其他类协同工作。

很多开发小伙伴在现实工作中对这点应该都有所体会,在程序员的工作中很多时候都需要去维护已有项目,迭代新的需求,然后就可能碰到这类场景。

比如老代码中有些类或者对象的某个方法,在项目中的其他地方有调用,但是某天,突然来了一个需求,增加一个功能模块,然后这个模块需要与这些类或者对象交互,实现与这个方法类似但存在细微不同点的功能。

针对这个需求,如果不应用适配器模式,我们可以有以下两种做法:

第一种,如果这个方法调用的地方比较少,这里是说如果,那我们可以简单粗暴地直接把这个方法改了,测试环节就稍微麻烦点,在测试新功能模块的同时还需要回归测试原本调用这个方法的地方。

第二种,给对象增加一个新的方法提供给新模块调用,当然这个新方法中的很多代码会与老方法中的代码重复。

很显然,这两种做法都不够好,第一种需要回归测试,而且很容易有遗漏,甚至可能引起原因不明的bug;第二种则可能使项目中存在很多冗余代码,而且还可能影响后期维护,比如修改某段逻辑就要修改两个方法的代码,如果是其他人来接手维护,很可能根本不知道是这样的情况。

适配器模式就可以应用于这类场景,它主要帮助我们解决以下问题:

  • 代码复用
  • 让接口不兼容的类协同工作

模式描述

适配器模式描述了它是如何帮助我们解决上述问题的:

  • 定义一个单独的适配器类,将一个类(待适配)的(不兼容)接口转换成客户端需要的另一个接口(target)。
  • 通过适配器来处理(重用)不具备所需接口的类。

也就是说,应用适配器模式主要做的事情,就是在代码中增加一个适配器的角色。

前端应用

JavaScript作为一种面向对象编程语言,当然也可以应用适配器模式。我们来看下面的一个例子:

Web端在以前使用Ajax技术处理异步的时候,都是通过XHR对象,但是随着Promise的推出,出现了更简洁的fetch方法,为了更方便地处理异步,现在某负责人准备在新项目中应用fetch方法,新项目从老项目中拷贝了基础文件,其中包括了Ajax代码,为了使项目成员快速熟悉,Ajax方法最好在用法上保持一致。

假设以下是原本的Ajax代码,是基于XMLHttpRequest对象进行封装的:

function Ajax(method, url, {query, params, headers, successCallback }) {
    // 1. 创建对象
    const xhr = new XMLHttpRequest();
    // 2. 初始化 设置请求类型和url
    method = method.toUpperCase();
    let queryString = '?';
    if (query && query instanceof Object) {
        for (const key in query) {
            queryString += `${key}=${query[key]}&`
        }
        url += queryString.substring(0, queryString.length - 1);
    }
    xhr.open(method, url);
    // 3. 设置请求头
    for (const key in headers) {
        xhr.setRequestHeader(key, headers[key]);
    }
    // 4. 发送请求
    let paramString = '';
    if (params && params instanceof Object) {
        for (const key in params) {
            paramString += `${key}=${params[key]}&`
        }
        paramString = paramString.substring(0, paramString.length - 1);
    }
    xhr.send(paramString);
    // 5. 事件绑定 处理服务端返回的结果
    // on 当...的时候
    // readystate 0-初始化创建的时候 1-open的时候 2-send的时候 3-服务端部分返回的时候 4-服务端返回全部的时候
    // change 改变
    xhr.onreadystatechange = function () {
        // 判断 服务端返回了所有的结果
        if (xhr.readyState === 4) {
            // 判断响应状态码 200 404 403 401 500
            // 2xx 成功
            if (xhr.status >= 200 && xhr.status < 300) {
                let result = {
                    status: xhr.status, // 状态码
                    statusText: xhr.statusText, // 状态字符串
                    responseHeaders: xhr.getAllResponseHeaders(), // 所有响应头
                    response: xhr.response // 响应体
                }
                successCallback(result);
            }
        }
    }
}

为了使项目中熟悉XHR调用方式的成员和熟悉fetch的成员能统一调用Ajax方法,我们可以使用适配器模式来对代码进行改造。

首先创建一个适配器对象,在JavaScript中我们知道,函数也是对象,所以我们定义如下函数:

async function AjaxAdapter(method, url, {query, params, headers, successCallback }) {
  // ...
}

这个函数的入参与原本的Ajax函数保持一致。在这里声明
async
异步函数是因为fetch方法的返回值是Promise类型。

然后我们修改
Ajax
函数,去调用这个适配器函数:

async function Ajax(method, url, {query, params, headers, successCallback }) {
    return AjaxAdapter(method, url, {query, params, headers, successCallback });
}

我们在适配器函数中去处理不同的使用方式,比如如果调用者传递了
successCallback
这个回调函数,说明他是旧方式的使用者,那么就在适配器函数中将异步返回的结果通过
successCallback
进行传递,否则就不对异步结果做处理,由调用者自行处理异步函数的结果。

这样无论是XHR的使用者还是fetch的使用者,都能同样使用
Ajax
函数获取异步结果,而不会感知到其中的不同,XHR的使用者就不必一定要去学习fetch的使用,只要像以前一样使用
Ajax
函数即可。

除了功能适配,数据适配在开发中也很常见,比如设定数据规范,以便于在不同系统和应用程序之间进行数据交互和处理。

总结

通过以上的探讨我们可以发现,适配器模式在实际生活和编程中的应用其实是很普遍和广泛的,为了提高兼容性而增加适配器角色也是一个很常见的行为,适配器的主要功能就是帮助我们抹平差异,也就是在适配器的内部会去根据差异来做一些处理,而这些处理对用户来说是透明的,不需要了解的。

1、概述

在 Kubernetes 集群中,所有操作的资源数据都存储在 Etcd 数据库上。为了确保在节点故障、集群迁移或其他异常情况下能够尽快恢复集群数据,我们需要定期对 Etcd 数据进行容灾备份操作。

在 Kubernetes 集群中,可以非常方便的针对 Etcd 数据进行备份,通常,只需在一个节点上对 Etcd 进行快照,即可完成数据备份。快照文件包含了所有 Kubernetes 的状态和关键信息。有了 Etcd 集群数据的备份,即使在灾难性场景下(如丢失所有控制平面节点),也能够快速恢复 Kubernetes 集群。

注意:
您可以参考博文《
定时备份etcd数据
》,详细了解 Etcd 数据备份的步骤。即便是高可用 Etcd 集群,只需要在一个节点上进行 Etcd 数据备份即可。然而,为了预防节点故障,强烈建议在所有 Etcd 节点上进行数据备份,并且建议定期将备份的 Etcd 快照数据传输到专门的数据存储服务器进行保存。

2、使用 Etcd 快照文件恢复 Etcd 数据实战

2.1 单节点恢复

描述: 当单节点资源清单数据丢失时,我们可采用如下方式进行快速恢复数据。

操作流程:

(1)停止单节点 Etcd 服务

systemctl stop etcd

(2)备份 Etcd 数据目录

mv /var/lib/etcd /var/lib/etcd.bak

(3)使用 Etcd 快照文件恢复 Etcd 数据

etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem  --endpoints 10.20.30.31:2379 snapshot restore  /var/backups/kube_etcd/etcd-2024-0206-snapshot.db \
--name=etcd01 \
--initial-cluster=etcd01=https://10.20.30.31:2380 \
--initial-advertise-peer-urls=https://10.20.30.31:2380 \
--data-dir=/var/lib/etcd

注意 1
:本博文 etcdctl 客户端命令默认使用 v3 版本 API。

注意 2:
执行恢复命令时,命令中涉及的节点IP、私钥、证书、快照文件等信息需要集群实际情况进行替换。

(4)启动单节点 Etcd 服务

systemctl start etcd

(5)查看 Etcd 节点状态

通过查看 Etcd 服务状态、Etcd 日志以及通过 Etcd 客户端查看 Etcd 节点状态、查看 Etcd 快照中保存的 key 来检查 Etcd 节点数据恢复情况。如果检查 Etcd 节点状态正常的话,至此单节点 Etcd 恢复正常。下面仅粘贴通过 Etcd 客户端查看 Etcd 节点状态命令。

etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem --endpoints="https://10.20.30.31:2379" endpoint status --write-out=table

2.2 高可用集群恢复

对于使用 Etcd 快照文件恢复高可用 Etcd 集群数据,需要对每个 Etcd 节点进行数据恢复,下面以 3 个节点 Etcd 集群为例,实战下对于高可用 Etcd 集群恢复。

(1)待恢复高可用集群节点信息

模拟高可用集群三台节点全部数据损坏情况,针对三台节点数据全部损坏并没法恢复情况, 需要重新安装高可用 Etcd 集群,并基于存储服务器上保存的 Etcd 快照文件进行数据恢复。

新安装 Etcd 集群 etcd.service 配置文件。

# /usr/lib/systemd/system/etcd.service
[Unit]
Description=Etcd Server
After=network.target
After=network-online.target
Wants=network-online.target
 
[Service]
Type=notify
EnvironmentFile=/opt/etcd/cfg/etcd.conf
ExecStart=/opt/etcd/bin/etcd
Restart=on-failure
LimitNOFILE=65536
 
[Install]
WantedBy=multi-user.target 

新安装 Etcd 集群 etcd 节点配置文件,这里仅展示 103 节点配置文件,104 和 105 节点配置文件仅是 ip 不同。

#[Member]
ETCD_NAME="etcd01"
ETCD_DATA_DIR="/var/lib/etcd"
ETCD_LISTEN_PEER_URLS="https://10.20.31.103:2380"
ETCD_LISTEN_CLIENT_URLS="https://10.20.31.103:2379,http://127.0.0.1:2379"
 
#[Clustering]
ETCD_INITIAL_ADVERTISE_PEER_URLS="https://10.20.31.103:2380"
ETCD_ADVERTISE_CLIENT_URLS="https://10.20.31.103:2379"
ETCD_INITIAL_CLUSTER="etcd01=https://10.20.31.103:2380,etcd02=https://10.20.31.104:2380,etcd03=https://10.20.31.105:2380"
ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster"
ETCD_INITIAL_CLUSTER_STATE="new"
ETCD_ENABLE_V2="true"
 
#[Security]
ETCD_CERT_FILE="/opt/etcd/ssl/server.pem"
ETCD_KEY_FILE="/opt/etcd/ssl/server-key.pem"
ETCD_TRUSTED_CA_FILE="/opt/etcd/ssl/ca.pem"
ETCD_CLIENT_CERT_AUTH="true"
ETCD_PEER_CERT_FILE="/opt/etcd/ssl/server.pem"
ETCD_PEER_KEY_FILE="/opt/etcd/ssl/server-key.pem"
ETCD_PEER_TRUSTED_CA_FILE="/opt/etcd/ssl/ca.pem"
ETCD_PEER_CLIENT_CERT_AUTH="true"

通过 etcdctl 客户端查看当前新安装 Etcd 集群节点信息。

[root@xxx ~]# /opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem --endpoints="https://10.20.31.103:2379,https://10.20.31.104:2379,https://10.20.31.105:2379" endpoint status --write-out=table
+---------------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|         ENDPOINT          |        ID        | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+---------------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| https://10.20.31.103:2379 | 3681f3c1503c3f87 |  3.4.13 |   20 kB |      true |      false |         2 |          8 |                  8 |        |
| https://10.20.31.104:2379 | 24b3b3c0efa27a98 |  3.4.13 |   20 kB |     false |      false |         2 |          8 |                  8 |        |
| https://10.20.31.105:2379 | 7acc5e40b5c32ffd |  3.4.13 |   20 kB |     false |      false |         2 |          8 |                  8 |        |
+---------------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+

通过 etcdmanager 客户端查看当前新安装 Etcd 集群节点信息。

通过 etcdctl 客户端查看当前新安装 Etcd 集群存储的 key。

[root@xxx ~]# /opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem --endpoints="https://10.20.31.103:2379,https://10.20.31.104:2379,https://10.20.31.105:2379" get --prefix "" --keys-only=true
[root@xxx ~]# 

通过 etcdmanager 客户端查看当前新安装 Etcd 集群存储的 key。

注意 1
:高可用 Etcd 集群安装步骤可以参考 《
搭建高可用Kubernetes集群之etcd v3.4.13集群搭建(一)
》这篇博文,本文不再赘余。

(2)所有 Etcd 节点停止 Etcd 服务

三台 Etcd 节点分别停止 Etcd 服务。

systemctl stop etcd

(3)所有 Etcd 节点备份 Etcd 数据目录

三台 Etcd 节点分别备份 Etcd 数据目录。

mv /var/lib/etcd /var/lib/etcd.bak

(4)所有 Etcd 节点使用快照文件恢复 Etcd 数据

10.20.31.103 节点使用快照文件恢复 Etcd 数据:
注意 data-dir 需要与 etcd 节点配置文件中配置路径一致,不然后面启动 etcd 服务会基于 etcd 节点配置文件中配置的路径作为新节点数据目录。

/opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem snapshot restore  snapshot.db \
--name etcd01 \
--initial-cluster=etcd01=https://10.20.31.103:2380,etcd02=https://10.20.31.104:2380,etcd03=https://10.20.31.105:2380 \
--initial-cluster-token=etcd-cluster \
--initial-advertise-peer-urls=https://10.20.31.103:2380 \
--data-dir=/var/lib/etcd

10.20.31.104节点使用快照文件恢复 Etcd 数据

注意 data-dir 需要与 etcd 节点配置文件中配置路径一致,不然后面启动 etcd 服务会基于 etcd 节点配置文件中配置的路径作为新节点数据目录。

/opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem snapshot restore  snapshot.db \
--name etcd02 \
--initial-cluster=etcd01=https://10.20.31.103:2380,etcd02=https://10.20.31.104:2380,etcd03=https://10.20.31.105:2380 \
--initial-cluster-token=etcd-cluster \
--initial-advertise-peer-urls=https://10.20.31.104:2380 \
--data-dir=/var/lib/etcd

10.20.31.105节点使用快照文件恢复 Etcd 数据

注意 data-dir 需要与 etcd 节点配置文件中配置路径一致,不然后面启动 etcd 服务会基于 etcd 节点配置文件中配置的路径作为新节点数据目录。

/opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem snapshot restore  snapshot.db \
--name etcd03 \
--initial-cluster=etcd01=https://10.20.31.103:2380,etcd02=https://10.20.31.104:2380,etcd03=https://10.20.31.105:2380 \
--initial-cluster-token=etcd-cluster \
--initial-advertise-peer-urls=https://10.20.31.105:2380 \
--data-dir=/var/lib/etcd

注意 1
:本博文 etcdctl 客户端命令默认使用 v3 版本 API。

注意 2:
执行恢复命令时,命令中涉及的节点IP、私钥、证书、快照文件等信息需要集群实际情况进行替换。

(5)所有节点启动 Etcd 服务

systemctl start etcd

(6)查看 Etcd 集群状态

通过 etcdctl 客户端查看当前新安装 Etcd 集群节点信息。

[root@xxx ~]# /opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem --endpoints="https://10.20.31.103:2379,https://10.20.31.104:2379,https://10.20.31.105:2379" endpoint status --write-out=table

通过 etcdctl 客户端查看当前新安装 Etcd 集群存储的 key。

[root@xxx ~]# /opt/etcd/bin/etcdctl --cacert=/opt/etcd/ssl/ca.pem --cert=/opt/etcd/ssl/server.pem --key=/opt/etcd/ssl/server-key.pem --endpoints="https://10.20.31.103:2379,https://10.20.31.104:2379,https://10.20.31.105:2379" get --prefix "" --keys-only=true
[root@xxx ~]# 

至此,高可用 Etcd 集群通过 Etcd 快照文件恢复集群数据成功, Etcd 集群能够在快照数据基础上对外提供服务了。

3、总结

只要一个快照文件,就能恢复 Etcd 集群,使用 etcdctl snapshot restore 命令,创建一个新的 Etcd 数据目录,所有节点都将从同一个快照文件进行恢复。恢复会覆写快照文件中一些元数据,例如 member ID 和 cluster ID,这些节点也就丢失它们之前的身份信息。抹掉元数据是为了防止新节点不小心加入别的 etcd 集群。

高可用 Etcd 集群安装:《
搭建高可用Kubernetes集群之etcd v3.4.13集群搭建(一)

定时备份 Etcd 数据:

定时备份etcd数据

去年8月的时候无聊,想起博客网页中的鼠标跟随圈圈效果,于是就想用C#在Windows操作系统级别的基础上去开发一个类似的应用,于是有了此文。上次在博问里也发帖咨询了一下(
https://q.cnblogs.com/q/143516
),但是没得到很好的答案,今天也无聊,于是就把这个应用开发翻了出来进行了开发,得到了这个鼠圈圈应用。

其它例子:

个人小作品集:
https://www.cnblogs.com/lzhdim/category/703308.html

开源研究系列:
https://www.cnblogs.com/lzhdim/category/705764.html

1、  项目目录;

项目的目录结构如下:

2、  代码介绍;

1) 判断打开圈圈窗体的代码;

2) 圈圈窗体的显示代码;

3、  运行界面;

因为运行时鼠标的效果比较快不好截图,这里不提供运行界面。

4、  使用介绍;

双击该应用程序文件打开该应用即可看到效果。在托盘图标中右键能够将应用程序设置成随系统启动运行或者显示关于窗体。

5、  源码下载;

该应用提供源码下载:

https://download.csdn.net/download/lzhdim/88815955

6、  其它建议;

该应用目前是第一版,后续估计再进行完善,比如将窗体改成圆形圈圈的不规则窗体的方式,目前只是实现了该应用的基本功能,与博客页面中的效果还是有差别。

上面对基于C#的鼠圈圈应用的源码进行了介绍,感兴趣的网友请到上面的系列中翻阅其它例子,或者下载这些例子进行代码复用。该系列将笔者对于C#的Winform的一些效果进行了开源,希望对需要的网友以帮助。

Etc. 快过年了,预祝大家春节快乐啦。。。

Ngbatis 源码阅读之资源加载器 DaoResourceLoader

DaoResourceLoader

Ngbatis
的资源文件加载器,扩展自
MapperResourceLoader
。本篇文章主要分析这两个类。

1. 相关类

  • MapperResourceLoader
  • DaoResourceLoader

2. MapperResourceLoader

在介绍
DaoResourceLoader
之前有必要先介绍一下
MapperResourceLoader

DaoResourceLoader

MapperResourceLoader
的扩展。

MapperResourceLoader
继承了
PathMatchingResourcePatternResolver
类,关于
PathMatchingResourcePatternResolver
的有关内容,可以查看
《Ngbatis源码学习之 Spring 资源管理 ResourceLoader》
这篇文章。

2.1. load

MapperResourceLoader 的作用是加载解析开发人员自定义的 XML 文件资源
,核心是
load()
方法。具体方法如下:

  /**
   * 加载多个开发者自建的 XXXDao.xml 资源。
   *
   * @return 所有 XXXDao 的全限定名 与 当前接口所对应 XXXDao.xml 解析后的全部信息
   */
  @TimeLog(name = "xml-load", explain = "mappers xml load completed : {} ms")
  public Map<String, ClassModel> load() {
    Map<String, ClassModel> resultClassModel = new HashMap<>();
    try {
      // 加载 Resource 资源
      Resource[] resources = getResources(parseConfig.getMapperLocations());
      // 遍历资源并逐一解析
      for (Resource resource : resources) {
        resultClassModel.putAll(parseClassModel(resource));
      }
    } catch (IOException | NoSuchMethodException e) {
      throw new ResourceLoadException(e);
    }
    // 返回解析 xml 后的全部信息
    return resultClassModel;
  }

可以看到在 load() 方法中首先调用 PathMatchingResourcePatternResolver 类的 getResources 方法加载指定文件夹位置下的所有 xml 文件,再对加载的 Resource 资源数组进行遍历,逐一对内容进行解析映射为模型类返回。

重点在 parseClassModel 方法。

2.2. parseClassModel

parseClassModel
方法是解析 xml 文件,将 xml 内容映射到 ClassModel 模型类的具体实现,代码如下:

  /**
   * 解析 单个开发者自定义的 XXXDao.xml 文件
   *
   * @param resource 单个 XXXDao.xml 的资源文件
   * @return 单个 XXXDao 的全限定名 与 当前接口所对应 XXXDao.xml 解析后的全部信息
   * @throws IOException 读取xml时产生的io异常
   */
  public Map<String, ClassModel> parseClassModel(Resource resource)
      throws IOException, NoSuchMethodException {
    Map<String, ClassModel> result = new HashMap<>();
    // 从资源中获取文件信息,使用 Jsoup 进行 IO 读取
    Document doc = Jsoup.parse(resource.getInputStream(), "UTF-8", "http://example.com/");
    // 传入 xml 解析器,获取 xml 信息
    Elements elementsByTag = doc.getElementsByTag(parseConfig.getMapper());

    for (Element element : elementsByTag) {
      ClassModel cm = new ClassModel();
      cm.setResource(resource);
      // 解析标签,获取 namespace 的值
      match(cm, element, "namespace", parseConfig.getNamespace());
      // 解析标签,获取 space 的值
      match(cm, element, "space", parseConfig.getSpace());

      // 如果标签中未设置 space,则从注解获取 space
      if (null == cm.getSpace()) {
        setClassModelBySpaceAnnotation(cm);
      }
      // 将需要初始化的空间名添加到列表并在 sessionPool 中,初始化 session.
      addSpaceToSessionPool(cm.getSpace());

      // 获取子节点(方法配置)
      List<Node> nodes = element.childNodes();
      // 便历子节点,解析获取 MethodModel
      Map<String, MethodModel> methods = parseMethodModel(cm, nodes);
      cm.setMethods(methods);
      // 将结果和加入到映射缓存,key 值为代理类名称。
      result.put(cm.getNamespace().getName() + PROXY_SUFFIX, cm);
    }
    return result;
  }

可以看到这个方法中解析 xml 主要分为以下几个步骤:

  • 使用 Jsoup 的方式加载 Resource 并传入 xml 解析器,从中获取 xml 信息
  • 遍历 Elements,获取到 namespace(全限定类名)和 space(图空间名称)的值,加入 ClassModel 模型类。若 space 的值未在 xml 中设置,则直接从对应 Dap 中设置的实体类中的注解里获取 space。当然,也可能为空。
  • 判断在配置文件中是否开启了 sessionPool 会话池,如果有则加入 space 列表,用于初始化 session。
  • 继续使用 Jsoup 的方法获取 xml 子节点的数据,这边的子节点就是对应的方法配置了。
  • 遍历子节点,在
    parseMethodModel
    方法来中解析 xml,并映射到 MethodModel 模型类中。
  • 将解析好的 ClassModel 加入到 Map 中,key 值为之后要创建的代理类名称。

所以总结下说这个方法就是加载 Resource,解析 xml,并映射为模型类,与代理类名称一一对应并返回供之后使用。

这个方法又涉及到了很多的具体的解析方法,重点查看
match
方法和
parseMethodModel
方法。

2.3. match

match
方法其实就是获取 xml 标签属性的值,与模型类中的属性进行一个匹配并且赋值的过程。具体代码查看如下:

  /**
   * 将 xml 中的标签属性及文本,与模型进行匹配并设值。(模型包含 类模型与方法模型)
   *
   * @param model  ClassModel 实例或 MethodModel 实例
   * @param node   当前 xml 单个 gql 的xml节点
   * @param javaAttr 欲填入 model 的属性名
   * @param attr   node 标签中的属性名
   */
  private void match(Object model, Node node, String javaAttr, String attr) {
    String attrTemp = null;
    try {
      String attrText = node.attr(attr);
      if (isBlank(attrText)) {
        return;
      }
      attrTemp = attrText;
      Field field = model.getClass().getDeclaredField(javaAttr);
      Class<?> type = field.getType();
      Object value = castValue(attrText, type);
      ReflectUtil.setValue(model, field, value);
    } catch (ClassNotFoundException e) {
      throw new ParseException("类型 " + attrTemp + " 未找到");
    } catch (Exception e) {
      e.printStackTrace();
    }
  }

代码其实很简单,传入模型类、node 标签、需要设置的模型类属性名、node 标签需要获取值的属性名这四个参数,获取 node 标签属性值之后,使用反射将属性值赋值给模型类对应的属性里去。

2.4. parseMethodModel

parseMethodModel 方法就是解析 xml 文件中方法标签,并映射到方法模型类中的具体实现了。具体代码如下:

  /**
   * 解析 一个 XXXDao 的多个方法。
   *
   * @param nodes   XXXDao.xml 中 &lt;mapper&gt; 下的子标签。即方法标签。
   * @return 返回当前XXXDao类的所有方法信息Map,k: 方法名,v:方法模型(即 xml 里一个方法标签的全部信息)
   */
  private Map<String, MethodModel> parseMethodModel(ClassModel cm, List<Node> nodes)
      throws NoSuchMethodException {
    Class namespace = cm.getNamespace();
    Map<String, MethodModel> methods = new HashMap<>();
    List<String> methodNames = getMethodNames(nodes);
    for (Node methodNode : nodes) {
      if (methodNode instanceof Element) {
        // nGQL 为自定义查询语句,若存在 nGQL 标签,则执行 parseNgqlModel 方法对标签进行解析
        if (((Element) methodNode).tagName().equalsIgnoreCase("nGQL")) {
          if (Objects.isNull(cm.getNgqls())) {
            cm.setNgqls(new HashMap<>());
          }
          // 解析 nGQL 语句,并映射到对应模型类
          NgqlModel ngqlModel = parseNgqlModel((Element) methodNode);
          cm.getNgqls().put(ngqlModel.getId(),ngqlModel);
        } else {
          // 解析 node 标签内容,并映射为 MethodModel 方法
          MethodModel methodModel = parseMethodModel(methodNode);
          // 将需要初始化的空间名添加到列表并在 sessionPool 中,初始化 session.
          addSpaceToSessionPool(methodModel.getSpace());
          // 根据方法名,利用反射获取唯一的方法
          Method method = getNameUniqueMethod(namespace, methodModel.getId());
          methodModel.setMethod(method);
          Assert.notNull(method,
            "接口 " + namespace.getName() + " 中,未声明 xml 中的出现的方法:" + methodModel.getId());
          // 返回类型检查
          checkReturnType(method, namespace);
          // 对接口进行分页支持
          pageSupport(method, methodModel, methodNames, methods, namespace);
          // 将解析结果加入到 Map 中
          methods.put(methodModel.getId(), methodModel);
        }
      }
    }
    return methods;
  }

可以看到在这个方法中,首先会判断 node 节点元素是否含有
nGQL
标签,如果有则解析 nGQL 语句并映射到
NgqlModel
自定义 nGQL 语句的模型类。解析 nGQL 标签节点的方法很简单,就是获取标签中的文本内容返回:

  protected NgqlModel parseNgqlModel(Element ngqlEl) {
    // 获取元素中的 id 和文本内容
    return  new NgqlModel(ngqlEl.id(),ngqlEl.text());
  }

如果没有 nGQL 标签,则调用 parseMethodModel 方法解析 node 节点元素,并映射为
MethodModel
方法模型。这个方法也很简单,在方法内部同样是调用了 match 来进行解析,前面已经描述过 match 的用法,不再赘述。

  /**
   * 解析 &lt;mapper&gt; 下的一个子标签,形成方法模型。
   * <p/>
   * @param node &lt;mapper&gt;  子标签
   * @return 方法模型
   */
  protected MethodModel parseMethodModel(Node node) {
    MethodModel model = new MethodModel();
    match(model, node, "id", parseConfig.getId());
    match(model, node, "parameterType", parseConfig.getParameterType());
    match(model, node, "resultType", parseConfig.getResultType());
    match(model, node, "space", parseConfig.getSpace());
    match(model, node, "spaceFromParam", parseConfig.getSpaceFromParam());

    List<Node> nodes = node.childNodes();
    model.setText(nodesToString(nodes));
    return model;
  }

映射处理完成之后,会再进行一些后置处理工作,包括返回类型的检查、对方法的分页支持等操作,加入 Map 后返回。

所以将 MapperResourceLoader 类的代码梳理下来能知道,它的作用就是解析 xml 的文件内容,并将其映射为模型类。

3. DaoResourceLoader

在 Ngbatis 内部包含了一个基础操作和内置预定义操作的 xml,会在启动时就被加载解析,作用是为开发人员提供不需要再次编写可直接使用的图库操作。而在 DaoResourceLoader 中就做了这件事情。

DaoResourceLoader 继承了 MapperResourceLoader,所以在了解了 MapperResourceLoader 的作用之后,DaoResourceLoader 类的内容就很好理解了,就是在 MapperResourceLoader 的基础上又扩展了一个加载基类接口所需要的 xml 文件的模板方法。

做法与 MapperResourceLoader 类中的加载方式类似,同样是通过调用 getResource 方法加载指定的 xml,并对 xml 内容进行解析返回。重点方法是
loadTpl()

  /**
   * 加载基类接口所需 nGQL 模板
   *
   * @return 基类接口方法名 与 nGQL 模板的 Map
   */
  public Map<String, String> loadTpl() {
    try {
      Resource resource = getResource(parseConfig.getMapperTplLocation());
      return parse(resource);
    } catch (IOException e) {
      throw new ResourceLoadException(e);
    }
  }

  /**
   * 资源文件解析方法。用于获取 基类方法与nGQL模板
   *
   * @param resource 资源文件
   * @return 基类接口方法名 与 nGQL 模板的 Map
   * @throws IOException 可能找不到 xml 文件的 io 异常
   */
  private Map<String, String> parse(Resource resource) throws IOException {
    Document doc = Jsoup.parse(resource.getInputStream(), "UTF-8", "http://example.com/");
    Map<String, String> result = new HashMap<>();
    // 获取基类 NebulaDaoBasic 的所有方法
    Method[] methods = NebulaDaoBasic.class.getMethods();
    // 遍历方法,并与 xml 文件中的方法名一一对应,解析返回
    for (Method method : methods) {
      String name = method.getName();
      Element elementById = doc.getElementById(name);
      if (elementById != null) {
        List<TextNode> textNodes = elementById.textNodes();
        // 获取 xml 文件中的文本内容
        String tpl = nodesToString(textNodes);
        // key 为方法名,value 为 xml 文件中标签内的文本内容
        result.put(name, tpl);
      }
    }
    return result;
  }
}

可以看到在 loadTpl 中,获取了
NebulaDaoBasic
基类的所有方法,并通过方法名找到 xml 与之对应的 node 标签,获取到文本内容并加入到 Map 返回。

4. 总结

总结一下,DaoResourceLoader 就是加载解析 xml 文件的资源加载器,包括加载解析自定义的 xml 文件和 NebulaDaoBasic 基类所需的基础 xml,将 xml 文件映射为模型类供之后的 Bean 处理使用。