Zygote——Android系统中java世界的受精卵(一、C/C++中的Zygote)
2021/10/15 20:16:34
本文主要是介绍Zygote——Android系统中java世界的受精卵(一、C/C++中的Zygote),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
0、引言
Android的底层内核是基于Linux构建而成,是在Native世界,而Android上层的应用是隶属Java世界。那么在Android系统启动过程中,系统是如何从Native孵化出Java世界的呢?这便是这篇文章的主角Zygote的主要职责。
本文所选Android系统版本是9.0 Pie,文中所有代码片段路径在代码块第一行已经标注。文章的目的是记录自己的学习历程与心得,不做商用或盈利,凡是学习过程中学习或引用过的大佬博文或著作都会尽力标注,在此感谢各位前辈的不吝分享。本文借鉴如下:
- 《Android系统启动-zygote篇》—— 袁辉辉
- 《Android系统进程Zygote启动过程的源代码分析》—— 罗升阳
- 《[深入理解Android卷一全文-第四章]深入理解zygote》 —— 邓平凡
- 《Android10.0系统启动之Zygote进程-[Android取经之路]》—— IngresGe
1、C/C++中的Zygote
上篇文章《Android9.0(Pie)1号进程init的启动流程学习》中说到,Android系统的1号进程init会通过.rc配置文件启动其他进程,zygote也是此方式被启动的。不过在pie\system\core\rootdir\目录下有好几个zygote的rc文件,根据生产环境的设备的CPU配置不同,有init.zygote32.rc、init.zygote32_64.rc、init.zygote64.rc、init.zygote64_32.rc这几个选择。在init.rc头部可以看到其导入对应配置的rc文件时,是根据属性ro.zygote来控制的,由于我的生产环境该属性值为zygote32,所以 参考《Android系统init进程启动及init.rc全解析》和《Android源码之init.rc文件详解》 来看看init.zygote32.rc吧。
// pie\system\core\rootdir\init.zygote32.rc service zygote /system/bin/app_process -Xzygote /system/bin --zygote --start-system-server class main //该服务指定类属为main,这样方便操作多个服务同时启动或停止 priority -20 //参考linux的进程优先级nice,取值范围【-20,19】,值越小优先级越高 user root //在执行此服务之前先切换用户名为root group root readproc reserved_disk //类似于user,切换组名 socket zygote stream 660 root system //在目标机/dev/socket/目录下创建一个unix domain类型的socket,该socket文件命名为zygote,端口为660,运行该程序需要root或system权限 onrestart write /sys/android_power/request_state wake //该服务重启时,向指定文件中写入内容 onrestart write /sys/power/state on onrestart restart audioserver //该服务重启时,重启指定服务 onrestart restart cameraserver onrestart restart media onrestart restart netd onrestart restart wificond writepid /dev/cpuset/foreground/tasks //创建子进程时向该文件中写入进程的pid
1.1、app_process
从第一行可以看出zygote只是对程序的重命名,实际运行的是目标机/system/bin目录下的app_process程序,其后面跟的是运行该程序所带的参数,即这个app_process是一个命令行程序,那么先找到这个程序的main函数,鉴于该函数较长,我们将主要代码分割成几个部分来看。
1.1.1、AndroidRuntime初始化
// pie\frameworks\base\cmds\app_process\app_main.cpp int main(int argc, char* const argv[]) { if (!LOG_NDEBUG) { //debug模式下打印一下传进来的参数,方便调试和定位问题 String8 argv_String; for (int i = 0; i < argc; ++i) { argv_String.append("\""); argv_String.append(argv[i]); argv_String.append("\" "); } ALOGV("app_process main with argv: %s", argv_String.string()); } AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv)); //调用父类AndroidRuntime的构造函数进行初始化 argc--; argv++; // 忽略程序名所占用的第一个参数argv[0] ………… }
这里的AppRuntime是继承的pie\frameworks\base\core\jni\AndroidRuntime.cpp,AppRuntime类的构造函数为空,所以转去调用父类AndroidRuntime的构造函数:
// pie\frameworks\base\core\jni\AndroidRuntime.cpp AndroidRuntime::AndroidRuntime(char* argBlockStart, const size_t argBlockLength) : mExitWithoutCleanup(false), //使用命令行传进来的参数初始化该类的成员变量, mArgBlockStart(argBlockStart), //首个参数起始位置, mArgBlockLength(argBlockLength) //以及所有参数块所占内存大小 { init_android_graphics(); //初始化android图形功能 mOptions.setCapacity(20); //虚拟机启动时需要一些option作为参数,这里设置option数量 assert(gCurRuntime == NULL); //每个进程都要进行空指针检测 gCurRuntime = this; }
1.1.2、spaced_commands TODO
这一部分spaced_commands相关的内容有点不知所云,但又不影响往后分析,所以先放着,后面明白了再来补充,有大佬知道的也可以评论里补充下,不胜感激。
// pie\frameworks\base\cmds\app_process\app_main.cpp int main(int argc, char* const argv[]) { ………… const char* spaced_commands[] = { "-cp", "-classpath" }; bool known_command = false; int i; for (i = 0; i < argc; i++) { if (known_command == true) { runtime.addOption(strdup(argv[i])); ALOGV("app_process main add known option '%s'", argv[i]); known_command = false; continue; } for (int j = 0; j < static_cast<int>(sizeof(spaced_commands) / sizeof(spaced_commands[0])); ++j) { if (strcmp(argv[i], spaced_commands[j]) == 0) { known_command = true; ALOGV("app_process main found known command '%s'", argv[i]); } } if (argv[i][0] != '-') { break; } if (argv[i][1] == '-' && argv[i][2] == 0) { ++i; // Skip --. break; } runtime.addOption(strdup(argv[i])); ALOGV("app_process main add option '%s'", argv[i]); } }
1.1.3、参数解析
// pie\frameworks\base\cmds\app_process\app_main.cpp int main(int argc, char* const argv[]) { ………… bool zygote = false; bool startSystemServer = false; bool application = false; String8 niceName; String8 className; ++i; // 跳过没有用到的参数"/system/bin" while (i < argc) { const char* arg = argv[i++]; if (strcmp(arg, "--zygote") == 0) { //解析到参数"--zygote"时, zygote = true; //说明为zygote模式 niceName = ZYGOTE_NICE_NAME; //准备app_process进程的别名ZYGOTE_NICE_NAME="zygote" } else if (strcmp(arg, "--start-system-server") == 0) { //解析到参数"--start-system-server"时 startSystemServer = true; //需要启动system_server } else if (strcmp(arg, "--application") == 0) { //如果参数中有"--application", application = true; //则为application模式 } else if (strncmp(arg, "--nice-name=", 12) == 0) { niceName.setTo(arg + 12); //application模式下设置别名 } else if (strncmp(arg, "--", 2) != 0) { className.setTo(arg); //application模式下设置类名 break; } else { --i; break; } } Vector<String8> args; if (!className.isEmpty()) { //非zygote模式下,传递参数"application"给RuntimeInit args.add(application ? String8("application") : String8("tool")); runtime.setClassNameAndArgs(className, argc - i, argv + i); //传递类名和剩余参数 if (!LOG_NDEBUG) { String8 restOfArgs; char* const* argv_new = argv + i; int argc_new = argc - i; for (int k = 0; k < argc_new; ++k) { restOfArgs.append("\""); restOfArgs.append(argv_new[k]); restOfArgs.append("\" "); } ALOGV("Class name = %s, args = %s", className.string(), restOfArgs.string()); } } else { //zygote模式下 maybeCreateDalvikCache(); //创建/data/dalvik-cache/目录 if (startSystemServer) { args.add(String8("start-system-server")); //传递参数"start-system-server" } char prop[PROP_VALUE_MAX]; if (property_get(ABI_LIST_PROPERTY, prop, NULL) == 0) { //读取abi接口list LOG_ALWAYS_FATAL("app_process: Unable to determine ABI list from property %s.", ABI_LIST_PROPERTY); return 11; } String8 abiFlag("--abi-list="); abiFlag.append(prop); args.add(abiFlag); //传递系统支持的CPU架构类型参数"--abi-list=armeabi-v7a,armeabi,……" for (; i < argc; ++i) { args.add(String8(argv[i])); //传递剩余的参数 } } }
这里主要是针对main函数传递进来的参数,解析zygote模式和application模式下的相关参数,根据参数进行设置类名、进程别名、置位标识、传递参数到AndroidRuntime等动作。
1.1.4、runtime.start()
// pie\frameworks\base\cmds\app_process\app_main.cpp int main(int argc, char* const argv[]) { ………… if (!niceName.isEmpty()) { runtime.setArgv0(niceName.string(), true); //设置进程的别名为前面准备好的niceName } if (zygote) { //zygote模式 runtime.start("com.android.internal.os.ZygoteInit", args, zygote); //调用父类AndroidRuntime的start()函数 } else if (className) { //application模式 runtime.start("com.android.internal.os.RuntimeInit", args, zygote); } else { fprintf(stderr, "Error: no class name or --zygote supplied.\n"); app_usage(); LOG_ALWAYS_FATAL("app_process: no class name or --zygote supplied."); } }
这里主要是为app_process进程设置别名,然后调用runtime.start()调用父类AndroidRuntime的start()函数,该函数的功能我们后面再看。到此位置的函数调用情况如下图所示:
1.2、AndroidRuntime::start()
1.2.1、环境变量相关
这个函数依然分割开来看,第一部分比较简单,就做了一些事件打印,和环境变量相关的设置与检测。
// pie\frameworks\base\core\jni\AndroidRuntime.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ALOGD(">>>>>> START %s uid %d <<<<<<\n", className != NULL ? className : "(unknown)", getuid()); static const String8 startSystemServer("start-system-server"); bool primary_zygote = false; for (size_t i = 0; i < options.size(); ++i) { if (options[i] == startSystemServer) { primary_zygote = true; const int LOG_BOOT_PROGRESS_START = 3000; LOG_EVENT_LONG(LOG_BOOT_PROGRESS_START, ns2ms(systemTime(SYSTEM_TIME_MONOTONIC))); //带时间打印一下startSystemServer事件 } } const char* rootDir = getenv("ANDROID_ROOT"); if (rootDir == NULL) { rootDir = "/system"; if (!hasDir("/system")) { LOG_FATAL("No root directory specified, and /system does not exist."); return; } setenv("ANDROID_ROOT", rootDir, 1); //设置环境变量 } const char* artRootDir = getenv("ANDROID_ART_ROOT"); //检查环境变量 if (artRootDir == NULL) { LOG_FATAL("No ART directory specified with ANDROID_ART_ROOT environment variable."); return; } const char* i18nRootDir = getenv("ANDROID_I18N_ROOT"); if (i18nRootDir == NULL) { LOG_FATAL("No runtime directory specified with ANDROID_I18N_ROOT environment variable."); return; } const char* tzdataRootDir = getenv("ANDROID_TZDATA_ROOT"); if (tzdataRootDir == NULL) { LOG_FATAL("No tz data directory specified with ANDROID_TZDATA_ROOT environment variable."); return; } ………… }
1.2.2、加载虚拟机库
// // pie\frameworks\base\core\jni\AndroidRuntime.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ………… JniInvocation jni_invocation; jni_invocation.Init(NULL); ………… }
接下来能看到的就是JniInvocation类,该类相关定义位于pie/libnativehelper/目录下,其主要功能是向外部提供了动态调用虚拟机内部的相关接口。首先来看代码中调用的Init()函数:
// pie\libnativehelper\JniInvocation.cpp bool JniInvocation::Init(const char* library) { #ifdef __ANDROID__ char buffer[PROP_VALUE_MAX]; #else char* buffer = NULL; #endif library = GetLibrary(library, buffer); //获取默认库libart.so //RTLD_NOW:dlopen函数的打开模式为立即解析出所有未定义符号,如果解析不出来,在dlopen会返回NULL //RTLD_NODELETE:在dlclose()期间不卸载库,因为即使在JNI_DeleteJavaVM返回后,一些线程可能还没有完成退出,如果卸载库,这可能会导致段错误 const int kDlopenFlags = RTLD_NOW | RTLD_NODELETE; handle_ = dlopen(library, kDlopenFlags); //打开libart.so库,获取到句柄 if (handle_ == NULL) { //如果打开失败,则再尝试一次,确保正常打开libart.so if (strcmp(library, kLibraryFallback) == 0) { ALOGE("Failed to dlopen %s: %s", library, dlerror()); return false; } ALOGW("Falling back from %s to %s after dlopen error: %s", library, kLibraryFallback, dlerror()); library = kLibraryFallback; handle_ = dlopen(library, kDlopenFlags); if (handle_ == NULL) { ALOGE("Failed to dlopen %s: %s", library, dlerror()); return false; } } //FindSymbol()函数通过调用dlsym()函数,获取JNI_GetDefaultJavaVMInitArgs、JNI_CreateJavaVM、JNI_GetCreatedJavaVMs这三个函数的地址 if (!FindSymbol(reinterpret_cast<void**>(&JNI_GetDefaultJavaVMInitArgs_), "JNI_GetDefaultJavaVMInitArgs")) { return false; } if (!FindSymbol(reinterpret_cast<void**>(&JNI_CreateJavaVM_), "JNI_CreateJavaVM")) { return false; } if (!FindSymbol(reinterpret_cast<void**>(&JNI_GetCreatedJavaVMs_), "JNI_GetCreatedJavaVMs")) { return false; } return true; }
可以看出Init函数主要功能就是寻找并打开默认虚拟机库,并根据打开文件句柄获取到库中几个关键函数的指针。需要说明下的是,这里GetLibrary()去获取的默认库是虚拟机的库,在较早的版本比如4.4 kitkak中使用的是dalvik虚拟机,所以获取的默认库是libdvm.so,在之后的高版本中就有art虚拟机了,所以默认库就是libart.so。两者的主要区别主要是art虚拟机把字节码的翻译优化从运行时提前到安装时, 以空间换时间,从而优化运行加载时间。
1.2.3、启动JavaVM
// pie\libnativehelper\JniInvocation.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ………… JNIEnv* env; if (startVm(&mJavaVM, &env, zygote, primary_zygote) != 0) { return; } onVmCreated(env); //空接口 ………… }
首先出现的JNIEnv是在JNI编程中经常会出现的东西,其相关的定义是在文件pie\libnativehelper\include_jni\jni.h中,所以这里也是为后面注册JNI函数做准备。先来看startVm()函数,其主要功能就是创建JavaVM,该函数中有很多addOption(操作,这里截取一部分进行说明。
// pie\frameworks\base\core\jni\AndroidRuntime.cpp int AndroidRuntime::startVm(JavaVM** pJavaVM, JNIEnv** pEnv, bool zygote, bool primary_zygote) { JavaVMInitArgs initArgs; ………… addOption(***); //此处省略一大堆通过属性值或配置来向Vector<JavaVMOption> mOptions中添加的动作 ………… //对结构体JavaVMInitArgs的成员赋值 initArgs.version = JNI_VERSION_1_4; initArgs.options = mOptions.editArray(); initArgs.nOptions = mOptions.size(); initArgs.ignoreUnrecognized = JNI_FALSE; //调用前面打开的libart.so库中的JNI_CreateJavaVM函数以创建JavaVM。 //JavaVM*本质上是每个进程的,而JNIEnv*是每个线程的。如果这里创建成功,就可以发出JNI调用了。 if (JNI_CreateJavaVM(pJavaVM, pEnv, &initArgs) < 0) { ALOGE("JNI_CreateJavaVM failed\n"); return -1; } return 0; }
1.2.4、注册JNI函数
// pie\libnativehelper\JniInvocation.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ………… if (startReg(env) < 0) { //注册JNI函数 ALOGE("Unable to register all android natives\n"); return; } ………… } // pie\libnativehelper\JniInvocation.cpp int AndroidRuntime::startReg(JNIEnv* env) { ATRACE_NAME("RegisterAndroidNatives"); //设置Threads.cpp中创建线程的函数为javaCreateThreadEtc androidSetCreateThreadFunc((android_create_thread_fn) javaCreateThreadEtc); ALOGV("--- registering native functions ---\n"); //每个注册函数都会返回一个或多个native引用的对象,此时虚拟机还没有完全启动起来, //所以这里先用Frame管理局部引用的生命周期,参数200是为了足够的空间来存储。 env->PushLocalFrame(200); if (register_jni_procs(gRegJNI, NELEM(gRegJNI), env) < 0) { //JNI函数注册动作 env->PopLocalFrame(NULL); return -1; } env->PopLocalFrame(NULL); return 0; }
参考《android系统核心机制 基础(02)Thread类解析》,可知这里的androidSetCreateThreadFunc()函数是pie\system\core\libutils\Threads.cpp中的函数,这里的动作是将Threads类中创建线程的函数从androidCreateRawThreadEtc()设置为了AndroidRuntime.cpp中定义的javaCreateThreadEtc()。虽说最终调用的还是androidCreateRawThreadEtc()函数,但线程函数改为了AndroidRuntime.cpp中的javaThreadShell()函数。
// pie\frameworks\base\core\jni\AndroidRuntime.cpp int AndroidRuntime::javaCreateThreadEtc( android_thread_func_t entryFunction, void* userData, const char* threadName, int32_t threadPriority, size_t threadStackSize, android_thread_id_t* threadId) { void** args = (void**) malloc(3 * sizeof(void*)); //javaThreadShell()函数中使用完后要记得free int result; LOG_ALWAYS_FATAL_IF(threadName == nullptr, "threadName not provided to javaCreateThreadEtc"); args[0] = (void*) entryFunction; args[1] = userData; args[2] = (void*) strdup(threadName); // javaThreadShell()函数中使用完后要记得free //最终还是调用androidCreateRawThreadEtc(),但将线程函数设置为了javaThreadShell() result = androidCreateRawThreadEtc(AndroidRuntime::javaThreadShell, args, threadName, threadPriority, threadStackSize, threadId); return result; } // 线程函数 int AndroidRuntime::javaThreadShell(void* args) { void* start = ((void**)args)[0]; void* userData = ((void **)args)[1]; char* name = (char*) ((void **)args)[2]; free(args); //释放上面javaCreateThreadEtc函数中malloc的args JNIEnv* env; int result; if (javaAttachThread(name, &env) != JNI_OK) //使当前创建的线程对VM可见 return -1; result = (*(android_thread_func_t)start)(userData); //运行创建的该线程 javaDetachThread(); //线程退出时将当前线程从虚拟机可见的线程集中分离 free(name); return result; }
androidSetCreateThreadFunc()函数分析完后,我们往下看比较重要的register_jni_procs()函数,其主要功能就是注册JNI函数了:
// pie\frameworks\base\core\jni\AndroidRuntime.cpp static int register_jni_procs(const RegJNIRec array[], size_t count, JNIEnv* env) { //传递进来的RegJNIRec类型的数组gRegJNI里面存储的全是一些注册函数的函数指针, //所以执行RegJNIRec的成员mProc,相当于执行对应的函数,去完成每个函数的注册动作。 for (size_t i = 0; i < count; i++) { if (array[i].mProc(env) < 0) { #ifndef NDEBUG ALOGD("----------!!! %s failed to load\n", array[i].mName); #endif return -1; } } return 0; } // RegJNIRec的定义 #ifdef NDEBUG #define REG_JNI(name) { name } struct RegJNIRec { int (*mProc)(JNIEnv*); }; #else #define REG_JNI(name) { name, #name } struct RegJNIRec { int (*mProc)(JNIEnv*); const char* mName; }; #endif //截取部分gRegJNI数组内容 static const RegJNIRec gRegJNI[] = { REG_JNI(register_com_android_internal_os_RuntimeInit), REG_JNI(register_com_android_internal_os_ZygoteInit_nativeZygoteInit), REG_JNI(register_android_os_SystemClock), REG_JNI(register_android_util_CharsetUtils), ………… }
1.2.5、准备Java main函数的形参argv
准备要进入Java世界、调用Java的main函数了,但需要先把将要执行main函数的类名"com.android.internal.os.ZygoteInit",和前面添加的虚拟机设置相关的一堆option传递过去,所以这里需要准备一下,以String[]的形式传递。
// pie\frameworks\base\core\jni\AndroidRuntime.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ………… jclass stringClass; jobjectArray strArray; jstring classNameStr; stringClass = env->FindClass("java/lang/String"); //先找到Java的String类 assert(stringClass != NULL); strArray = env->NewObjectArray(options.size() + 1, stringClass, NULL); //相当于strArray = new String[options.size() + 1]; assert(strArray != NULL); //字符串需要转换为Java世界的UTF格式,相当于classNameStr = new String("com.android.internal.os.ZygoteInit"); classNameStr = env->NewStringUTF(className); assert(classNameStr != NULL); env->SetObjectArrayElement(strArray, 0, classNameStr); //相当于strArray[0] = classNameStr; 即"com.android.internal.os.ZygoteInit" //把存储在Vector中的所有option都转换为Java世界的String类型,顺序存储到strArray中[1, options.size()]的位置 for (size_t i = 0; i < options.size(); ++i) { jstring optionsStr = env->NewStringUTF(options.itemAt(i).string()); assert(optionsStr != NULL); env->SetObjectArrayElement(strArray, i + 1, optionsStr); } ………… }
1.2.6、启动虚拟机
// pie\frameworks\base\core\jni\AndroidRuntime.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ………… //将"com.android.internal.os.ZygoteInit"转化为"com/android/internal/os/ZygoteInit"格式 char* slashClassName = toSlashClassName(className != NULL ? className : ""); jclass startClass = env->FindClass(slashClassName); //根据路径找到ZygoteInit类 if (startClass == NULL) { ALOGE("JavaVM unable to locate class '%s'\n", slashClassName); } else { jmethodID startMeth = env->GetStaticMethodID(startClass, "main", "([Ljava/lang/String;)V"); //找到ZygoteInit类的static main方法 if (startMeth == NULL) { ALOGE("JavaVM unable to find main() in '%s'\n", className); } else { env->CallStaticVoidMethod(startClass, startMeth, strArray); //执行ZygoteInit.main()函数 #if 0 if (env->ExceptionCheck()) threadExitUncaughtException(env); #endif } } ………… }
这里的动作就很好理解了,无非就是找到com/android/internal/os/ZygoteInit.java,然后执行该类的main函数,自此将进入Java世界。需要注意理解的是,根据代码中的注释,这里就是启动虚拟机的过程,当前线程也就成为了虚拟机的主线程,所以这个函数只有在虚拟机退出(比如崩溃)的时候才会return。
1.2.7、退出时释放资源
// pie\frameworks\base\core\jni\AndroidRuntime.cpp void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote) { ………… free(slashClassName); //释放字符串所占空间 ALOGD("Shutting down VM\n"); //虚拟机崩溃或退出的时候才会执行到这里 if (mJavaVM->DetachCurrentThread() != JNI_OK) //将当前线程从虚拟机可见的线程集中分离 ALOGW("Warning: unable to detach main thread\n"); if (mJavaVM->DestroyJavaVM() != 0) //销毁前面创建的JavaVM ALOGW("Warning: VM did not shut down cleanly\n"); }
到此,Zygote进程的C/C++部分的代码就先告一段落了,后面就要进入Java世界了。Java部分也有很多内容需要说明,鉴于之前写的几篇篇幅过长的博文观感不佳,所以这篇博文先写到这里,Zygote 的Java部分放在下一篇博文《Zygote——Android系统中java世界的受精卵(二、Welcome To Java)》中来详细分析吧。老规矩,结尾一图总结一下调用流程:
这篇关于Zygote——Android系统中java世界的受精卵(一、C/C++中的Zygote)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-26Mybatis官方生成器资料详解与应用教程
- 2024-11-26Mybatis一级缓存资料详解与实战教程
- 2024-11-26Mybatis一级缓存资料详解:新手快速入门
- 2024-11-26SpringBoot3+JDK17搭建后端资料详尽教程
- 2024-11-26Springboot单体架构搭建资料:新手入门教程
- 2024-11-26Springboot单体架构搭建资料详解与实战教程
- 2024-11-26Springboot框架资料:新手入门教程
- 2024-11-26Springboot企业级开发资料入门教程
- 2024-11-26SpringBoot企业级开发资料详解与实战教程
- 2024-11-26Springboot微服务资料:新手入门全攻略