首页> 搜索结果页
"开发encoding场景" 检索
共 1487 条结果
通用缓存存储设计实践
目录介绍01.整体概述说明1.1 项目背景介绍1.2 遇到问题记录1.3 基础概念介绍1.4 设计目标1.5 产生收益分析02.市面存储方案2.1 缓存存储有哪些2.2 缓存策略有哪些2.3 常见存储方案2.4 市面存储方案说明2.5 存储方案的不足03.存储方案原理3.1 Sp存储原理分析3.2 MMKV存储原理分析3.3 LruCache考量分析3.4 DiskLru原理分析3.5 DataStore分析3.6 HashMap存储分析3.7 Sqlite存储分析3.8 使用存储的注意点3.9 各种数据存储文件04.通用缓存方案思路4.1 如何兼容不同缓存4.2 打造通用缓存Api4.3 切换不同缓存方式4.4 缓存的过期处理4.5 缓存的阀值处理4.6 缓存的线程安全性4.7 缓存数据的迁移4.8 缓存数据加密处理4.9 缓存效率的对比05.方案基础设计5.1 整体架构图5.2 UML设计图5.3 关键流程图5.4 模块间依赖关系06.其他设计说明6.1 性能设计说明6.2 稳定性设计6.3 灰度设计6.4 降级设计6.5 异常设计说明6.6 兼容性设计6.7 自测性设计07.通用Api设计7.1 如何依赖该库7.2 初始化缓存库7.3 切换各种缓存方案7.4 数据的存和取7.5 线程安全考量7.6 查看缓存文件数据7.7 如何选择合适方案08.其他说明介绍8.1 遇到的坑分析8.2 遗留的问题8.3 未来的规划8.4 参考链接记录01.整体概述说明1.1 项目背景介绍项目中很多地方使用缓存方案有的用sp,有的用mmkv,有的用lru,有的用DataStore,有的用sqlite,如何打造通用api切换操作不同存储方案?缓存方案众多,且各自使用场景有差异,如何选择合适的缓存方式?针对不同场景选择什么缓存方式,同时思考如何替换之前老的存储方案,而不用花费很大的时间成本!针对不同的业务场景,不同的缓存方案。打造一套通用的方案屏蔽各种缓存方式的差异性,暴露给外部开发者统一的API,外部开发者简化使用,提高开发效率和使用效率……1.2 遇到问题记录记录几个常见的问题问题1:各种缓存方案,分别是如何保证数据安全的,其内部使用到了哪些锁?由于引入锁,给效率上带来了什么影响?问题2:各种缓存方案,进程不安全是否会导致数据丢失,如何处理数据丢失情况?如何处理脏数据,其原理大概是什么?问题3:各种缓存方案使用场景是什么?有什么缺陷,为了解决缺陷做了些什么?比如sp存在缺陷的替代方案是DataStore,为何这样?问题4:各种缓存方案,他们的缓存效率是怎样的?如何对比?接入该库后,如何做数据迁移,如何覆盖操作?思考一个K-V框架的设计问题1-线程安全:使用K-V存储一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;问题2-内存缓存:由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;问题3-事务:由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;问题4-事务串行化:由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;问题5-异步或同步写回:由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;有时候又要求数据读写是同步的;问题6-增量更新:由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;问题7-变更回调:由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;问题8-多进程:由于程序可能有多进程需求,那么框架如何保证多进程数据同步?问题9-可用性:由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;问题10-高效性:性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;问题11-安全性:如果程序需要存储敏感数据,如何保证数据完整性和保密性;问题12-数据迁移:如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;问题13-研发体验:是否模板代码冗长,是否容易出错。各种K—V框架使用体验如何?常见存储框架设计思考导图1.3 基础概念介绍最初缓存的概念提及缓存,可能很容易想到Http的缓存机制,LruCache,其实缓存最初是针对于网络而言的,也是狭义上的缓存,广义的缓存是指对数据的复用。缓存容量,就是缓存的大小每一种缓存,总会有一个最大的容量,到达这个限度以后,那么就须要进行缓存清理了框架。这个时候就需要删除一些旧的缓存并添加新的缓存。1.4 设计目标打造通用存储库:设计一个缓存通用方案,其次,它的结构需要很简单,因为很多地方需要用到,再次,它得线程安全。灵活切换不同的缓存方式,使用简单。内部开源该库:作为技术沉淀,当作专项来推动进展。高复用低耦合,便于拓展,可快速移植,解决各个项目使用内存缓存,sp,mmkv,sql,lru,DataStore的凌乱。抽象一套统一的API接口。1.5 产生收益分析统一缓存API兼容不同存储方案打造通用api,抹平了sp,mmkv,sql,lru,dataStore等各种方案的差异性。简化开发者使用,功能强大而使用简单!02.市面存储方案2.1 缓存存储有哪些比较常见的是内存缓存以及磁盘缓存。内存缓存:这里的内存主要指的存储器缓存;磁盘缓存:这里主要指的是外部存储器,手机的话指的就是存储卡。内存缓存:通过预先消耗应用的一点内存来存储数据,便可快速的为应用中的组件提供数据,是一种典型的以空间换时间的策略。磁盘缓存:读取磁盘文件要比直接从内存缓存中读取要慢一些,而且需要在一个UI主线程外的线程中进行,因为磁盘的读取速度是不能够保证的,磁盘文件缓存显然也是一种以空间换时间的策略。二级缓存:内存缓存和磁盘缓存结合。比如,LruCache将图片保存在内存,存取速度较快,退出APP后缓存会失效;而DiskLruCache将图片保存在磁盘中,下次进入应用后缓存依旧存在,它的存取速度相比LruCache会慢上一些。2.2 缓存策略有哪些缓存的核心思想主要是什么呢一般来说,缓存核心步骤主要包含缓存的添加、获取和删除这三类操作。那么为什么还要删除缓存呢?不管是内存缓存还是硬盘缓存,它们的缓存大小都是有限的。当缓存满了之后,再想其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存。这个跟线程池满了以后的线程处理策略相似!缓存的常见的策略有那些FIFO(first in first out):先进先出策略,相似队列。LFU(less frequently used):最少使用策略,RecyclerView的缓存采用了此策略。LRU(least recently used):最近最少使用策略,Glide在进行内存缓存的时候采用了此策略。2.3 常见存储方案内存缓存:存储在内存中,如果对象销毁则内存也会跟随销毁。如果是静态对象,那么进程杀死后内存会销毁。Map,LruCache等等磁盘缓存:后台应用有可能会被杀死,那么相应的内存缓存对象也会被销毁。当你的应用重新回到前台显示时,你需要用到缓存数据时,这个时候可以用磁盘缓存。SharedPreferences,MMKV,DiskLruCache,SqlLite,DataStore,Room,Realm,GreenDao等等2.4 市面存储方案说明内存缓存Map:内存缓存,一般用HashMap存储一些数据,主要存储一些临时的对象LruCache:内存淘汰缓存,内部使用LinkedHashMap,会淘汰最长时间未使用的对象磁盘缓存SharedPreferences:轻量级磁盘存储,一般存储配置属性,线程安全。建议不要存储大数据,不支持跨进程!MMKV:腾讯开源存储库,内部采用mmap。DiskLruCache:磁盘淘汰缓存,写入数据到file文件SqlLite:移动端轻量级数据库。主要是用来对象持久化存储。DataStore:旨在替代原有的 SharedPreferences,支持SharedPreferences数据的迁移Room/Realm/GreenDao:支持大型或复杂数据集其他开源缓存库ACache:一款高效二级存储库,采用内存缓存和磁盘缓存2.5 存储方案的不足存储方案SharedPreferences的不足1.SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。每次更改,都需要将整个HashMap序列化为XML格式的报文然后整个写入文件。2.SP读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性API3.commit() / apply()操作可能会造成ANR问题存储方案MMKV的不足1.没有类型信息,不支持getAll。由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。2.需要引入so,增加包体积:引入MMKV需要增加的体积还是不少的。3.文件只增不减:MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。存储方案DataStore的不足1.只是提供异步API,没有提供同步API方法。在进行大量同步存储的时候,使用runBlocking同步数据可能会卡顿。2.对主线程执行同步 I/O 操作可能会导致 ANR 或界面卡顿。可以通过从 DataStore 异步预加载数据来减少这些问题。03.存储方案原理3.1 Sp存储原理分析SharedPreferences,它是一个轻量级的存储类,特别适合用于保存软件配置参数。轻量级,以键值对的方式进行存储。采用的是xml文件形式存储在本地,程序卸载后会也会一并被清除,不会残留信息。线程安全的。它有一些弊端如下所示对文件IO读取,因此在IO上的瓶颈是个大问题,因为在每次进行get和commit时都要将数据从内存写入到文件中,或从文件中读取。多线程场景下效率较低,在get操作时,会锁定SharedPreferences对象,互斥其他操作,而当put,commit时,则会锁定Editor对象,使用写入锁进行互斥,在这种情况下,效率会降低。不支持跨进程通讯,由于每次都会把整个文件加载到内存中,不建议存储大的文件内容,比如大json。有一些使用上的建议如下建议不要存储较大数据;频繁修改的数据修改后统一提交而不是修改过后马上提交;在跨进程通讯中不去使用;键值对不宜过多读写操作性能分析第一次通过Context.getSharedPreferences()进行初始化时,对xml文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map中,接下来所有的读操作,只需要从这个Map中取就可以3.2 MMKV存储原理分析早期微信的需求微信聊天对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的。只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。该需求对应的技术考量考量1:把聊天页面的显示文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。但这种方式涉及到io流读写,且消息多会有性能问题。考量2:App程序都崩溃了,如何保证要存储的内容,都写入到磁盘中呢?考量3:保存聊天内容到磁盘的行为,这个做成同步还是异步呢?如果是异步,如何保证聊天消息的时序性?考量4:如何存储数据是同步行为,针对群里聊天这么多消息,如何才能避免卡顿呢?考量5:存储数据放到主线程中,用户在群聊天页面猛滑消息,如何爆发性集中式对磁盘写入数据?MMKV存储框架介绍MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。MMKV设计的原理内存准备:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。数据组织:数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。写入优化:考虑到主要使用场景是频繁地进行写入更新,需要有增量更新的能力。考虑将增量 kv 对象序列化后,append 到内存末尾。空间增长:使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。需要在性能和空间上做个折中。MMKV诞生的背景针对该业务,高频率,同步,大量数据写入磁盘的需求。不管用sp,还是store,还是disk,还是数据库,只要在主线程同步写入磁盘,会很卡。解决方案就是:使用内存映射mmap的底层方法,相当于系统为指定文件开辟专用内存空间,内存数据的改动会自动同步到文件里。用浅显的话说:MMKV就是实现用「写入内存」的方式来实现「写入磁盘」的目标。内存的速度多快呀,耗时几乎可以忽略,这样就把写磁盘造成卡顿的问题解决了。3.3 LruCache考量分析在LruCache的源码中,关于LruCache有这样的一段介绍:cache对象通过一个强引用来访问内容。每次当一个item被访问到的时候,这个item就会被移动到一个队列的队首。当一个item被添加到已经满了的队列时,这个队列的队尾的item就会被移除。LruCache核心思想LRU是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LrhCache和DiskLruCache,分别用于实现内存缓存和硬盘缓存,其核心思想都是LRU缓存算法。LruCache使用是计数or计量使用计数策略:1、Message 消息对象池:最多缓存 50 个对象;2、OkHttp 连接池:默认最多缓存 5 个空闲连接;3、数据库连接池使用计量策略:1、图片内存缓存;2、位图池内存缓存那么思考一下如何理解 计数 or 计量 ?针对计数策略使用Lru仅仅只统计缓存单元的个数,针对计量则要复杂一点。LruCache策略能否增加灵活性在缓存容量满时淘汰,除了这个策略之外,能否再增加一些辅助策略,例如在 Java 堆内存达到某个阈值后,对 LruCache 使用更加激进的清理策略。比如:Glide 除了采用 LRU 策略淘汰最早的数据外,还会根据系统的内存紧张等级 onTrimMemory(level) 及时减少甚至清空 LruCache。/** * 这里是参考glide中的lru缓存策略,低内存的时候清除 * @param level level级别 */ public void trimMemory(int level) { if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { clearMemory(); } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { trimToSize(maxSize() / 2); } }关于Lru更多的原理解读,可以看:AppLruCache3.4 DiskLru原理分析DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache最大的特点就是持久化存储,所有的缓存以文件的形式存在。在用户进入APP时,它根据日志文件将DiskLruCache恢复到用户上次退出时的情况,日志文件journal保存每个文件的下载、访问和移除的信息,在恢复缓存时逐行读取日志并检查文件来恢复缓存。DiskLruCache缓存基础原理流程图关于DiskLruCache更多的原理解读,可以看:AppLruDisk3.5 DataStore分析为何会有DataStoreDataStore 被创造出来的目标就是替代 Sp,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。DataStore优势是异步ApiDataStore 的主要优势之一是异步API,所以本身并未提供同步API调用,但实际上可能不一定始终能将周围的代码更改为异步代码。提出一个问题和思考如果使用现有代码库采用同步磁盘 I/O,或者您的依赖项不提供异步API,那么如何将DataStore存储数据改成同步调用?使用阻塞式协程消除异步差异使用 runBlocking() 从 DataStore 同步读取数据。runBlocking()会运行一个新的协程并阻塞当前线程直到内部逻辑完成,所以尽量避免在UI线程调用。频繁使用阻塞式协程会有问题吗要注意的一点是,不用在初始读取时调用runBlocking,会阻塞当前执行的线程,因为初始读取会有较多的IO操作,耗时较长。更为推荐的做法则是先异步读取到内存后,后续有需要可直接从内存中拿,而非运行同步代码阻塞式获取。3.6 HashMap存储分析内存缓存的场景比如 SharedPreferences 存储中,就做了内存缓存的操作。3.7 Sqlite存储分析注意:缓存的数据库是存放在/data/data/databases/目录下,是占用内存空间的,如果缓存累计,容易浪费内存,需要及时清理缓存。3.8 使用缓存注意点在使用内存缓存的时候须要注意防止内存泄露,使用磁盘缓存的时候注意确保缓存的时效性针对SharedPreferences使用建议有:因为 SharedPreferences 虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。它设计初衷是轻量级,建议当存储文件中key-value数据超过30个,如果超过30个(这个只是一个假设),则开辟一个新的文件进行存储。建议不同业务模块的数据分文件存储……针对MMKV使用建议有:如果项目中有高频率,同步存储数据,使用MMKV更加友好。针对DataStore使用建议有:建议在初始化的时候,使用全局上下文Context给DataStore设置存储路径。针对LruCache缓存使用建议:如果你使用“计量”淘汰策略,需要重写 SystemLruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。3.9 各种数据存储文件SharedPreferences 存储文件格式如下所示<?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <string name="name">杨充</string> <int name="age" value="28" /> <boolean name="married" value="true" /> </map>MMKV 存储文件格式如下所示MMKV的存储结构,分了两个文件,一个数据文件,一个校验文件crc结尾。大概如下所示:这种设计最直接问题就是占用空间变大了很多,举一个例子,只存储了一个字段,但是为了方便MMAP映射,磁盘直接占用了8k的存储。LruDiskCache 存储文件格式如下所示DataStore 存储文件格式如下所示04.通用缓存方案思路4.1 如何兼容不同缓存定义通用的存储接口不同的存储方案,由于api不一样,所以难以切换操作。要是想兼容不同存储方案切换,就必须自己制定一个通用缓存接口。定义接口,然后各个不同存储方案实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api定义一个接口,这个接口有什么呢?主要是存和取各种基础类型数据,比如saveInt/readInt;saveString/readString等通用抽象方法4.2 打造通用缓存Api通用缓存Api设计思路:通用一套api + 不同接口实现 + 代理类 + 工厂模型定义缓存的通用API接口,这里省略部分代码interface ICacheable { fun saveXxx(key: String, value: Int) fun readXxx(key: String, default: Int = 0): Int fun removeKey(key: String) fun totalSize(): Long fun clearData() }基于接口而非实现编程的设计思想将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。4.3 切换不同缓存方式传入不同类型方便创建不同存储方式隐藏存储方案创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体存储方案的类名。需要符合开闭原则那么具体该怎么实现呢?看到下面代码是不是有种很熟悉的感觉,没错,正是使用了工厂模式,灵活切换不同的缓存方式。但针对应用层调用api却感知不到影响。public static ICacheable getCacheImpl(Context context, @CacheConstants.CacheType int type) { if (type == CacheConstants.CacheType.TYPE_DISK) { return DiskFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_LRU) { return LruCacheFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_MEMORY) { return MemoryFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_MMKV) { return MmkvFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_SP) { return SpFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_STORE) { return StoreFactory.create().createCache(context); } else { return MmkvFactory.create().createCache(context); } }4.4 缓存的过期处理说一个使用场景比如你准备做WebView的资源拦截缓存,针对模版页面,为了提交加载速度。会缓存css,js,图片等资源到本地。那么如何选择存储方案,如何处理过期问题?思考一下该问题比如WebView缓存方案是数据库存储,db文件。针对缓存数据,猜想思路可能是Lru策略,或者标记时间清除过期文件。那么缓存过期处理的策略有哪些定时过期:每个设置过期时间的key都需要创建⼀个定时器,到过期时间就会立即清除。惰性过期:只有当访问⼀个 key 时,才会判断该key是否已过期,过期则清除。定期过期:每隔⼀定的时间,会扫描⼀定数量的数据库的 expires 字典中⼀定数量的key(是随机的), 并 清除其中已过期的key 。分桶策略:定期过期的优化,将过期时间点相近的 key 放在⼀起,按时间扫描分桶。4.5 缓存的阀值处理淘汰一个最早的节点就足够吗?以Lru缓存为案例做分析……标准的 LRU 策略中,每次添加数据时最多只会淘汰一个数据,但在 LRU 内存缓存中,只淘汰一个数据单元往往并不够。例如在使用 “计量” 的内存图片缓存中,在加入一个大图片后,只淘汰一个图片数据有可能依然达不到最大缓存容量限制。那么在LRUCache该如何做呢?在复用 LinkedHashMap 实现 LRU 内存缓存时,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判断接口可能就不够看了,因为它每次最多只能淘汰一个数据单元。LruCache是如何解决这个问题这个地方就需要重写LruCache中的sizeOf()方法,然后拿到key和value对象计算其内存大小。4.6 缓存的线程安全性为何要强调缓存方案线程安全性缓存虽好,用起来很快捷方便,但在使用过程中,大家一定要注意数据更新和线程安全,不要出现脏数据。针对LruCache中使用LinkedHashMap读写不安全情况保证LruCache的线程安全,在put,get等核心方法中,添加synchronized锁。这里主要是synchronized (this){ put操作 }针对DiskLruCache读写不安全的情况DiskLruCache 管理多个 Entry(key-values),因此锁粒度应该是 Entry 级别的。get 和 edit 方法都是同步方法,保证内部的 Entry Map 的安全访问,是保证线程安全的第一步。4.7 缓存数据的迁移如何将Sp数据迁移到DataStore通过属性委托的方式创建DataStore,基于已有的SharedPreferences文件进行创建DataStore。将sp文件名,以参数的形式传入preferencesDataStore,DataStore会自动将该文件中的数据进行转换。val Context.dataStore: DataStore<Preferences> by preferencesDataStore( name = "user_info", produceMigrations = { context -> listOf(SharedPreferencesMigration(context, "sp_file_name")) })如何将sp数据迁移到MMKVMMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface。MMKV preferences = MMKV.mmkvWithID("myData"); // 迁移旧数据 { SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE); preferences.importFromSharedPreferences(old_man); old_man.edit().clear().commit(); }思考一下,MMKV框架实现了sp的两个接口,即磨平了数据迁移差异性那么使用这个方式,借鉴该思路,你能否尝试用该方法,去实现LruDiskCache方案的sp数据一键迁移。4.8 缓存数据加密思考一下,如果让你去设计数据的加密,你该怎么做?具体可以参考MMKV的数据加密过程。4.9 缓存效率的对比测试数据测试写入和读取。注意分别使用不同的方式,测试存储或获取相同的数据(数据为int类型数字,还有String类型长字符串)。然后查看耗时时间的长短……比较对象SharePreferences/DataStore/MMKV/LruDisk/Room。使用华为手机测试测试数据案例1在主线程中测试数据,同步耗时时间(主线程还有其他的耗时)跟异步场景有较大差别。测试数据案例2测试1000组长字符串数据,MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。最后思考说明从最终的数据来看,这几种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时。而在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。05.方案基础设计5.1 整体架构图统一存储方案的架构图5.2 UML设计图通用存储方案UML设计图5.3 代码说明图项目中代码相关说明图5.4 关键流程图mmap的零拷贝流程图5.5 模块间依赖关系存储库依赖的关系MMKV需要依赖一些腾讯开源库的服务;DataStore存储需要依赖datastore相关的库;LruDisk存储需要依赖disk库如果你要拓展其他的存储方案,则需要添加其依赖。需要注意,添加的库使用compileOnly。06.其他设计说明6.1 性能设计关于基础库性能如何考量具体性能可以参考测试效率的对比。6.2 稳定性设计针对多进程初始化遇到问题:对于多进程在Application的onCreate创建几次,导致缓存存储库初始化了多次。问题分析:该场景不是该库的问题,建议判断是否是主进程,如果是则进行初始化。如何解决:思路是获取当前进程名,并与主进程对比,来判断是否为主进程。具体可以参考:优雅判断是否是主进程6.3 灰度设计暂无灰度设计6.4 降级设计由于缓存方式众多,在该库中配置了降级,如何设置降级//设置是否是debug模式 CacheConfig cacheConfig = builder.monitorToggle(new IMonitorToggle() { @Override public boolean isOpen() { //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关 return true; } }) //创建 .build(); CacheInitHelper.INSTANCE.init(this,cacheConfig);降级后的逻辑处理是如果是降级逻辑,则默认使用谷歌官方存储框架SharedPreferences。默认是不会降级的!if (CacheInitHelper.INSTANCE.isToggleOpen()){ //如果是降级,则默认使用sp return SpFactory.create().createCache(); }6.5 异常设计说明DataStore初始化遇到的坑遇到问题:不能将DataStore初始化代码写到Activity里面去,否则重复进入Activity并使用Preferences DataStore时,会尝试去创建一个同名的.preferences_pb文件。问题分析:SingleProcessDataStore#check(!activeFiles.contains(it)),该方法会检查如果判断到activeFiles里已经有该文件,直接抛异常,即不允许重复创建。如何解决:在项目中只在顶层调用一次 preferencesDataStore 方法,这样可以更轻松地将 DataStore 保留为单例。MMKV遇到的坑说明MMKV 是有数据损坏的概率的,MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。6.6 兼容性设计MMKV数据迁移比较难MMKV都是按字节进行存储的,实际写入文件把类型擦除了,这也是MMKV不支持getAll的原因,虽然说getAll用的不多问题不大,但是MMKV因此就不具备导出和迁移的能力。比较好的方案是每次存储,多用一个字节来存储数据类型,这样占用的空间也不会大很多,但是具备了更好的可扩展性。6.7 自测性设计MMKV不太方便查看数据和解析数据官方目前支持了5个平台,Android、iOS、Win、MacOS、python,但是没有提供解析数据的工具,数据文件和crc都是字节码,除了中文能看出一些内容,直接查看还是存在大量乱码。比如线上出了问题,把用户的存储文件捞上来,还得替换到系统目录里,通过代码断点去看,这也太不方便了。Sp,FastSp,DiskCache,Store等支持查看文件解析数据傻瓜式的查看缓存文件,操作缓存文件。具体看该库:MonitorFileLib磁盘查看工具07.通用Api设计7.1 如何依赖该库依赖该库如下所示//通用缓存存储库,支持sp,fastsp,mmkv,lruCache,DiskLruCache等 implementation 'com.github.yangchong211.YCCommonLib:AppBaseStore:1.4.8'7.2 初始化缓存库通用存储库初始化CacheConfig.Builder builder = CacheConfig.Companion.newBuilder(); //设置是否是debug模式 CacheConfig cacheConfig = builder.debuggable(BuildConfig.DEBUG) //设置外部存储根目录 .extraLogDir(null) //设置lru缓存最大值 .maxCacheSize(100) //内部存储根目录 .logDir(null) //创建 .build(); CacheInitHelper.INSTANCE.init(MainApplication.getInstance(),cacheConfig); //最简单的初始化 //CacheInitHelper.INSTANCE.init(CacheConfig.Companion.newBuilder().build());7.3 切换各种缓存方案如何调用api切换各种缓存方案//这里可以填写不同的type val cacheImpl = CacheFactoryUtils.getCacheImpl(CacheConstants.CacheType.TYPE_SP)7.4 数据的存和取存储数据和获取数据//存储数据 dataCache.saveBoolean("cacheKey1",true); dataCache.saveFloat("cacheKey2",2.0f); dataCache.saveInt("cacheKey3",3); dataCache.saveLong("cacheKey4",4); dataCache.saveString("cacheKey5","doubi5"); dataCache.saveDouble("cacheKey6",5.20); //获取数据 boolean data1 = dataCache.readBoolean("cacheKey1", false); float data2 = dataCache.readFloat("cacheKey2", 0); int data3 = dataCache.readInt("cacheKey3", 0); long data4 = dataCache.readLong("cacheKey4", 0); String data5 = dataCache.readString("cacheKey5", ""); double data6 = dataCache.readDouble("cacheKey5", 0.0);也可以通过注解的方式存储数据class NormalCache : DataCache() { @BoolCache(KeyConstant.HAS_ACCEPTED_PARENT_AGREEMENT, false) var hasAcceptParentAgree: Boolean by this } //如何使用 object CacheHelper { //常规缓存数据,记录一些重要的信息,慎重清除数据 private val normal: NormalCache by lazy { NormalCache().apply { setCacheImpl( DiskCache.Builder() .setFileId("NormalCache") .build() ) } } fun normal() = normal } //存数据 CacheHelper.normal().hasAcceptParentAgree = true //取数据 val hasAccept = CacheHelper.normal().hasAcceptParentAgree7.5 查看缓存文件数据android缓存路径查看方法有哪些呢?将手机打开开发者模式并连接电脑,在pc控制台输入cd /data/data/目录,使用adb主要是方便测试(删除,查看,导出都比较麻烦)。如何简单快速,傻瓜式的查看缓存文件,操作缓存文件,那么该项目小工具就非常有必要呢!采用可视化界面读取缓存数据,方便操作,直观也简单。一键接入该工具FileExplorerActivity.startActivity(this);开源项目地址:https://github.com/yangchong211/YCAndroidTool查看缓存文件数据如下所示7.6 如何选择合适方案比如常见的缓存、浏览器缓存、图片缓存、线程池缓存、或者WebView资源缓存等等那就可以选择LRU+缓存淘汰算法。它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。比如针对高频率,同步存储,或者跨进程等存储数据的场景那就可以选择MMKV这种存储方案。它的核心思想就是高速存储数据,且不会阻塞主线程卡顿。比如针对存储表结构,或者一对多这类的数据那就可以选择DataStore,Room,GreenDao等存储库方案。比如针对存储少量用户类数据其实也可以将json转化为字符串,然后选择sp,mmkv,lruDisk等等都可以。08.其他说明介绍8.1 遇到的坑分析Sp存储数据commit() / apply()操作可能会造成ANR问题commit()是同步提交,会在UI主线程中直接执行IO操作,当写入操作耗时比较长时就会导致UI线程被阻塞,进而产生ANR;apply()虽然是异步提交,但异步写入磁盘时,如果执行了Activity / Service中的onStop()方法,那么一样会同步等待SP写入完毕,等待时间过长时也会引起ANR问题。首先分析一下SharedPreferences源码中apply方法SharedPreferencesImpl#apply(),这个方法主要是将记录的数据同步写到Map集合中,然后在开启子线程将数据写入磁盘SharedPreferencesImpl#enqueueDiskWrite(),这个会将runnable被写入了队列,然后在run方法中写数据到磁盘QueuedWork#queue(),这个将runnable添加到sWork(LinkedList链表)中,然后通过handler发送处理队列消息MSG_RUN然后再看一下ActivityThread源码中的handlePauseActivity()、handleStopActivity()方法。ActivityThread#handlePauseActivity()/handleStopActivity(),Activity在pause和stop的时候会调用该方法ActivityThread#handlePauseActivity()#QueuedWork.waitToFinish(),这个是等待QueuedWork所有任务处理完的逻辑QueuedWork#waitToFinish(),这个里面会通过handler查询MSG_RUN消息是否有,如果有则会waiting等待那么最后得出的结论是handlePauseActivity()的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。但普通存储的场景,这种可能性很小。8.2 项目开发分享通用缓存存储库开源代码https://github.com/yangchong211/YCCommonLib/tree/master/AppBaseStore
文章
存储  ·  缓存  ·  安全  ·  算法  ·  API  ·  数据库  ·  数据安全/隐私保护  ·  Android开发  ·  数据格式  ·  开发者
2023-03-07
Golang 中那些隐秘的角落
我们真的有用好 recover() 吗?在一次系统报警调查中,发现某个组件 panic 且没有恢复运行,panic 的日志为是 "fatal error: concurrent map writes",当时只能手动重启该组件。查看其源码时发现,panic 位置对应的函数中,已经存在 recover() 函数,抽象得到的源码逻辑如下所示:package main import ( "fmt" ) func concurrentMapWrite() { defer func() { if err := recover(); err != nil { fmt.Printf("Panic occurred due to %+v, Recovered in f", err) } }() m := map[int]int{} idx := 0 for { go func() { m[idx] = 1 }() idx++ } } func main() { concurrentMapWrite() } 当时初步判断 recover 并没有捕获到 "fatal error: concurrent map writes",为了验证自己的猜想,进行了如下的一系列调查。在 defer 中使用 recover()Golang 程序运行不符合预期时往往会通过“错误”以及“异常”来反馈给用户。前者是代码逻辑出现错误时返回,是编程者意料之中的错误,不会破坏程序的运行;后者往往是代码中出现了不可预期的错误,导致程序无法继续运行下去,如果不加以处理,就会导致程序异常退出,这是十分危险的。为了提高程序的稳健性,我们需要依赖 Golang 的 recover 以及 defer 机制来保证程序在发生异常后能够继续维持运行,避免程序意外退出,保证业务的稳定运行。defer 关键字中包含的内容会在其所在函数返回之前执行;recover 函数用于将 goroutine 从异常场景下恢复,只有在 defer 中调用才会生效。其使用方式如下如下:func div(x, y int) int { return x / y } func f() { defer func() { if err := recover(); err != nil { fmt.Printf("Panic occurred due to %+v, Recovered in f", err) } }() fmt.Println(div(1, 0)) }上述 defer...recover 相当于 java 中的 try/catch 关键字,能够保证程序能够不被异常中断执行,我们知道使用 try...catch 可以捕获所有类型的异常,只要 catch 后跟所有异常的基类 Exception 即可;然后Golang为什么却不是这样呢?不可恢复的 panic不同于 try...catch,在 Golang 中并不是所有异常都能够被 recover 捕获到:当异常是通过 runtime.panic() 抛出时,能够被 recover 方法捕获;当异常是通过 runtime.throw() 或者 runtime.fatal() 抛出时,不能够被 recover 方法捕获。在上述实际场景中遇到的 “concurrent map writes” 异常就是通过 runtime.fatal() 抛出来的,具体源码(runtime/map.go):// Like mapaccess, but allocates a slot for the key if it is not present in the map. func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { if h == nil { panic(plainError("assignment to entry in nil map")) } // 省略 ... if h.flags&hashWriting != 0 { fatal("concurrent map writes") } hash := t.hasher(key, uintptr(h.hash0)) // Set hashWriting after calling t.hasher, since t.hasher may panic, // in which case we have not actually done a write. h.flags ^= hashWriting if h.buckets == nil { h.buckets = newobject(t.bucket) // newarray(t.bucket, 1) } again: // 省略 ... bucketloop: // 省略 ... done: if h.flags&hashWriting == 0 { fatal("concurrent map writes") } h.flags &^= hashWriting if t.indirectelem() { elem = *((*unsafe.Pointer)(elem)) } return elem }map 通过标志位 h.flags 来检查 map 是否存并发写情况,如果存在,则调用 fatal 方法,此时错误为 "fatal error",会强制退出程序,详情见 fatal 源码:// fatal triggers a fatal error that dumps a stack trace and exits. // // fatal is equivalent to throw, but is used when user code is expected to be // at fault for the failure, such as racing map writes. // // fatal does not include runtime frames, system goroutines, or frame metadata // (fp, sp, pc) in the stack trace unless GOTRACEBACK=system or higher. // //go:nosplit func fatal(s string) { // Everything fatal does should be recursively nosplit so it // can be called even when it's unsafe to grow the stack. systemstack(func() { print("fatal error: ", s, "\n") }) fatalthrow(throwTypeUser) } // fatalthrow implements an unrecoverable runtime throw. It freezes the // system, prints stack traces starting from its caller, and terminates the // process. // //go:nosplit func fatalthrow(t throwType) { pc := getcallerpc() sp := getcallersp() gp := getg() if gp.m.throwing == throwTypeNone { gp.m.throwing = t } // Switch to the system stack to avoid any stack growth, which may make // things worse if the runtime is in a bad state. systemstack(func() { startpanic_m() if dopanic_m(gp, pc, sp) { // crash uses a decent amount of nosplit stack and we're already // low on stack in throw, so crash on the system stack (unlike // fatalpanic). crash() } exit(2) }) *(*int)(nil) = 0 // not reached }从 fatal 方法的注释中可知,该方法等同于 throw 方法,但是只会抛出用户层面的异常,系统层面的异常由 runtime.throw 抛出。fatal 方法中又调用了 fatalthrow 方法, 该方法的注释中明确表示:"fatalthrow implements an unrecoverable runtime throw",因此通过这个方法抛出的异常均属于不可恢复异常。"concurrent map writes" 之所以被视为不可恢复异常,是因为 Golang 检测到数据竞争时,map 内部的结构已经被破坏了,继续运行可能会产生不可预期的结果,因此会强制结束程序。 以下罗列了一些其他不可恢复的异常种类:Out of memoryConcurrent map writesStack memory exhaustionAttempting to launch a nil function as a goroutineAll goroutines are asleep - deadlockThread limit exhaustion参考:[1] https://go-review.googlesource.com/c/go/+/390421[2] https://github.com/golang/go/blob/master/src/runtime/map.go#L578[3] https://stackoverflow.com/questions/57486620/are-all-runtime-errors-recoverable-in-go[4] https://www.jianshu.com/p/15c459c85141[5] https://www.zhihu.com/question/305845656/answer/728440889切片扩容有哪些坑?在开发过程中,将切片作为参数传递到函数中,然后在函数中修改切片内容,对应的修改预期一定能够同步更新到实参中,然而实际开发测试中发现,有的场景符合预期,有的却不符合预期。若在函数中对该切片进行扩容且扩容后的切片大小不超过其原始容量,此时修改切片中已有的元素,则修改会同步到实参切片中,而扩容不会同步到实参切片中;若在函数中对该切片进行扩容且扩容后的切片大小超过其原始容量,则修改不会同步到实参切片中,同时扩容不会同步到实参切片中,示例代码如下:package main import ( "fmt" ) func appendSliceWithinCap(s []string) { s = append(s, "two") s[0] = "appendSliceWithinCap" } func appendSliceOverCap(s []string) { s = append(s, "two") s = append(s, "three") s[0] = "appendSliceOverCap" } func main() { fmt.Println("hello main") s := make([]string, 1, 2) s[0] = "one" fmt.Println(s) // ["one"] appendSliceWithinCap(s) fmt.Println(s) // ["appendSliceWithinCap"] appendSliceOverCap(s) fmt.Println(s) // ["appendSliceWithinCap"] } 切片扩容机理函数中对切片参数中已有元素的更新会影响实参切片自身维护了一个指针属性,用于指向它底层数组中的某些元素的集合,其结构体如下所示:type slice struct { array unsafe.Pointer // 指向底层数组的指针 len int // 切片的长度 cap int // 切片的容量 }Golang 官方文档声明:函数参数传参只有值传递一种方式。值传递方式会在调用函数时将实际参数拷贝一份传递到函数中,slice 参数被传递到函数中时,其 array、len 以及 cap 都被复制了一份,因此函数中 slice 和实参 slice 是共享了底层 slice 数组的,函数中对 slice 中已有元素的修改会同步到实参 slice 中。切片的扩容策略切片可以通过 append 函数以追加元素的方式进行动态扩容,扩容的元素会存储在切片已有的存储空间中,然而切片的存储空间上限由切片容量决定,当扩容的元素数量超过切片容量时,切片必须对底层数组进行扩容才能容纳这些元素,我们通过 go/src/runtime/slice.go 中的 growslice 方法来解析下此时 Golang(1.19.2+) 扩容切片的策略:// growslice allocates new backing store for a slice. // // arguments: // // oldPtr = pointer to the slice's backing array // newLen = new length (= oldLen + num) // oldCap = original slice's capacity. // num = number of elements being added // et = element type // // return values: // // newPtr = pointer to the new backing store // newLen = same value as the argument // newCap = capacity of the new backing store // // Requires that uint(newLen) > uint(oldCap). // Assumes the original slice length is newLen - num // // A new backing store is allocated with space for at least newLen elements. // Existing entries [0, oldLen) are copied over to the new backing store. // Added entries [oldLen, newLen) are not initialized by growslice // (although for pointer-containing element types, they are zeroed). They // must be initialized by the caller. // Trailing entries [newLen, newCap) are zeroed. // // growslice's odd calling convention makes the generated code that calls // this function simpler. In particular, it accepts and returns the // new length so that the old length is not live (does not need to be // spilled/restored) and the new length is returned (also does not need // to be spilled/restored). func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice { oldLen := newLen - num // 省略 ... newcap := oldCap doublecap := newcap + newcap if newLen > doublecap { newcap = newLen } else { const threshold = 256 if oldCap < threshold { newcap = doublecap } else { // Check 0 < newcap to detect overflow // and prevent an infinite loop. for 0 < newcap && newcap < newLen { // Transition from growing 2x for small slices // to growing 1.25x for large slices. This formula // gives a smooth-ish transition between the two. newcap += (newcap + 3*threshold) / 4 } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { newcap = newLen } } } // 省略 ... // The check of overflow in addition to capmem > maxAlloc is needed // to prevent an overflow which can be used to trigger a segfault // on 32bit architectures with this example program: // // type T [1<<27 + 1]int64 // // var d T // var s []T // // func main() { // s = append(s, d, d, d, d) // print(len(s), "\n") // } if overflow || capmem > maxAlloc { panic(errorString("growslice: len out of range")) } var p unsafe.Pointer if et.ptrdata == 0 { p = mallocgc(capmem, nil, false) // The append() that calls growslice is going to overwrite from oldLen to newLen. // Only clear the part that will not be overwritten. // The reflect_growslice() that calls growslice will manually clear // the region not cleared here. memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { // Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory. p = mallocgc(capmem, et, true) if lenmem > 0 && writeBarrier.enabled { // Only shade the pointers in oldPtr since we know the destination slice p // only contains nil pointers because it has been cleared during alloc. bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.size+et.ptrdata) } } memmove(p, oldPtr, lenmem) return slice{p, newLen, newcap} }当扩容后的元素总数超过切片容量大小,其扩容策略如下:threshold = 256若扩容后的元素数量超过两倍原始容量,则直接将扩容后元素数量赋值给新容量,否则执行如下若原容量小于 threshold,则将原始容量的两倍赋值给新容量,否则执行如下在原始容量基础上,每次增加 (原始容量 + threshold * 3)/ 4,直到其不小于扩容后的元素数量扩容策略完成,得到新容量值后,会基于该值申请内存,然后将原数组中的数据以及扩容的数据拷贝到新内存中,此时完成切片的动态扩容,其公式如下:由上可知,当函数对形参 slice 进行扩容且扩容后的元素数量超过原始切片容量时,底层数组会迁移到另片内存区域,因此函数中对形参 slice 已有元素的更新无法影响到实参 slice。参考:[1] https://www.liangtian.me/post/go-slice/[2] https://juejin.cn/post/6888117219213967368[3]https://github.com/golang/go/blob/4c61e079c087052355c137ab8fcd9abf8728e50a/src/runtime/slice.goContext 是如何影响 grpc 通信超时控制的上述场景是我在实际开发应用过程中抽象出来的 grpc 通信过程,这也是一个较为通用的过程,client 端将带有超时时间的 context 传递到 server 端,server 端在超时时间内需要完成请求处理并返回响应给 client 端,若超过超时请求时间,那么链接将会断开,client 端将不会收到任何响应。然而在实际开发应用中,发现即便 server 端的 context 超时了,但是其请求响应仍会偶发性地传递到 client 端,导致我们的一个功能出现了不可预期的情况,为了用代码描述对应的交互流程,我在这里放了简化后的示例代码,描述了当时的交互逻辑。grpc 超时传递流程在 Golang grpc 通信过程中,超时信息会在不同通信端进行传递的,传递的介质是 Http2 Request Frame。grpc client 在发送请求之前,会将信息封装在不同的的 Frame 中,例如 Data Frame 用来存放请求的 response payload;Header Frame 用户存在一些跨 goroutine 传递的数据,例如路径信息。而超时信息就存放在 Header Frame 中,其源码如下所示:// NewStream 方法的调用链路:grpc.Invoke -> invoke -> sendRequest -> NewStream // NewStream creates a stream and register it into the transport as "active" // streams. func (t *http2Client) NewStream(ctx context.Context, callHdr *CallHdr) (_ *Stream, err error) { // 省略 ... // HPACK encodes various headers. Note that once WriteField(...) is // called, the corresponding headers/continuation frame has to be sent // because hpack.Encoder is stateful. t.hBuf.Reset() t.hEnc.WriteField(hpack.HeaderField{Name: ":method", Value: "POST"}) t.hEnc.WriteField(hpack.HeaderField{Name: ":scheme", Value: t.scheme}) t.hEnc.WriteField(hpack.HeaderField{Name: ":path", Value: callHdr.Method}) t.hEnc.WriteField(hpack.HeaderField{Name: ":authority", Value: callHdr.Host}) t.hEnc.WriteField(hpack.HeaderField{Name: "content-type", Value: "application/grpc"}) t.hEnc.WriteField(hpack.HeaderField{Name: "user-agent", Value: t.userAgent}) t.hEnc.WriteField(hpack.HeaderField{Name: "te", Value: "trailers"}) if callHdr.SendCompress != "" { t.hEnc.WriteField(hpack.HeaderField{Name: "grpc-encoding", Value: callHdr.SendCompress}) } if dl, ok := ctx.Deadline(); ok { // Send out timeout regardless its value. The server can detect timeout context by itself. timeout := dl.Sub(time.Now()) t.hEnc.WriteField(hpack.HeaderField{Name: "grpc-timeout", Value: encodeTimeout(timeout)}) } // 省略 ... }client server 端在收到超时信息后,将 grpc-timeout 字段从 Header 中取出,基于该超时信息新建一个 context 实例,其源码如下所示:// processHeaderField 方法调用链:grpc.Server -> handleRawConn -> serveNewHTTP2Transport -> serveStreams -> HandleStreams -> operateHeaders -> processHeaderField // operateHeader takes action on the decoded headers. func (t *http2Server) operateHeaders(frame *http2.MetaHeadersFrame, handle func(*Stream)) (close bool) { buf := newRecvBuffer() s := &Stream{ id: frame.Header().StreamID, st: t, buf: buf, fc: &inFlow{limit: initialWindowSize}, } var state decodeState for _, hf := range frame.Fields { state.processHeaderField(hf) } // 省略 ... s.recvCompress = state.encoding if state.timeoutSet { s.ctx, s.cancel = context.WithTimeout(context.TODO(), state.timeout) } else { s.ctx, s.cancel = context.WithCancel(context.TODO()) } // 省略 ... } func (d *decodeState) processHeaderField(f hpack.HeaderField) { switch f.Name { // 省略 ... case "grpc-timeout": d.timeoutSet = true var err error d.timeout, err = decodeTimeout(f.Value) if err != nil { d.setErr(streamErrorf(codes.Internal, "transport: malformed time-out: %v", err)) return } // 省略 ... } }在 grpc client 端,会去不断检查 context.Done() 来判断 context 是否超时,若超时,则会断开链接。然而,也会存在 context timeout races 的情况,例如,client 端 context 已经超时,但是此时下一轮检查还未开始,同时 server 端恰好返回了响应信息,此时虽然 client 端 context 超时了,但是仍然会接收到 server 端的响应并处理;更普遍的情况是 select { case <- ctx; ...; case <- response; ...},这就会导致有 50% 的概率未检测到 context 超时,详情请参考我之前在 grpc-go 中提的 issue。确保 grpc 响应超时错误在我之前经历的错误场景中, server 端 context 出现超时,并返回响应给 client 端,此时 client 端预期应该也会超时并断开链接,但实际是会成功接收到 client 端的响应,由于处理逻辑的问题,当时的响应并不包含超时错误,因此 client 端在接收到请求后会重新发送一次请求,重新发送完成后,才检测到 context 超时,最终断开链接,导致了错误的出现。因此,在应用过程中,需要在 server 端 context timeout 时,保证返回的 response 中的错误信息是 grpc.DeadlineExceeded,让 client 端也感知到 timeout 的发生,避免不必要逻辑的发生。参考:[1] https://github.com/grpc/grpc-go[2] https://github.com/grpc/grpc-go/issues/5206#issuecomment-1058564271[3] https://xiaomi-info.github.io/2019/12/30/grpc-deadline/
文章
存储  ·  Java  ·  Serverless  ·  Go
2022-12-07
MaxCompute-Java自建UDF入门详解
准备环境1. 安装JDK建议安装JDK 1.8以上版本2. 安装开发工具本文档测试使用IDEA工具,具体安装方式可以参考在线资料。开发流程1. 创建项目创建MaxCompute Studio项目:在MaxCompute Studio项目下创建MaxCompute Java Module,如下所示<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.aliyun.odps.myJava</groupId> <artifactId>MyFirstModel</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-sdk-core</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-sdk-udf</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-udf-local</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-sdk-mapred</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-mapred-local</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-sdk-graph</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>com.aliyun.odps</groupId> <artifactId>odps-graph-local</artifactId> <version>${sdk.version}</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.28.odps</version> </dependency> </dependencies> <properties> <sdk.version>0.36.4-public</sdk.version> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> </project>2. 开发UDF注意在哪个目录下创建的类,注册的时候注意下类所在的位置)。为了区分,我们创建两个UDF类做验证对比学习,如下所示:3. 本地测试创建测试类,在本地测试,并验证结果符合预期,如下所示:public class MyUDFTest { @Before public void start() { System.out.println("start"); } @After public void end() { System.out.println("end"); } @Test public void testMyUDF() { MyUDF myFirstUDF = new MyUDF(); System.out.println(myFirstUDF.evaluate("zhangsan")); } @Test public void testMyUDFCopy() { MyUDFCopy myUDFCopy = new MyUDFCopy(); System.out.println(myUDFCopy.evaluate("zhangsan")); } }4. 本地打包pom文件添加 <packaging>jarpackaging>, 双击执行Maven命令打jar包,如下所示:打包后这里可以看到对应jar包,如下所示: 5. 上传资源并提交登录Dataworks数据开发控制台,上传资源并提交(开发环境测试可以先不发布),如下所示:6. 注册函数并提交登录Dataworks数据开发控制台,创建函数并提交(开发环境测试可以先不发布)。测试验证写了两个类,注意注册配置的区别,如下所示:7. 创建SQL节点执行验证SELECT MYUDF("zhangsan"),MYUDFCOPY("zhangsan");获取代码由于测试的代码逻辑较为简单, 需要根据实际场景自行编辑逻辑,注意针对入参做下空值处理import com.aliyun.odps.udf.UDF; public class MyUDF extends UDF { public String evaluate(String s) { return "hello" + s; } }package com.aliyun; import com.aliyun.odps.udf.UDF; public class MyUDFCopy extends UDF { public String evaluate(String s) { return "hi:" + s; } }
文章
SQL  ·  分布式计算  ·  DataWorks  ·  Java  ·  MaxCompute  ·  开发工具  ·  Maven
2022-11-25
IoT小程序在展示中央空调采集数据和实时运行状态上的应用
  利用前端语言实现跨平台应用开发似乎是大势所趋,跨平台并不是一个新的概念,“一次编译、到处运行”是老牌服务端跨平台语言Java的一个基本特性。随着时代的发展,无论是后端开发语言还是前端开发语言,一切都在朝着减少工作量,降低工作成本的方向发展。  与后端开发语言不同,利用前端语言实现跨平台有先天的优势,比如后端语言Java跨平台需要将源代码编译为class字节码文件后,再放进 Java 虚拟机运行;而前端语言JavaScript是直接将源代码放进JavaScript解释器运行。这就使得以JavaScript为跨平台语言开发的应用,可移植性非常强大。  目前跨平台技术按照解决方案分类,主要分为 Web 跨平台、容器跨平台、小程序跨平台。这里,我们主要以小程序跨端为例,测试对比IoT小程序和其他小程序在开发和应用上的优缺点。说到小程序,大家肯定想到微信小程序,实际在各大互联网公司:支付宝、百度、头条等等都有自己的小程序,小程序跨平台和Web跨平台十分类似,都是基于前端语言实现,小程序跨平台的优势在于可以调用系统底层能力,例如:蓝牙、相机等,性能方面也优于Web跨平台。  IoT小程序和大多数小程序一样,它是一套跨平台应用显示框架,它利用JS语言低门槛和API标准化大幅度降低了IoT应用的研发难度,其官方框架介绍如下:  IoT小程序在前端框架能力、应用框架能力、图形框架能力都进行了适配和优化。那么接下来,我们按照其官方步骤搭建开发环境,然后结合中央空调数据采集和状态显示的实际应用场景开发物联网小程序应用。一、IoT小程序开发环境搭建  IoT小程序开发环境搭建一共分为四步,对于前端开发来说,安装NodeJS、配置cnpm、安装VSCode都是轻车熟路,不需要细讲,唯一不同的是按照官方说明安装IoT小程序的模拟器和VSCode开发插件HaaS UI,前期开发环境准备完毕,运行Demo查看一下效果,然后就可以进行IoT小程序应用开发了。搭建开发环境,安装HaaS UI插件和运行新建项目,出现一下界面说明开发环境搭建成功,就可以进行IoT小程序开发了:二、开发展示中央空调采集数据和运行状态的IoT小程序应用应用场景  中央空调的维保单位会对中央空调进行定期维护保养,定期的维护保养可排出故障隐患,减少事故发生,降低运行费用,延长设备的使用寿命,同时保障正常的工作时序。除了定期的维护保养外,还需要实时监测中央空调的运行参数(温度、累计排污量、不锈钢_腐蚀率等)和运行状态,及时发现中央空调运行过程中某些参数低于或高于报警值的问题,以便及时定位诊断中央空调存在的问题,然后进行相应的维护保养操作。架构实现  中央空调的数据采集和展示是典型的物联网应用架构,在中央空调端部署采集终端,通过Modbus通信协议采集中央空调设备参数,然后再由采集终端通过MQTT消息发送的我们的云端服务器,云端服务器接收到MQTT消息后转发到消息队列Kafka中,由云服务器上的自定义服务应用订阅Kafka主题,再存储到我们时序数据库中。下图展示了物联网应用的整体架构和IoT小程序在物联网架构中的位置:  IoT小程序框架作为跨平台应用显示框架,顾名思义,其在物联网应用中主要作为显示框架开发。在传统应用中,我们使用微信小程序实现采集数据和运行状态的展示。而IoT小程序支持部署在AliOS Things、Ubuntu、Linux、MacOS、Window等系统中,这就使得我们可以灵活的将IoT小程序部署到多种设备终端中运行。  下面将以阿里云ASP-80智显面板为例,把展示中央空调采集数据和运行状态的IoT小程序部署在阿里云ASP-80智显面板中。IoT小程序开发  我们将从IoT小程序提供的前端框架能力、应用框架能力、图形框架能力来规划相应的功能开发。  IoT小程序采用Vue.js(v2.6.12)开源框架,实现了W3C标准的标签和样式子集;定义了四个应用生命周期,分别是:onLaunch,onShow,onHide,onDestroy;定义了十四个前端基础组件,除了基础的CSS样式支持外,还提供了对Less的支持;Net网络请求通过框架内置的JSAPI实现。  为了快速熟悉IoT小程序框架的开发方式,我们将在VSCode中导入官方公版案例,并以公版案例为基础框架开发我们想要的功能。简单实现通过网络请求获取中央空调采集数据并展示:1、在VSCode编辑器中导入从IoT小程序官网下载的公版案例,下载地址。2、因为IoT小程序前端框架使用的是Vue.js框架,所以在新增页面时也是按照Vue.js框架的模式,将页面添加到pages目录。我们是空调项目的IoT小程序,所以这里在pages目录下新增air-conditioning目录用于存放空调IoT小程序相关前端代码。3、在app.json中配置新增的页面,修改pages项,增加"air-conditioning": "pages/air-conditioning/index.vue"。{ "pages": { ...... "air-conditioning": "pages/air-conditioning/index.vue", ...... }, "options": { "style": { "theme": "theme-dark" } } }4、在air-conditioning目录下新增index.vue前端页面代码,用于展示空调的采集数据是否正常及历史曲线图。设计需要开发的界面如下,页面的元素有栅格布局、Tabs 标签页、Radio单选框、日期选择框、曲线图表等元素。5、首先是实现Tabs标签页,IoT小程序没有Tabs组件,只能自己设置多个Text组件自定义样式并添加click事件来实现。 <div class="tab-list"> <fl-icon name="back" class="nav-back" @click="onBack" /> <text v-for="(item, index) in scenes" :key="index" :class="'tab-item' + (index === selectedIndex ? ' tab-item-selected' : '')" @click="tabSelected(index)" >{{ item }}</text > </div> ...... data() { return { scenes: ["设备概览", "实时数据", "数据统计", "状态统计"], selectedIndex: 0 }; }, ......6、添加采集数据显示列表,在其他小程序框架中,尤其是以Vue.js为基础框架的小程序框架,这里有成熟的组件,而IoT小程序也是需要自己来实现。<template> <div class="scene-wrapper" v-if="current"> <div class="label-temperature-wrapper top-title"> <div class="label-temperature-wrapper left-text"> <text class="label-temperature">设备编码:</text> <text class="label-temperature-unit">{{deviceNo}}</text> </div> <div class="label-temperature-wrapper right-text"> <text class="label-temperature">数据日期:</text> <text class="label-temperature-unit">{{collectTime}}</text> </div> </div> <div class="main-wrapper"> <div class="section"> <div class="demo-block icon-block"> <div class="icons-item" v-for="(value, key, index) in IconTypes" :key="index"> <div class="label-title-wrapper"> <text class="label-title left-text">{{paramName}}</text> <text class="label-title-unit right-text" style="padding-right: 5px;">{{paramWarn}}</text> </div> <div class="label-zhibiao-wrapper"> <text class="label-zhibiao">当前值:</text> <text class="label-zhibiao-unit">{{value}}</text> </div> <div class="label-zhibiao-wrapper" style="margin-bottom: 10px;"> <text class="label-zhibiao">目标值:</text> <text class="label-zhibiao-unit">{{targetValue}}</text> </div> </div> </div> </div> </div> </div> </template>  在开发过程中发现,IoT小程序对样式的支持不是很全面,本来想将组件放置在同一行,一般情况下,只需要使用标准CSS样式display: inline就可以实现,但这里没有效果只能通过Flexbox进行布局在同一行。在设置字体方面,本来想把采集数据显示的描述字段加粗,用于突出显示,但是使用CSS样式font-weight无效,无论是设置数值还是blod,没有一点加粗效果。7、界面实现之后,需要发送数据请求,来查询采集数据并显示在界面上。IoT小程序通过框架内置JSAPI的Net网络提供网络请求工具。目前从官方文档和代码中来看,官方框架只提供了http请求,没有提供物联网中常用的WebSocket和MQTT工具,估计需要自定义扩展系统JSAPI实现其他网络请求。 created() { const http = $falcon.jsapi.http http.request({ url: 'http://服务域名/device/iot/query/data/point', data: { 'deviceNo': '97306000000000005', 'rangeType': 'mo', 'lastPoint': '1', 'beginDateTime': '2023-02-10+16:09:42', 'endDateTime': '2023-03-12+16:09:42' }, header: { 'Accept': 'application/json;charset=UTF-8', 'Accept-Encoding': 'gzip, deflate, br', 'Content-Type': 'application/json;charset=UTF-8', 'Authorization': '有效token' } }, (response) => { console.log(response) var obj = JSON.parse(response.result) console.log(obj.success) console.log(JSON.parse(obj.data)) }); },  按照官方要求编写http请求,发现默认未开启https请求:Protocol "https" not supported or disabled in libcurl。切换为http请求,返回数据为乱码,设置Accept-Encoding和Accept为application/json;charset=UTF-8仍然无效,且返回数据为JSON字符串,需要自己手动使用JSON.parse()进行转换,对于习惯于应用成熟框架的人来说,十分不友好。想了解更多关于 $falcon.jsapi.http的相关配置和实现,但是官方文档只有寥寥几句,没有详细的说明如何使用和配置,以及http请求中遇到一些常见问题的解决方式。8、IoT小程序框架提供画布组件,原则上来讲可以实现常用的曲线图表功能,但是如果使用其基础能力从零开始开发一套图表系统,耗时又耗力,所以这里尝试引入常用的图表组件库ECharts,使用ECharts在IoT小程序上显示曲线图表。执行cnpm install echarts --save安装echarts组件cnpm install echarts --save新建echarts配置文件,按需引入// 加载echarts,注意引入文件的路径 import echarts from 'echarts/lib/echarts' // 再引入你需要使用的图表类型,标题,提示信息等 import 'echarts/lib/chart/bar' import 'echarts/lib/chart/pie' import 'echarts/lib/component/legend' import 'echarts/lib/component/title' import 'echarts/lib/component/tooltip' export default echarts新增echarts组件ChartDemo.vue<template> <div ref="chartDemo" style="height:200px;" ></div> </template> <script> import echarts from '@/utils/echarts-config.js' const ChartDemo = { name: 'ChartDemo', data() { return { chart: null } }, watch: { option: { handler(newValue, oldValue) { this.chart.setOption(newValue) }, deep: true } }, mounted() { this.chart = echarts.init(this.$refs.chartDemo) }, methods: { setOption(option) { this.chart && this.chart.setOption(option) }, throttle(func, wait, options) { let time, context, args let previous = 0 if (!options) options = {} const later = function() { previous = options.leading === false ? 0 : new Date().getTime() time = null func.apply(context, args) if (!time) context = args = null } const throttled = function() { const now = new Date().getTime() if (!previous && options.leading === false) previous = now const remaining = wait - (now - previous) context = this args = arguments if (remaining <= 0 || remaining > wait) { if (time) { clearTimeout(time) time = null } previous = now func.apply(context, args) if (!time) context = args = null } else if (!time && options.trailing !== false) { time = setTimeout(later, remaining) } } return throttled } } } export default ChartDemo </script> 在base-page.js中注册全局组件...... import ChartDemo from './components/ChartDemo.vue'; export class BasePage extends $falcon.Page { constructor() { super() } beforeVueInstantiate(Vue) { ...... Vue.component('ChartDemo', ChartDemo); } }新建空调采集数据展示页history-charts.vue,用于展示Echarts图表<template> <div class="scene-wrapper" v-if="current"> <div class="brightness-wrap"> <ChartBlock ref="chart2"></ChartBlock> </div> </div> </template> <script> let option2 = { title: { text: '中央空调状态图', subtext: '运行状态占比', left: 'center' }, tooltip: { trigger: 'item', formatter: '{a} <br/>{b} : {c} ({d}%)' }, legend: { orient: 'vertical', left: 'left', data: ['开机', '关机', '报警', '故障', '空闲'] }, series: [ { name: '运行状态', type: 'pie', radius: '55%', center: ['50%', '60%'], data: [ { value: 335, name: '开机' }, { value: 310, name: '关机' }, { value: 234, name: '报警' }, { value: 135, name: '故障' }, { value: 1548, name: '空闲' } ], emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } } } ] } export default { props:{ current:{ type:Boolean, default:false } }, data() { return { }; }, methods: { }, mounted: function() { this.$refs.chart2.setOption(option2) } }; </script>执行HaaS UI: Build-Debug ,显示打包成功执行HaaS UI: Simulator ,显示“当前HaaS UI: Simulator任务正在执行,请稍后再试”  本来想在模拟器上看一下Echarts显示效果,但是执行HaaS UI: Simulator时一直显示任务正在执行。然后以为是系统进程占用,但是重启、关闭进程等操作一系列操作下来,仍然显示此提示,最后将Echarts代码删除,恢复到没有Echarts的状态,又可以执行了。这里不清楚是否是IoT小程序不支持引入第三方图表组件,从官方文档中没有找到答案。后来又使用echarts的封装组件v-charts进行了尝试,结果依然不能展示。  如果不能使用第三方组件,那么只能使用IoT官方小程序提供的画布组件来自己实现图表功能,官方提供的画布曲线图示例。9、通过IoT小程序提供的组件分别实现显示中央空调采集数据的实时数据、数据统计、状态统计图表。-实现实时数据折线图<template> <div class="scene-wrapper" v-show="current"> <div class="main-wrapper"> <div class="label-temperature-wrapper top-title"> <div class="label-temperature-wrapper left-text"> <text class="label-temperature">设备编码:</text> <text class="label-temperature-unit">{{deviceNo}}</text> </div> <div class="label-temperature-wrapper right-text"> <text class="label-temperature">数据日期:</text> <text class="label-temperature-unit">{{collectTime}}</text> </div> </div> <canvas ref="c2" class="canvas" width="650" height="300"></canvas> </div> </div> </template> <script> export default { name: "canvas", props: {}, data() { return { deviceNo: '97306000000000005', collectTime: '2023-03-11 23:59:59' }; }, mounted() { this.c2(); }, methods: { c2() { let ctx = typeof createCanvasContext === "function" ? createCanvasContext(this.$refs.c2) : this.$refs.c1.getContext("2d"); // Demo测试数据 let arr = [{key:'01:00',value:61.68},{key:'02:00',value:83.68},{key:'03:00',value:56.68},{key:'04:00',value:86.68},{key:'05:00',value:53.68}, {key:'06:00',value:41.68},{key:'07:00',value:33.68}]; this.drawStat(ctx, arr); }, //该函数用来绘制折线图 drawStat(ctx, arr) { //画布的款高 var cw = 700; var ch = 300; //内间距padding var padding = 35; //原点,bottomRight:X轴终点,topLeft:Y轴终点 var origin = {x:padding,y:ch-padding}; var bottomRight = {x:cw-padding,y:ch-padding}; var topLeft = {x:padding,y:padding}; ctx.strokeStyle='#FF9500'; ctx.fillStyle='#FF9500'; //绘制X轴 ctx.beginPath(); ctx.moveTo(origin.x,origin.y); ctx.lineTo(bottomRight.x,bottomRight.y); //绘制X轴箭头 ctx.lineTo(bottomRight.x-10,bottomRight.y-5); ctx.moveTo(bottomRight.x,bottomRight.y); ctx.lineTo(bottomRight.x-10,bottomRight.y+5); //绘制Y轴 ctx.moveTo(origin.x,origin.y); ctx.lineTo(topLeft.x,topLeft.y); //绘制Y轴箭头 ctx.lineTo(topLeft.x-5,topLeft.y+10); ctx.moveTo(topLeft.x,topLeft.y); ctx.lineTo(topLeft.x+5,topLeft.y+10); //设置字号 var color = '#FF9500'; ctx.fillStyle=color; ctx.font = "13px scans-serif";//设置字体 //绘制X方向刻度 //计算刻度可使用的总宽度 var avgWidth = (cw - 2*padding - 50)/(arr.length-1); for(var i=0;i<arr.length;i++){ //循环绘制所有刻度线 if(i > 0){ //移动刻度起点 ctx.moveTo(origin.x+i*avgWidth,origin.y); //绘制到刻度终点 ctx.lineTo(origin.x+i*avgWidth,origin.y-10); } //X轴说明文字:1月,2月... var txtWidth = 35; ctx.fillText( arr[i].key, origin.x+i*avgWidth-txtWidth/2 + 10, origin.y+20); } //绘制Y方向刻度 //最大刻度max var max = 0; for(var i=0;i<arr.length;i++){ if(arr[i].value>max){ max=arr[i].value; } } console.log(max); /*var max = Math.max.apply(this,arr); console.log(max);*/ var avgValue=Math.floor(max/5); var avgHeight = (ch-padding*2-50)/5; for(var i=1;i<arr.length;i++){ //绘制Y轴刻度 ctx.moveTo(origin.x,origin.y-i*avgHeight); ctx.lineTo(origin.x+10,origin.y-i*avgHeight); //绘制Y轴文字 var txtWidth = 40; ctx.fillText(avgValue*i, origin.x-txtWidth-5, origin.y-i*avgHeight+6); } //绘制折线 for(var i=0;i<arr.length;i++){ var posY = origin.y - Math.floor(arr[i].value/max*(ch-2*padding-50)); if(i==0){ ctx.moveTo(origin.x+i*avgWidth,posY); }else{ ctx.lineTo(origin.x+i*avgWidth,posY); } //具体金额文字 ctx.fillText(arr[i].value, origin.x+i*avgWidth, posY ) } ctx.stroke(); //绘制折线上的小圆点 ctx.beginPath(); for(var i=0;i<arr.length;i++){ var posY = origin.y - Math.floor(arr[i].value/max*(ch-2*padding-50)); ctx.arc(origin.x+i*avgWidth,posY,4,0,Math.PI*2);//圆心,半径,画圆 ctx.closePath(); } ctx.fill(); } } }; </script>-数据统计图表<template> <div class="scene-wrapper" v-show="current"> <div class="main-wrapper"> <div class="label-temperature-wrapper top-title"> <div class="label-temperature-wrapper left-text"> <text class="label-temperature">设备编码:</text> <text class="label-temperature-unit">{{deviceNo}}</text> </div> <div class="label-temperature-wrapper right-text"> <text class="label-temperature">数据日期:</text> <text class="label-temperature-unit">{{collectTime}}</text> </div> </div> <canvas ref="c1" class="canvas" width="650" height="300"></canvas> </div> </div> </template> <script> export default { name: "canvas", props: {}, data() { return { deviceNo: '97306000000000005', collectTime: '2023-03-13 20:23:36' }; }, mounted() { this.c1(); }, methods: { c1() { let ctx = typeof createCanvasContext === "function" ? createCanvasContext(this.$refs.c1) : this.$refs.c1.getContext("2d"); this.draw(ctx); }, draw(ctx){ var x0=30,//x轴0处坐标 y0=280,//y轴0处坐标 x1=700,//x轴顶处坐标 y1=30,//y轴顶处坐标 dis=30; //先绘制X和Y轴 ctx.beginPath(); ctx.lineWidth=1; ctx.strokeStyle='#FF9500'; ctx.fillStyle='#FF9500'; ctx.moveTo(x0,y1);//笔移动到Y轴的顶部 ctx.lineTo(x0,y0);//绘制Y轴 ctx.lineTo(x1,y0);//绘制X轴 ctx.stroke(); //绘制虚线和Y轴值 var yDis = y0-y1; var n=1; ctx.fillText(0,x0-20,y0);//x,y轴原点显示0 while(yDis>dis){ ctx.beginPath(); //每隔30划一个虚线 ctx.setLineDash([2,2]);//实线和空白的比例 ctx.moveTo(x1,y0-dis); ctx.lineTo(x0,y0-dis); ctx.fillText(dis,x0-20,y0-dis); //每隔30划一个虚线 dis+=30; ctx.stroke(); } var xDis=30,//设定柱子之前的间距 width=40;//设定每个柱子的宽度 //绘制柱状和在顶部显示值 for(var i=0;i<12;i++){//假设有8个月 ctx.beginPath(); var color = '#' + Math.random().toString(16).substr(2, 6).toUpperCase();//随机颜色 ctx.fillStyle=color; ctx.font = "13px scans-serif";//设置字体 var height = Math.round(Math.random()*220+20);//在一定范围内随机高度 var rectX=x0+(width+xDis)*i,//柱子的x位置 rectY=height;//柱子的y位置 ctx.color='#FF9500'; ctx.fillText((i+1)+'月份',rectX,y0+15);//绘制最下面的月份稳住 ctx.fillRect(rectX,y0, width, -height);//绘制一个柱状 ctx.fillText(rectY,rectX+10,280-rectY-5);//显示柱子的值 } }, } }; </script>-状态统计图表<template> <div class="scene-wrapper" v-show="current"> <div class="main-wrapper"> <div class="label-temperature-wrapper top-title"> <div class="label-temperature-wrapper left-text"> <text class="label-temperature">设备编码:</text> <text class="label-temperature-unit">{{deviceNo}}</text> </div> <div class="label-temperature-wrapper right-text"> <text class="label-temperature">数据日期:</text> <text class="label-temperature-unit">{{collectTime}}</text> </div> </div> <canvas ref="c3" class="canvas" width="600" height="300"></canvas> </div> </div> </template> <script> export default { name: "canvas", props: {}, data() { return { deviceNo: '97306000000000005', collectTime: '2023-03-13 20:29:36' }; }, mounted() { this.c3(); }, methods: { c3() { let ctx = typeof createCanvasContext === "function" ? createCanvasContext(this.$refs.c3) : this.$refs.c3.getContext("2d"); this.drawPie(ctx); }, drawPie(pen){ // Demo测试数据 var deg = Math.PI / 180 var arr = [ { name: "开机", time: 8000, color: '#7CFF00' }, { name: "关机", time: 1580, color: '#737F9C' }, { name: "空闲", time: 5790, color: '#0ECC9B' }, { name: "故障", time: 4090, color: '#893FCD' }, { name: "报警", time: 2439, color: '#EF4141' }, ]; //总价 pen.translate(30,-120); arr.tatol = 0; for (let i = 0; i < arr.length; i++) { arr.tatol = arr.tatol + arr[i].time } var stardeg = 0 arr.forEach(el => { pen.beginPath() var r1 = 115 pen.fillStyle = el.color pen.strokeStyle='#209AAD'; pen.font = "15px scans-serif"; //求出每个time的占比 var angle = (el.time / arr.tatol) * 360 //利用占比来画圆弧 pen.arc(300, 300, r1, stardeg * deg, (stardeg + angle) * deg) //将圆弧与圆心相连接,形成扇形 pen.lineTo(300, 300) var r2 = r1+10; if(el.name === '关机' || el.name === '空闲') { r2 = r1+30 } //给每个扇形添加数组的name var y1 = 300 + Math.sin((stardeg + angle) * deg-angle*deg/2 ) *( r2) var x1 = 300 + Math.cos((stardeg + angle) * deg-angle*deg/2 ) * (r2) pen.fillText(`${el.name}`, x1, y1) stardeg = stardeg + angle pen.fill() pen.stroke() }); }, } }; </script>三、将IoT小程序更新到ASP-80智显面板查看运行效果  将IoT小程序更新到ASP-80智显面板,在硬件设备上查看IoT应用运行效果。如果是使用PC端初次连接,那么需要安装相关驱动和配置,否则无法使用VSCode直接更新IoT小程序到ASP-80智显面板。1、如果使用Win10将IoT小程序包更新到ASP-80智显面板上,必须用到CH340串口驱动,第一次通过TypeC数据线连接设备,PC端设备管理器的端口处不显示端口,这时需要下载Windows版本的CH340串口驱动下载链接 。2、将下载的驱动文件CH341SER.ZIP解压并安装之后,再次查看PC端设备管理器端口就有了USB Serial CH340端口。3、使用SourceCRT连接ASP-80智显面板,按照官方文档说明,修改配置文件,连接好WiFi无线网,下一步通过VSCode直接更新IoT小程序到ASP-80智显面板上查看测试。4、所有准备工作就绪后,点击VSCode的上传按钮HaaS UI: Device,将应用打包并上传至ASP-80智显面板。在选择ip地址框的时候,输入我们上一步获取到的ip地址192.168.1.112,其他参数保持默认即可,上传成功后,VSCode控制台提示安装app成功。5、IoT小程序安装成功之后就可以在ASP-80智显面板上查看运行效果了。  综上所述,IoT小程序框架在跨系统平台(AliOS Things、Ubuntu、Linux、MacOS、Window等)方面提供了非常优秀的基础能力,应用的更新升级提供了多种方式,在实际业务开发过程中可以灵活选择。IoT小程序框架通过JSAPI提供了调用系统底层应用的能力,同时提供了自定义JSAPI扩展封装的方法,这样就足够业务开发通过自定义的方式满足特殊的业务需求。  虽然多家互联网公司都提供了小程序框架,但在128M 128M这样的低资源设备里输出,IoT小程序是比较领先的,它不需要另外下载APP作为小程序的容器,降低了资源的消耗,这一点是其他小程序框架所不能比拟的。  但是在前端框架方面,实用组件太少。其他小程序已发展多年,基于基础组件封装并开源的前端组件应用场景非常丰富,对于中小企业来说,习惯于使用成熟的开源组件,如果使用IoT小程序开发物联网应用可能需要耗费一定的人力物力。既然是基于Vue.js的框架,却没有提供引入其他优秀组件的文档说明和示例,不利于物联网应用的快速开发,希望官方能够完善文档,详细说明IoT小程序开发框架配置项,将来能够提供更多的实用组件。
文章
数据采集  ·  小程序  ·  前端开发  ·  JavaScript  ·  Ubuntu  ·  物联网  ·  Java  ·  Linux  ·  iOS开发  ·  MacOS
2023-03-22
Rocketmq-消息驱动
1、前言我们都知道,不管是单体项目也好,微服务分布式项目也罢,都逃不脱mq的使用,那么什么是mq?MQ的应用场景有哪些?消息消费有哪些要注意的细节问题?微服务中如何接入MQ?相信大家都会有或多或少的困惑点和不懂点,那这篇文章就带领大家了解Rocketmq-消息驱动。如果小伙伴们对微服务感兴趣,欢迎订阅微服务专栏:从0-1学习微服务,为了感谢粉丝们的支持,目前限时该专栏限时免费,感谢支持。微服务专栏传送门:https://blog.csdn.net/weixin_44427181/category_12053421.html?spm=1001.2014.3001.54822、MQ简介2.1 什么是MQ?在进入MQ讲解之前,什么是MQ?MQ(Message Queue)是一种跨进程的通信机制,用于传递消息。通俗点说,就是一个先进先出的数据结构。2.2 MQ的应用场景2.2.1 异步解耦异步解耦是消息队列 MQ 的主要特点,主要目的是减少请求响应时间和解耦。主要的使用场景就是将比较耗时而且不需要即时(同步)返回结果的操作作为消息放入消息队列。同时,由于使用了消息队列 MQ,只要保证消息格式不变,消息的发送方和接收方并不需要彼此联系,也不需要受对方的影响,这叫做解耦合。案例:最常见的一个场景是用户注册后,需要发送注册邮件和短信通知,以告知用户注册成功。传统的做法如下:此架构下注册、邮件、短信三个任务全部完成后,才返回注册结果到客户端,用户才能使用账号登录。但是对于用户来说,注册功能实际只需要注册系统存储用户的账户信息后,该用户便可以登录,而后续的注册短信和邮件不是即时需要关注的步骤。所以实际当数据写入注册系统后,注册系统就可以把其他的操作放入对应的消息队列 MQ 中然后马上返 回用户结果,由消息队列 MQ 异步地进行这些操作。架构图如下:2.2.2 流量削峰流量削峰也是消息队列 MQ 的常用场景,一般在秒杀或团队抢购(高并发)活动中使用广泛。在秒杀或团队抢购活动中,由于用户请求量较大,导致流量暴增,秒杀的应用在处理如此大量的访问流量后,下游的通知系统无法承载海量的调用量,甚至会导致系统崩溃等问题而发生漏通知的情况。为解决这些问题,可在应用和下游通知系统之间加入消息队列 MQ。秒杀处理流程如下所述:用户发起海量秒杀请求到秒杀业务处理系统。秒杀处理系统按照秒杀处理逻辑将满足秒杀条件的请求发送至消息队列 MQ。下游的通知系统订阅消息队列 MQ 的秒杀相关消息,再将秒杀成功的消息发送到相应用户。用户收到秒杀成功的通知。2.3 常见的MQ目前业界有很多MQ产品,比较出名的有下面这些:ZeroMQ号称最快的消息队列系统,尤其针对大吞吐量的需求场景。扩展性好,开发比较灵活,采用C语言实现,实际上只是一个socket库的重新封装,如果做为消息队列使用,需要开发大量的代码。ZeroMQ仅 提供非持久性的队列,也就是说如果down机,数据将会丢失。RabbitMQ:使用erlang语言开发,性能较好,适合于企业级的开发。但是不利于做二次开发和维护。ActiveMQ:历史悠久的Apache开源项目。已经在很多产品中得到应用,实现了JMS1.1规范,可以和spring-jms轻 松融合,实现了多种协议,支持持久化到数据库,对队列数较多的情况支持不好。RocketMQ:阿里巴巴的MQ中间件,由java语言开发,性能非常好,能够撑住双十一的大流量,而且使用起来很简单。Kafka:Kafka是Apache下的一个子项目,是一个高性能跨语言分布式Publish/Subscribe消息队列系统,相对 于ActiveMQ是一个非常轻量级的消息系统,除了性能非常好之外,还是一个工作良好的分布式系统。3、RocketMQ入门3.1 RocketMQ环境搭建接下来我们先在linux平台下安装一个RocketMQ的服务:3.1.1 下载RocketMQhttp://rocketmq.apache.org/release_notes/release-notes-4.4.0/环境要求Linux 64位操作系统64bit JDK 1.8+3.1.2 安装RocketMQ1、上传文件到Linux系统:[root@heima rocketmq]# ls /usr/local/src/ rocketmq-all-4.4.0-bin-release.zip2、解压到安装目录:[root@heima src]# unzip rocketmq-all-4.4.0-bin-release.zip [root@heima src]# mv rocketmq-all-4.4.0-bin-release ../rocketmq3.1.3 启动RocketMQ1、切换到安装目录:[root@heima rocketmq]# ls benchmark bin conf lib LICENSE NOTICE README.md2、启动NameServer[root@heima rocketmq]# nohup ./bin/mqnamesrv & [1] 1467 \# 只要进程不报错,就应该是启动成功了,可以查看一下日志 [root@heima rocketmq]# tail -f /root/logs/rocketmqlogs/namesrv.log3、启动Broker:\# 编辑bin/runbroker.sh 和 bin/runserver.sh文件,修改里面的 \# JAVA_OPT="${JAVA_OPT} -server -Xms8g -Xmx8g -Xmn4g" \# 为JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m" [root@heima rocketmq]# nohup bin/mqbroker -n localhost:9876 & [root@heima rocketmq]# tail -f /root/logs/rocketmqlogs/broker.log3.1.4 测试RocketMQ1、测试消息发送[root@heima rocketmq]# export NAMESRV_ADDR=localhost:9876 [root@heima rocketmq]# bin/tools.sh org.apache.rocketmq.example.quickstart.Producer2、测试消息接收[root@heima rocketmq]# export NAMESRV_ADDR=localhost:9876 [root@heima rocketmq]# bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer3、关闭RocketMQ[root@heima rocketmq]# bin/mqshutdown broker [root@heima rocketmq]# bin/mqshutdown namesrv3.2 RocketMQ的架构及概念如上图所示,整体可以分成4个角色,分别是:NameServer,Broker,Producer,Consumer。Broker(邮递员):Broker是RocketMQ的核心,负责消息的接收,存储,投递等功能。NameServer(邮局):消息队列的协调者,Broker向它注册路由信息,同时Producer和Consumer 向其获取路由信息Producer(寄件人)消息的生产者,需要从NameServer获取Broker信息,然后与 Broker建立连接,向Broker发送消息。Consumer(收件人) :消息的消费者,需要从NameServer获取Broker信息,然后与Broker建立连接,从Broker获取消息Topic(地区):用来区分不同类型的消息,发送和接收消息前都需要先创建Topic,针对Topic来发送和接收消息。Message Queue(邮件)为了提高性能和吞吐量,引入了Message Queue,一个Topic可以设置一个或多个Message Queue,这样消息就可以并行往各个Message Queue发送消息,消费者也可以并行的从多个Message Queue读取消息。Message:Message 是消息的载体。Producer Group:生产者组,简单来说就是多个发送同一类消息的生产者称之为一个生产者组。Consumer Group:消费者组,消费同一类消息的多个 consumer 实例组成一个消费者组。3.3 RocketMQ控制台安装1、下载\# 在git上下载下面的工程 rocketmq-console-1.0.0 https://github.com/apache/rocketmq-externals/releases2、修改配置文件\# 修改配置文件 rocketmq-console\src\main\resources\application.properties server.port=7777 #项目启动后的端口号 #nameserv的地址,注意防火墙要开启 9876端口 rocketmq.config.namesrvAddr=192.168.109.131:98763、打成jar包,并启动\# 进入控制台项目,将工程打成jar包 mvn clean package -Dmaven.test.skip=true \# 启动控制台 java -jar target/rocketmq-console-ng-1.0.0.jar4、访问控制台4、案例接下来我们模拟一种场景: 下单成功之后,向下单用户发送短信。设计图如下:![](https://img-blog.csdnimg.cn/f4288431663049daba76d82d3b501e8a.png)4.1 订单微服务发送消息1、在 shop-order 中添加rocketmq的依赖<!--rocketmq--> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-spring-boot-starter</artifactId> <version>2.0.2</version> </dependency> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.4.0</version> </dependency>2、添加配置rocketmq: name-server: 192.168.109.131:9876 #rocketMQ服务的地址 producer: group: shop-order # 生产者组3、编写测试代码@RestController @Slf4j public class OrderController2 { @Autowired private OrderService orderService; @Autowired private ProductService productService; @Autowired private RocketMQTemplate rocketMQTemplate; //准备买1件商品 @GetMapping("/order/prod/{pid}") public Order order(@PathVariable("pid") Integer pid) { log.info(">>客户下单,这时候要调用商品微服务查询商品信息"); //通过fegin调用商品微服务 Product product = productService.findByPid(pid); if (product == null) { Order order = new Order(); order.setPname("下单失败"); return order; } log.info(">>商品信息,查询结果:" + JSON.toJSONString(product)); Order order = new Order(); order.setUid(1); order.setUsername("测试用户"); order.setPid(product.getPid()); order.setPname(product.getPname()); order.setPprice(product.getPprice()); order.setNumber(1); orderService.save(order); //下单成功之后,将消息放到mq中 rocketMQTemplate.convertAndSend("order-topic", order); return order; } }4.2 用户微服务订阅消息1、修改 shop-user 模块配置<?xml version="1.0" encoding="UTF-8" ?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>springcloud-alibaba</artifactId> <groupId>com.itheima</groupId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>shop-user</artifactId> <dependencies> <dependency> <groupId>com.itheima</groupId> <artifactId>shop-common</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos- discovery</artifactId> </dependency> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-spring-boot-starter</artifactId> <version>2.0.2</version> </dependency> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.4.0</version> </dependency> </dependencies> </project>2、修改主类@SpringBootApplication @EnableDiscoveryClient public class UserApplication { public static void main(String[] args) { SpringApplication.run(UserApplication.class, args); } }3、修改配置文件server: port: 8071 spring: application: name: service-user datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql:///shop? serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true username: root password: root jpa: properties: hibernate: hbm2ddl: auto: update dialect: org.hibernate.dialect.MySQL5InnoDBDialect cloud: nacos: discovery: server-addr: 127.0.0.1:8848 rocketmq: name-server: 192.168.109.131:98764、编写消息接收服务@Slf4j @Service @RocketMQMessageListener(consumerGroup = "shop-user", topic = "order-topic") public class SmsService implements RocketMQListener<Order> { @Override public void onMessage(Order order) { log.info("收到一个订单信息{},接下来发送短信", JSON.toJSONString(order)); } }5、启动服务,执行下单操作,观看后台输出。5、事务消息RocketMQ提供了事务消息,通过事务消息就能达到分布式事务的最终一致。事务消息交互流程:事务消息发送步骤:发送方将半事务消息发送至RocketMQ服务端。RocketMQ服务端将消息持久化之后,向发送方返回Ack确认消息已经发送成功,此时消息为半事务消息。发送方开始执行本地事务逻辑。发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到Commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到 Rollback 状态则删除半事务消息,订阅方将不会接受该消息。事务消息回查步骤:在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达服务端,经过固定时 间后服务端将对该消息发起消息回查。发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行操作。实体类://事物日志 @Entity(name = "shop_txlog") @Data public class TxLog { @Id private String txLogId; private String content; private Date date; }Service类:@Service public class OrderServiceImpl4 { @Autowired private OrderDao orderDao; @Autowired private TxLogDao txLogDao; @Autowired private RocketMQTemplate rocketMQTemplate; public void createOrderBefore(Order order) { String txId = UUID.randomUUID().toString(); //发送半事务消息 rocketMQTemplate.sendMessageInTransaction( "tx_producer_group","tx_topic", MessageBuilder.withPayload(order).setHeader("txId",txId).build(), order ); } //本地事务 @Transactional public void createOrder(String txId, Order order) { //本地事物代码 orderDao.save(order); //记录日志到数据库,回查使用 TxLog txLog = new TxLog(); txLog.setTxLogId(txId); txLog.setContent("事物测试"); txLog.setDate(new Date()); txLogDao.save(txLog); } }事务监听器:@RocketMQTransactionListener(txProducerGroup = "tx_producer_group") public class OrderServiceImpl4Listener implements RocketMQLocalTransactionListener { @Autowired private TxLogDao txLogDao; @Autowired private OrderServiceImpl4 orderServiceImpl4; //执行本地事物 @Override public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { //本地事物 orderServiceImpl4.createOrder((String) msg.getHeaders().get("txId"),(Order) arg); return RocketMQLocalTransactionState.COMMIT; } catch (Exception e) { return RocketMQLocalTransactionState.ROLLBACK; } } //消息回查 @Override public RocketMQLocalTransactionState checkLocalTransaction(Message msg) { //查询日志记录 TxLog txLog = txLogDao.findById((String) msg.getHeaders().get("txId")).get(); if (txLog == null) { return RocketMQLocalTransactionState.COMMIT; } else { return RocketMQLocalTransactionState.ROLLBACK; } }6、总结本篇文章介绍了Rocketmq的概念,如何搭建Rocketmq,以及微服务如何集成Rocketmq。如果小伙伴们对微服务感兴趣,欢迎订阅微服务专栏:从0-1学习微服务,为了感谢粉丝们的支持,目前限时该专栏限时免费,感谢支持。微服务专栏传送门:https://blog.csdn.net/weixin_44427181/category_12053421.html?spm=1001.2014.3001.5482
文章
消息中间件  ·  存储  ·  Java  ·  Linux  ·  Kafka  ·  Apache  ·  双11  ·  C语言  ·  RocketMQ  ·  微服务
2023-03-20
python小知识-Gradio:快速构建你的webApp
Gradio:快速构建你的webApp1. 什么是Gradio如果你了解web开发,一定会知道开发一款webApp需要涉及很多技术栈:前端:HTML + CSS + JS (可能会涉及不同的CSS框架和JS框架如jquery VUE react等)后端语言:如python/javaweb容器:如flask/tomcat如果你只会python,又不想重头学习上述技术,你要怎么办?据我所知,有两种解决方案:streamlit (https://streamlit.io/)Gradio (https://gradio.app/)streamlit之前我有介绍过,今天要分享的是Gradio, 提供的功能和streamlit类似,你只要会python就可以快速构建一个webApp。从上图可知,Gradio定位是快速构建一个针对人工智能的python的webApp库,在Hugging Face等提供各种模型推理展示的平台广告使用,阿里的魔塔展示也是基于此。大家思考下,Gradio作为一款python库,底层逻辑是什么?结果:Gradio展示的还是web元素过程:所以Gradio是即懂python又懂web开发(css/js/html)的开发者,通过python对这些web技术做了封装pipline:python语言--> css/js/htmlstreamlit应该也是如此,之前介绍过的pyecharts也是如此(封装的是百度的可视化框架echarts。开源牛人开发,方便你我,点赞!2. 简单使用我们来感受下Gradio的便捷之处。安装要求python>=3.7pip install -U pip -i https://pypi.tuna.tsinghua.edu.cn/simple pip install gradio -i https://pypi.tuna.tsinghua.edu.cn/simple# app.py import gradio as gr def greet(name): return "Hello " + name + "!" demo = gr.Interface(fn=greet, inputs="text", outputs="text") demo.launch(server_name="0.0.0.0") # 启动 # python -u app.py # Running on local URL: http://0.0.0.0:7860 # To create a public link, set `share=True` in `launch()`上面的代码就是简单一个webApp,功能是输入一个文本,输出一个文本。代码中关键点:导入包 import gradio as grgr.Interface 构建一个app, 确定输入inputs和输出outputs的类型,已经处理输入inputs的函数(这个函数返回一个outputs的类型)提供一个app的功能模块函数launch 启动一个web容器,对外提供服务梳理下web渲染流程根据输入输出类型(如text)封装html组件(with css样式,布局等)点击submit:通过js获取输入的值传递(ajax)给后台处理函数(greet),通过js回调函数接收函数的返回值,然后通过js赋值给html元素3. 组件介绍上面只是介绍了Gradio的简单的使用,Gradio提供了丰富的html组件,如文本框,图像,视频,下拉框,单选框,复选框等等。我们再来看一个在机器视觉推理比较常见的展示:输入一个图片,输出一个图片,并提供下载。 import gradio as gr from transformers import DPTFeatureExtractor, DPTForDepthEstimation import torch import numpy as np from PIL import Image import open3d as o3d from pathlib import Path import os feature_extractor = DPTFeatureExtractor.from_pretrained("Intel/dpt-large") model = DPTForDepthEstimation.from_pretrained("Intel/dpt-large") def process_image(image_path): image_path = Path(image_path) image_raw = Image.open(image_path) image = image_raw.resize( (800, int(800 * image_raw.size[1] / image_raw.size[0])), Image.Resampling.LANCZOS) # prepare image for the model encoding = feature_extractor(image, return_tensors="pt") # forward pass with torch.no_grad(): outputs = model(**encoding) predicted_depth = outputs.predicted_depth ## ... 省略 return [img, gltf_path, gltf_path] title = "Demo: zero-shot depth estimation with DPT + 3D Point Cloud" description = "This demo is a variation from the original DPT Demo. It uses the DPT model to predict the depth of an image and then uses 3D Point Cloud to create a 3D object." examples = [["examples/1-jonathan-borba-CgWTqYxHEkg-unsplash.jpg"]] iface = gr.Interface(fn=process_image, inputs=[gr.Image( type="filepath", label="Input Image")], outputs=[gr.Image(label="predicted depth", type="pil"), gr.Model3D(label="3d mesh reconstruction", clear_color=[ 1.0, 1.0, 1.0, 1.0]), gr.File(label="3d gLTF")], title=title, description=description, examples=examples, allow_flagging="never", cache_examples=False) iface.launch(debug=True, enable_queue=False) 上面的代码忽略了一些模型推理的细节,主要关注渲染对应的结果就是inputs和outputs。可知,inputs和outputs都是可以多个,Gradio根据类型展示相应的组件其中:inputs是gr.Image图像,对应的处理函数的参数为文件路径type="filepath"其中:outputs有三个输出(分布是图片,一个3d图片,一个是文件),这里的三个输出要对应处理函数的三个放回。三个输出会对应三个展示渲染,两个图片和一个文件下载另外一个从展示结果看,最下面的位置有一个内部案例的列表 通过examples=examples参数,进行展示渲染,这是非常有用的,用来展示模型的最佳效果图。更多的组件使用详见APIhttps://gradio.app/docs/另外,可以通过.launch(share=True)来分享功能,这个功能可以生成一个域名,可以在外部直接访问。4. 总结本文简单分享了通过python库Gradio快速构建webApp的过程,总结如下:Gradio的本质是封装html+css+js等组件的python库Gradio最佳场景为:展示机器学习的推理效果(可交互)gr.Interface来渲染效果,注意inputs和outputs就是待渲染的内容记住详细组件API:https://gradio.app/docs/
文章
机器学习/深度学习  ·  人工智能  ·  JavaScript  ·  前端开发  ·  数据可视化  ·  Java  ·  应用服务中间件  ·  API  ·  Python  ·  容器
2022-12-30
《Spring核心技术》第6章:深度解析@PropertySource注解
大家好,我是冰河~~本章难度:★★★☆☆本章重点:进一步学习并掌握@PropertySource注解加载配置文件的案例和流程,从源码级别彻底掌握@PropertySource注解在Spring底层的执行流程。本节目录如下所示:学习指引注解说明注解源码注解使用场景使用案例源码时序图源码解析总结思考VIP服务一、学习指引@PropertySource注解是用来干啥的呢?在日常开发中,你有没有遇到过这样一种场景:项目中需要编写很多配置文件,将一些系统信息配置化,此时,往往需要编写专门的工具类或者方法来读取并解析这些配置文件,将配置文件中的配置项内容加载到系统内存中。后续在使用这些配置项时,可以直接通过工具类或者方法获取加载到内存中的配置项。没错,@PropertySource注解就是Spring中提供的一个可以加载配置文件的注解,并且可以将配置文件中的内容存放到Spring的环境变量中。二、注解说明简单介绍下@PropertySource注解吧!@PropertySource注解是Spring中提供的一个通过指定配置文件位置来加载配置文件的注解,并且可以将配置文件中的内容存放到Spring的环境变量中。除了可以通过Spring的环境变量读取配置项之外,还可以通过@Value注解获取配置项的值。另外,Spring中还提供了一个@PropertySources注解,在@PropertySources注解注解中,可以引入多个@PropertySource注解。2.1 注解源码Spring中提供了@PropertySource和@PropertySources两个注解来加载配置文件。1.@PropertySource注解@PropertySource注解只能标注到类上,能够通过指定配置文件的位置来加载配置文件,@PropertySource注解除了可以加载properties配置文件外,也可以加载xml配置文件和yml配置文件。如果加载yml配置文件时,可以自定义PropertySourceFactory实现yml配置文件的解析操作。@PropertySource注解的源码详见:org.springframework.context.annotation.PropertySource。/** * @author Chris Beams * @author Juergen Hoeller * @author Phillip Webb * @author Sam Brannen * @since 3.1 */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(PropertySources.class) public @interface PropertySource { String name() default ""; String[] value(); /** * @since 4.0 */ boolean ignoreResourceNotFound() default false; /** * @since 4.3 */ String encoding() default ""; /** * @since 4.3 */ Class<? extends PropertySourceFactory> factory() default PropertySourceFactory.class; }从源码可以看出,@PropertySource注解是从Spring3.1版本开始提供的注解,注解中各个属性的含义如下所示。name:表示加载的资源的名称,如果为空,则会根据加载的配置文件自动生成一个名称。value:表示加载的资源的路径,这个路径可以是类路径,也可以是文件路径。ignoreResourceNotFound:表示当配置文件未找到时,是否忽略文件未找到的错误。默认值为false,也就是说当未找到配置文件时,Spring启动就会报错。encoding:表示解析配置文件使用的字符集编码。factory:表示读取对应配置文件的工厂类,默认的工厂类是PropertySourceFactory。2.@PropertySources注解除了@PropertySource注解,Spring中还提供了一个@PropertySources注解。@PropertySources注解中的源码详见:org.springframework.context.annotation.PropertySources。/** * @author Phillip Webb * @since 4.0 */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface PropertySources { PropertySource[] value(); }从源码可以看出,@PropertySources是从Spring4.0版本开始提供的注解,在@PropertySources注解中,只提供了一个PropertySource数组类型的value属性。所以,@PropertySources注解可以引入多个@PropertySource注解。2.2 注解使用场景在基于Spring的注解开发项目的过程中,由于不再使用Spring的XML文件进行配置,如果将配置项直接写到类中,就会造成配置项与类的紧耦合,后续对于配置项的修改操作非常不方便,不利于项目的维护和扩展。此时,可以将这些配置项写到properties文件或者yml文件中,通过@PropertySource注解加载配置文件。另外,如果项目本身就存在大量的properties配置文件或者yml配置文件,也可以统一由Spring的@PropertySource注解进行加载。三、使用案例结合案例学着印象才会更深刻~~本节,主要实现一个通过@PropertySource注解加载properties配置文件,将properties配置文件中的配置项加载到Spring的环境变量中,获取Spring环境变量中配置项的值,并进行打印。案例的具体实现步骤如下所示。(1)新增test.properties文件在spring-annotation-chapter-06工程的resources目录下新增test.properties文件,文件内容如下所示。name=binghe age=18(2)新增PropertySourceConfig类PropertySourceConfig类的源码详见:spring-annotation-chapter-06工程下的io.binghe.spring.annotation.chapter06.config.PropertySourceConfig。@Configuration @PropertySource(value = "classpath:test.properties") public class PropertySourceConfig { }可以看到,PropertySourceConfig类是Spring的配置类,并且使用@PropertySource注解指定了test.properties配置文件的路径。(3)新增PropertySourceTest类PropertySourceTest类的源码详见:spring-annotation-chapter-06工程下的io.binghe.spring.annotation.chapter06.PropertySourceTest。public class PropertySourceTest { public static void main(String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(PropertySourceConfig.class); ConfigurableEnvironment environment = context.getEnvironment(); System.out.println(environment.getProperty("name") + " ====>>> " + environment.getProperty("age")); } }可以看到,在PropertySourceTest类的main()方法中,通过AnnotationConfigApplicationContext类的对象获取到ConfigurableEnvironment类型的环境变量对象environment,然后通过environment对象获取配置文件中的name和age的值并进行打印。(4)运行PropertySourceTest类运行PropertySourceTest类的main()方法,输出的结果信息如下所示。binghe ====>>> 18可以看到,正确的输出了配置文件中的值。说明:使用@PropertySource注解可以加载properties配置文件中的配置项,并将配置项加载到Spring的环境变量中,通过Spring的环境变量就可以获取到配置项的值。四、源码时序图结合时序图理解源码会事半功倍,你觉得呢?本节,就以源码时序图的方式,直观的感受下@PropertySource注解在Spring源码层面的执行流程。@PropertySource注解在Spring源码层面的执行流程如图6-1~6-2所示。由图6-1~图6-2可以看出,@PropertySource注解在Spring源码层面的执行流程会涉及到PropertySourceTest类、AnnotationConfigApplicationContext类、AbstractApplicationContext类、PostProcessorRegistrationDelegate类、ConfigurationClassPostProcessor类、ConfigurationClassParser类、PropertySourceRegistry类、PropertySourceProcessor类和DefaultPropertySourceFactory类。具体的源码执行细节参见源码解析部分。五、源码解析源码时序图整清楚了,那就整源码解析呗!@PropertySource注解在Spring源码层面的执行流程,结合源码执行的时序图,会理解的更加深刻。(1)运行案例程序启动类案例程序启动类源码详见:spring-annotation-chapter-06工程下的io.binghe.spring.annotation.chapter06.PropertySourceTest,运行PropertySourceTest类的main()方法。在PropertySourceTest类的main()方法中调用了AnnotationConfigApplicationContext类的构造方法,并传入了PropertySourceConfig类的Class对象来创建IOC容器。接下来,会进入AnnotationConfigApplicationContext类的构造方法。注意:@PropertySource注解在Spring源码中的执行流程的(2)~(11)步与第5章的@Import注解相同,这里不再赘述,直接跳到ConfigurationClassParser类的doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass, Predicatefilter)方法。(2)解析ConfigurationClassParser类的doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass, Predicatefilter)方法源码详见:org.springframework.context.annotation.ConfigurationClassParser#doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass, Predicatefilter),重点关注如下代码片段。protected final SourceClass doProcessConfigurationClass( ConfigurationClass configClass, SourceClass sourceClass, Predicate<String> filter) throws IOException { //#############省略其他代码################ for (AnnotationAttributes propertySource : AnnotationConfigUtils.attributesForRepeatable( sourceClass.getMetadata(), PropertySources.class, org.springframework.context.annotation.PropertySource.class)) { if (this.propertySourceRegistry != null) { this.propertySourceRegistry.processPropertySource(propertySource); } } //#############省略其他代码################ }可以看到,在ConfigurationClassParser类的doProcessConfigurationClass()方法中,遍历获取到的@PropertySources注解和@PropertySource注解的属性,并且调用propertySourceRegistry对象的processPropertySource()方法解析注解属性的值。(3)解析PropertySourceRegistry类的processPropertySource(AnnotationAttributes propertySource)方法源码详见:org.springframework.context.annotation.PropertySourceRegistry#processPropertySource(AnnotationAttributes propertySource)。void processPropertySource(AnnotationAttributes propertySource) throws IOException { String name = propertySource.getString("name"); if (!StringUtils.hasLength(name)) { name = null; } String encoding = propertySource.getString("encoding"); if (!StringUtils.hasLength(encoding)) { encoding = null; } String[] locations = propertySource.getStringArray("value"); Assert.isTrue(locations.length > 0, "At least one @PropertySource(value) location is required"); boolean ignoreResourceNotFound = propertySource.getBoolean("ignoreResourceNotFound"); Class<? extends PropertySourceFactory> factoryClass = propertySource.getClass("factory"); Class<? extends PropertySourceFactory> factorClassToUse = (factoryClass != PropertySourceFactory.class ? factoryClass : null); PropertySourceDescriptor descriptor = new PropertySourceDescriptor(Arrays.asList(locations), ignoreResourceNotFound, name, factorClassToUse, encoding); this.propertySourceProcessor.processPropertySource(descriptor); this.descriptors.add(descriptor); }可以看到,在PropertySourceRegistry类的processPropertySource()方法中,解析@PropertySource注解中的属性后,将解析出的属性值封装到PropertySourceDescriptor对象中,调用propertySourceProcessor对象的processPropertySource()方法,并传入PropertySourceDescriptor对象进行进一步处理。(4)解析PropertySourceProcessor类的processPropertySource(PropertySourceDescriptor descriptor)方法源码详见:org.springframework.core.io.support.PropertySourceProcessor#processPropertySource(PropertySourceDescriptor descriptor)。public void processPropertySource(PropertySourceDescriptor descriptor) throws IOException { String name = descriptor.name(); String encoding = descriptor.encoding(); List<String> locations = descriptor.locations(); Assert.isTrue(locations.size() > 0, "At least one @PropertySource(value) location is required"); boolean ignoreResourceNotFound = descriptor.ignoreResourceNotFound(); PropertySourceFactory factory = (descriptor.propertySourceFactory() != null ? instantiateClass(descriptor.propertySourceFactory()) : DEFAULT_PROPERTY_SOURCE_FACTORY); for (String location : locations) { try { String resolvedLocation = this.environment.resolveRequiredPlaceholders(location); Resource resource = this.resourceLoader.getResource(resolvedLocation); addPropertySource(factory.createPropertySource(name, new EncodedResource(resource, encoding))); } catch (IllegalArgumentException | FileNotFoundException | UnknownHostException | SocketException ex) { //#########省略其他代码################ } } }可以看到,在processPropertySource()方法中,会通过@PropertySource注解的属性值解析出配置文件的内容,并且通过factory对象的createPropertySource()方法来创建PropertySource对象。(5)解析DefaultPropertySourceFactory类的createPropertySource(String name, EncodedResource resource)方法源码详见:org.springframework.core.io.support.DefaultPropertySourceFactory#createPropertySource(String name, EncodedResource resource)。@Override public PropertySource<?> createPropertySource(String name, EncodedResource resource) throws IOException { return (name != null ? new ResourcePropertySource(name, resource) : new ResourcePropertySource(resource)); }createPropertySource()方法的源码比较简单,不再赘述。(6)回到PropertySourceProcessor类的processPropertySource(PropertySourceDescriptor descriptor)方法在PropertySourceProcessor类的processPropertySource()方法中,创建完PropertySource对象后,会调用addPropertySource()方法将获取到的属性值添加到Spring的环境变量中。(7)解析PropertySourceProcessor类的addPropertySource(PropertySource<?> propertySource)方法源码详见:org.springframework.core.io.support.PropertySourceProcessor#addPropertySource(PropertySource<?> propertySource)。private void addPropertySource(org.springframework.core.env.PropertySource<?> propertySource) { String name = propertySource.getName(); MutablePropertySources propertySources = this.environment.getPropertySources(); if (this.propertySourceNames.contains(name)) { org.springframework.core.env.PropertySource<?> existing = propertySources.get(name); if (existing != null) { PropertySource<?> newSource = (propertySource instanceof ResourcePropertySource ?((ResourcePropertySource) propertySource).withResourceName() : propertySource); if (existing instanceof CompositePropertySource) { ((CompositePropertySource) existing).addFirstPropertySource(newSource); } else { if (existing instanceof ResourcePropertySource) { existing = ((ResourcePropertySource) existing).withResourceName(); } CompositePropertySource composite = new CompositePropertySource(name); composite.addPropertySource(newSource); composite.addPropertySource(existing); propertySources.replace(name, composite); } return; } } if (this.propertySourceNames.isEmpty()) { propertySources.addLast(propertySource); } else { String firstProcessed = this.propertySourceNames.get(this.propertySourceNames.size() - 1); propertySources.addBefore(firstProcessed, propertySource); } this.propertySourceNames.add(name); }可以看到,在PropertySourceProcessor类的addPropertySource()方法中,会将解析出的配置文件的内容添加到Spring的环境变量中。具体就是在PropertySourceProcessor类的addPropertySource()方法中,获取到ConfigurableEnvironment中的MutablePropertySources对象,用来存储解析出的配置文件中的配置项内容。如果有相同的配置项内容,将existing对象强转为CompositePropertySource类型,把新旧相同的配置项进行合并,再放到MutablePropertySources对象中。后续就可以通过Spring的环境变量,来获取到配置文件中的配置项内容。至此,@PropertySource注解在Spring源码中的执行流程分析完毕。六、总结@PropertySource注解讲完了,我们一起总结下吧!本章,首先介绍了@PropertySource注解的源码和使用场景,随后,简单给出了一个@PropertySource注解的使用案例。接下来,详细分析了@PropertySource注解的源码时序图和@PropertySource注解在Spring源码层面的执行流程。七、思考既然学完了,就开始思考几个问题吧?关于@PropertySource注解,通常会有如下几个经典面试题:@PropertySource注解的执行流程?@PropertySource注解是如何将配置文件加载到环境变量的?@PropertySource注解有哪些使用场景?Spring中为何会设计一个@PropertySource注解来加载配置文件?从@PropertySource注解的设计中,你得到了哪些启发?
文章
XML  ·  存储  ·  Java  ·  数据格式  ·  Spring  ·  容器
2023-03-18
ICASSP2023|达摩院语音实验室入选论文全况速览
近日,语音技术领域国际会议ICASSP公布了本届论文审稿结果,阿里巴巴达摩院语音实验室有14篇论文被大会收录。本次被接收的论文研究方向涵盖语音识别、语音唤醒、语音增强、说话人日志、语义理解、多模态预训练等。ICASSP (International Conference on Acoustics, Speech, and Signal Processing) 是国际声学,语音和信号处理会议,是IEEE信号处理协会组织的年度旗舰会议。历届的ICASSP会议都备受全球信号处理领域研究学者的广泛关注,ICASSP2023将于6月4号至6月10号于希腊举办。01论文题目:TOLD: A Novel Two-Stage Overlap-Aware Framework for Speaker Diarization论文作者:王嘉明、杜志浩、张仕良论文单位:阿里巴巴集团核心内容:基于端到端神经网络的说话人日志模型(EEND)在说话人重叠场景下取得了不错的结果。在EEND中,说话人日志被定义为一个多标签分类问题,其对于每个说话人的估计是独立的,忽略了说话人之间的依赖关系。为了克服这一缺陷,本文采用幂集编码(power set encoding,PSE),将说话人日志重新定义为一个单标签分类问题,提出了overlap-aware EEND (EEND-OLA) 模型,实现了对于说话人重叠和依赖的显式建模。此外,受到两阶段混合系统成功的启发,本文进一步提出了Two-stage OverLap-aware Diarization framework (TOLD) 模型,通过引入说话人重叠后处理(speaker overlap-aware post-processing,SOAP)来迭代改善说话人日志的结果。实验结果表明,与原始的EEND模型相比,本文提出的EEND-OLA在DER指标上实现了14.39%的相对改进,而采用SOAP则能进一步带来19.33%的相对改进,最终,本文提出的TOLD在CALLHOME数据集上取得了10.14%的DER。论文题目:MossFormer: Pushing the Performance Limit of Monaural Speech Separation Using Gated Single-Head Transformer with Convolution-Augmented Joint Self-Attentions论文作者:赵胜奎, 马斌论文单位:阿里巴巴集团论文链接:https://arxiv.org/abs/2302.11824模型已上线至ModelScope社区:https://modelscope.cn/models/damo/speech_mossformer_separation_temporal_8k/summary核心内容:基于Transformer架构的模型在单声道语音分离方面提供了显著的性能改进,然而,和Cramer-Rao上限相比,仍存在明显差距,主要原因在于自注意力运算的二次复杂度使Transformer架构受限于输入序列的长度,在语音分离任务上为了能够处理较长的时域输入序列,最新的基于Transformer的语音分离模型沿用双路径框架,将输入序列截断成较小的序列块,然后分别处理块内和块间的信息,该处理方式使跨块间的长距离信息依赖需要通过中间状态隐式建模,这一事实可能对长距离建模能力产生负面影响,导致模型次优表现,另外,Transformer架构主要针对序列块进行建模,而对局部特征模式的建模效率低下。在这项工作中,我们提出一种带有卷积增强联合自注意力的门控单头Transformer架构进行语音分离任务,命名为MossFormer(Monaural speech separation Transformer)。MossFormer采用了联合局部和全局自注意力架构,同时对局部块执行二次复杂度自注意力和对整个序列执行线性低成本的自注意力,能够直接建模全序列的元素信息交流,有效地提升双路径架构中跨块的间接元素信息交流的性能。此外,我们采用了一种较强大的自注意力门控机制 (Gated Attention)来提升性能和降低复杂度,不需要使用多头自注意力机制,而是采用简化的单头自注意力机制。除了关注长距离建模外,我们还通过卷积来增强MossFormer的局部特征建模能力。因此,MossFormer模型在WSJ0-2/3mix和WHAM!/ WHAMR!基准测试中显著优于以前的模型。不仅在WSJ0-3mix上达到了21.2 dB的SI-SDRi上限,并且仅比WSJ0-2mix上限23.1 dB低0.3 dB。左图为MossFormer模型整体结构示意图, 由一个卷积编码器-解码器结构和一个掩蔽网络组成,编码器-解码器结构负责特征提取和波形重建,掩码网络执行从编码器输出到𝐶组掩码的非线性映射。右图为MossFormer模块架构示意图,一个MossFormer 模块由四个卷积模块、缩放&偏移&旋转位置编码操作、联合局部和全局单头自注意力(SHSA)以及三个门控操作组成,负责进行长序列的处理。03论文题目:D2Former: a Fully Complex Dual-Path Dual-Decoder Conformer Network Using Joint Complex Masking and Complex Spectral Mapping for Monaural Speech Enhancement论文作者:赵胜奎, 马斌论文单位:阿里巴巴集团论文链接:https://arxiv.org/abs/2302.11832核心内容:在时频域(Time-Frequency Domain)中,基于实数网络的单声道语音增强算法已被广泛的进行了研究。然而,考虑到模型特征输入和模型目标输出在时频域中具有自然的复数值特性,因此非常需要一个完全为复数运算的网络模型来更有效地对复数值特征表示和复数值特征序列进行学习和建模。此外,时频域的语音相位作为语音感知质量的一个重要因素,已被广泛验证可以与语音幅度谱一起通过模型学习复数值掩蔽或复数值频谱的方式从带噪的语音中进行估计。许多最近的研究大多集中在独立的复数值掩蔽或复数值频谱估计上,而忽略了它们各自学习目标的局限性。为了有效改善上述问题,我们提出了一个基于Conformer结构的完全使用复数值网络的语音增强模型:D2Former。在D2Former设计中,我们将Conformer中的实数值注意力机制扩展到复数值注意力机制,并结合时间序列和频域序列的双路径处理模式,更有效地对复数值时频语音特征序列进行建模。我们基于沿时间轴的复数值扩张卷积(Dilation Convolution)和沿频率轴的递归复数值前馈序列记忆网络 (Complex FSMN),通过双路径学习模式进一步提升编码器和解码器中的时频特征表示和处理能力。此外,我们通过一个多任务联合学习框架来结合复数值掩蔽和复数值频谱映射两个训练目标的优势,来改善模型学习的性能。因此,D2Former充分利用了复数值网络运算、双路径特征处理、和联合目标训练的优势,在与之前的模型相比中,D2Former以最小的模型参数量(0.87M)在VoiceBank+Demand基准测试中取得了最好的语音增强综合效果。图2. D2Former各模块的网络结构示意图。 (a) 复数值双路径编码器模块,(b) 复数值双路径Conformer模块,(c) 复数值双路径掩蔽解码器模块,(d) 复数值双路径频谱解码器模块。04论文题目:Pushing the Limits of Self-supervised Speaker Verification Using Regularized Distillation Framework论文作者:陈亚峰,郑斯奇,王绘,程路遥,陈谦论文单位:阿里巴巴集团论文链接:https://arxiv.org/pdf/2211.04168.pdf核心内容:在无法获得说话人标签的语音数据条件下,训练一个鲁棒性强的说话人识别系统是一个极具挑战性的任务。研究表明全监督说话人识别和自监督说话人识别之间仍存在不小的性能差距。在这篇文章中,我们将自监督学习框架DINO应用于说话人识别任务,并针对说话人识别任务提出多样性正则和冗余度消除正则。多样性正则提高特征多样性,冗余度正则减小特征冗余度。不同数据增强方案的优劣在该系统中得以验证。大量的实验在公开数据集VoxCeleb上开展,表现出Regularized DINO框架的优越性。论文题目:Meeting Action Item Detection with Regularized Context Modeling论文作者:刘嘉庆,邓憧,张庆林,陈谦,王雯论文单位:阿里巴巴集团核心内容:随着技术的进步和疫情的推动,线上会议成为越来越普遍的协作沟通方式。在自动语音识别(ASR)的帮助下,我们可以越来越便捷地获取会议音频对应的转写文本(即会议记录)。然而,从会议记录中提取重要信息(如议题、决策、待办等)形成会议纪要,仍然主要依赖于人工整理。因此,很多任务被提出希望机器自动识别重要信息,辅助人工整理会议纪要。其中,行动项识别是在会议记录中自动识别待办的相关内容。行动项识别相关的数据集非常稀缺,为此我们构建并计划开源第一个带有行动项标注的中文会议数据集。在此基础上,我们提出了 Context-Drop 方法,通过对比学习来更好地建模局部和全局上下文,在行动项抽取表现和鲁棒性方面均取得了更好的效果。此外,我们探索了 Lightweight model ensemble 的方法,利用不同的预训练模型,提高行动项抽取的表现。06论文题目:MUG: A General Meeting Understanding and Generation Benchmark论文作者:张庆林,邓憧,刘嘉庆,于海,陈谦,王雯,鄢志杰,刘静林,任意,赵洲 论文单位:阿里巴巴集团,浙江大学数据集详细信息链接:https://www.modelscope.cn/datasets/modelscope/Alimeeting4MUG/summary核心内容:从视频会议和在线课程中收集信息时,听取长时间的视频/音频记录是非常低效的。即使ASR系统将录音转录成长篇的口语文档,读取ASR转录也只能在一定程度上加快查找信息的速度。众多研究表明,关键词提取、主题分割和摘要等一系列自然语言处理应用显著提高了用户获取重要信息的效率。会议场景是应用这些口语处理能力最有价值的场景之一。然而,缺乏针对这些口语处理任务进行注释的大规模公共会议数据集严重阻碍了它们的发展。为了推动口语处理的进步,我们建立了一个大规模的通用会议理解与生成基准(MUG),用于评估各种口语处理任务的性能,包括主题分割、主题级和会话级摘要、主题标题生成、关键词提取和行动项检测。为了方便MUG基准测试,我们构建并发布了一个全面的长篇口语处理开发的大规模会议数据集,即AliMeeting4MUG Corpus,其中包括424个涵盖不同主题的普通话会议记录,手动标注了音视频会议下人工转写文稿的SLP任务。在论文中,我们详细介绍了该语料库、SLP任务和评估方法、基线系统及其性能。07论文题目:Auxiliary Pooling Layer for Spoken Language Understanding论文作者:马煜坤, Trung Hieu Nguyen, 倪瑾杰, 王雯, 陈谦, 张冲, 马斌作者单位:阿里巴巴集团,南洋理工大学核心内容:端到端口语理解需要有语义标注信息的语音数据,而且可能会受到标注数据不足的影响。最近很多研究工作聚焦在利用未标注的语音数据对语音编码器进行预训练。然而,对于预训练语音表征来说,编码语义信息仍然是一个挑战。现有的研究通过在固定粒度上使用不同的对齐损失来探索从预训练文本模型转移知识。在本文中,我们通过 APLY(一种辅助池化层)解决了从文本到语音表示的可变粒度问题,它明确地融合了全局信息和自适应编码的本地上下文。我们在三个口语理解基准测试上展示了 APLY 的有效性。图示. 跨模态知识蒸馏结构示意图Wav2vec2编码器是学生模型。BERT编码器是预训练文本模型。Auxiliary Pooling Layer用于融合多颗粒度信息,以更好地对齐BERT模型中编码的知识。08论文题目:Weighted Sampling for Masked Language Modeling论文作者:张琳涵,陈谦,王雯,邓憧, Xin Cao,Kongzhang Hao,Yuxin Jiang,Wei Wang论文单位:阿里巴巴集团,新南威尔士大学,香港科技大学(广州)核心内容:掩码语言模型(Masked Language Modeling,MLM)被广泛用于预训练语言模型。标准的随机掩码策略会导致预训练语言模型(PLMs)偏向于高频词。对于罕见词的表示学习效果较差,且PLMs在下游任务中的性能受到限制。为了缓解这种频率偏差问题,我们提出了两种简单而有效的加权采样策略,以基于词频和训练损失进行掩码。我们将这两种策略应用于BERT,并获得了加权采样BERT(WSBERT)。在语义文本相似性基准(Semantic Textual Similarity benchmark,STS)上的实验表明,WSBERT在句子嵌入方面明显优于BERT。将WSBERT与校准方法和提示学习相结合,进一步提高了句子嵌入的性能。我们还研究了在GLUE基准上微调WSBERT,并表明加权采样也提高了骨干PLM的迁移学习能力。我们进一步分析并提供了WSBERT如何改善token嵌入的见解。论文题目:Adaptive Knowledge Distillation between Text and Speech Pre-trained Models 论文作者:倪瑾杰, 马煜坤, 王雯, 陈谦, 黄殿文,  Han Lei, Trung Hieu Nguyen, 张冲, 马斌作者单位:南洋理工大学,阿里巴巴集团核心内容:通过学习大量的语音语料库,许多自监督语音模型在近期取得了成功。通过知识蒸馏,这些模型也可以从在丰富文本资源上预训练的语言模型所编码的知识中受益。但是,由于文本和语音表征空间之间存在模态差异,因此从文本到语音的知识蒸馏过程更具挑战性。本研究我们关注如何使用少量数据即可对文本和语音的嵌入空间进行对齐,而无需修改模型结构。由于现有的研究往往忽略了文本和语音之间的语义和粒度差距,从而影响了蒸馏的效果,我们提出了先验信息自适应知识蒸馏(PAD),它可以自适应地利用可变粒度和先验显著性分布的文本/语音单元,以实现文本和语音预训练模型之间更好的全局和局部对齐。我们在三个口语理解基准上进行了评估,以展示PAD在转移语言知识方面比其他蒸馏方法更有效。论文题目:deHuBERT: Disentangling Noise in a Self-supervised Model for Robust Speech Recognition论文作者:黄殿文,张芮熙,叶家祺,杨钊,倪瑾杰,张冲,马煜坤,倪崇嘉,Eng Siong Chng,马斌论文单位:阿里巴巴集团,南洋理工大学,西安交通大学核心内容:自监督学习利用大量未标注语料库训练的语音预训练模型,为构建良好的语音识别模型提供了一种有效的途径。然而,当前许多模型都是在单一来源的干净语料库上训练的,当在复杂场景中存在噪声时测试表现较差,导致模型识别率降低。因此,减少噪声对识别率的损失对于实际应用至关重要。受冗余度简化原则(H. Barlow's redundancy-reduction principle)的启发,我们提出了一种新的训练框架deHuBERT,旨在通过降噪编码来提高模型对噪声的鲁棒性。deHuBERT基于原始HuBERT算法,并引入一个辅助损失函数,通过将不同信噪比的噪声对之间的自相关矩阵和交叉相关矩阵驱向单位矩阵,促使模型从输入音频数据中学习到与噪声无关的噪音解耦语音表征。实验表明,deHuBERT能够显著提高模型在域内、域外噪声场景下的识别效果,同时不影响干净数据集上的效果。论文题目:Contrastive Speech Mixup for Low-resource Keyword Spotting论文作者:黄殿文,张芮熙,叶家祺,张冲,马煜坤,Trung Hieu Nguyen,倪崇嘉,Eng Siong Chng,马斌论文单位:阿里巴巴集团,南洋理工大学核心内容:基于神经网络技术的关键词识别模型通常需要大量的训练数据才能学习到较好的语音表征,以在大多数智能设备上使用。然而,随着智能设备越来越趋于个性化,关键词识别模型需要利用少量的用户数据来快速进行领域自适应。为了应对低资源关键词识别问题,我们提出了一种名为CosMix的对比语音混合数据增强算法。CosMix在现有的数据混合增强技术中引入了一个辅助对比损失函数,以最大化原始样本和增强样本之间的相对相似性。通过加入增强约束,利用同一数据样本的两个不同数据增强样本(即嘈杂混合和干净预混合音频),引导模型学习到更简单但内容信息更丰富的语音表征。我们在谷歌语音命令数据集上进行实验验证,并将训练集缩小到每个关键词两分半钟以模拟低资源条件,实验结果表明,CosMix适用于多种基础模型,并且在性能方面均得到了一致的提高,展现了该方法的有效性。12论文题目:Speech and Noise Dual-Stream Spectrogram Refine Network with Speech Distortion Loss for Robust Speech Recognition论文作者:芦皓宇、李楠、王龙标、党建武、王晓宝、张仕良论文单位:天津大学、阿里巴巴集团核心内容:近年来,语音增强前端(speech enhancement)和语音识别(speech recognition)后端的联合训练被广泛用于提高语音识别系统的鲁棒性。传统的联合训练方法仅使用增强语音作为后端输入。由于具有不同强度的各种类型的噪声使得语音增强系统很难直接将语音从输入中分离出来。此外,在增强语音中经常观察到语音失真和残留噪声,并且语音和噪声的失真是不同的。大多数现有方法都侧重于融合增强特征和噪声特征来解决这个问题。在本文中,我们提出了一个双流频谱图精炼网络(dual-stream spectrogram refine network)来同时精炼语音和噪声,并将噪声与从带噪的原始输入信号中解耦出来。我们在AISHELL-1任务上验证了所提出的方案,实验结果表明我们提出的方法可以获得更好的增强效果,语音识别字错误率相对降低8.6%。
文章
机器学习/深度学习  ·  达摩院  ·  自然语言处理  ·  算法  ·  搜索推荐  ·  前端开发  ·  测试技术  ·  语音技术  ·  网络架构
2023-03-18
你真的会在函数计算FC上部署springboot云上博客吗
Serverless中文名称为无服务器模式的一种云原生开发模型,可以让程序员专注于构建整个应用逻辑和运行程序,而不需要进行服务器的操心。这一部分可以理解成这样,程序员减少了不必要的工作,然后将服务器外包给各个云开发商,比如说我们现在用的众多云(阿里云微软云、亚马逊和Google云服务等)开发人员可以通过将产品开发的打包到容器当中然后进行部署即可。无服务器框架的特点有点像是节能电梯,用的时候启动,不用的时候就休息,也就是通过这种流量的方式进行计费。值得一提的这种外包的无框架服务,涵盖了负载平衡、安全补丁、容量管理、扩展、日志和监控等任务。我们现在大多采用的是功能即服务的一种驱动计算模式,所有的东西只需要程序员将编写逻辑,部署到云平台管理的容器中然后按需执行,这部分最大的特点就是让客户(程序员)有更大的权限实现应用的自定义。阿里云函数计算是什么?很多人看到这个问题时,尤其是一些新人,对他们来说都是大问题。函数计算是事件驱动的全托管计算服务。这里和传统最大的区别之一就是不需要任何基础设施,只需要一个可以上网的笔记本,不需要复杂昂贵的本地设备客户端设备。我们可以直接将本地编写的代码上传到云端,通过函数计算可以快速使用阿里云服务完成应用场景的快速搭建。流程:整个工作流程基本上是通过开发者将编写的应用按照桉树计算支持的开发语言,通过函数控制台或者Serverless Devs上传,通过事件触发和函数计算调用API进行出发。在此过程执行期间,函数计算根据用户的需求,进行自动扩容,整个过程公开透明。创建服务的整个流程:现在有体验活动,我们可以免费体验整个流程,整个的规格达到了40GB/秒,大家如果感兴趣可以去阿里云社区进行体验。链接的话放在这里:https://developer.aliyun.com/topic/serverless2022进入这个界面点击免费开通实现千万产品免费订购即可创建服务的整个流程其实比较简单,主要通过5个步骤即可完成,具体的操作步骤如下:1.打开后台的函数计算FC点击左侧任务栏中的服务即函数或者创建服务2.点击创建服务我们可以根据名称和描述正常填写内容来创建服务内容。值得注意的是,当您选择打开日志功能和链路跟踪功能时,这里的一切都将被获取和收集。在高级选项中,我们可以选择服务角色,默认状态是阿里云服务RAM角色role收集你的资源。因为是首个应用,所有一切都没有开通,所以会提示日志服务还没有开通,我们立即开通即可。这里的日志服务可以通过以下链接进行查看:https://sls.console.aliyun.com/lognext/profile然后我们在进一步及逆行函数计算FC创建过程中,需要我们再次授权,就可完成一个服务的创建在应用创建中我们可以进行选择其中的一个进行创建:在创建应用中我们需要进行文件管理的权限开通:NAS文件系统(阿里云文件存储NAS是一个可共享访问,弹性扩展,高可靠,高性能的分布式文件系统。兼容POSIX 文件接口,可支持数千台计算节点共享访问,可以挂载到弹性计算ECS、神龙裸金属、容器服务ACK、弹性容器ECI、批量计算BCS、高性能计算EHPC,AI训练PAI等计算业务上提供高性能的共享存储,用户无需修改应用程序,即可无缝迁移业务系统上云。)同样每一个应用在创建的过程中的时候就会有不同的而角色快捷创建,这是提供的模板和场景的情况下接下来是应用程序的创建,我直接通过GitHub绑定,方便以后的管理等几秒钟就能完成部署了,非常方便快捷我们可以通过我们建好的通过域名进行访问,然后我们下载相关的工具可以在本地进行:我们可以在后台看到我们所创建的一系列函数,可以查看运行环境、调用次数等等。这样一个简单的应用场景就部署好了,后期我们可以通过填充式的代码来完成相应的程序编写。开发步骤1.开通函数计算开通前需要您已注册阿里云账号并完成实名认证,进入函数计算官网函数计算https://fcnext.console.aliyun.com如果您还未开通函数计算,需在函数计算开通页面勾选呢函数计算服务协议之后点击立即开通,开通完成后会自动跳转至函数计算控制台。2.新建服务在函数计算控制台点击 "服务及函数" 进入服务列表,在服务列表点击创建服务,填写服务的配置,点击确认即可创建服务,注:开启日志和链路追踪功能可以更方便的进行代码调试、故障分析、数据分析和查看函数内部时间的消耗等(推荐开启,但可能会收取部分费用)。3.新建函数在服务列表下点击创建的服务名称进入函数管理页面,点击创建函数,在创建函数页面选择 "使用自定义运行时创建" 填写函数的相关配置,在运行环境中选择Java 17的示例模版函数,配置完成后页面下拉至底部点击创建即可部署一个springboot项目。4.自定义域名配置函数创建完成后直接在浏览器中直接打开请求地址,将会以附件的方式下载响应。这是因为 Http 触发器会自动在响应头中添加 Content-Disposition: attachment 字段,我们可以使用自定义域名避免该问题,进入域名管理页面点击添加自定义域名,在路由配置中选择自己创建的服务和函数,配置完成后,就可以在浏览器中输入配置的域名,可以看到页面显示的Hello World!内容,到此一个简单的springboot项目就部署配置完成了。5.开发导出代码继续进行项目开发,我们可以在函数计算控制台进入到该函数的详情页面在函数代码页使用在线IED进行开发和调试,也可以在"导出函数"这里导出函数的代码,使用自己的代码编辑器进行开发。代码结构pom.xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> DemoApplication package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @SpringBootApplication @RestController public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @GetMapping("/") public String hello(@RequestParam(value = "name", defaultValue = "World") String name) { return String.format("Hello %s!", name); } } 配置监听端口在application.properties配置文件中添加监听端口,这里的配置端口需要和函数配置的监听端口保持一致
文章
弹性计算  ·  人工智能  ·  监控  ·  负载均衡  ·  Cloud Native  ·  Java  ·  Serverless  ·  程序员  ·  文件存储  ·  容器
2022-07-27
手把手教你使用Spring Boot从零开始制作用户注册登录功能
用户登录是网站基本上最常见的功能了。当然,使用Spring Boot实现用户登录也不是难事,今天我将分享,从零开始制作一个用户登录功能的过程。当然,大家还是需要掌握Spring Boot基本使用以及MyBatis的配置(SSM基本操作),再来看这篇文章比较好。在最开头我们先约定:主类所在的项目包路径:com.example.userlogin,后面其余新建的软件包都在这个包路径之下。文章最末尾我也会给出示例的仓库地址。1,基本知识-用户登录的实现原理对于用户登录,我们一定都听说过cookie这个词。其实,cookie是网络编程中使用最广泛的技术之一,它用于储存用户的登录信息。它储存在用户本地,每次随着网络请求发送给服务端,服务端就用这个判断用户是否登录。可以看看这个图:可见,用户未登录之前,http请求是不带cookie的,登录后,客户端会将登录信息放在请求中给服务端,服务端进行验证,登录成功后,服务端会把用户信息放在cookie里面,随着响应返回给客户端,客户端就会储存这个cookie。下次再访问网站,cookie会和客户端的请求一起发给服务端,服务端验证信息正确,判断这个用户是登录状态。cookie里面也是以key-value形式储存数据,并且cookie有它自己的属性例如生命周期、生效域名等等。但是实际上,cookie里面由于存放着用户信息例如用户名密码等等,很容易存在安全隐患,cookie可以被拦截甚至伪造。因此现在网站登录都使用session机制,它和cookie机制最大的区别就是用户信息不再放在客户端而是服务端,交互过程如图:可见session机制就是以cookie作为载体,cookie里面只储存一个session id,与服务端通信。每个客户端每一次登录请求都会和服务端会生成唯一的session id,相当于客户端每次只要告诉服务端session id,服务端就可以找到相对应的客户端数据。至于session id是什么,其实也很好理解。session id用于标识某个电脑和服务端这一次登录的会话,也就是说在服务端看来,一个session id对应着一台电脑,并且它是唯一的。首先登录的时候,服务端验证用户名密码正确,就生成一个唯一的session id,并把这个用户信息存在服务端里面,这个信息就对应着这个session id。与此同时这个session id就放在cookie里面发给客户端保存。下次客户端访问服务端,就带着这个cookie,服务端利用里面的session id在服务端找到对应的用户信息即可进行验证。因此下面示例我们都使用session机制进行。使用Spring Boot可以很轻松的实现session读写。2,开始-进行配置我们项目的pom.xml文件如下:<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.0.4</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>user-login</artifactId> <version>1.0.0</version> <name>UserLogin</name> <description>UserLogin</description> <properties> <java.version>17</java.version> <maven.compiler.source>${java.version}</maven.compiler.source> <maven.compiler.target>${java.version}</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- Jackson - Json注解 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-annotations</artifactId> <version>2.12.4</version> </dependency> <!-- codec - 加密 --> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> </dependency> <!-- commons-lang3 - 实用工具 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <!-- Spring Session --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-core</artifactId> </dependency> <!-- Spring Validation - 校验工具 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Spring Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis - 数据库框架 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.1</version> </dependency> <!-- MySQL连接驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.32</version> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Spring Test --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>主要是看看依赖部分需要用到什么,依赖可以根据自己实际情况进行增删,这里我主要用这些,大多数在创建Spring Boot工程时也可以进行选择。然后配置项目配置文件application.properties# 网站端口配置 server.port=8802 # JSON配置,设定不对未知字段和空值字段进行序列化节省流量 spring.jackson.deserialization.fail-on-unknown-properties=false spring.jackson.default-property-inclusion=non_null # MySQL数据库地址和账户配置(根据自己实际情况进行填写) spring.datasource.url=jdbc:mysql://localhost:3306/miyakogame?serverTimezone=GMT%2B8 spring.datasource.username=swsk33 spring.datasource.password=dev-2333对于Mybatis的Mapper XML文件,默认位于src/main/resources/com/{xxx}/{xxx}/dao/之下,即和你的dao包位置对应。例如我们项目的Mapper类一般会放在com.example.userlogin.dao下,那么Spring Boot就默认去这个地方扫描Mapper XML:src/main/resources/com/example/userlogin/dao,目录需手动创建。当然我们也可以进行指定:mybatis.mapper-locations=file:Resources/mybatisMapper/*.xml这里我们的项目就不配置MyBatis的XML位置了,就使用默认位置。配置全部根据自己实际情况进行填写。3,封装一个请求结果类Result<T>为了方便起见,我们通常封装一个结果类,里面主要是请求结果代码、消息、是否操作成功和数据体,可以根据自己需要进行修改。新建软件包model,并在其中新建Result<T>内容如下:package com.example.userlogin.model; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.io.Serializable; /** * 请求结果类 * * @param <T> 数据体类型 */ @Setter @Getter @NoArgsConstructor public class Result<T> implements Serializable { /** * 消息 */ private String message; /** * 是否操作成功 */ private boolean success; /** * 返回的数据主体(返回的内容) */ private T data; /** * 设定结果为成功 * * @param msg 消息 */ public void setResultSuccess(String msg) { this.message = msg; this.success = true; this.data = null; } /** * 设定结果为成功 * * @param msg 消息 * @param data 数据体 */ public void setResultSuccess(String msg, T data) { this.message = msg; this.success = true; this.data = data; } /** * 设定结果为失败 * * @param msg 消息 */ public void setResultFailed(String msg) { this.message = msg; this.success = false; this.data = null; } }一般都会返回给前端这个结果对象,更加便捷的传递是否成功以及操作消息等等。注意前后端传递交互的数据都需要实现序列化接口并且要有无参构造器,这里使用了Lombok的注解,下面也一样。4,建立用户类,并初始化数据库表创建软件包dataobject,并在其中建立用户类User,实际根据业务需要不同,用户类可能有很多属性,这里我们只建立最简单的如下:package com.example.userlogin.dataobject; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.io.Serializable; /** * 用户类 */ @Setter @Getter @NoArgsConstructor @JsonIgnoreProperties(value = {"password"}, allowSetters = true) public class User implements Serializable { /** * 用户id */ private Integer id; /** * 用户名 */ @NotEmpty(message = "用户名不能为空!") private String username; /** * 密码 */ @NotEmpty(message = "密码不能为空!") @Size(min = 8, message = "密码长度不能小于8!") private String password; }我们设定了用户的最基本属性,并对其设定了校验规则(不熟悉Spring Validation可以看看这篇文章),密码为敏感信息,因此我们使用Jackson注解设定密码字段为不允许序列化(不会传给前端)。创建了用户类,我们数据库表也需要对应起来,初始化一个用户表,我这里sql如下:drop table if exists `user`; create table `user` ( `id` int unsigned auto_increment, `username` varchar(16) not null, `password` varchar(32) not null, primary key (`id`) ) engine = InnoDB default charset = utf8mb4;连接MySQL并use相应数据库,将这个sql文件使用source命令执行即可。每个对象都有主键id,且一般设为自增无符号整数,这样有最高的数据库读写效率。密码一般使用MD5加密储存,因此固定32位长度。一般每个数据库表都有创建时间gmt_created和修改时间gmt_modified字段,这里简单起见省略。5,创建数据服务层-DAO并配置Mapper XMLDAO层主要是Java的对于数据库操作的接口和实现类。MyBatis的强大之处,就是只需定义接口,就可以实现操作数据库。创建软件包dao,新建接口UserDAO:package com.example.userlogin.dao; import com.example.userlogin.dataobject.User; import org.apache.ibatis.annotations.Mapper; @Mapper public interface UserDAO { /** * 新增用户 * * @param user 用户对象 * @return 新增成功记录条数 */ int add(User user); /** * 修改用户信息 * * @param user 用户对象 * @return 修改成功记录条数 */ int update(User user); /** * 根据id获取用户 * * @param id 用户id * @return 用户对象 */ User getById(Integer id); /** * 根据用户名获取用户 * * @param username 用户名 * @return 用户对象 */ User getByUsername(String username); }根据实际需要定义数据库增删改查方法,这里就定义这些。注意这里接口上面要打上@Mapper注解表示它是个数据持久层接口。且一般来说,增删改方法的返回值都是int,表示操作成功记录条数,查方法一般是返回相应对象或者对象的List。然后编写Mapper XML文件,我们在项目文件夹下的src/main/resources目录下创建多级目录:com/example/userlogin/dao,在这个目录下存放XML文件。创建UserDAO.xml内容如下:<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.userlogin.dao.UserDAO"> <resultMap id="userResultMap" type="com.example.userlogin.dataobject.User"> <id column="id" property="id"/> <result column="username" property="username"/> <result column="password" property="password"/> </resultMap> <insert id="add" parameterType="com.example.userlogin.dataobject.User"> insert into `user` (username, password) values (#{username}, #{password}) </insert> <update id="update" parameterType="com.example.userlogin.dataobject.User"> update `user` set password=#{password} where id = #{id} </update> <select id="getById" resultMap="userResultMap"> select * from `user` where id = #{id} </select> <select id="getByUsername" resultMap="userResultMap"> select * from `user` where username = #{username} </select> </mapper>这样,数据库操作层就完成了!一般约定某一个对象(xxx)的数据库操作层接口一般命名为xxxDAO(有的企业也命名为xxxMapper),一个xxxDAO接口对应一个xxxDAO.xml文件。6,创建用户服务层现在就要进行正式的服务层逻辑了,完成我们的主要功能:用户注册、登录、信息修改。其实,用户注册就是前端发送用户注册信息(封装为User对象),后端检验然后往数据库增加一条用户记录的过程;登录也是前端发送用户登录信息,同样封装为User对象,后端根据这个对象的username字段从数据库取出用户、进行比对最后设定session的过程;修改用户也是前端发送修改后的用户信息的User对象,后端进行比对,然后修改数据库相应记录的过程。先新建包service,在其中添加用户服务接口UserService:package com.example.userlogin.service; import com.example.userlogin.dataobject.User; import com.example.userlogin.model.Result; import jakarta.servlet.http.HttpSession; import org.springframework.stereotype.Service; @Service public interface UserService { /** * 用户注册 * * @param user 用户对象 * @return 注册结果 */ Result<User> register(User user); /** * 用户登录 * * @param user 用户对象 * @return 登录结果 */ Result<User> login(User user); /** * 修改用户信息 * * @param user 用户对象 * @return 修改结果 */ Result<User> update(User user) throws Exception; /** * 判断用户是否登录(实际上就是从session取出用户id去数据库查询并比对) * * @param session 传入请求session * @return 返回结果,若用户已登录则返回用户信息 */ Result<User> isLogin(HttpSession session); }然后再在包service下建立包impl,然后在里面新建用户服务实现类UserServiceImpl:package com.example.userlogin.service.impl; import com.example.userlogin.api.UserAPI; import com.example.userlogin.dao.UserDAO; import com.example.userlogin.dataobject.User; import com.example.userlogin.model.Result; import com.example.userlogin.service.UserService; import com.example.userlogin.util.ClassExamine; import jakarta.servlet.http.HttpSession; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class UserServiceImpl implements UserService { @Autowired private UserDAO userDAO; @Override public Result<User> register(User user) { Result<User> result = new Result<>(); // 先去数据库找用户名是否存在 User getUser = userDAO.getByUsername(user.getUsername()); if (getUser != null) { result.setResultFailed("该用户名已存在!"); return result; } // 加密储存用户的密码 user.setPassword(DigestUtils.md5Hex(user.getPassword())); // 存入数据库 userDAO.add(user); // 返回成功消息 result.setResultSuccess("注册用户成功!", user); return result; } @Override public Result<User> login(User user) { Result<User> result = new Result<>(); // 去数据库查找用户 User getUser = userDAO.getByUsername(user.getUsername()); if (getUser == null) { result.setResultFailed("用户不存在!"); return result; } // 比对密码(数据库取出用户的密码是加密的,因此要把前端传来的用户密码加密再比对) if (!getUser.getPassword().equals(DigestUtils.md5Hex(user.getPassword()))) { result.setResultFailed("用户名或者密码错误!"); return result; } // 设定登录成功消息 result.setResultSuccess("登录成功!", getUser); return result; } @Override public Result<User> update(User user) throws Exception { Result<User> result = new Result<>(); // 去数据库查找用户 User getUser = userDAO.getById(user.getId()); if (getUser == null) { result.setResultFailed("用户不存在!"); return result; } // 检测传来的对象里面字段值是否为空,若是就用数据库里面的对象相应字段值补上 if (!StringUtils.isEmpty(user.getPassword())) { // 加密储存 user.setPassword(DigestUtils.md5Hex(user.getPassword())); } // 对象互补 ClassExamine.objectOverlap(user, getUser); // 存入数据库 userDAO.update(user); result.setResultSuccess("修改用户成功!", user); return result; } @Override public Result<User> isLogin(HttpSession session) { Result<User> result = new Result<>(); // 从session中取出用户信息 User sessionUser = (User) session.getAttribute(UserAPI.SESSION_NAME); // 若session中没有用户信息这说明用户未登录 if (sessionUser == null) { result.setResultFailed("用户未登录!"); return result; } // 登录了则去数据库取出信息进行比对 User getUser = userDAO.getById(sessionUser.getId()); // 如果session用户找不到对应的数据库中的用户或者找出的用户密码和session中用户不一致则说明session中用户信息无效 if (getUser == null || !getUser.getPassword().equals(sessionUser.getPassword())) { result.setResultFailed("用户信息无效!"); return result; } result.setResultSuccess("用户已登录!", getUser); return result; } }需要注意的是服务接口需要有@Service注解,接口实现类要有@Component注解,还自动注入了DAO的实例进行数据库操作。这里着重强调一下update操作。可以看见,在DAO层的update的SQL语句中并没有判断传入的用户对象的字段是否为空或者是否和原来一致,而是直接将传入的用户对象覆盖至数据库中相应的用户记录上了。因此,一般情况下,我们在Service层进行判断。一般来说,Service层update方法会先从数据库取出原始用户信息(上述名为getUser的对象),然后和传入的用户信息对象(上述名为user的对象)进行字段对比。如果传入的user中某个字段为空,说明这个字段的信息是不需要修改的,这时使用原始用户对象的相应字段值填上去,如果不为空,则保留其值,最后再传入DAO层修改数据库。也因此,前端在发起修改请求时,也会将不用修改的字段留空。这就是我们平时进行对象更新的逻辑。不过当一个用户的字段多了,我们是不是要写很多个if来逐一判断呢?当然是不行的。所以这里我封装了一个方法,利用反射,检测被补全的对象(传入对象)和完整对象(从数据库取出的对象)的字段,如果被补全对象某个字段为空,这说明这个字段值是不用修改的,用完整对象对应的字段值补全,不为空说明被补全对象(传入对象)中这个字段值是新的值,是要修改的,因此保持其不变。当然,密码是特殊的字段,因为需要加密储存,因此需要单独判断前端是否传入了新的密码,如果是就加密并赋给相应字段。在上述代码中我也进行了判断。这里新建util包,新建ClassExamine类封装一个补全对象的方法,如下:package com.example.userlogin.util; import org.apache.commons.lang3.StringUtils; import java.lang.reflect.Field; /** * 类检测实用类 */ public class ClassExamine { /** * 对象字段互补。传入一个同类型被补充对象和完整对象,如果被补充对象中有字段为null或者字符串为空,就用完整对象对应的字段值补上去;如果被补充对象中某字段不为空则保留它自己的值。 * * @param origin 被补充对象 * @param intactObject 完整对象 * @param <T> 传入对象类型 */ public static <T> void objectOverlap(T origin, T intactObject) throws Exception { Field[] fields = origin.getClass().getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); if (field.getType() == String.class) { if (StringUtils.isEmpty((String) field.get(origin))) { field.set(origin, field.get(intactObject)); } } else { if (field.get(origin) == null) { field.set(origin, field.get(intactObject)); } } } } }以及上述判断用户登录的方法,其中的session是从Controller类中传来的,(因为Controller类可以获取请求中的session),这里先不要纠结session的问题,我们往下看。7,创建用户登录API服务层写完了,现在就是前后端交互的桥梁需要打通了-编写API,这样前端才能发送请求调用我们后端的服务。我们的API要实现用户登录、注册、判断用户是否登录、修改用户信息、用户登出这几个功能。新建包api,然后在里面新建类UserAPI:package com.example.userlogin.api; import com.example.userlogin.dataobject.User; import com.example.userlogin.model.Result; import com.example.userlogin.service.UserService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; @RestController public class UserAPI { /** * session的字段名 */ public static final String SESSION_NAME = "userInfo"; @Autowired private UserService userService; /** * 用户注册 * * @param user 传入注册用户信息 * @param errors Validation的校验错误存放对象 * @param request 请求对象,用于操作session * @return 注册结果 */ @PostMapping("/register") public Result<User> register(@RequestBody @Valid User user, BindingResult errors, HttpServletRequest request) { Result<User> result; // 如果校验有错,返回注册失败以及错误信息 if (errors.hasErrors()) { result = new Result<>(); result.setResultFailed(errors.getFieldError().getDefaultMessage()); return result; } // 调用注册服务 result = userService.register(user); return result; } /** * 用户登录 * * @param user 传入登录用户信息 * @param errors Validation的校验错误存放对象 * @param request 请求对象,用于操作session * @return 登录结果 */ @PostMapping("/login") public Result<User> login(@RequestBody @Valid User user, BindingResult errors, HttpServletRequest request) { Result<User> result; // 如果校验有错,返回登录失败以及错误信息 if (errors.hasErrors()) { result = new Result<>(); result.setResultFailed(errors.getFieldError().getDefaultMessage()); return result; } // 调用登录服务 result = userService.login(user); // 如果登录成功,则设定session if (result.isSuccess()) { request.getSession().setAttribute(SESSION_NAME, result.getData()); } return result; } /** * 判断用户是否登录 * * @param request 请求对象,从中获取session里面的用户信息以判断用户是否登录 * @return 结果对象,已经登录则结果为成功,且数据体为用户信息;否则结果为失败,数据体为空 */ @GetMapping("/is-login") public Result<User> isLogin(HttpServletRequest request) { // 传入session到用户服务层 return userService.isLogin(request.getSession()); } /** * 用户信息修改 * * @param user 修改后用户信息对象 * @param request 请求对象,用于操作session * @return 修改结果 */ @PutMapping("/update") public Result<User> update(@RequestBody User user, HttpServletRequest request) throws Exception { Result<User> result = new Result<>(); HttpSession session = request.getSession(); // 检查session中的用户(即当前登录用户)是否和当前被修改用户一致 User sessionUser = (User) session.getAttribute(SESSION_NAME); if (sessionUser.getId() != user.getId().intValue()) { result.setResultFailed("当前登录用户和被修改用户不一致,终止!"); return result; } result = userService.update(user); // 修改成功则刷新session信息 if (result.isSuccess()) { session.setAttribute(SESSION_NAME, result.getData()); } return result; } /** * 用户登出 * * @param request 请求,用于操作session * @return 结果对象 */ @GetMapping("/logout") public Result<Void> logout(HttpServletRequest request) { Result<Void> result = new Result<>(); // 用户登出很简单,就是把session里面的用户信息设为null即可 request.getSession().setAttribute(SESSION_NAME, null); result.setResultSuccess("用户退出登录成功!"); return result; } }因为是API,所以使用@RestController标注类,可见注册是通过前端发送POST请求后端接收,修改用的则是PUT请求,且在各个方法中加入参数HttpServletRequest request,这个名为request的对象就代表客户端这次对这个接口的访问的请求。可以通过这个名为request对象对session进行获取。每一个不同的请求都会有一个唯一的session,每个session中的信息都是key-value形式储存,这里我们只在session里面储存用户信息,因此我们把用户信息的key设为一个固定的名字userInfo,通过建立个常量SESSION_NAME。每个不同机器的请求都会生成独一无二的session,上面这段代码操作,我们可以理解为:登录/注册用户后,取得了用户信息,我们将每个不同机器的用户信息储存在了与它们相对应的session里面,并在其中设定用户信息的key为userInfo。其实,session中内容储存形式和cookie是一样的,都是key-value的形式,我们可以理解为和Java中的Map对象是差不多的。这里session指的是session机制中储存在服务端的用户信息。通过HttpServletRequest的getSession方法,即可获取这个请求对应的session对象,为HttpSession类型,其中setAttribute方法用于设定session中的键值对,getAttribute方法用于获取session中信息。可见,session中的内容是由我们自定义的,只不过通常我们需要把用户对象存进去以验证是否登录。上述我们只存放了用户对象在session里面,实际开发中大家还可以存点别的东西进去。当然不建议存太多信息,否则会增加服务器负载。这里我们还将session对象传入上述Service层的判断用户是否登录方法中,在其中对session中用户信息进行读取,然后利用session中用户对象的id去数据库中取出并进行比对判断用户是否有效登录。用户没有登录,则session获取到的用户对象就一定是null。对于这里API类的代码,建议大家可以联系上面的session机制示意图一起看,这样就更好理解。8,配置cookie属性其实到上面第7步,我们的功能基本上完整了,但是还有一些重要配置需要进行。我们还要开启session功能,并配置cookie属性例如过期时间等等。新建包config,在其中建立配置类SessionConfig:package com.example.userlogin.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.session.MapSessionRepository; import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; import java.util.concurrent.ConcurrentHashMap; /** * session配置类 */ @Configuration @EnableSpringHttpSession public class SessionConfig { /** * 设定cookie序列化器的属性 */ @Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setCookieName("JSESSIONID"); // 用正则表达式配置匹配的域名,可以兼容 localhost、127.0.0.1 等各种场景 serializer.setDomainNamePattern("^.+?\.(\w+\.[a-z]+)$"); // cookie生效路径 serializer.setCookiePath("/"); // 设置是否只能服务器修改,浏览器端不能修改 serializer.setUseHttpOnlyCookie(false); // 最大生命周期的单位是分钟 serializer.setCookieMaxAge(24 * 60 * 60); return serializer; } /** * 注册序列化器 */ @Bean public MapSessionRepository sessionRepository() { return new MapSessionRepository(new ConcurrentHashMap<>()); } }注意配置类打上@Configuration注解,@EnableSpringHttpSession开启session。9,配置拦截器虽然我们有判断用户登录的API,但是如果我们页面很多,每一个都要判断登录,就会很麻烦。通过拦截器即可对指定的路径设定拦截点。继续在config包下创建拦截器类UserInterceptor:package com.example.userlogin.config; import com.example.userlogin.service.UserService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; /** * 拦截器 */ public class UserInterceptor implements HandlerInterceptor { @Autowired private UserService userService; // Controller方法执行之前 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 同样在这里调用用户服务传入session,判断用户是否登录或者有效 // 未登录则重定向至主页(假设主页就是/) if (!userService.isLogin(request.getSession()).isSuccess()) { response.sendRedirect("/"); return false; } return true; } // Controller方法执行之后 @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } // 整个请求完成后(包括Thymeleaf渲染完毕) @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }可见拦截器有三个方法,分别对应三个切入点。一般我们只需要修改在Controller执行之前的那个,它可以像API一样操作session,返回true时表示允许继续访问这个Controller,否则终止访问。通过HttpServletResponse的sendRedirect方法可以发送重定向。拦截器类写好了,接下来就是注册拦截器了。在config类中创建配置类InterceptorRegister:package com.example.userlogin.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.ArrayList; import java.util.List; /** * 拦截器注册 */ @Configuration public class InterceptorRegister implements WebMvcConfigurer { /** * 把我们定义的拦截器类注册为Bean */ @Bean public HandlerInterceptor getInterceptor() { return new UserInterceptor(); } /** * 添加拦截器,并配置拦截地址 */ @Override public void addInterceptors(InterceptorRegistry registry) { List<String> pathPatterns = new ArrayList<>(); pathPatterns.add("/update"); registry.addInterceptor(getInterceptor()).addPathPatterns(pathPatterns); } }在addInterceptors方法里面,我们进行拦截器注册,并配置拦截地址。上述例子只添加拦截/update这个路径,其余不拦截。还可以设定拦截全部,只排除/login,如下写addInterceptors方法:@Override public void addInterceptors(InterceptorRegistry registry) { List<String> pathPatterns = new ArrayList<>(); pathPatterns.add("/login"); registry.addInterceptor(getInterceptor()).excludePathPatterns(pathPatterns); }在此,一个比较简易但完整的用户注册登录就做完了!10,体验一下启动程序,并使用ApiPost软件来体验一下用户登录等服务接口的功能。(1) 注册一个用户先访问/register注册一个用户:(2) 测试判断登录接口先不急着登录,访问/is-login看看:可见由于我们还未登录,所以请求中没有sessionId,后端也无法找到这个请求的session数据,因此返回未登录。(3) 登录然后再次判断这次我们访问/login登录:然后再访问/is-login接口:可见这一次,我们的请求中就带着sessionId了!服务端也可以找到对应的session。现在许多系统都是前后端分离的系统,因此这个判断登录/is-login接口也是很有必要的,除了判断这个请求是否已经登录之外,还可以直接拿取已登录的用户的信息,而无需传入id去查询。11,总结用户登录注册看起来要写的东西很多,但实际上流程很清晰,也不难理解。我们发现,DAO层就是单纯操作数据库,返回用户对象;Service层基本上就是用于验证信息正确性,返回封装的Result对象;Controller层进一步设定session,也是返回封装Result对象。每个不同的部分各司其职,完成了我们整个业务逻辑。其实不仅仅是用户登录系统,我们使用Spring Boot搭建任何系统,无外乎都是以下几个大步骤:构建数据模型:dataobject包的内容和model包的内容,dataobject一般是数据库中的对象,最好是画个类图编写DAO层:用于操作数据库,定义好需要用到的操作数据库的方法例如增删改查、根据用户名查找用户等等编写Service层:即为服务层,用于调用DAO层,一般来说我们会把大量的代码写在这里,这里包含了许多逻辑,一个网站有哪些服务(用户登录,注册等等),都定义在这层,这一层是操作DAO层并处理数据的一层,也包含了业务逻辑编写API层:即为接口,是处在最外面的一层了,调用Service层,是前后端交流的桥梁示例程序仓库地址:传送门ApiPost测试配置:传送门
文章
XML  ·  前端开发  ·  Java  ·  数据库连接  ·  API  ·  数据库  ·  数据安全/隐私保护  ·  数据格式  ·  Spring  ·  mybatis
2023-03-13
...
跳转至:
冰河技术
28 人关注 | 0 讨论 | 455 内容
+ 订阅
  • 【高并发】InterruptedException异常会对并发编程产生哪些影响?
  • 这次我设计了一款TPS百万级别的分布式、高性能、可扩展的RPC框架
  • 多年亿级流量下的高并发经验总结,我毫无保留的写在了这本书中
查看更多 >
达摩院语音实验室
58 人关注 | 0 讨论 | 54 内容
+ 订阅
  • 十问科学家丨解锁下一个语音技术关键场景还有多远?
  • Alibaba at Interspeech 2021 | 达摩院语音实验室9篇入选论文解读
  • 天气炎热等公交,实时查询动口不动手|语音AI在高德
查看更多 >
阿里云支持与服务
2118 人关注 | 4890 讨论 | 809 内容
+ 订阅
  • windows出现错误0x800401E5:没有供标记使用的对象
  • Nginx 配置HTTPS证书提示报错
  • Alibaba Cloud Linux 3安装Wordpress以及更换wordpress版本
查看更多 >
开发与运维
5766 人关注 | 133244 讨论 | 318452 内容
+ 订阅
  • 私有化部署 Outline
  • OpenSupports 在 NGINX 下的配置
  • 【大数据开发运维解决方案】记一次同事不慎用root起动weblogic以及启动日志卡在The server started in RUNNING mode 问题解决过程
查看更多 >
大数据
188703 人关注 | 30735 讨论 | 83736 内容
+ 订阅
  • 【大数据开发运维解决方案】记一次同事不慎用root起动weblogic以及启动日志卡在The server started in RUNNING mode 问题解决过程
  • 【大数据开发运维解决方案】ogg(GoldenGate)三大进程常用参数
  • 【大数据开发运维解决方案】GoldenGate replicat进程延迟分析步骤
查看更多 >