Motivation-To-Solution

背景

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

问题

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

  1. 要更换 Factory,用户必须修改代码,这虽然有点繁琐,但不算致命

  2. 致命的是:如果要更换的是第三方的 Factory,必须在代码中引入对第三方 Factory 的依赖!

    • 如果通过其它语言(例如 Java)使用,还需要专门为第三方依赖实现 binding

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

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

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

1
2
3
4
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(的基类)对象(的创建函数),这样就需要一个保存了这种映射关系的全局映射表。
仍以 RocksDBTableFactory 为例,现有代码大致这样:

1
2
3
4
5
6
7
8
9
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

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

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

1
2
3
4
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;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 文件中):

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

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

1
TableFactory::NewTableFactory(table_factory_class, table_factory_options);

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)

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

1
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

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

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

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

相应的用户代码就是:

1
NewPluginProduct<TableFactory>(table_factory_class, table_factory_options);

应用

在 ToplingDB 中,我们使用了这种旁落插件化设计模式,当然,相应的实现代码比这里的 demo 代码要复杂很多。
更进一步,同样是在 ToplingDB 中,我们还支持:

  • 对象的旁路序列化
  • 对象的 REST API 及 Web 可视化展示/修改

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

1
2
3
4
5
6
7
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:

1
2
3
4
5
6
7
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>*>;