当前位置: 首页 > news >正文

MMKV源码学习

kv数据持久化需要的功能

假设要设计一个kv的存储功能:

  1. 首先是可靠性,在各种情况下能够将kv保存
  2. 性能的要求,当时是越快越好,存储占用的越少越好

MMKV号称满足这些特性:

  1. 可靠,实时写入
  2. 高性能

如果撇去高可靠性,可以采取内存缓冲的模式,例如先存入dic,然后在合适的时间同步到文件。这种方式考虑同步的时机是一方面,而且在crash时可能dic未同步到文件。

如果撇如高性能,可以采用直接的读写文件,例如采用增量式的编码,将kv写入文件,面临的问题也很明显,就是频繁的磁盘io,效率是很低的。

MMKV的设计

mmap.png
在内存映射后,操作文件使用指针就可以完成,文件与映射区的同步由内核完成,MMKV维护着一个<String, AnyObject>的dic,在写时同时写入dic和映射区,也就是同时写入dic和文件,所以dic和持久化的数据是同步的,既然是同步的,所以读时直接取dic中的值就好了。

下面对基本流程的总结:

  1. 内存映射 mmap
  2. crc校验
  3. aes加密
  4. 线程安全
  5. 内存警告

mmap

有关mmap相关的知识和使用可以看这里。对于常用kv存储来说,兼顾性能和可靠性

所以由mmap的相关知识和MMKV的设计可以猜想,MMKV使用mmap要做什么事情:

  1. 映射文件到内存,保存映射区的指针,方便写操作(定义了MiniCodedOutputData实现了对data按字节拷贝到指定区域内存)
  2. 从映射区为dic初始化,方便读操作

mmap在MMKV中的使用:

//  MMKV.mm

- (void)loadFromFile {
	m_fd = open(m_path.UTF8String, O_RDWR, S_IRWXU);    // open  得到文件描述符m_fd
	if (m_fd < 0) {
		MMKVError(@"fail to open:%@, %s", m_path, strerror(errno));
	} else {
		m_size = 0;
		struct stat st = {};
		if (fstat(m_fd, &st) != -1) {
			m_size = (size_t) st.st_size;   // 获取文件大小,为按页对齐做准备
		}
		// round up to (n * pagesize)  按页对齐
		if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
			m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
			if (ftruncate(m_fd, m_size) != 0) { //  按页对齐
				MMKVError(@"fail to truncate [%@] to size %zu, %s", m_mmapID, m_size, strerror(errno));
				m_size = (size_t) st.st_size;
				return;
			}
		}
		//  1: 映射内存,获取内存中的指针m_ptr
		m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);    
		if (m_ptr == MAP_FAILED) {
			MMKVError(@"fail to mmap [%@], %s", m_mmapID, strerror(errno));
		} else {    
			const int offset = pbFixed32Size(0);
			NSData *lenBuffer = [NSData dataWithBytesNoCopy:m_ptr length:offset freeWhenDone:NO];
			@try {
			// 文件中真正使用的空间有多大,因为文件被按页对齐后,真正使用的空间清楚,所以在文件开始做了记录
				m_actualSize = MiniCodedInputData(lenBuffer).readFixed32(); 
			} @catch (NSException *exception) {
				MMKVError(@"%@", exception);
			}
			MMKVInfo(@"loading [%@] with %zu size in total, file size is %zu", m_mmapID, m_actualSize, m_size);
			if (m_actualSize > 0) { // 当文件中有记录时,如果第一次使用或是已经清理过,实际使用空间将为0
				bool loadFromFile, needFullWriteback = false;
				if (m_actualSize < m_size && m_actualSize + offset <= m_size) { // 检查文件是否正常
					if ([self checkFileCRCValid] == YES) {  
						loadFromFile = true;
					} else {    // 校验失败后的行为
						loadFromFile = false;
						if (g_callbackHandler && [g_callbackHandler respondsToSelector:@selector(onMMKVCRCCheckFail:)]) {
							auto strategic = [g_callbackHandler onMMKVCRCCheckFail:m_mmapID];
							if (strategic == MMKVOnErrorRecover) {  // 如果校验失败后要继续使用
								loadFromFile = true;    
								needFullWriteback = true;
							}
						}
					}
				} else {    // 根据文件中记录,文件不正常
					MMKVError(@"load [%@] error: %zu size in total, file size is %zu", m_mmapID, m_actualSize, m_size);
					loadFromFile = false;
					if (g_callbackHandler && [g_callbackHandler respondsToSelector:@selector(onMMKVFileLengthError:)]) {
						auto strategic = [g_callbackHandler onMMKVFileLengthError:m_mmapID];
						if (strategic == MMKVOnErrorRecover) {  // 文件不正常后要继续使用
							loadFromFile = true;
							needFullWriteback = true;
							[self writeAcutalSize:m_size - offset]; // 重新记录下文件的相关信息
						}
					}
				}
				if (loadFromFile) { // 假定文件是正常的,从文件中读取
					NSData *inputBuffer = [NSData dataWithBytesNoCopy:m_ptr + offset length:m_actualSize freeWhenDone:NO];
					if (m_cryptor) {
						inputBuffer = decryptBuffer(*m_cryptor, inputBuffer);
					}
					// 2. 初始化m_dic
					//  如果文件存在错误(例如crc校验不通过),会导致数据错误或是丢失
					m_dic = [MiniPBCoder decodeContainerOfClass:NSMutableDictionary.class withValueClass:NSData.class fromData:inputBuffer];
					//  定位到文件尾部
					m_output = new MiniCodedOutputData(m_ptr + offset + m_actualSize, m_size - offset - m_actualSize);
					// 如果文件存在错误,decode到m_dic过程中可能会丢弃部分数据,所以要将m_dic,保证m_dic与文件的同步
					if (needFullWriteback) {    
						[self fullWriteback];
					}
				} else {    // 文件不正常且不打算恢复,需要重建,丢弃原来的数据
					[self writeAcutalSize:0];
					m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
					[self recaculateCRCDigest];
				}
			} else {    //  文件中没有kv,没有必要读入dic
				m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
				[self recaculateCRCDigest];
			}
			MMKVInfo(@"loaded [%@] with %zu values", m_mmapID, (unsigned long) m_dic.count);
		}
	}
	if (m_dic == nil) {
		m_dic = [NSMutableDictionary dictionary];
	}

    
	if (![self isFileValid]) { 
		MMKVWarning(@"[%@] file not valid", m_mmapID);
	}

    // 修改文件的属性
	tryResetFileProtection(m_path);
	tryResetFileProtection(m_crcPath);
	m_needLoadFromFile = NO;
}
复制代码

写入

为了保证性能,采用增量写入的方式,这需要编解码支持,MMKV的实现了一套增量编解码方案。增量编码是基于性能的考虑,不用将m_dic中的数据全部写回。

所以写入要实现的功能:

  1. 将kv写入m_dic
  2. 检查文件剩余空间是否够,不够的话按照一定的策略分配(分配策略会选择牺牲少量磁盘空间换取效率,并且会整理kv防止占用过大存储空间),将kv写入内存映射区域,保持两者同步

在iOS的中,当app进入后台后,内存可能会被swap出,提供给活跃的app,这样会降低效率(因为要再换进内存呀),MMKV提供了后台写保护的功能(基于性能考虑):

// MMKV.mm

/// 提供对映射内存的保护,防止被系统交换

- (BOOL)protectFromBackgroundWritting:(size_t)size writeBlock:(void (^)(MiniCodedOutputData *output))block {
	if (m_isInBackground) { // 如果在后台,锁定要写入的内存,防止被换出,影响效率
        // 因为mlock的offset是以页为单位的,所以要计算锁定的页偏移
		static const int offset = pbFixed32Size(0);
		static const int pagesize = getpagesize();
		size_t realOffset = offset + m_actualSize - size;
		size_t pageOffset = (realOffset / pagesize) * pagesize;
		size_t pointerOffset = realOffset - pageOffset;
		size_t mmapSize = offset + m_actualSize - pageOffset;
		char *ptr = m_ptr + pageOffset;
        // 锁定要写入的内存区域
		if (mlock(ptr, mmapSize) != 0) {
			MMKVError(@"fail to mlock [%@], %s", m_mmapID, strerror(errno));
			// just fail on this condition, otherwise app will crash anyway
			//block(m_output);
			return NO;
		} else {
			@try {
				MiniCodedOutputData output(ptr + pointerOffset, size);
				block(&output);
				m_output->seek(size);
			} @catch (NSException *exception) {
				MMKVError(@"%@", exception);
				return NO;
			} @finally {
				munlock(ptr, mmapSize);
			}
		}
	} else {
		block(m_output);    // 未在后台,不需要锁定
	}

	return YES;
}
复制代码
// MMKV.mm 
// 检查文件剩余空间是否够,不够的话按照一定的策略分配

- (BOOL)ensureMemorySize:(size_t)newSize {
	[self checkLoadData];

	if (![self isFileValid]) {
		MMKVWarning(@"[%@] file not valid", m_mmapID);
		return NO;
	}

	if (newSize >= m_output->spaceLeft()) {
		// try a full rewrite to make space
		static const int offset = pbFixed32Size(0);
		NSData *data = [MiniPBCoder encodeDataWithObject:m_dic];
		size_t lenNeeded = data.length + offset + newSize;
		size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.count);
        // 将要使用的空间,持续扩容一半直到足够,并在扩容后,重新映射
		size_t futureUsage = avgItemSize * std::max<size_t>(8, m_dic.count / 2);
		// 1. no space for a full rewrite, double it
		// 2. or space is not large enough for future usage, double it to avoid frequently full rewrite
		if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
			size_t oldSize = m_size;
			do {
				m_size *= 2;
			} while (lenNeeded + futureUsage >= m_size);
			MMKVInfo(@"extending [%@] file size from %zu to %zu, incoming size:%zu, futrue usage:%zu",
			         m_mmapID, oldSize, m_size, newSize, futureUsage);

			// if we can't extend size, rollback to old state
			if (ftruncate(m_fd, m_size) != 0) { //  扩充文件
				MMKVError(@"fail to truncate [%@] to size %zu, %s", m_mmapID, m_size, strerror(errno));
				m_size = oldSize;
				return NO;
			}
            
            // 文件大小变了,所以要重新映射,先关闭原来的
			if (munmap(m_ptr, oldSize) != 0) {
				MMKVError(@"fail to munmap [%@], %s", m_mmapID, strerror(errno));
			}
            
            // 从老位置开始映射,因为可能系统没把这块内存分配出去,可能效率会高一些,没有找到证明此写法的详细资料
			m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
			if (m_ptr == MAP_FAILED) {
				MMKVError(@"fail to mmap [%@], %s", m_mmapID, strerror(errno));
			}

			// check if we fail to make more space
			if (![self isFileValid]) {
				MMKVWarning(@"[%@] file not valid", m_mmapID);
				return NO;
			}
			// keep m_output consistent with m_ptr -- writeAcutalSize: may fail
			delete m_output;
			m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
			m_output->seek(m_actualSize);
		}

        // 加密
		if (m_cryptor) {
			m_cryptor->reset();
			auto ptr = (unsigned char *) data.bytes;
			m_cryptor->encrypt(ptr, ptr, data.length);
		}

		if ([self writeAcutalSize:data.length] == NO) {
			return NO;
		}

		delete m_output;
		m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
		BOOL ret = [self protectFromBackgroundWritting:m_actualSize // 全量写回,实现kv的排重
		                                    writeBlock:^(MiniCodedOutputData *output) {
			                                    output->writeRawData(data);
		                                    }];
		if (ret) {
			[self recaculateCRCDigest];
		}
		return ret;
	}
	return YES;
}

复制代码
// MMKV.mm 
// 2. 检查文件剩余空间是否够,不够的话按照一定的策略分配,将kv写入内存映射区域,保持两者同步
- (BOOL)appendData:(NSData *)data forKey:(NSString *)key {
	size_t keyLength = [key lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
	size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength); // size needed to encode the key
	size += data.length + pbRawVarint32Size((int32_t) data.length);   // size needed to encode the value

	BOOL hasEnoughSize = [self ensureMemorySize:size];
	if (hasEnoughSize == NO || [self isFileValid] == NO) {
		return NO;
	}
    // 文件是空的,全量写入,case与编码方式相关
	if (m_actualSize == 0) {
		NSData *allData = [MiniPBCoder encodeDataWithObject:m_dic];
		if (allData.length > 0) {
			if (m_cryptor) {
				m_cryptor->reset();
				auto ptr = (unsigned char *) allData.bytes;
				m_cryptor->encrypt(ptr, ptr, allData.length);
			}
			BOOL ret = [self writeAcutalSize:allData.length];
			if (ret) {
				ret = [self protectFromBackgroundWritting:m_actualSize
				                               writeBlock:^(MiniCodedOutputData *output) {
					                               output->writeRawData(allData); // note: don't write size of data
				                               }];
				if (ret) {
					[self recaculateCRCDigest];
				}
			}
			return ret;
		}
		return NO;
	} else {    // case与编码方式相关,增量写入
		BOOL ret = [self writeAcutalSize:m_actualSize + size];
		if (ret) {
			static const int offset = pbFixed32Size(0);
			ret = [self protectFromBackgroundWritting:size
			                               writeBlock:^(MiniCodedOutputData *output) {
				                               output->writeString(key);
				                               output->writeData(data); // note: write size of data
			                               }];
			if (ret) {
				auto ptr = (uint8_t *) m_ptr + offset + m_actualSize - size;
				if (m_cryptor) {    // 这里是在写入内存映射区后才做的加密,因为写入的data加入了其他需要的bit(data长度)
					m_cryptor->encrypt(ptr, ptr, size);
				}
				[self updateCRCDigest:ptr withSize:size];
			}
		}
		return ret;
	}
}
复制代码
// MMKV.mm 
// 写入方法
- (BOOL)setRawData:(NSData *)data forKey:(NSString *)key {
	if (data.length <= 0 || key.length <= 0) {
		return NO;
	}
	CScopedLock lock(m_lock);

	[m_dic setObject:data forKey:key];  // 1. 写入m_dic
	m_hasFullWriteBack = NO;

	return [self appendData:data forKey:key];   // 2. 写入文件
}
复制代码

读:

因为m_dic已经保证与文件同步了,所以直接读m_dic就可以了,在需要读数据时进行解码,所以在读时是要明确知道数据类型,如果搞错了,行为是不确定的

// MMKV.mm

// 从m_dic中获取value(NSData类型)
- (NSData *)getRawDataForKey:(NSString *)key {
	CScopedLock lock(m_lock);
	[self checkLoadData];
	return [m_dic objectForKey:key];
}

- (id)getObjectOfClass:(Class)cls forKey:(NSString *)key {
	if (key.length <= 0) {
		return nil;
	}
	NSData *data = [self getRawDataForKey:key]; // 从获取data
	if (data.length > 0) {  // 解码, 支持NSObject<NSCoding>的类型和自定义解码器支持的类型

		if ([MiniPBCoder isMiniPBCoderCompatibleType:cls]) {
			return [MiniPBCoder decodeObjectOfClass:cls fromData:data]; 
		} else {
			if ([cls conformsToProtocol:@protocol(NSCoding)]) {
				return [NSKeyedUnarchiver unarchiveObjectWithData:data];
			}
		}
	}
	return nil;
}
复制代码

crc校验

对于大文件的写入,可能发生错误的几率较大,所以对保存kv的文件使用crc32进行校验(可靠性),产生crc码也需要保存,但是因为crc比较小,所以发生错误的几率是比较小的,如果crc文件也要校验,那就是个无尽的循环了。在每次映射结束后都会做crc校验。每次写入时要更新crc码。crc码的更新方式有两种:

  1. 重新计算全部数据的crc码
  2. 做增量的crc码计算
// MMKV
- (BOOL)checkFileCRCValid {
	if (m_ptr != nullptr && m_ptr != MAP_FAILED) {
		int offset = pbFixed32Size(0);
		m_crcDigest = (uint32_t) crc32(0, (const uint8_t *) m_ptr + offset, (uint32_t) m_actualSize);   // 获取文件的crc码

		// for backward compatibility
		if (!isFileExist(m_crcPath)) {
			MMKVInfo(@"crc32 file not found:%@", m_crcPath);
			return YES;
		}
		NSData *oData = [NSData dataWithContentsOfFile:m_crcPath];
		uint32_t crc32 = 0;
		@try {
			MiniCodedInputData input(oData);
			crc32 = input.readFixed32();    // 获取已经记录的crc码
		} @catch (NSException *exception) {
			MMKVError(@"%@", exception);
		}
		if (m_crcDigest == crc32) {
			return YES; // 校验通过
		}
		MMKVError(@"check crc [%@] fail, crc32:%u, m_crcDigest:%u", m_mmapID, crc32, m_crcDigest);
	}
	return NO;
}
复制代码
// MMKV.mm
// 通过增量更新crc码
- (void)updateCRCDigest:(const uint8_t *)ptr withSize:(size_t)length {
	if (ptr == nullptr) {
		return;
	}
	// 将原来crc码传入,进行增量的crc码计算,第一个参数是原来的crc码,如果原来的crc码为0,则相当于全量
	m_crcDigest = (uint32_t) crc32(m_crcDigest, ptr, (uint32_t) length);    

	if (m_crcPtr == nullptr || m_crcPtr == MAP_FAILED) {
		[self prepareCRCFile];
	}
	if (m_crcPtr == nullptr || m_crcPtr == MAP_FAILED) {
		return;
	}

	static const size_t bufferLength = pbFixed32Size(0);
	if (m_isInBackground) {
		if (mlock(m_crcPtr, bufferLength) != 0) {
			MMKVError(@"fail to mlock crc [%@]-%p, %d:%s", m_mmapID, m_crcPtr, errno, strerror(errno));
			// just fail on this condition, otherwise app will crash anyway
			return;
		}
	}

	@try {
		MiniCodedOutputData output(m_crcPtr, bufferLength);
		output.writeFixed32((int32_t) m_crcDigest);
	} @catch (NSException *exception) {
		MMKVError(@"%@", exception);
	}
	if (m_isInBackground) {
		munlock(m_crcPtr, bufferLength);
	}
}
复制代码

aes加密

MMKV 使用了 AES CFB-128 算法来加密/解密。具体是采用了 OpenSSL(1.1.0i 版)的实现。我们选择 CFB 而不是常见的 CBC 算法,主要是因为 MMKV 使用 append-only 实现插入/更新操作,流式加密算法更加合适。-- 摘自MMKV github wiki

线程安全

MMKV是线程安全的

MMKV使用c++的类初始化和析构的特性定义了ScopedLock(作用域锁):

class CScopedLock {
    NSRecursiveLock *m_oLock;

public:
    CScopedLock(NSRecursiveLock *oLock) : m_oLock(oLock) { [m_oLock lock]; }    // 初始化时加锁

    ~CScopedLock() {    // 析构时解锁
        [m_oLock unlock];
        m_oLock = nil;
    }
};

/*
{
    CScopedLock lock(g_instanceLock);
    操作临界资源。。。
} 超出作用域,调用lock的析构函数,解锁
*/

复制代码

使用了NSRecursiveLock进行加锁,这降低了死锁的风险,但是对性能会有少量的消耗

  1. 对于每个mmkv实例都会放入一个global的dic保存(缓存),来避免每次都要走做初始化,并且为该对象添加了强引用,防止被释放,并添加了g_instanceLock锁来保障,每次dic进行写操作时,加锁保护,而且保证初始化行为线程安全
  2. 在mmkv中的实例变量是临界资源,所以每次都要加锁,这里需要注意的是在多线程的其情况下,close之后再使用,其行为是不确定的,原因如下:
// call this method if the instance is no longer needed in the near future
// any subsequent call to the instance is undefined behavior
- (void)close;

- (void)close {
	CScopedLock g_lock(g_instanceLock);
	CScopedLock lock(m_lock);
	MMKVInfo(@"closing %@", m_mmapID);

	[self clearMemoryCache];

    // 这里从dic中移除了该实例,所以引用计数会-1,不同线程有不同autoreleasepool,所以可能被释放
	[g_instanceDic removeObjectForKey:m_mmapID];    
}
复制代码

内存警告

因为内存过高肯能会OOM,而且会降低app运行速度(内存交换),所以在内存警告时对内存释放

// MMKV

// 主要是两个工作:1. 清理内存中m_dic 2. 关闭映射
- (void)clearMemoryCache {
	CScopedLock lock(m_lock);

	if (m_needLoadFromFile) {
		MMKVInfo(@"ignore %@", m_mmapID);
		return;
	}
	m_needLoadFromFile = YES;

	[m_dic removeAllObjects];   // 清理m_dic
	m_hasFullWriteBack = NO;

	if (m_output != nullptr) {
		delete m_output;
	}
	m_output = nullptr;

	if (m_ptr != nullptr && m_ptr != MAP_FAILED) {
		if (munmap(m_ptr, m_size) != 0) {   // 关闭映射
			MMKVError(@"fail to munmap [%@], %s", m_mmapID, strerror(errno));
		}
	}
	m_ptr = nullptr;

	if (m_fd >= 0) {
		if (close(m_fd) != 0) { // 关闭文件
			MMKVError(@"fail to close [%@], %s", m_mmapID, strerror(errno));
		}
	}
	m_fd = -1;
	m_size = 0;
	m_actualSize = 0;

	if (m_cryptor) {
		m_cryptor->reset();
	}
}
复制代码

转载于:https://juejin.im/post/5c8cc0c8f265da2d864b865f

相关文章:

  • Zookeeper简介
  • 微服务架构,你必须要知道的一些事儿!
  • 自动化测试|录制回放效果差异检测
  • JAVA springcloud ssm b2b2c多用户商城系统源码(一)构建第一个SpringBoot工程
  • Selenium-Switch--切换浏览器tab/iframe/alart
  • 好程序员大数据教程Hadoop全分布安装(非HA)
  • JavaScript中in操作符(for..in)、Object.keys()和Object.getOwnPropertyNames()的区别
  • Day01:总结一下str的常见操作吧~
  • Bytom储蓄分红合约解析
  • 软件测试2019:第二次作业
  • 企业应用开发(3)--用户故事
  • CAP的简单理解
  • 2018-2019-2 网络对抗技术 20165320 Exp2 后门原理与实践
  • Chrome 存在数据泄漏问题,谷歌更新说明却没提
  • Flutter (三) Dart 语言基础详解 (异步,生成器,隔离,元数据,注释)
  • 实现windows 窗体的自己画,网上摘抄的,学习了
  • C语言笔记(第一章:C语言编程)
  • Elasticsearch 参考指南(升级前重新索引)
  • Git 使用集
  • github指令
  • Git学习与使用心得(1)—— 初始化
  • iOS编译提示和导航提示
  • jdbc就是这么简单
  • Netty 4.1 源代码学习:线程模型
  • Python实现BT种子转化为磁力链接【实战】
  • REST架构的思考
  • spark本地环境的搭建到运行第一个spark程序
  • uni-app项目数字滚动
  • Vue官网教程学习过程中值得记录的一些事情
  • 高度不固定时垂直居中
  • 前端路由实现-history
  • 前端每日实战:70# 视频演示如何用纯 CSS 创作一只徘徊的果冻怪兽
  • 前端学习笔记之观察者模式
  • 浅谈Kotlin实战篇之自定义View图片圆角简单应用(一)
  • 详解移动APP与web APP的区别
  • d²y/dx²; 偏导数问题 请问f1 f2是什么意思
  • ionic异常记录
  • 阿里云IoT边缘计算助力企业零改造实现远程运维 ...
  • 阿里云服务器如何修改远程端口?
  • 容器镜像
  • !!Dom4j 学习笔记
  • #etcd#安装时出错
  • #if和#ifdef区别
  • $NOIp2018$劝退记
  • (04)odoo视图操作
  • (07)Hive——窗口函数详解
  • (1)虚拟机的安装与使用,linux系统安装
  • (3)Dubbo启动时qos-server can not bind localhost22222错误解决
  • (附源码)spring boot基于小程序酒店疫情系统 毕业设计 091931
  • (附源码)ssm旅游企业财务管理系统 毕业设计 102100
  • (篇九)MySQL常用内置函数
  • (七)理解angular中的module和injector,即依赖注入
  • (转)从零实现3D图像引擎:(8)参数化直线与3D平面函数库
  • (转)全文检索技术学习(三)——Lucene支持中文分词
  • .bat批处理(七):PC端从手机内复制文件到本地