需求
文件云同步场景中,我们希望对于同一个文件,每次修改不会造成整个文件重新上传,而是只上传变化的部分。这样就能实现在多端快速备份/同步一个文件。那么在另一台设备上的同一个文件,就能通过下载这个变化的部分同步到最新的版本。
比较核心的部分就是如何计算变化部分、生成变化文件、通过变化文件恢复文件。
文件的差量计算
计算文件差量这种一看就知道肯定有现成的库,不出所料很容易就搜索到 rsync 这个 linux 自带的工具。
~ rsync -h
rsync version 2.6.9 protocol version 29
Copyright (C) 1996-2006 by Andrew Tridgell, Wayne Davison, and others.
<http://rsync.samba.org/>
Capabilities: 64-bit files, socketpairs, hard links, symlinks, batchfiles,inplace, IPv6, 64-bit system inums, 64-bit internal inums
rsync comes with ABSOLUTELY NO WARRANTY. This is free software, and you are welcome to redistribute it under certain conditions. See the GNU General Public Licence for details.
rsync is a file transfer program capable of efficient remote update via a fast differencing algorithm.
rsync 可以在本地计算机与远程计算机之间之间同步文件。最大特点是会检查发送方和接收方已有的文件,仅传输有变动的部分。rsync 算法与大多数差分算法不同,因为它不需要两个文件的存在来计算增量,只需要一个文件和一个文件的签名(大概理解成一个文件的每个固定长度块的签名集合)就可以算出不同的部分。
$ rsync -r source destination
但是显然我们需要的只是生成变动的部分,然后存到服务器,并不需要 rsync 帮我们完成传输。
出于对大佬们的职业素养基本尊重,这种核心算法的东西必然会有人会封装起来。于是通过一番 github 搜索之后, librsync 出现了。
librsync does not implement the rsync wire protocol. (librsync 不实现 rsync 有线协议。)
简直完美!那我只需要把这玩意弄下来工作就完成一大半啦!
问题来了,我不懂C
对于C的了解,我仅仅是在大学时通过了臭名昭著大名鼎鼎的谭浩强和VC++学习了一下,老早就还给老师了。不过语言这种东西一通百通,更何况我只是用又不是改里面的代码。
最大的障碍是我不懂 CMake Makefile ,更没尝试过将 C 库用到 nodejs 环境中(当然N-API还是听过的)。
有障碍是好事,学!学会了就能搞定了!
Github Actions
长年逛Github的经验告诉我,了解一个项目可以看三个地方:
- README
- 测试用例
- Github Actions(CICD)
在Github上的项目基本上越是大佬就越会在 Github Actions 维护一套漂亮的流水线,上面集成了各种打包测试等流程。
所以我的习惯是看完README,没发现需要的信息就去直接看测试用例和Actions里面的流水线逛一逛。
很快啊,我们就找到了一个叫 check 的流水线,很明显里面会有测试构建等相关代码嘛。
- name: Run tests
working-directory: ${{github.workspace}}/build
run: ctest -C ${{matrix.build}} --output-on-failure
ctest 是 cmake 带的一个测试工具。而 cmake 简单来说就是 c 项目的构建工具(构建、静态库、动态库、可执行程序是什么就不在这里展开了)
而 cmake 的配置文件一般是放在项目根目录的 CMakeList.txt 文件。
直接在 CMakeList.txt 中搜索 test。很好,看不太明白。
再看看,发现 CMakeList.txt 里面定义了一个可执行文件的构建,叫 rdiff。全局搜索一下,发现 rdiff.c
static void help(void)
{
printf("Usage: rdiff [OPTIONS] signature [BASIS [SIGNATURE]]\n"
" [OPTIONS] delta SIGNATURE [NEWFILE [DELTA]]\n"
" [OPTIONS] patch BASIS [DELTA [NEWFILE]]\n" "\n"
很好,rdiff给我们封装好了三个功能
- signature 生产文件签名
- delta 根据文件签名和新文件生成差量部分文件
- patch 根据旧文件和差量部分文件生成新文件
很明显3个功能完全满足需求了。从 Github Action 中下载产物并使用其中的 rdiff 可执行文件测试一下,OK没有任何问题。
接下来就是fork一份项目并尝试将 signature delta patch 封装成 N-API 就可以在 nodejs 进行调用了!
N-API
对于不太熟悉N-API的同学我在这里简单解释一下,众所周知 nodejs 是 C++ 写的,那么当然理论上就可以用动态库了(.node 文件实质上就是 .dylib 或者 .dll 文件),那么我们将 C 编译成对应的产物,nodejs 就可以将其引入并使用。
实际上也挺简单的。参考参考别人的库和教程,使用 cmake-js 和 node-addon-api,将 librsync 代码拷过来再给 CMakeList 改改,很简单的就成功了 rdiff-napi
const {
signature,
delta,
patch,
signatureAsync,
deltaAsync,
patchAsync,
} = require("./prebuild/darwin-x64/rdiff.node");
signature(
path.join(__dirname, "tests/old_file"),
path.join(__dirname, `tests/cache/old.sig`)
);
delta(
path.join(__dirname, `tests/cache/old.sig`),
path.join(__dirname, "tests/new_file"),
path.join(__dirname, `tests/cache/old2new.delta`)
);
patch(
path.join(__dirname, "tests/old_file"),
path.join(__dirname, `tests/cache/old2new.delta`),
path.join(__dirname, `tests/cache/new_file_from_patch`)
);
异步化
基本的需求这样就可以完成了,传入文件地址,生成,非常完美!
接下来可以研究下性能方面的优化了。现在这几个函数很明显是同步函数,里面会有大量io操作(读取、写入文件),熟悉 nodejs 的同学都知道,在文件操作相关 nodejs 是提供了类似 fs.promises 这种异步/回调的非同步方法的,这些方法相比同步都有一定的性能提升。
怎么才能让这几个函数不阻塞io呢?或者说如何将这几个函数改成异步?
返回 Promise
我天真的认为,将函数返回值改为 Promise,nodejs 是不是就会自动将其异步化从而不阻塞io?
node-addon-api 提供了 Promise 的API:
Napi::Value async_signature_warp(const Napi::CallbackInfo &info)
{
Napi::Env env = info.Env();
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
deferred.Resolve(signature_warp(info));
return deferred.Promise();
}
放到测试里跑一跑,怎么异步的耗时比同步的还差个20%-30%?而且是相当稳定的差20%-30%。
显然有问题啊!c代码内的文件操作仍然会在主进程里面,这么操作怎么会使得这个任务真正变成异步呢?
真正的异步
遇事不决看文档, node-addon-api Async Operations 里面就提供了很好的说明和如何使用的例子。
照着例子写写改改,成功!
class SignatureAsyncWorker : public Napi::AsyncWorker
{
public:
static Napi::Value Do(const Napi::CallbackInfo &info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString())
{
Napi::TypeError::New(env, "arg1::String, arg2::String expected").ThrowAsJavaScriptException();
}
// convert javascripts datatype to c++
Napi::String basis_file_name = info[0].As<Napi::String>();
Napi::String sig_file_name = info[1].As<Napi::String>();
SignatureAsyncWorker *worker =
new SignatureAsyncWorker(
env,
"SignatureAsyncWorker",
basis_file_name.Utf8Value(),
sig_file_name.Utf8Value());
worker->Queue();
return worker->_deferred.Promise();
}
SignatureAsyncWorker(Napi::Env env,
const char *resource_name,
std::string basis_file_name,
std::string sig_file_name)
: AsyncWorker(env, resource_name),
_deferred(Napi::Promise::Deferred::New(env)),
basis_file_name(basis_file_name),
sig_file_name(sig_file_name) {}
protected:
void Execute() override
{
rs_result result = rdiff_sig(basis_file_name.c_str(), sig_file_name.c_str());
_result = result == 0;
}
virtual void OnOK() override { _deferred.Resolve(Napi::Boolean::New(Env(), _result)); }
virtual void OnError(const Napi::Error &e) override { _deferred.Reject(Napi::String::New(Env(), e.Message())); }
private:
Napi::Promise::Deferred _deferred;
std::string basis_file_name;
std::string sig_file_name;
bool _result;
};
很好,这次时间消耗确确实实是下降大概20%!(测试代码写的比较随便,仅供参考,主要是证明是否有下降)
libuv
Napi::AsyncWorker 做了什么?需要回答这个问题得到 nodejs 的异步I/O原理去回答。
异步I/O的原理大致概括为:主进程一直event loop中发起子进程处理I/O和消费子进程完成数据获取后的回调通知。而管理这一套多子进程的东西就是 libuv 。
而 nodejs 对 addon 开放了 libuv ,也就是说我们可以通过 Napi::AsyncWorker 创建一个在另一个线程的任务去实现异步,避免阻塞主进程。
nodejs 原生模块中,以下的api是使用了 libuv:
- 所有的文件 fs api,除了 watch 的 api
- 加密 crypto 的异步 api
- dns.loopup
- 所有压缩的 zlib api
换句话说,如果我们有其他计算或I/O密集任务,在 js 里面是无法实现丢到子进程里面运行的。(当然 js 写不了不用 libuv 的I/O密集任务)。在这种情况下我们可以使用 addon 利用 libuv 的多线程进行优化,就比如 rsync 这种 io 和 计算都相对密集的场景。
总结
上面写的都很浅,N-API libuv 等每一个点都能往细处深究,librsync 里面用到的算法和压缩也可以深入了解,有兴趣的同学可以研究研究。
不只是 C/C++ ,在 rust 也有 napi-rs 可以开发 addon。rust 相比起来还是好上手很多的(指依赖管理、构建等)。