Android 源码解析: SharedPreferences的解析

Android源码解析:SharedPreferences的解析

导言

SharedPreferences是Android中的一种轻量的数据持久化手段,可能也是我们在学习Android时接触到的第一种特殊的本地数据持久化手段,本篇文章就将从源码角度分析SharedPreferences的原理。

源码分析

一般我们使用SharedPreferences是这样使用的:

//sp的使用--写入数据
val sp = getPreferences(Context.MODE_PRIVATE)
val editor = sp.edit()
editor.putString("cc","123")
editor.apply()
//读取数据
val ans = sp.getString("cc","null")
Toast.makeText(this, ans, Toast.LENGTH_SHORT).show()

我们接下来就以这段程序为例分析SharedPreferences的原理。

获取Preferences对象

我们可以有多种方法可以获得Preferences对象:

  • getPreferences(int mode)
  • getDefaultSharedPreferences(context context)
  • getSharedPreferences(String key,int mode)

这段示例中我们以getPreferences方法为例,实际上这个方法的完整显示应该是getActivity().getPreferences(),也就是说必须在Activity上调用该方法,我们来看该方法:

public SharedPreferences getPreferences(@Context.PreferencesMode int mode) {return getSharedPreferences(getLocalClassName(), mode);
}

可以看到该方法最终还是会调用到getSharedPreferences(String key,int mode)方法,只不过此处以本Activity的类名为关键字传递到第一个参数中,接下来我们继续看跳转到的第二个方法中:

public SharedPreferences getSharedPreferences(File file, int mode) {return mBase.getSharedPreferences(file, mode);
}

这最终就调用到了与Activity相关联的Context的方法中,这个mBase不出所料应该是ContextImpl,我们来看这个方法:

    public SharedPreferences getSharedPreferences(String name, int mode) {......File file;//同步代码块,以ContextImpl类为锁进行锁定synchronized (ContextImpl.class) {//当文件路径还没加载时if (mSharedPrefsPaths == null) { //创建一个Map来存储文件路径mSharedPrefsPaths = new ArrayMap<>();}//尝试从文件路径存储中查找路径file = mSharedPrefsPaths.get(name);//若查找不到具体的文件,说明文件还没有被创建if (file == null) {//调用getSharedPreferencesPath方法file = getSharedPreferencesPath(name);//将新创建出来的文件路径放入Map中mSharedPrefsPaths.put(name, file);}}// 跳转到另一个重载的方法中return getSharedPreferences(file, mode);}

重要的代码部分我已经加上了注释,此处的方法就是创建出一个Map来存储同一个Context下的Sp对象(路径),若目标的Sp对象不存在还要创建一个Sp对象然后将其存储到Map中,我们具体先来看getSharedPreferencesPath方法:

public File getSharedPreferencesPath(String name) {return makeFilename(getPreferencesDir(), name + ".xml");
}

可以看到这个方法实际上就是创建出来了一个新的文件,父路径为getPreferencesDir()的值,子路径为name+xml,意思就是创建出来的是一个xml文件,也就是说Sp实际上是通过xml文件来存储具体数据的。

然后我们来看最后跳转到的另一个方法中:

public SharedPreferences getSharedPreferences(File file, int mode) {//实际的Sp实现类SharedPreferencesImpl sp;//仍然是以ContextImpl为锁进行同步synchronized (ContextImpl.class) {//获取Sp缓存final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();//获得Sp的具体实例sp = cache.get(file);//当不能成功从缓存中获取Sp时if (sp == null) {checkMode(mode);if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {if (isCredentialProtectedStorage()&& !getSystemService(UserManager.class).isUserUnlockingOrUnlocked(UserHandle.myUserId())) {throw new IllegalStateException("SharedPreferences in credential encrypted "+ "storage are not available until after user is unlocked");}}//创建一个新的Sp实例sp = new SharedPreferencesImpl(file, mode);//加入到缓存中cache.put(file, sp);//返回Sp实例return sp;}}if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {// If somebody else (some other process) changed the prefs// file behind our back, we reload it.  This has been the// historical (if undocumented) behavior.sp.startReloadIfChangedUnexpectedly();}return sp;
}

这段代码的重要逻辑我也已经标注出来了,我们需要额外看的可能就是getSharedPreferencesCacheLocked()获取缓存的过程:

private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {if (sSharedPrefsCache == null) {sSharedPrefsCache = new ArrayMap<>();}final String packageName = getPackageName();ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);if (packagePrefs == null) {packagePrefs = new ArrayMap<>();sSharedPrefsCache.put(packageName, packagePrefs);}return packagePrefs;}

可以看到它获取当前Sp缓存还是通过另一个缓存获取的,也就是说是通过两级缓存来存取数据的。上级缓存是用来缓存同一个包名下的缓存,下级缓存是用来获得具体的Sp实例的。

所以总结下来就是当缓存中没有对应的Sp实例时创建一个Sp实例塞入缓存中,如果缓存中有就直接返回对应Sp实例。

Commit提交修改

首先我们要找到这个方法需要来到Sp的具体实现类SharedPreferencesImpl中的内部类EditorImpl,不过在分析该方法之前我们还需要先看一下另一个方法commitToMemory,它也是EditorImpl中的方法:

private MemoryCommitResult commitToMemory() {long memoryStateGeneration;boolean keysCleared = false;List<String> keysModified = null;Set<OnSharedPreferenceChangeListener> listeners = null;Map<String, Object> mapToWriteToDisk;//以当前的Sp实例(持有的外部类实例)为锁来同步synchronized (SharedPreferencesImpl.this.mLock) {//当还有未完成的磁盘写入时if (mDiskWritesInFlight > 0) {//更新Map,将之前的Map内容也写入到当前Map中mMap = new HashMap<String, Object>(mMap);}//更新要写入磁盘的MapmapToWriteToDisk = mMap;//标记正在写入的标记值+mDiskWritesInFlight++;//判断是否有监听器boolean hasListeners = mListeners.size() > 0;//如果存在监听器的话if (hasListeners) {keysModified = new ArrayList<String>();listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());}//等待编辑锁synchronized (mEditorLock) {boolean changesMade = false;//如果Clear位为trueif (mClear) {//写入磁盘的map不为空if (!mapToWriteToDisk.isEmpty()) {//修改位置为truechangesMade = true;mapToWriteToDisk.clear();}keysCleared = true;mClear = false;}//遍历需要修改的Map中的数据for (Map.Entry<String, Object> e : mModified.entrySet()) {String k = e.getKey();Object v = e.getValue();// v == this 时 或者 v 为空时// v == this 对应的是 remove方法if (v == this || v == null) {// 需要写入磁盘的map中不包含当前key时,直接跳过本次循环if (!mapToWriteToDisk.containsKey(k)) {continue;}//将其从需要写入磁盘的map中移除mapToWriteToDisk.remove(k);} else {//当无修改的时候直接跳过if (mapToWriteToDisk.containsKey(k)) {Object existingValue = mapToWriteToDisk.get(k);if (existingValue != null && existingValue.equals(v)) {continue;}}//否则将其写入需要写入磁盘的mapmapToWriteToDisk.put(k, v);}//将changesMade标志位置为truechangesMade = true;//如果有监听器的话if (hasListeners) {//将需要修改的键值对的key值写入keysModified中keysModified.add(k);}}//清除mModified这个mapmModified.clear();//如果有修改要提交到磁盘中去if (changesMade) {//自增相当于是一个版本号mCurrentMemoryStateGeneration++;}memoryStateGeneration = mCurrentMemoryStateGeneration;}}//返回一个对象,这个对象描述的就是需要写入磁盘中的数据的相关信息return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,listeners, mapToWriteToDisk);
}

该方法的一些注释已经写在上面了,这主要是将Editor之前的操作,比如在执行commit之前调用到的putString等操作封装成一个MemoryCommitResult对象,这个对象就是用来描述需要写入磁盘中的数据的相关信息。

看完了commitToMemory方法,我们接下来再来看commit方法:

public boolean commit() {long startTime = 0;if (DEBUG) {startTime = System.currentTimeMillis();}//将之前的操作提交形成一个提交对象MemoryCommitResult mcr = commitToMemory();// 加入到外部类的磁盘写队列中SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);try {//等待写入完成mcr.writtenToDiskLatch.await();} catch (InterruptedException e) {return false;} finally {if (DEBUG) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " committed after " + (System.currentTimeMillis() - startTime)+ " ms");}}//写入完成之后唤醒监听器notifyListeners(mcr);//返回是否写入成功return mcr.writeToDiskResult;
}

主要的注释也已经写入在代码中了,commit的整个流程还是很好懂的,首先就是通过我们之前介绍过的commitToMemory方法将之前的操作封装成一个提交信息,然后将其添加到SP的任务队列中,等待其写入完成,最后返回结果即可。

接着我们来看加入到任务队列中的过程,具体来说就是SharedPreferencesImpl.this.enqueueDiskWrite( mcr, null );这一句:

private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) {//判断是否是同步提交的(postWriteRunnable == null 时说明是同步提交的)final boolean isFromSyncCommit = (postWriteRunnable == null);//将写任务包装成一个Runnablefinal Runnable writeToDiskRunnable = new Runnable() {@Overridepublic void run() {synchronized (mWritingToDiskLock) {//同步执行writeToFile方法,也就是写入磁盘中,具体来说是每个Sp对应的xml文件writeToFile(mcr, isFromSyncCommit);}//将正在写入的任务数--synchronized (mLock) {mDiskWritesInFlight--;}//异步写入的时候会添加一个postWriteRunnable任务,在此处执行if (postWriteRunnable != null) {postWriteRunnable.run();}}};//当操作为同步提交if (isFromSyncCommit) {boolean wasEmpty = false;synchronized (mLock) {wasEmpty = mDiskWritesInFlight == 1;}//当只有当前这一个任务需要提交的时候if (wasEmpty) {//当empyt标志位为true,直接执行我们上面包装好的RunnablewriteToDiskRunnable.run();return;}}//若是异步或者同步提交前有其他任务才会将其添加到工作队列中执行,第二个参数为shouldDelay//标志位,即需不需要进行延时100ms,可以看到当同步时不需要延时而异步时需要延时QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

这里相关的代码逻辑也已经在方法中标注出来了。此处的特殊处理在于同步异步时的处理,当为同步提交且当前任务是唯一的任务时将直接执行当前任务而不需要经过任务队列,否则将通过任务队列处理。同步时提交到任务队列执行时不需要进行延时,而异步提交时需要进行100ms的延时。为什么是100ms的延时呢?我们等等再来看这一部分的源码。

Apply提交修改

看完了同步提交,我们接下来再来看异步提交。首先我们紧接着上面关于任务队列的操作,紧接上面的QueuedWork.queue方法:

public static void queue(Runnable work, boolean shouldDelay) {Handler handler = getHandler();synchronized (sLock) {sWork.add(work);if (shouldDelay && sCanDelay) {handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);} else {handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);}}
}

可以看到这个方法很短,其实具体还是通过Handler机制来提交任务的,那其对应的Thread在哪里?可以在getHandler方法中看到:

private static Handler getHandler() {synchronized (sLock) {if (sHandler == null) {//创建一个HandlerThread用作工作线程HandlerThread handlerThread = new HandlerThread("queued-work-looper",Process.THREAD_PRIORITY_FOREGROUND);//启动工作线程handlerThread.start();//创建出HandlersHandler = new QueuedWorkHandler(handlerThread.getLooper());}return sHandler;}
}private static class QueuedWorkHandler extends Handler {static final int MSG_RUN = 1;QueuedWorkHandler(Looper looper) {super(looper);}public void handleMessage(Message msg) {if (msg.what == MSG_RUN) {//处理挂起的任务processPendingWork();}}
}

由于写磁盘也是耗时操作,所以说SharedPreferences在执行写任务的时候是会创建一个HandlerThread线程作为工作线程,并且将其与Handler关联起来,通过Handler处理任务队列。我们之前所说的延时100ms具体是通过queue方法体现的:

public static void queue(Runnable work, boolean shouldDelay) {Handler handler = getHandler();synchronized (sLock) {sWork.add(work);if (shouldDelay && sCanDelay) {handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);} else {handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);}}
}

这里通过handler将任务发送到MessageQueue中。如果shouldDelaysCanDelay标志位均为true就会通过Handler的sendMessageDelay方法,第二个参数即为延时的毫秒数,我们可以看到它的具体取值:
在这里插入图片描述
可以看到只是一个100ms的延时。

好了,现在言归正传,我们来看apply方法的源码:

public void apply() {final long startTime = System.currentTimeMillis();final MemoryCommitResult mcr = commitToMemory();final Runnable awaitCommit = new Runnable() {@Overridepublic void run() {try {mcr.writtenToDiskLatch.await();} catch (InterruptedException ignored) {}if (DEBUG && mcr.wasWritten) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " applied after " + (System.currentTimeMillis() - startTime)+ " ms");}}};QueuedWork.addFinisher(awaitCommit);Runnable postWriteRunnable = new Runnable() {@Overridepublic void run() {awaitCommit.run();QueuedWork.removeFinisher(awaitCommit);}};SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);// Okay to notify the listeners before it's hit disk// because the listeners should always get the same// SharedPreferences instance back, which has the// changes reflected in memory.notifyListeners(mcr);
}

可以看到这整个apply方法和commit是差不多的,区别之一是commit方法的mcr.writtenToDiskLatch.await()这一句是直接在commit方法中执行的,该方法就是用来等待写入完成的;而apply方法是将该方法封装进入一个Runnable对象中再塞入工作队列中执行,所以就不会在调用处引起阻塞。除此之外我们还可以在apply中发现的不同点是调用到了addFinisher方法,这个就和具体的工作队列类QueuedWork有关了。

工作队列QueuedWork

该特殊的工作队列和其他的工作队列的不同之处应该就在于其持有的Finisher队列,具体来说这个队列是保证该队列中的任务一定会被执行,什么叫一定被执行呢?众所周知诸如Activity等组件是存在其生命周期的,如果当其生命周期终结时任务队列中的剩余任务自然也不会被执行了,该队列的存在保证剩余的任务一定会被处理,具体我们可以在waitToFinish方法的注释中看出来:

Trigger queued work to be processed immediately. The queued work is processed on a separate thread asynchronous. While doing that run and process all finishers on this thread. The finishers can be implemented in a way to check weather the queued work is finished. Is called from the Activity base class’s onPause(), after BroadcastReceiver’s onReceive, after Service command handling, etc. (so async work is never lost)

这个方法将在Activity的onPause方法中执行来确保Finisher队列中的任务一定会被执行。

读取数据的过程

读取数据的过程我们就以getString方法为例:

public String getString(String key, @Nullable String defValue) {synchronized (mLock) {awaitLoadedLocked();String v = (String)mMap.get(key);return v != null ? v : defValue;}
}

显然是通过一个map来取数据的,不过在这之前会执行一个awaitLoadedLocked,顾名思义就是等待Sp读取磁盘文件的过程,这个过程我们就不再深入了,不过在读取的过程中也是加锁的。所以我们可以说SharedPerferences是线程安全的工具。

总结

最后我们来对SharedPreferences的工作流程进行一下总结,首先是它的创建:
在这里插入图片描述
接着是它的写入过程:
在这里插入图片描述

读取过程很简单就不写了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/152474.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

android 修改输出apk的包名

一&#xff0c;打包方式使用IDE菜单选项 二、在app级别的build.gradle下配置&#xff1a; static def releaseTime() {return new Date().format("yyyyMMdd.kkmm", TimeZone.getTimeZone("GMT8")) }android.applicationVariants.all { variant ->print…

怎么压缩pdf文件?分享缩小pdf文件的简单方法

在我们的日常生活和工作中&#xff0c;往往需要处理大量的PDF文件&#xff0c;而很多时候这些文件的大小会成为传输和存储的难题。为了解决这个问题&#xff0c;下面我们将介绍三种方法来压缩PDF文件&#xff0c;一起来看看吧~ 一、嗨格式压缩大师 首先&#xff0c;最简单也是…

CSS 实现:常见布局

1 设备与视口 设备屏幕尺寸是指屏幕的对角线长度。像素是计算机屏幕能显示一种特定颜色的最小区域&#xff0c;分为设备像素和逻辑像素。 在 Apple 的视网膜屏&#xff08;Retina&#xff09;中&#xff0c;默认每 4 个设备像素为一组&#xff0c;渲染出普通屏幕中一个像素显示…

buuctf-[BSidesCF 2020]Had a bad day 文件包含

打开环境 就两个按钮&#xff0c;随便按按 url变了 还有 像文件包含&#xff0c;使用php伪协议读取一下&#xff0c;但是发现报错&#xff0c;而且有两个.php,可能是自己会加上php后缀 所以把后缀去掉 /index.php?categoryphp://filter/convert.base64-encode/resourcei…

【APP】上架指南:iOS App Store 首次上架被拒原因分析与解决方案

目录 一、前言 二、APP 审核备案新规 &#xff08;1&#xff09;iOS 上架审核申请被拒 &#xff08;2&#xff09;苹果应用商店重大调整 &#xff08;3&#xff09;首次备案流程 ① 阿里云备案 ② 华为云备案 ③ 腾讯云备案 三、iOS 首次上架拒审原因分析 &#…

[羊城杯 2020]easyser - 反序列化+SSRF+伪协议(绕过死亡die)

[羊城杯 2020]easyser 一、解题过程&#xff08;一&#xff09;、一阶段&#xff08;二&#xff09;、二阶段 二、思考总结 一、解题过程 &#xff08;一&#xff09;、一阶段 可以直接使用ctf-wscan扫描一下有什么文件&#xff0c;或者直接试试robots.txt能不能行 直接打开…

【算法训练-数组 三】【数组矩阵】螺旋矩阵、旋转图像、搜索二维矩阵

废话不多说&#xff0c;喊一句号子鼓励自己&#xff1a;程序员永不失业&#xff0c;程序员走向架构&#xff01;本篇Blog的主题是螺旋矩阵&#xff0c;使用【二维数组】这个基本的数据结构来实现 螺旋矩阵【EASY】 二维数组的结构特性入手 题干 解题思路 根据题目示例 mat…

用《斗破苍穹》的视角打开C#3 标签与反射(人物创建与斗技使用)

随着剧情的发展&#xff0c;主线人物登场得越来越多&#xff0c;时不时跳出一个大佬&#xff0c;对我张牙舞爪地攻击。眼花缭乱的斗技让我不厌其烦&#xff0c;一个不小心&#xff0c;我就记不清楚在哪里遇上过什么人&#xff0c;他会什么斗技了。这时候&#xff0c;我就特别希…

通过IP地址管理提升企业网络安全防御

在今天的数字时代&#xff0c;企业面临着越来越多的网络安全威胁。这些威胁可能来自各种来源&#xff0c;包括恶意软件、网络攻击和数据泄露。为了提高网络安全防御&#xff0c;企业需要采取一系列措施&#xff0c;其中IP地址管理是一个重要的方面 1. IP地址的基础知识 首先&a…

华为数通方向HCIP-DataCom H12-831题库(单选题:221-240)

第221题 以下哪些项能被正则表达式^30.成功匹配? A、200 100 300 B、100 200 300 C、300 200 100 D、300 100 200 答案:CD 解析: 30.其中的“点”表示的是任何的一个数字,表示的是as-path的开头;所以以300开头的都是满足题目需求的。 第222题 以下哪些项的Community属性能…

安卓 Android 终端接入阿里云 IoT 物联网平台

在全球智能手机市场里&#xff0c;谷歌开发的安卓(Android)移动操作系统市场占有率已经高达90%。随着物联网智能硬件升级&#xff0c;安卓(Android)也逐渐成为智能摄像头&#xff0c;智能对讲门禁&#xff0c;人脸识别闸机&#xff0c;智能电视&#xff0c;智能广告屏等带屏 Io…

Android多线程学习:线程

一、概念 进程&#xff1a;系统资源分配的基本单位&#xff0c;进程之间相互独立&#xff0c;不能直接访问其他进程的地址空间。 线程&#xff1a;CPU调度的基本单位&#xff0c;线程之间共享所在进程的资源&#xff0c;包括共享内存&#xff0c;公有数据&#xff0c;全局变量…

Java虚拟机内存模型

JVM虚拟机将内存数据分为&#xff1a; 程序计数器、虚拟机栈、本地方法栈、Java堆、方法区等部分。 程序计数器用于存放下一条运行的指令&#xff1b; 虚拟机栈和本地方法栈用于存放函数调用堆栈信息&#xff1b; Java堆用于存放Java程序运行时所需的对象等数据&#xff1b…

webpack不同环境下使用CSS分离插件mini-css-extract-plugin

1.背景描述 使用mini-css-extract-plugin插件来打包css文件&#xff08;从css文件中提取css代码到单独的文件中&#xff0c;对css代码进行代码压缩等&#xff09;。 本次采用三个配置文件&#xff1a; 公共配置文件&#xff1a;webpack.common.jsdev开发环境配置文件&#x…

接口测试及常用接口测试工具

首先&#xff0c;什么是接口呢&#xff1f; 接口一般来说有两种&#xff0c;一种是程序内部的接口&#xff0c;一种是系统对外的接口。 系统对外的接口&#xff1a;比如你要从别的网站或服务器上获取资源或信息&#xff0c;别人肯定不会把数据库共享给你&#xff0c;他只能给你…

maven 初学

1. maven 安装 配置安装 路径 maven 下载位置: D:\software\apache-maven-3.8.6 默认仓库位置: C:\Users\star-dream\.m2\repository 【已更改】 本地仓库设置为&#xff1a;D:\software\apache-maven-3.8.6\.m2\repository 镜像已更改为阿里云中央镜像仓库 <mirrors>…

算法通过村第十二关-字符串|黄金笔记|冲刺难题

文章目录 前言最长公共前缀纵向比较横向比较 字符串压缩问题表示数值的字符串总结 前言 提示&#xff1a;我有时候在想&#xff0c;我是真的不太需要其他人&#xff0c;还是因为跟他们在一起时没法自己&#xff0c;所以才保持距离。我们的交谈就像是平行而毫无交集的自言自语。…

Python—Scrapy实践项目

爬取豆瓣电影2022年Top250部经典电影 1.项目概述 从https://movie.douban/top250爬取电影的标题、评分、主题。我在之前使用普通的爬虫实现了类似的功能&#xff0c;可以对比来进行学习&#xff08;Python爬虫——爬虫基础模块和类库&#xff08;附实践项目&#xff09;&#…

矩阵的相似性度量的常用方法

矩阵的相似性度量的常用方法 1&#xff0c;欧氏距离 欧式距离是最易于理解的一种距离计算方法&#xff0c;源自欧式空间中两点间的距离公式。 (1)二维平面上的点 a ( x 1 , y 1 ) a(x_1,y_1) a(x1​,y1​)和点 b ( x 2 , y 2 ) b(x_2,y_2) b(x2​,y2​)的欧式距离为 d ( x …

【网络】抓包工具Wireshark下载安装和基本使用教程

&#x1f341; 博主 "开着拖拉机回家"带您 Go to New World.✨&#x1f341; &#x1f984; 个人主页——&#x1f390;开着拖拉机回家_Linux,大数据运维-CSDN博客 &#x1f390;✨&#x1f341; &#x1fa81;&#x1f341; 希望本文能够给您带来一定的帮助&#x1…