MongoDB初体验

# 缘起

最近遇到了一个问题,需要分析算法迭代过程中的中间结果,主要是几个矩阵和一些统计量。如果保存成文件的话,迭代次数太多,磁盘占用会很严重,而且 Windows 11 资源管理器效率比较低,经常出现卡顿的现象,整个算法在 Windows 上需要迭代上万次,光读取文件都要花费很长时间。好在分析的时候文件只需要读一次,但是这样依然不便于管理。之前做出租车数据的时候就吃过这样的亏,把文件拷给其他同事的时候,要复制一整天,而且这还是通过网络直接传输的。所以这次一定要避免这样的情况。

于是我想到了数据库。我在很早之前就有所有数据全部使用数据库管理的想法,因为这样既便于数据管理和使用,又方便共享,而且效率还比较高。但是这次由于涉及到多种数据格式(矩阵和数量),关系型数据库显然已经不再适用了,使用非关系型数据库更为合适。因此我就开始研究如何使用 MongoDB 保存我所需要的数据。

# 安装 MongoDB

在之前,安装数据库应该是一个比较麻烦的事情,涉及到创建数据目录等诸多配置。但是如果使用 Docker ,一切就不是问题了。再加上不再对硬盘进行分区,在 WSL 中也有足够的硬盘空间创建各类容器。使用下面的 docker-compose 配置文件创建一个 MongoDB 容器:

```yml
version: '3.1'

services:

  mongo:
    image: mongo
    restart: always
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password
    ports:
      - 27017:27017

  mongo-express:
    image: mongo-express
    restart: always
    ports:
      - 8081:8081
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: admin
      ME_CONFIG_MONGODB_ADMINPASSWORD: password
      ME_CONFIG_MONGODB_URL: mongodb://admin:password@mongo:27017/
```

启动后,即可通过 27017 端口访问数据库,通过 8081 端口访问一个网页端的管理界面。

# 在C++代码中操作数据库

由于整个算法流程是C++编写的,中间结果只能由C++代码导出到数据库中,因此需要使用C++代码操作数据库。

## 安装 mongocxx 库

编译安装 mongocxx 库(自带 bsoncxx 库),首先需要安装 libmongoc (和 libbsonc ,下略),并满足[官方文档](http://mongocxx.org/mongocxx-v3/installation/linux/)中指定的版本号要求。如果是 Ubuntu 20.04 那么 apt 提供的版本可能并不适配最新版 mongocxx ,需要手动编译安装最新版 libmongoc 。安装并不复杂,使用 CMake 配置并构建即可。注意非 Windows 系统需要 MNMLSTC/core 库,由于我是在 WSL 中安装的,因此也需要安装这个库。

然后同样使用 CMake 配置 mongocxx 库

```bash
cmake ..                                \
    -DCMAKE_BUILD_TYPE=Release          \
    -DCMAKE_INSTALL_PREFIX=/usr/local   \
    -DBSONCXX_POLY_USE_MNMLSTC=1
```

最后一行是需要的,用于指定使用 MNMLSTC 库。然后还有一点:如果要构建静态库,需要在 CMake 中设置 `CMAKE_BUILD_SHARED=OFF`,修改 `CMakeLists.txt` 或者增加 `-DCMAKE_BUILD_SHARED=OFF` 参数都可以。静态库比较方便使用,尤其是如果要在R语言中使用的话,静态库要方便一些。

## 链接到 mongocxx 库

我的算法在开发时使用 CMake 进行工程管理。在 CMake 中使用如下配置使程序可以链接到 mongocxx

```cmake
find_package(libbsoncxx-static REQUIRED)
find_package(libmongocxx-static REQUIRED)
include_directories(${LIBMONGOCXX_STATIC_INCLUDE_DIRS})
include_directories(${LIBBSONCXX_STATIC_INCLUDE_DIRS})
target_link_libraries(target ${LIBMONGOCXX_STATIC_LIBRARIES} ${LIBBSONCXX_STATIC_LIBRARIES})
```

四个宏 `LIBMONGOCXX_STATIC_INCLUDE_DIRS` `LIBBSONCXX_STATIC_INCLUDE_DIRS` `LIBMONGOCXX_STATIC_LIBRARIES` `LIBBSONCXX_STATIC_LIBRARIES` 虽然在 CMakeCache 文件中找不到,但是确实是定义了,具体可以查看下列文件(如果 mongocxx 安装在 `/usr/local` 中的话)

```plaintext
/usr/local/lib/cmake/libmongocxx-static-3.6.7/libmongocxx-static-config.cmake
/usr/local/lib/cmake/libbsoncxx-static-3.6.7/libbsoncxx-static-config.cmake
```

这样就可以在代码中找到 mongocxx 库了。

## 使用 mongocxx 库

[官方教程](http://mongocxx.org/mongocxx-v3/tutorial/)中对于如何使用 mongocxx 库的讲解很详细。主要分为以下几步:

1. 创建实例 `instance` ,该实例在程序运行中只能创建一次,重复创建则会抛出异常。
2. 创建客户端 `client` ,需要使用连接字符串 `mongodb://user:password@host:port` 。
3. 创建文档 `document` 进行操作。

由于 `instance` 只能创建一次,所以保存成静态变量比较合适,也符合静态变量的特点。

创建 `document` 有两种方式,一种是像上述文档中介绍的使用流式创建方式,比较符合C++的风格。但是由于我的数据存在向量,需要使用循环语句像数组中添加数据。假设一个需要保存一个向量 `gamma` ,使用流式操作符就需要如下形式:

```cpp
auto in_array = builder << "gamma" << builder::stream::open_array;
for (auto&& e : gamma) {
    in_array = in_array << e;
}
auto after_array = in_array << builder::stream::close_array;
```

由于使用 `open_array` 之后返回值类型会发生改变,所以需要创建很多中间变量,以存储 `builder` 的最新状态。好处是不用创建临时变量保存 `array`。

另一种方式是使用 `bsoncxx::builder::basic::document` 类,即基础创建方式,创建一个 `document` 然后向其中添加(`append`)键值对,值的类型可以是普通数据类型或者 `bsoncxx::builder::basic::array` 类型,那么下面代码就可以创建一个 `array` 类型的值

```cpp
auto builder = document{};
bsoncxx::builder::basic::array array_value;
for (auto &&i : gamma)
    array_value.append(i);
builder.append(kvp("gamma", array_value));
```

这样好处是 `builder` 的是不变的,从头到尾都是这一个。但是依然要创建很多中间变量用于保存数组类型的值。尤其是在查询的时候,还是要创建一个变量保存用于查询的文档,不如流式直接使用临时变量存储查询文档更简洁。

总之两种方式各有各的好处。流式创建如果可以像 `ofstream` 一样不改变流运算符返回值类型就万事大吉了,现在不停改变返回值导致需要创建中间变量的做法太不优雅了。不知道将几种流运算符返回值定义成联合体会不会更优雅一点。

# 在R代码中操作数据库

R语言有很强的数据分析能力,也有 **mongolite** 包提供连接到 MongoDB 数据库的功能。我们这里只在R中读取数据,不进行数据库操作,毕竟R操作 JSON 的能力有限。

> 虽然有 **jsonlite** 包提供了一定的支持,但是看到下面的情况,我还是表示无法理解
>
> ```R
> toJSON(list("_id" = 0))
> # {"_id":[0]}
> ```
>
> 后来得知,需要设置 `auto_unbox=TRUE` 来将所有长度是1的向量自动解包,也就是说
>
> ```R
> toJSON(list("_id" = 0), auto_unbox = T)
> # {"_id":0}
> ```
>
> 但是遇到长度有可能是1的向量,又需要 `I()` 函数进行包裹以避免被解包。总感觉这样把一个简单的事情搞得复杂了(这在R语言中并不少见)。

## 连接数据库

连接数据库还是使用连接字符串 `uri`,格式不变 `mongodb://user:password@host:port` 。使用 `mongo()` 函数直接指定数据库和集合。

```R
coll <- mongo(collection = "coll", db = "db", url = connection_string)
```

变量 `coll` 可以当作其他面向对象编程语言中的类,有很多成员函数。例如,如果要断开连接,就使用 `$disconnect()` 函数。

## 查询数据库

如果要查询数据,就使用 `$find()` 函数,其定义如下

```R
find(query = '{}', fields = '{"_id" : 0}', sort = '{}', skip = 0, limit = 0, handler = NULL, pagesize = 1000)
```

和一般的 MongoDB 查询很像,第一个参数是查询字符串,第二个参数是筛选的字段,第三个指定排序方式。这里传入的都是 JSON 格式的字符串,如果不是很复杂可以手写,太复杂的才建议使用 **jsonlite** 提供的函数。

这个函数返回的结果会自动转换成 `DataFrame` 类型,可以直接使用 **dplyr** 或者 **purrr** 包进行处理。例如我这里计算 `gamma` 估计的 RMSE

```R
coef_collection$find(fields = '{"_id": 0}') %>%
    as_tibble() %>% rowwise() %>%
    mutate(iter = iter + 1, gamma = sqrt(mean((gamma - beta_real$gamma)^2)))
```

此外还有 `$count()` `$aggregate` `$iterate` 函数,提供了多种查询功能。以及 `$insert()` `$update()` `$drop()` 进行数据库修改、删除的功能。这里就不详细介绍了。

# 总结

这次 MongoDB 的体验还是很不错的,非关系型的管理方式节省了大量设计表的时间和精力,而且不论是使用C++还是R语言,对于数据库的操作还是比较简单易懂的。日后应该会经常使用 MongoDB 来辅助研究工作。