当前位置: 首页 > 工具软件 > ToplingDB > 使用案例 >

ToplingDB 的 旁路插件化

谢麒
2023-12-01

背景

在 RocksDB 中,有大量 XXXXFactory 这样的类,例如 TableFactory,它用来实现 SST 文件的插件化,用户要换一种 SST,只需要配置一个相应的 TableFactory 即可,这其实已经很灵活了。

问题

这种简单的 Factory 机制存在两个问题:

  1. 要更换 Factory,用户必须修改代码,这虽然有点繁琐,但不算致命
  2. 致命的是:如果要更换的是第三方的 Factory,必须在代码中引入对第三方 Factory 的依赖!
  3. 如果通过其它语言(例如 Java)使用,还需要专门为第三方依赖实现 binding

当年在 TerarkDB 中,我为了实现无缝集成 TerarkZipTable,避免用户修改代码,使用了一种非常 Hack 的方案:在 DB::Open 中拦截配置,如果发现了相关的环境变量,就启用 TerarkZipTable。 这样就允许用户不用修改代码,只需要定义环境变量就能使用 TerarkZipTable

这种配置方式实现了 TerarkDB 当时的预期目标,但只是一个简陋的补丁!

作为一个完备的、系统化的解决方案,我们(ToplingDB)期望的插件化,仍以 TableFactory 为例,应该让用户可以这样定义 TableFactory

std::string table_factory_class = ReadFromSomeWhere(...);
  std::string table_factory_options = ReadFromSomeWhere(...);
  Options opt;
  opt.table_factory = NewTableFactory(table_factory_class, table_factory_options);

传统插件化方案

要在更换 Factory 时只需要修改配置而不用修改代码,我们需要将相应的配置项(例如类名)映射到 Factory(的基类)对象(的创建函数),这样就需要一个保存了这种映射关系的全局映射表。 仍以 RocksDB 的 TableFactory 为例,现有代码大致这样:

class TableFactory {
  public:
    virtual Status NewTableReader(...) const = 0;
    virtual TableBuilder* NewTableBuilder(...) const = 0;
    // more ...
  };
  TableFactory* NewBlockBasedTableFactory(const BlockBasedTableOptions&);
  TableFactory* NewCuckooTableFactory(const CuckooTableOptions&);
  TableFactory* NewPlainTableFactory(const PlainTableOptions&);

我们增加一个全局 map,把类名映射到 NewXXX 函数,但首先就碰到一个问题:这几个函数的 prototype 是不同的,为了统一化,我们把这些 XXXOptions 序列化为 string

TableFactory* NewBlockBasedTableFactoryFromString(const std::string&);
  TableFactory* NewCuckooTableFactoryFromString(const std::string&);
  TableFactory* NewPlainTableFactoryFromString(const std::string&);

现在可以开始下一步了,定义一个全局 map,并注册这三个 Factory

std::map<std::string, TableFactory*(*)(const std::string&)> table_factory_map;
  table_factory_map["BlockBasedTable"] = &NewBlockBasedTableFactoryFromString;
  table_factory_map["CuckooTable"] = &NewCuckooTableFactoryFromString;
  table_factory_map["PlainTable"] = &NewPlainTableFactoryFromString;

大体框架是这样,但是,具体到细节,大致会是这样:

class TableFactory {
  public: // 略去不相关代码 ...
    using Map = std::map<std::string, TableFactory*(*)(const std::string&)>;
    static Map& get_reg_map() { static Map m; return m; }
    static TableFactory*
    NewTableFactory(const std::string& clazz, const std::string& options) {
      return get_reg_map()[clazz](options); // 省略错误检查
    }
    struct AutoReg {
     AutoReg(const std::string& clazz, TableFactory*(*fn)(const std::string&))
        { get_reg_map()[clazz] = fn; }
    };
  };
  #define REGISTER_TABLE_FACTORY(clazz, fn) \
     static TableFactory::AutoReg gs_##fn(clazz, &fn)

在某 .cc 文件中的全局作用域(下面三个注册可能分散在每个 Table 各自的 .cc 文件中):

REGISTER_TABLE_FACTORY("BlockBasedTable", NewBlockBasedTableFactoryFromString);
  REGISTER_TABLE_FACTORY("CuckooTable", NewCuckooTableFactoryFromString);
  REGISTER_TABLE_FACTORY("PlainTable", NewPlainTableFactoryFromString);

前面用户代码的调用处改成这样就可以了:

TableFactory::NewTableFactory(table_factory_class, table_factory_options);

这实际上就是很多成熟系统使用的插件化机制。我们把 AutoReg 放入 TableFactory 类中,作为一个内部类,其原因是为了避免污染外层 namespace, REGISTER_TABLE_FACTORY 用来在全局作用域定义一个 AutoReg 对象,该对象在 main 函数执行之前初始化,定义这么一个宏主要是为了方便、统一化,以及可读性,理论上,不使用 REGISTER_TABLE_FACTORY 而完全手写 AutoReg 也是可以的。

接下来的问题是,RocksDB 有大量这样的 XXXFactory,对于每个 XXXFactory,我们都写一套这样的代码,工作量很大,很枯燥,还容易出错。于是我们抽象出一个 Factoryable 的模板类:

template<class Product>
  class Factoryable { // Factoryable 位于某个公共头文件如 factoryable.h
    using Map = std::map<std::string, Product*(*)(const std::string&)>;
    static Map& get_reg_map() { static Map m; return m; }
    static Product*
    NewProduct(const std::string& clazz, const std::string& params) {
      return get_reg_map()[clazz](params); // 省略错误检查
    }
    struct AutoReg {
        AutoReg(const std::string& clazz, Product*(*fn)(const std::string&))
        { get_reg_map()[clazz] = fn; }
    };
  };
  class TableFactory : public Factoryable<TableFactory> {
  public:
   // 此处的 RocksDB 原有代码不做任何改动
  };
  #define REGISTER_FACTORY_PRODUCT(clazz, fn) \
     static decltype(*fn(std::string())::AutoReg gs_##fn(clazz, &fn)

相应的,前面用户代码的调用处改成这样:

TableFactory::NewProduct(table_factory_class, table_factory_options);

至此,我们只需要对原版 RocksDB 做少量的修改,就解决了我们的两个问题,一切似乎都很美好。但是,RocksDB 中有很多这样的 XXXFactory,并且,很多即便不是名为 XXXFactory 的 class,也需要这样的 Factory 机制,例如 Comparator,例如 EventListener……

旁路插件化方案

对于我们(ToplingDB)来讲,RocksDB 是上游代码,如果上游能及时地接受我们的修改,传统的这种插件化方案其实已经足够好了。如果只是一两个这样的修改,我们可以努力说服上游接受这些修改,但是我们需要对 RocksDB 中大量的 class 都做这样的修改,上游就很难接受了。

所以,我们能否对原版 RocksDB 不做任何修改,就解决这两个问题呢?

其实只要从传统的思维框架“让 class 拥有 Factory 插件化功能” 转变到 ”为 class 添加 Factory 插件化功能“,前面的 Factoryable 代码都不用做任何修改,只需要改一下其中的宏定义 REGISTER_FACTORY_PRODUCT

#define REGISTER_FACTORY_PRODUCT(clazz, fn) \
     static Factoryable<decltype(*fn(std::string())>::AutoReg gs_##fn(clazz, &fn)

为了语义上更合逻辑,我们将 Factoryable 重命名为 PluginFactory,再增加一个全局模板函数:

template<class Product>
  Product* NewPluginProduct(const std::string& clazz, const std::string& params) {
    return PluginFactory<Product>::NewProduct(clazz, params);
  }

相应的用户代码就是:

NewPluginProduct<TableFactory>(table_factory_class, table_factory_options);

应用

在 ToplingDB 中,我们使用了这种旁落插件化设计模式,当然,相应的实现代码比这里的 demo 代码要复杂很多。 更进一步,同样是在 ToplingDB 中,我们还支持: * 对象的旁路序列化 * 对象的 REST API 及 Web 可视化展示/修改

这两个功能完整复用了 PluginFactory,只是额外定义了两个模板类,SeDeFunc:

template<class Object> struct SerDeFunc {
    virtual ~SerDeFunc() {}
    virtual Status Serialize(const Object&, string* output) const = 0;
    virtual Status DeSerialize(Object*, const Slice& input) const = 0;
  };
  template<class Object>
  using SerDeFactory = PluginFactory<std::shared_ptr<SerDeFunc<Object> > >;

以及 PluginManipFunc:

template<class Object> struct PluginManipFunc {
    virtual ~PluginManipFunc() {} // Repo 指 ConfigRepository
    virtual void Update(Object*, const json&, const Repo&) const = 0;
    virtual string ToString(const Object&, const json&, const Repo&) const = 0;
  };
  template<class Object>
  using PluginManip = PluginFactory<const PluginManipFunc<Object>*>;
 类似资料: