曳杖危楼去。
斗垂天、沧波万顷,月流烟渚。
扫尽浮云风不定,未放扁舟夜渡。
宿雁落、寒芦深处。
怅望关河空吊影,正人间、鼻息鸣鼍鼓。
谁伴我,醉中舞。
十年一梦扬州路。
倚高寒、愁生故国,气吞骄虏。
要斩楼兰三尺剑,遗恨琵琶旧语。
谩暗涩铜华尘土。
唤取谪仙平章看,过苕溪、尚许垂纶否。
风浩荡,欲飞举。
今天是我阳了的第四天,也是正式失业后的第二十天,仅以此词明志,定要守住这“黄沙百战穿金甲,不破楼兰终不还”的魄力!另外关于面试,我觉得它是一次双向选择的过程,而我现在还没想清楚要怎么选择,因为并没有想明白这其中的意义和价值,所以没有付出太多行动,被动的挂掉了所有面试。十多年了,这是我休过最长的一次假,人生或许真的有其他活法吧。
关于前几篇的《点灯大师》系列,原本打算一直更新到 Linux 驱动点灯的( 我手上起码有三种 Linux 的开发板 ),后面发生的事情太过魔幻,就耽搁了,按目前的境况而言,估计要无限期的鸽了。这灯终还是没能点到最后呀,不过只要灯点得好,就有机会接触到另外的点灯大师:
这是我离稚晖君最近的一次了,所以截屏纪念了一下,听说他最近也离职了,这也让我失去了更新下一篇《点灯大师》的动力,毕竟我计划下一篇的 SoC 是 Hi3861( 海思的 )。
回归到本篇的主题吧,也算是回归到我的本职工作了,这几年的工作内容越来越偏门,真正在 iOS 平台上有所研究的,也只能说是 JavaScriptCore
用得比较熟了吧 (笑死…)。本篇内容不多,但涉及到的黑魔法,我觉得在做相关基建时,还是挺实用的,毕竟在 iOS 平台上,JS 引擎除了 JSCore,没有其他选择了。
调整 Inspector 的调用线程
为了描述该魔法的使用场景,我们先构建一段测试代码,看测试结果再做解释。先建立一个 NSThread
子类,用于运转一个 RunLoop
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @implementation JSRunThread
- (instancetype)initWithName:(NSString *)name { NSParameterAssert(name.length); self = [super init]; self.name = name; return self; }
- (void)main { @autoreleasepool { CFRunLoopSourceContext noSpinCtx = {0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL}; CFRunLoopSourceRef noSpinSource = CFRunLoopSourceCreate(NULL, 0, &noSpinCtx); CFRunLoopAddSource(CFRunLoopGetCurrent(), noSpinSource, kCFRunLoopDefaultMode); CFRelease(noSpinSource); while (kCFRunLoopRunStopped != CFRunLoopRunInMode(kCFRunLoopDefaultMode, ((NSDate *)[NSDate distantFuture]).timeIntervalSinceReferenceDate, NO)) { NSAssert(NO, @"not reached assertion"); } } }
@end
|
基于这个 JSRunThread
我们再构建一段测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @interface JSCRunLoopTest () { JSContext *_context; JSRunThread *_thread; }
@end
@implementation JSCRunLoopTest
- (instancetype)init { self = [super init]; _thread = [[JSRunThread alloc] initWithName:@"js.test.thread"]; [_thread start]; [self performSelector:@selector(_setupJSContext) onThread:_thread withObject:nil waitUntilDone:YES]; return self; }
- (void)_setupJSContext { NSAssert([NSThread.currentThread isEqual:_thread], @""); _context = [[JSContext alloc] initWithVirtualMachine:[JSVirtualMachine new]]; _context.name = @"test js context"; _context[@"testMethod"] = ^(NSString *val) { NSLog(@"invoke under thread: %@", [NSThread currentThread]); }; [_context evaluateScript:@"testMethod()"]; }
@end
|
最终测试的地方类似这么写:
1 2
| static JSCRunLoopTest *test; test = [JSCRunLoopTest new];
|
代码不复杂,这里也简单解释下,主要就是构建一个 JSContext
,并且是从后台线程进行的初始化,然后注入了一个原生方法 testMethod
,并在该方法里打印当前线程。后台线程创建 JSVirtualMachine
的原因是将 GC 线程放置到后台,而并不能限制住 testMethod
的入口线程,也就是说,testMethod
里打印的值是不确定的,这取决于我们是从哪个线程进行的 evaluateScript
。从实际应用的角度而言,我们通常会限制住 evaluateScript
的线程为 JSVirtualMachine
的初始化线程,那么就能确保 testMethod
的入口线程是单一的,也就是说不会产生多线程的竞争问题。
不过还有例外,当我们打开 Safari 的 Inspector,直接从控制台输入 testMethod
进行调用时,你会发现它的入口线程是主线程。这种差异性会给开发、调试带来一些问题,有可能是死锁,或者运行异常。一些自动化测试框架的原理跟 Safari Inspector 一致,所以也会有同样的问题。
手动去差异性处理线程切换的意义不大,因为这些差异仅产生在测试阶段,线上不应该带上这种不可能触达的代码逻辑,所以就需要使用以下私有 API 了:
1
| JS_EXPORT void JSGlobalContextSetDebuggerRunLoop(JSGlobalContextRef ctx, CFRunLoopRef runLoop);
|
我们将这个 API 直接定义到测试代码的头部,再进行如下调整:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| JS_EXPORT void JSGlobalContextSetDebuggerRunLoop(JSGlobalContextRef ctx, CFRunLoopRef runLoop);
@interface JSCRunLoopTest () { JSContext *_context; JSRunThread *_thread; }
@end
... 此处省略
- (void)_setupJSContext { NSAssert([NSThread.currentThread isEqual:_thread], @""); _context = [[JSContext alloc] initWithVirtualMachine:[JSVirtualMachine new]]; _context.name = @"test js context";
JSGlobalContextSetDebuggerRunLoop(_context.JSGlobalContextRef, CFRunLoopGetCurrent()); ... }
|
问题就完美解决了,注意这里链接了私有 API 的符号,千万不要这样直接提交 AppStore,被拒的可能性非常大。并且这个一般线上也不会用到,所以可以考虑通过 DEBUG 宏来进行区分。
强制关闭 Safari 的 Inspector
前面说到了 Safari Inspector,这里还引入了另外一个问题,当 Safari Inspector 处于打开的状态时,它会类似的强持有了 JSContext
,导致 JSContext
所持有的资源无法被释放。所以,在以往的工作经验中,QA 进行 API 测试时经常会忘记关闭掉 Safari Inspector,从而导致了类似内存泄漏的状况,也导致了一些独占资源的断言失败,从而引发一些不太好解释的缺陷。
而 JavaScriptCore
并没有提供关闭掉 Safari Inspector 的方法,这里就要使用另外的私有 API 了,当我们明确当前 JSContext
的生命周期已经结束的时候,尝试强制去关闭掉 Safari Inspector。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| #include <JavaScriptCore/JavaScriptCore.h> #include <dlfcn.h>
static uintptr_t JSCSymbolAddr(const char *symb) { Dl_info dli; memset(&dli, 0, sizeof(dli)); dladdr((const void *)JSContextGroupCreate, &dli); auto handle = dlopen(dli.dli_fname, RTLD_NOLOAD); auto addr = (uintptr_t)dlsym(handle, symb); dlclose(handle); return addr; }
void ForceCloseSafariInspector(void) { auto ins = ({ auto addr = JSCSymbolAddr("_ZN9Inspector15RemoteInspector9singletonEv"); if (!addr) return; ((void *(*)(void))addr)(); }); { auto addr = JSCSymbolAddr("_ZN9Inspector15RemoteInspector4stopEv"); if (!addr) return; ((void (*)(decltype(ins)))addr)(ins); } [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]]; { auto addr = JSCSymbolAddr("_ZN9Inspector15RemoteInspector5startEv"); if (!addr) return; ((void (*)(decltype(ins)))addr)(ins); } }
|
直接调用 ForceCloseSafariInspector
即可强制关闭掉 Safari Inspector 了,不过这里是重启了 JavaScriptCore
内部的 Inspector Server,所以会导致所有的 Safari Inspector 都会被关闭掉。另外上述代码是 C++ 和 Objective-C 混编的,所以要使用 .mm 文件来承载。同样这里的私有 API 也是不建议直接提交 AppStore 的,并且这里也只是测试时才会有的场景,使用特定的宏进行差异化编译即可。
设定执行耗时的看门狗
通常而言,我们使用 JSContext
主要是为了动态化考虑,也就是所执行的 JavaScript
代码并不太受管控,特别是当我们本身是一个小程序、小游戏框架而言,业务方代码参差不齐,甚至可能有恶意性代码。比如下面这样一个无限死循环的代码:
1 2 3
| while (true) { var a = '123'; }
|
这会导致 CPU 跑满,从而可能导致主 App 被系统看门狗杀掉。即便不是恶意代码,也有可能无意间引入了类似的死循环,那么为了主 App 的健壮性,面对这样的代码该如何防备呢?那么这里就又是另外的一些私有 API 的使用了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| typedef bool (*JSShouldTerminateCallback) (JSContextRef ctx, void* context);
JS_EXPORT void JSContextGroupSetExecutionTimeLimit(JSContextGroupRef group, double limit, JSShouldTerminateCallback callback, void* context); JS_EXPORT JSStringRef JSContextCreateBacktrace(JSContextRef ctx, unsigned maxStackSize);
static bool JSShouldTerminate(JSContextRef ctx, void *context) { NSString *bt = ({ JSStringRef x = JSContextCreateBacktrace(ctx, 1024 * 1024 * 4); (__bridge NSString *)JSStringCopyCFString(kCFAllocatorDefault, x); }); NSLog(@"JS long time execution backtrace:\n%@", bt); return true; }
... 此处省略
JSContextGroupSetExecutionTimeLimit(JSContextGetGroup(_context.JSGlobalContextRef), 3.0, JSShouldTerminate, NULL);
... 此处省略
|
通过以上两个私有 API,我们设定了 JavaScript
执行的时间限制,超过 3 秒的 JavaScript
执行就会被强制结束,并且会将相关的 JavaScript
堆栈输出。此 API 可用于监控,也可用作为 JavaScript
的看门狗。当然,如果想带到线上,还是要考虑下如何隐蔽的使用该私有 API 了,本篇后续会介绍一种相对安全的私有 API 调用方式。
实现 AppStore 应用的远程调试
在 iOS 平台使用 JavaScriptCore 还有一个经常会遇到的问题,当我们的 App 使用非开发者证书进行签名后( 比如企业证书、AppStore证书 ),就无法再使用 Safari Inspector 进行调试了。苹果这样设计是从应用的安全性角度进行考量,无可厚非。不过如果我们应用场景是一个较为开放的平台服务,第三方接入时难免需要进行问题调试和排查,这样就失去了很多便利。
比较常见的做法,是提供类似 vConsole 这种页面级别的调试工具进行日志查看,但总归没有 Inspector 来得好用,那有没有办法再度打开 Inspector 能力呢?答案是肯定的,之所以 Safari 无法再发现我们的 JSContext
,是因为 iOS 系统中的 webinspectord
守护进程进行了目标过滤,它会检查目标应用证书及 entitlement,一次常规的 Safari Inspector 到我们 JSContext
连接是这样一个链路:
Safari Inspector <-> webinspectord <-> JSContext
俩俩间通过 RPC/IPC 进行通信,其实 webinspectord
仅仅只是做消息的分发和过滤,最终消息的处理能力还是存在目标 App 中,也就是 JavaScriptCore 框架中。那么我们可以自己构建一条通道,直接将 Inspector 和 JSContext 进行连接,比较适合的是开启 WebSocket 服务,所以最终的链路变成了这样:
Inspector <-> WebSocket Server <-> JSContext
和 webinspectord
类似,WebSocket Server 主要做的也是消息分发,但由于 Safari Inspector 没法进行 WebSocket 连接,所以这里的 Inspector 使用了 Chrome DevTools。于是 WebSocket Server 这一层又多了一份职责:Safari Inspector 协议和 Chrome DevTools 协议的相互转换,双方协议定义参考下面:
如何让 JSContext
能处理并响应这些调试协议呢?主要依靠的是以下这个私有 API:
1 2
| JS_EXPORT Inspector::AugmentableInspectorController* JSGlobalContextGetAugmentableInspectorController(JSGlobalContextRef);
|
这个方法返回了一个 C++ 的抽象类 AugmentableInspectorController
的实例,这个类定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| class AugmentableInspectorController { public: virtual ~AugmentableInspectorController() { }
virtual AugmentableInspectorControllerClient* augmentableInspectorControllerClient() const = 0; virtual void setAugmentableInspectorControllerClient(AugmentableInspectorControllerClient*) = 0;
virtual const FrontendRouter& frontendRouter() const = 0; virtual BackendDispatcher& backendDispatcher() = 0; virtual void registerAlternateAgent(std::unique_ptr<InspectorAgentBase>) = 0;
bool connected() const { return frontendRouter().hasFrontends(); } };
|
那么根据 C++ 类的布局,我们其实只要构建相同大小的虚方法表,强转后即可调用到这其中任何一个虚方法。如果这块有所疑问,可以看我之前的这篇 《温习 C++ 的虚函数》 文章。这里我们只要关注 frontendRouter
和 backendDispatcher
即可,backendDispatcher
用来处理协议请求,frontendRouter
用来接收协议处理后的响应以及内部的一些纯通知类协议。
其中 FrontendRouter
核心定义如下:
1 2 3 4 5 6 7 8 9 10
| class JS_EXPORT_PRIVATE FrontendRouter : public RefCounted<FrontendRouter> { public: ...
void connectFrontend(FrontendChannel&); void disconnectFrontend(FrontendChannel&); void disconnectAllFrontends();
... };
|
这里的 connectFrontend
需要传入一个抽象类 FrontendChannel
实例引用,这个可以理解为类似 iOS 中常见的 Delegate
,用观察者模式来监听协议返回和通知。这个 FrontendChannel
定义如下:
1 2 3 4 5 6 7 8 9 10 11 12
| class FrontendChannel { public:
enum class ConnectionType { Remote, Local };
virtual ~FrontendChannel() { } virtual ConnectionType connectionType() const = 0; virtual void sendMessageToFrontend(const String& message) = 0; };
|
同样,我们构建一个虚函数布局相同的实例即可,这里关注的就是 sendMessageToFrontend
方法,该方法是由 JSContext
内部进行回调的。那么如何调用 FrontendRouter
的 connectFrontend
这个方法呢?毕竟它不是虚函数了,无法从 this
指针进行偏移获得,并且它还是一个实例方法。其实也很简单,我们找到该方法的符号:
_ZN9Inspector14FrontendRouter15connectFrontendERNS_15FrontendChannelE
然后使用前面的 JSCSymbolAddr
方法查询到该符号对应的地址,将地址转换成 C 方法进行调用,根据 iOS 平台的方法调用约定,C++ 实例方法的第一个参数为 this
,所以调用方式大致如下:
1 2 3 4 5 6 7 8 9 10 11
| auto router = ...; auto channel = ...;
auto symbol = "_ZN9Inspector14FrontendRouter15connectFrontendERNS_15FrontendChannelE"; auto addr = JSCSymbolAddr(symbol);
auto func = (void (*)(decltype(router), decltype(channel)))addr;
func(router, channel);
|
BackendDispatcher
需要关注的就比较简单了,它定义如下:
1 2 3 4 5 6 7 8
| class JS_EXPORT_PRIVATE BackendDispatcher : public RefCounted<BackendDispatcher> { public: ...
void dispatch(const String& message);
... };
|
我们只需要使用这个 dispatch
方法即可,使用方法和前面 connectFrontend
一致,符号转地址再调用。那么完整的链接其实就已经说完了,从代码的角度再总结一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| Inspector -> WebSocket Server -> JSGlobalContextGetAugmentableInspectorController -> AugmentableInspectorController.backendDispatcher -> BackendDispatcher.dispatch
JSGlobalContextGetAugmentableInspectorController -> AugmentableInspectorController.frontendRouter -> FrontendRouter.connectFrontend
FrontendChannel.sendMessageToFrontend -> WebSocket Server -> Inspector
|
另外再补充一点,这里所有的通讯字符串都是使用了 WTF
中的字符串类型,我们无法直接使用。所以要获取到能使用的字符串,还要构建一个内存布局相同的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| class JSCString { public: JSCString(NSString *objcStr) { size_t alloc_size = sizeof(DataView) + objcStr.length; data_view_ = std::shared_ptr<DataView>((DataView *)malloc(alloc_size), [](DataView *buf) { free(buf); }); memset((void *)data_view_.get(), 0, alloc_size); data_view_->ref_count = 2; data_view_->length = (unsigned)objcStr.length; if (@available(iOS 13.0, *)) { data_view_->datas = (const char *)(&data_view_->hash_flag + 1); data_view_->hash_flag = 0x4; } else { data_view_->datas = (const char *)(&data_view_->datas + 1); } memcpy((void *)data_view_->datas, objcStr.UTF8String, objcStr.length); } NSString *getObjCString() const { if (this->data_view_ == nullptr) { return nil; } return [[NSString alloc] initWithBytes:data_view_->datas length:data_view_->length encoding:NSUTF8StringEncoding]; } private: struct DataView { unsigned ref_count; unsigned length; const char *datas; unsigned hash_flag; }; std::shared_ptr<DataView> data_view_; };
|
那么在实现 FrontendChannel
接收回调时,参数就可以直接使用 这个 JSCString
,同理,dispatch
方法直接传入该类实例即可,其它不再赘述。
附:较为安全的 C/C++ 私有 API 的调用方式
本文中所有的私有 API 都是 C/C++ 的,所以这里不讨论 ObjC 私有 API 调用的安全性。对于 C/C++ 私有 API 的调用,本文也提供了两种方式,一种是自己将声明导出,一种是通过符号进行查找 (JSCSymbolAddr)。
导出声明的方式会产生延迟绑定的符号,毕竟链接的是动态库,那么私有 API 就会在相应的符号表里一览无余,都过不了苹果的第一道静态扫描。所以第一种方式仅可用作非生产环境里,一旦提交至 AppStore 审核,大概率会被拒。
第二种使用符号查找地址的方式,那么可以将符号字符串进行混淆存储,避免被静态扫描到,然后再在运行时进行反混淆处理。这样可能相对比较安全,但还不保险,毕竟我们使用了一个非常敏感的函数: dlsym
。如果苹果在该函数内部进行了一些追踪,那么还是能直接输出我们使用到的私有 API。为了安全起见,我们就不能使用该函数,可以自己构建一个类似的函数,也就是查阅符号表,获取符号地址,再加上 ASLR 偏移地址,既可以获得真正的私有 API 运行时地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| #include <JavaScriptCore/JavaScriptCore.h> #include <dlfcn.h> #include <string> #include <mach-o/dyld.h> #include <mach-o/nlist.h> #include <mach-o/loader.h>
#ifdef __LP64__ typedef struct mach_header_64 mach_header_t; typedef struct segment_command_64 segment_command_t; typedef struct section_64 section_t; typedef struct nlist_64 nlist_t; #define LC_SEGMENT_ARCH_DEPENDENT LC_SEGMENT_64 #else typedef struct mach_header mach_header_t; typedef struct segment_command segment_command_t; typedef struct section section_t; typedef struct nlist nlist_t; #define LC_SEGMENT_ARCH_DEPENDENT LC_SEGMENT #endif
static void _get_jsc_header_and_slide(mach_header_t **header, intptr_t *slide) { Dl_info dli; memset(&dli, 0, sizeof(dli)); dladdr((const void *)JSContextGetGlobalObject, &dli); *header = (mach_header_t *)dli.dli_fbase; uint32_t c = _dyld_image_count(); for (uint32_t i = 0; i < c; i++) { if ((uintptr_t)(dli.dli_fbase) == (uintptr_t)_dyld_get_image_header(i)) { *slide = _dyld_get_image_vmaddr_slide(i); break; } } }
static void _get_jsc_symb_table(nlist_t **symtab, char **strtab, uint32_t *nsyms, intptr_t *slide) { mach_header_t *header = nullptr; _get_jsc_header_and_slide(&header, slide); if (header == nullptr) { return; } segment_command_t *cur_seg_cmd; segment_command_t *linkedit_segment = NULL; struct symtab_command* symtab_cmd = NULL; uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t); for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) { cur_seg_cmd = (segment_command_t *)cur; if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) { if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) { linkedit_segment = cur_seg_cmd; } } else if (cur_seg_cmd->cmd == LC_SYMTAB) { symtab_cmd = (struct symtab_command*)cur_seg_cmd; } } if (!symtab_cmd || !linkedit_segment) { return; } uintptr_t linkedit_base = (uintptr_t)*slide + linkedit_segment->vmaddr - linkedit_segment->fileoff; *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff); *strtab = (char *)(linkedit_base + symtab_cmd->stroff); *nsyms = symtab_cmd->nsyms; }
static uintptr_t _get_jsc_symb_addr(const char *symb) { static NSMutableDictionary *_cache = nil; if (_cache == nil) { _cache = [[NSMutableDictionary alloc] initWithCapacity:10]; } NSNumber *cache_value = _cache[@(symb)]; if (cache_value != nil) { return cache_value.unsignedIntegerValue; } nlist_t *symtab = nullptr; char *strtab = nullptr; uint32_t nsyms = 0; intptr_t slide = 0; _get_jsc_symb_table(&symtab, &strtab, &nsyms, &slide); if (!symtab || !strtab || !nsyms) { return 0; } for (int i = 0; i < nsyms; i++) { char *symbol_name = strtab + symtab[i].n_un.n_strx; if (symbol_name[0] && symbol_name[1] && strcmp(symb, &symbol_name[1]) == 0) { uintptr_t addr = (uintptr_t)symtab[i].n_value + slide; cache_value = @(addr); [_cache setObject:cache_value forKey:@(symb)]; return addr; } } return 0; }
|
使用以上代码,现将私有 API 的符号进行混淆存储,然后运行时先反混淆,最终使用 _get_jsc_symb_addr
来获取对应私有 API 的地址即可。
总结
本篇其实比较简单,也算不上什么奇淫技巧,但在某些场景还是会有其实用价值。另外本文中所有的黑魔法,都是我对 WebKit 源码进行阅读后获得的,所以建议大家如果有其他想法,且发现目前 JavaScriptCore
/ WKWebView
公开的 API 无法满足,那么可以尝试去 WebKit 源码中进行查找。比如如何获得 JSContext
分配的堆内存大小,如何快速验证 WKWebView
崩溃后的兜底逻辑,其实用点心都能找到答案。
另外,三年前刷了一两百道 LeetCode,这一次不想刷了,因为它给不了我所想要的出路。后续我大概率会关注目前 Java/Go 的后端技术发展,毕竟这么多年没关注,都已经诞生了 SpringBoot 这样妖孽化的框架了。