概述
MMKV是支持多平台的高性能键值对持久化存储组件,其核心原理是利用mmap内存映射文件,关于它的详细介绍和更多原理参看MMKV开源git地址。
从零开始手写(其实是抄写-.-!)简易版MMKV(另起名叫EZKV),即先只关注最核心的功能(实现最小化系统),再从主干开枝散叶,逐步进行完善。
预期目标
第一版目标仅实现最基本的key-value数据存储和读取功能,先忽略数据加密、数据校验、数据容错、跨平台、线程同步、进程同步、ASHMEM等等等。
其中还涉及到几个比较关键的前置知识,需要提前熟悉:
- 【mmap内存映射文件】 最核心的原理。
- 【protobuf变长编码】 数据存储格式,key-value写入、读取、计算大小都是按照变长编码方式。必须先知道原理,才能理解代码中的位运算操作。
技术设计
主要逻辑在Native层,Java作为门面通过JNI调用C/C++。
以下列举了几个关键类的作用:
EZKV即操作门面,一个EZKV实例对应一个存储key-value数据的文件:
- m_mmapID:EZKV ID,默认为"ezkv.default"
- m_path:数据文件路径,EZKVPath_t是std::string的类型别名
- m_dic:缓存key-value的容器,EZKVMAP即std::unordered_map<std::string, KeyValueHolder>
- m_file:表示内存映射文件
- m_actualSize:记录存储的key-value数据的内容的大小
- m_output:用于写入key-value的辅助类
MemoryFile表示内存映射文件,封装内存映射相关操作,记录内存信息:
- m_name:数据文件路径
- m_fd:数据文件描述符
- m_ptr:m_fd内存映射分配的起始地址
- m_size:文件大小,也是映射内存分配的大小
KeyValueHolder用于记录一条KV对。注意m_dic中的value项不是直接存的value值,而是KeyValueHolder,通过其中记录的信息再来获取映射内存中的值:
- computedKVSize:基于offset的偏移量 = key size + key + value size, 可以直接定位到该KV对的value值
- keySize:key值占用的大小
- valueSize:value值占用的大小
- offset:从m_ptr开始到该KV对的偏移量,可以快速定位到该KV对
MMBuffer作用相当于读取或写入时的缓冲区:
union {
struct {
MMBufferCopyFlag isNoCopy;
size_t size; // 几字节数据
void *ptr; // 内存起始地址
};
struct {
uint8_t paddedSize;
// make at least 10 bytes to hold all primitive types (negative int32, int64, double etc) on 32 bit device
// on 64 bit device it's guaranteed larger than 10 bytes
uint8_t paddedBuffer[10]; // 用于存储较小的基本类型的值,存储在栈上,而不是堆上,避免malloc和free
};
};
CodedOutputData用于写入数据,封装了写入的方法:
- m_ptr:写入到的区域的起始地址
- m_size:可写入区域的大小
- m_position:当前写入位置
CodedOutputData用于读取数据,封装了读取的方法:
- m_ptr:从区域读取的起始地址
- m_size:可读取区域的大小
- m_position:当前读取位置
编码实现
完整项目工程见GitHub:https://github.com/chidehang/EZKV/tree/practice01
SDK初始化
初始化主要完成三件事:
1.加载库、动态注册函数
2.初始化一些全局变量
3.创建存放数据文件的目录
提供给接入方的初始化方法,首先获取存储数据文件的目录的路径,接着加载库,最后调用native方法进行初始化,核心逻辑都在native方法中:
[EZKV.java]
public static String initialize(Context context) {
String root = context.getFilesDir().getAbsolutePath() + "/ezkv";
// 加载so库
System.loadLibrary("ezkv");
// 传入目录进行初始化
nInitialize(root);
EZKV.rootDir = root;
return EZKV.rootDir;
}
private static native void nInitialize(String rootDir);
JNI中函数有静态注册和动态注册,这里采用动态注册的方式,当执行loadLibrary后会回调JNI_OnLoad函数,在该函数中进行函数绑定注册:
[native-bridge.cpp]
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
// 提前缓存后续会用到但不会改变的jclass和g_fileID
g_currentJVM = vm;
JNIEnv *env;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return -1;
}
if (g_cls) {
env->DeleteGlobalRef(g_cls);
}
static const char *clsName = "com/cdh/ezkv/EZKV";
jclass instance = env->FindClass(clsName);
if (!instance) {
LOGE("fail to locate class: %s", clsName);
return -2;
}
g_cls = reinterpret_cast<jclass>(env->NewGlobalRef(instance));
if (!g_cls) {
LOGE("fail to create global reference for %s", clsName);
return -3;
}
// 动态注册函数
int ret = registerNativeMethods(env, g_cls);
if (ret != 0) {
LOGE("fail to register native methods for class %s, ret = %d", clsName, ret);
return -4;
}
g_fileID = env->GetFieldID(g_cls, "nativeHandle", "J");
if (!g_fileID) {
LOGE("fail to locate fileID");
return -5;
}
return JNI_VERSION_1_6;
}
TIPS:通常在库加载时提前缓存会用到的jclass和g_fileID,避免JNI调用时再查找,提升效率。
进一步看registerNativeMethods函数,在该方法中注册函数映射表:
[native-bridge.cpp]
static JNINativeMethod g_methods[] = {
{"nInitialize", "(Ljava/lang/String;)V", (void *) ezkv::nInitialize},
{"nGetDefaultEZKV", "()J", (void *) ezkv::nGetDefaultEZKV},
{"nPageSize", "()J", (void *) ezkv::nPageSize},
{"nEncodeInt", "(JLjava/lang/String;I)Z", (void *) ezkv::nEncodeInt},
{"nDecodeInt", "(JLjava/lang/String;I)I", (void *) ezkv::nDecodeInt},
{"nEncodeString", "(JLjava/lang/String;Ljava/lang/String;)Z", (void *) ezkv::nEncodeString},
{"nDecodeString", "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/Str