【Node.js】VM模块深剖二:Script的编译与沙箱执行原理

4999 字
25 分钟
【Node.js】VM模块深剖二:Script的编译与沙箱执行原理

引言:vm 模块的“隐形力量”——从项目场景说起#

想象这样的场景:

  • 你在开发低代码平台,允许用户编写自定义函数处理表单逻辑;
  • 你维护电商系统,需要运行第三方供应商的营销脚本计算优惠金额;
  • 你搭建社区论坛,允许用户提交脚本美化签名,但要防止恶意代码阻塞服务。

这些场景的核心需求是安全隔离:让用户代码在“沙箱”中运行,无法访问主程序的敏感资源(如processrequire),同时控制执行时间防止阻塞。Node.js 的vm模块正是为解决这类问题而生——它是沙箱隔离的基础,也是CommonJS 模块编译的幕后功臣。

一、V8 的底层基石:Script 与 UnboundScript#

要理解 Node.js vm模块的Script类,必须先掌握 V8 的两个核心概念——UnboundScript(未绑定上下文的“代码模板”)与Script(绑定上下文的“执行实例”)。它们是 V8 实现“代码复用”与“沙箱隔离”的底层基础。

1.1 可复用的“代码模板”:UnboundScript#

UnboundScript 是 V8 对编译后 JavaScript 代码的“模板化存储”——类比一份通用剧本:剧本的文字和结构是固定的,但可以在不同的剧场(上下文)、不同的演员(变量环境)中演出,无需重新编写剧本。

核心特点

  • 编译一次,复用多次:UnboundScript 存储了源代码字符串编译后的字节码(剧本的文字+排版)。多次执行同一代码时,无需重新编译(同一剧本可以演 100 场),直接复用字节码,性能显著提升;
  • 不依赖上下文:UnboundScript 不绑定任何globalThis(剧场的“舞台设置”),可以“嫁接到”任意 V8 Context(任意剧场)中执行;
  • 轻量级:仅包含代码的“编译结果”,不包含执行时的状态(如变量值),因此可以高效传递和复用。

V8 API 示例(伪代码)以下是 V8 中生成 UnboundScript 的核心逻辑(简化后),展示如何将源代码编译为“可复用模板”:

#include <v8.h>
using namespace v8;
int main() {
// 1. 初始化V8运行时(Isolate:JavaScript的“独立沙盒”)
Isolate* isolate = Isolate::New();
Isolate::Scope isolate_scope(isolate);
HandleScope handle_scope(isolate);
// 2. 定义要编译的JavaScript代码(“剧本”内容)
Local<String> source = String::NewFromUtf8(isolate,
"console.log(`Hello, ${name}!`)" // 带变量的模板代码
);
// 3. 包裹源代码,准备编译
ScriptCompiler::Source script_source(source);
// 4. 编译为UnboundScript(生成“剧本模板”)
Local<UnboundScript> unbound_script =
ScriptCompiler::CompileUnboundScript(isolate, &script_source)
.ToLocalChecked(); // 编译成功后返回UnboundScript
// 后续:UnboundScript可绑定到任意Context执行(见1.2节)
return 0;
}

这段代码的关键是CompileUnboundScript——它将源代码编译为字节码模板(UnboundScript),而非直接执行。这一步是“复用性”的核心:后续无论在多少个 Context 中执行这段代码,都不需要重新编译。

1.2 绑定上下文的“执行实例”:Script#

当 UnboundScript绑定到某个具体的 V8 Context(剧场),就变成了 Script——类比“剧本的具体演出”:剧本还是那个剧本,但演出的剧场(上下文)、演员(变量)是固定的,只能在这个剧场中执行。

核心特点

  • 绑定上下文:每个 Script 对应一个V8 Context(即globalThis环境),执行时所有操作都限制在该 Context 内(演出只能在当前剧场进行,不能跑到其他剧场);
  • 状态隔离:Script 的执行状态(如变量值)完全封装在绑定的 Context 中,不会影响其他 Script 或主程序(不同剧场的演出互不干扰);
  • 单次执行:Script 是 UnboundScript 的“实例化”,每次绑定 Context 都会生成新的 Script(同一剧本在不同剧场演出,是不同的“场次”)。

V8 API 示例(伪代码)延续 1.1 节的“剧本”例子,展示如何将 UnboundScript 绑定到 Context,生成 Script 并执行:

// 延续1.1节的代码...
int main() {
// ...(初始化Isolate、生成UnboundScript)
// 1. 创建Context(“剧场”:定义变量环境)
Local<Context> context = Context::New(isolate);
Context::Scope context_scope(context); // 进入该Context的作用域
// 2. 给Context注入变量(“演员”:设置name的值)
Local<String> name_key = String::NewFromUtf8(isolate, "name");
Local<String> name_value = String::NewFromUtf8(isolate, "Alice");
context->Global()->Set(context, name_key, name_value).Check();
// 3. 将UnboundScript绑定到Context,生成Script(“具体演出”)
Local<Script> script = unbound_script->BindToCurrentContext(context)
.ToLocalChecked();
// 4. 执行Script(“开始演出”)
script->Run(context); // 输出:Hello, Alice!
// 5. 换一个Context执行(“另一个剧场”)
Local<Context> another_context = Context::New(isolate);
Context::Scope another_context_scope(another_context);
another_context->Global()->Set(another_context, name_key,
String::NewFromUtf8(isolate, "Bob") // 不同的“演员”
).Check();
Local<Script> another_script = unbound_script->BindToCurrentContext(another_context)
.ToLocalChecked();
another_script->Run(another_context); // 输出:Hello, Bob!
return 0;
}

1.3 关系类比:UnboundScript vs Script#

用“剧本”比喻总结两者的关系,更直观:

V8 概念比喻核心区别
UnboundScript通用剧本可复用、不绑定剧场(上下文)
Script剧本的具体演出绑定剧场(上下文)、单次执行
Context剧场(含舞台/演员)提供执行环境(globalThis

小结#

UnboundScript 是 V8 的“代码复用利器”——编译一次,多次执行;Script 是“隔离执行的载体”——绑定上下文,确保安全。两者的组合,是 Node.js vm模块实现沙箱隔离高效执行的底层基础。

下一节将看到,Node.js 如何通过ContextifyScriptScript类,将 V8 的底层概念封装成更易用的 API。

二、Node.js 的封装:ContextifyScript 与 Script 类#

Node.js 并未直接暴露 V8 的底层 API,而是通过ContextifyScript(C++层)和Script 类(JS 层)做了高层封装,让沙箱使用更友好。

2.1 ContextifyScript:V8 UnboundScript 的“壳”#

ContextifyScript 是 Node.js 在 C++层对 UnboundScript 的封装,核心作用是:

  1. 编译源代码:将用户代码编译为 V8 的 UnboundScript;
  2. 绑定上下文:将 UnboundScript 绑定到 Node.js 的ContextifyContext(对 V8 Context 的封装);
  3. 执行脚本:处理微任务、错误捕获等逻辑。

其 C++伪代码如下:

class ContextifyScript {
private:
v8::Local<v8::UnboundScript> unbound_script_; // 存储V8的UnboundScript
public:
// 构造函数:编译源代码为UnboundScript
ContextifyScript(v8::Isolate* isolate, const std::string& code) {
v8::Local<v8::String> source = v8::String::NewFromUtf8(isolate, code.c_str());
v8::ScriptCompiler::Source script_source(source);
unbound_script_ = v8::ScriptCompiler::CompileUnboundScript(isolate, &script_source).ToLocalChecked();
}
// 绑定上下文并执行
v8::Local<v8::Value> RunInContext(v8::Local<v8::Context> context) {
v8::Context::Scope context_scope(context); // 切换上下文作用域
v8::Local<v8::Script> script = unbound_script_->BindToCurrentContext(context).ToLocalChecked();
return script->Run(context); // 执行Script
}
};

2.2 Script 类:高层 API 的“门面”#

Node.js 的vm.Script继承自 ContextifyScript,为开发者提供了更简洁的 API:

  • runInContext(context):在指定上下文(沙箱)中执行;
  • runInThisContext():在主程序上下文(globalThis)中执行;
  • runInNewContext(sandbox):创建新沙箱并执行(等价于createContext+runInContext)。

2.3 示例:用 Script 类运行用户自定义营销脚本(电商场景)#

假设你需要运行第三方供应商的营销脚本,计算商品优惠金额:

const vm = require('vm')
// 1. 定义沙箱:仅暴露需要的变量(商品列表、优惠券规则)
const sandbox = {
products: [
{ name: '手机', price: 5000 },
{ name: '耳机', price: 500 },
],
coupon: { type: 'percentage', value: 10 }, // 10%折扣
calculateTotal: (products) => products.reduce((sum, p) => sum + p.price, 0),
}
// 2. 创建上下文(沙箱),设置微任务模式为afterEvaluate(执行完脚本立即处理微任务)
const context = vm.createContext(sandbox, { microtaskMode: 'afterEvaluate' })
// 3. 用户提交的营销脚本(计算优惠后金额)
const marketingScript = `
const total = calculateTotal(products);
const discountedTotal = total * (1 - coupon.value / 100);
console.log(\`优惠后金额:\${discountedTotal}元\`);
`
// 4. 执行脚本
const script = new vm.Script(marketingScript)
script.runInContext(context) // 输出:优惠后金额:4950元

关键安全点

  • 用户脚本无法访问主程序的processrequire等敏感对象;
  • 沙箱中的变量(productscoupon)是拷贝(需深拷贝避免引用穿透,见下文实践总结),用户代码无法修改主程序数据。

三、EvalMachine:Script 执行的“心脏”#

ContextifyScript 的runInContext方法最终调用EvalMachine函数——这是 Script 执行的核心逻辑,负责处理上下文切换、微任务、错误捕获、超时控制

3.1 EvalMachine 的核心流程#

EvalMachine 的执行流程可拆解为以下 5 步(伪代码):

bool ContextifyScript::EvalMachine(v8::Local<v8::Context> context, int timeout) {
// 1. 切换上下文作用域:后续操作都在context中执行
v8::Context::Scope context_scope(context);
// 2. 创建TryCatch:捕获未处理的异常
v8::TryCatch try_catch(isolate_);
// 3. 绑定UnboundScript到context,生成Script
v8::Local<v8::Script> script = unbound_script_->BindToCurrentContext(context).ToLocalChecked();
// 4. 执行Script
v8::Local<v8::Value> result = script->Run(context);
// 5. 处理微任务:若context有独立队列,立即执行(避免等待主事件循环)
if (context->HasMicrotaskQueue()) {
context->GetMicrotaskQueue()->PerformCheckpoint();
}
// 6. 处理错误:若有异常,向上抛出
if (try_catch.HasCaught()) {
try_catch.ReThrow();
return false;
}
return true;
}

3.2 微任务的“归属”问题:沙箱内 vs 主上下文#

微任务(如 Promise 的then回调)的执行顺序取决于函数的上下文归属

  • 沙箱内声明的函数(如箭头函数):微任务加入沙箱队列;
  • 主上下文的函数(如console.log):微任务加入主队列。

示例(电商营销脚本的微任务处理):

const vm = require('vm')
const sandbox = {
products: [{ price: 100 }, { price: 200 }],
console: console, // 允许输出日志
}
const context = vm.createContext(sandbox, { microtaskMode: 'afterEvaluate' })
// 主上下文的Promise:统计商品总数(微任务加入主队列)
new Promise((resolve) => {
const totalProducts = sandbox.products.length
resolve(`主程序:商品总数${totalProducts}`)
}).then((msg) => console.log(msg))
// 沙箱内的Promise:计算优惠后金额(微任务加入沙箱队列)
const marketingScript = `
new Promise(resolve => {
const total = products.reduce((sum, p) => sum + p.price, 0);
resolve(\`沙箱:优惠后金额\${total * 0.9}元\`);
}).then(msg => console.log(msg)); // 箭头函数:沙箱内的函数
`
// 执行脚本
vm.runInContext(marketingScript, context)
// 输出顺序:
// 主程序:商品总数2
// 沙箱:优惠后金额270元

解释

  • 主程序的 Promise 回调是主上下文的函数,微任务加入主队列;
  • 沙箱内的 Promise 回调是箭头函数(沙箱内声明),微任务加入沙箱队列;
  • 由于microtaskMode: 'afterEvaluate',沙箱脚本执行完后立即处理沙箱队列,因此沙箱的微任务先输出。

四、看门狗(Watchdog):超时控制的“监督员”#

恶意代码(如死循环)会阻塞事件循环,导致服务无法响应。vm超时机制靠“看门狗”线程解决——它会在脚本超时时强制终止执行。

4.1 核心思想:RAII(资源获取即初始化)#

看门狗利用 C++的RAII特性(资源获取即初始化),将线程、定时器等资源与对象生命周期绑定:

  • 构造函数:启动新线程,初始化定时器(超时时间);
  • 析构函数:停止线程,释放资源(自动执行,无需手动管理)。

4.2 看门狗的工作流程#

用流程图表示看门狗的执行逻辑:

sequenceDiagram participant 主程序(主线程) participant 看门狗(新线程) 主程序->>看门狗: 创建Watchdog对象(启动线程+定时器) 主程序->>V8: 执行用户脚本 alt 未超时 V8->>主程序: 脚本执行完成 主程序->>看门狗: 析构Watchdog(发送停止信号) 看门狗->>看门狗: 收到信号,停止事件循环 else 超时 看门狗->>V8: 定时器触发,调用TerminateExecution()强制终止 V8->>主程序: 脚本终止,返回超时错误 主程序->>看门狗: 析构Watchdog end

4.3 示例:用看门狗阻止恶意代码(社区论坛场景)#

假设用户提交了死循环脚本,试图阻塞服务:

const vm = require('vm')
// 用户提交的恶意代码:死循环输出
const maliciousCode = `
while (true) {
console.log('正在占用CPU...');
}
`
// 创建Script,设置1秒超时
const script = new vm.Script(maliciousCode)
try {
script.runInThisContext({ timeout: 1000 }) // 1秒后超时
} catch (err) {
console.error('脚本执行超时:', err.message) // 输出:脚本执行超时:Script execution timed out after 1000ms
}

效果:看门狗线程在 1 秒后强制终止死循环,主程序的事件循环得以继续运行,避免服务崩溃。

五、compileFunction:直接编译函数的“捷径”#

除了Script类,vm还提供compileFunction方法——直接将代码编译为函数,避免Script的上下文绑定开销,同时解决了 CommonJS 模块编译的错误堆栈偏移问题。

5.1 为什么用 compileFunction?#

  • 错误堆栈更准确:直接编译函数,无需拼接 Wrapper(早期 CommonJS 的问题);
  • 性能更高:无需创建Script对象,直接生成函数;
  • 更贴近 Node.js 实际:当前 CommonJS 模块的编译逻辑已从Script切换到compileFunction

5.2 示例:用 compileFunction 编译 CommonJS 模块(对比早期方式)#

早期方式(Script 拼接 Wrapper)

const moduleCode = `exports.foo = 'bar';`
const wrapper = `(function(exports, require, module) { ${moduleCode} })`
const script = new vm.Script(wrapper)
const moduleFn = script.runInThisContext()
moduleFn(module.exports, require, module)
// 若模块代码抛出错误,堆栈会指向Wrapper的第一行:
// Error: 模块错误
// at Object.<anonymous> (wrapper:1:1)

当前方式(compileFunction)

const moduleCode = `exports.foo = 'bar';`
const moduleFn = vm.compileFunction(moduleCode, [
'exports',
'require',
'module',
])
moduleFn(module.exports, require, module)
// 错误堆栈会准确指向模块代码的行号:
// Error: 模块错误
// at Object.<anonymous> (mod.js:1:7)

六、实践总结:vm 的最佳实践#

1. 沙箱隔离:深拷贝避免引用穿透#

反例:直接传递引用类型到沙箱,导致主程序数据被修改:

const user = { name: 'Alice', balance: 1000 }
const sandbox = { user } // 直接传递引用
const context = vm.createContext(sandbox)
vm.runInContext(`user.balance = 0`, context) // 沙箱内修改balance
console.log(user.balance) // 输出:0(主程序的user被修改)

正例:用lodash.cloneDeep深拷贝引用类型,隔离数据:

const _ = require('lodash')
const user = { name: 'Alice', balance: 1000 }
const sandbox = { user: _.cloneDeep(user) } // 深拷贝
const context = vm.createContext(sandbox)
vm.runInContext(`user.balance = 0`, context)
console.log(user.balance) // 输出:1000(主程序的user未被修改)

2. 超时控制:必须设置 timeout#

反例:未设置超时,恶意代码阻塞事件循环:

const maliciousCode = `while(true) {}`
const script = new vm.Script(maliciousCode)
script.runInThisContext() // 事件循环被阻塞,服务无法响应

正例:设置超时时间,阻止恶意代码:

const maliciousCode = `while(true) {}`
const script = new vm.Script(maliciousCode)
try {
script.runInThisContext({ timeout: 1000 })
} catch (err) {
console.error('超时:', err.message) // 输出:超时:Script execution timed out after 1000ms
}

3. 避免 Wrapper 拼接:用 compileFunction 编译模块#

反例:用 Script 拼接 Wrapper,导致错误堆栈偏移:

const moduleCode = `throw new Error('模块错误');`
const wrapper = `(function() { ${moduleCode} })`
const script = new vm.Script(wrapper)
script.runInThisContext() // 错误堆栈指向Wrapper的第一行

正例:用 compileFunction,错误堆栈准确:

const moduleCode = `throw new Error('模块错误');`
const moduleFn = vm.compileFunction(moduleCode)
moduleFn() // 错误堆栈指向moduleCode的第一行

七、总结:从 V8 到 Node.js 的“层层封装”#

vm模块的Script类是V8 底层概念Node.js 高层 API的完美桥梁,其设计逻辑可总结为三层递进:

  1. V8 层:UnboundScript(未绑定上下文的“代码模板”)→ Script(绑定上下文的“执行实例”);
  2. Node.js C++层:ContextifyScript(封装 UnboundScript,管理编译结果)→ ContextifyContext(封装 V8 Context,实现沙箱隔离);
  3. Node.js JS 层vm.Script(继承 ContextifyScript,提供友好的高层 API)→ vm.createContext(创建沙箱,绑定globalThis)。

这种分层设计的核心优势在于:

  • 性能高效:UnboundScript 的复用避免了重复编译,多上下文执行时性能提升显著;
  • 安全隔离:ContextifyContext 通过拦截器限制globalThis的访问,用户代码无法触及主程序的敏感资源;
  • 灵活易用vm.Script的 API 隐藏了 V8 底层细节,开发者无需关注 Context 切换,快速实现沙箱。

八、扩展场景:vm 与 ShadowRealm 的对比#

随着 ES 标准的演进,ShadowRealm(ES2023 实验性特性)成为vm的未来替代方案。它是更现代的沙箱机制,与vm的核心区别如下:

特性vm 模块ShadowRealm
隔离级别基于 V8 Context(共享 Isolate)基于独立 Realm(完全隔离)
API 复杂度需手动创建上下文、绑定变量简洁的new ShadowRealm()
资源限制需手动冻结globalThis默认隔离,仅暴露import
兼容性Node.js 0.10+(成熟)Node.js 20+(实验性,需--experimental-shadow-realm

示例:用 ShadowRealm 实现插件系统
假设你需要开发一个插件系统,允许第三方开发者提交脚本,ShadowRealm 是更安全的选择:

// Node.js 20+(启用实验性flag)
const realm = new ShadowRealm()
// 1. 暴露有限API给插件(如获取用户信息)
realm.evaluate(`
globalThis.getUserInfo = (userId) => ({
id: userId,
name: 'Alice',
balance: 1000
});
`)
// 2. 加载第三方插件(假设插件代码为字符串)
const pluginCode = `
export function calculateDiscount(userId) {
const user = getUserInfo(userId);
return user.balance * 0.9; // 10%折扣
}
`
// 3. 执行插件并获取结果
const calculateDiscount = await realm.importValue(
pluginCode,
'calculateDiscount'
)
const result = calculateDiscount(123)
console.log(result) // 输出:900

结论:ShadowRealm 是vm的未来,但当前vm更成熟,适合生产环境;ShadowRealm 适合需要严格隔离的场景(如插件系统、低代码平台)。

九、实践中的“暗坑”与解决方案#

即使掌握了vm的原理,生产中仍可能遇到“看不见的坑”,以下是高频问题及解决方法:

9.1 原型链污染(Prototype Pollution)#

问题:用户代码可修改Object.prototype,影响主程序的所有对象:

const sandbox = { foo: 'bar' }
const context = vm.createContext(sandbox)
vm.runInContext(`Object.prototype.hacked = true`, context) // 污染原型链
console.log({}.hacked) // 输出:true(主程序对象被篡改)

解决方案冻结Object.prototype,或使用Object.freeze限制沙箱的全局对象:

const sandbox = Object.freeze({
foo: 'bar',
Object: Object.freeze(Object), // 冻结Object,防止修改原型
})
const context = vm.createContext(sandbox)

9.2 异步操作的“超时逃逸”#

问题vm的超时机制仅控制同步代码,无法终止异步任务(如setTimeoutPromise):

const script = new vm.Script(`
setTimeout(() => {
console.log('恶意代码仍在运行...');
}, 2000);
`)
script.runInThisContext({ timeout: 1000 }) // 同步代码超时,但异步任务仍执行

解决方案重写沙箱的异步 API,跟踪所有异步任务,超时后强制清理:

const sandbox = {
timers: [], // 跟踪所有定时器
setTimeout: (fn, delay) => {
const timer = setTimeout(() => {
fn()
// 执行完后从列表中移除
sandbox.timers.splice(sandbox.timers.indexOf(timer), 1)
}, delay)
sandbox.timers.push(timer)
return timer
},
}
const context = vm.createContext(sandbox)
try {
script.runInContext(context, { timeout: 1000 })
} catch (err) {
// 超时后清理所有未执行的定时器
sandbox.timers.forEach(clearTimeout)
console.error('脚本超时,已清理异步任务')
}

9.3 引用穿透的“隐形修改”#

问题:若沙箱中传递了主程序的引用类型(如对象、数组),用户代码可修改其内部属性:

const user = { name: 'Alice', balance: 1000 }
const sandbox = { user } // 直接传递引用
const context = vm.createContext(sandbox)
vm.runInContext(`user.balance = 0`, context) // 沙箱内修改
console.log(user.balance) // 输出:0(主程序数据被篡改)

解决方案深拷贝引用类型,切断沙箱与主程序的关联:

// 原生深拷贝(适合JSON兼容数据)
const sandbox = {
user: JSON.parse(JSON.stringify(user)),
}
// 复杂对象用lodash.cloneDeep
// const _ = require('lodash');
// const sandbox = { user: _.cloneDeep(user) };

十、最后的话:vm 的“安全边界”#

vm是沙箱隔离的基础,但不是“绝对安全”的——它的隔离性仅针对globalThis的顶层属性,无法防御以下攻击:

  1. 原型链污染:修改Object.prototype影响所有对象;
  2. 异步逃逸:通过setTimeout等 API 绕过超时控制;
  3. 引用穿透:修改主程序传递的引用类型对象。

生产环境中,需结合以下策略强化安全:

  • 最小权限原则:仅向沙箱暴露必要的 API(如console、业务函数),禁止processrequire等敏感对象;
  • 深拷贝隔离:所有传递给沙箱的对象必须深拷贝;
  • 异步任务跟踪:重写沙箱的异步 API,超时后清理所有未执行任务;
  • 定期更新:关注 Node.js 的vm模块更新,修复已知漏洞(如原型链污染)。

结语:从“会用”到“用好”vm#

vm模块是 Node.js 中最“低调”却最“核心”的模块之一——它支撑了 CommonJS 的编译,守护了沙箱的安全。从理解 V8 的 Script/UnboundScript,到掌握 Node.js 的 ContextifyScript,再到解决生产中的“暗坑”,这个过程是从“会用”到“用好”vm的关键。

希望本文能帮你揭开vm的“神秘面纱”,在实际项目中安全、高效地使用沙箱,写出更健壮的 Node.js 代码。

参考资料#

  1. Node.js 官方文档:vm 模块
  2. V8 官方文档:Script 与 UnboundScript
  3. 《Node.js:来一打 C++扩展》(朴灵著,深入 V8 底层)
  4. ES 提案:ShadowRealm
  5. Node.js 安全指南:沙箱隔离最佳实践

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【Node.js】VM模块深剖二:Script的编译与沙箱执行原理
https://blog.fridolph.top/posts/2023-10-06__vm2/
作者
Fridolph
发布于
2023-10-06
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录