news 2026/5/11 0:59:43

Godot 4项目模板实战:模块化架构与工程化开发指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Godot 4项目模板实战:模块化架构与工程化开发指南

1. 项目概述与核心价值

最近在社区里看到不少朋友对 Godot 引擎跃跃欲试,但往往卡在第一步:如何快速搭建一个结构清晰、易于维护的初始项目?很多新手会直接从官方文档的“Hello World”开始,但随着功能增加,代码很快就变得混乱不堪,后期重构的成本极高。我自己在尝试了多个开源模板后,最终被zfoo-project/godot-start这个项目吸引,它不是一个简单的“Hello World”,而是一个为中小型游戏项目量身定制的、开箱即用的工程化起点。

简单来说,godot-start是一个基于 Godot 4 的、高度结构化的项目模板。它最大的价值在于,将游戏开发中那些重复、繁琐但又至关重要的“基建”工作提前做好了。比如,如何组织场景树、如何管理全局状态和事件、如何实现一个可扩展的UI系统、如何配置多语言和音效。当你基于这个模板开始一个新项目时,你不再是从零开始堆砌代码和场景,而是站在一个经过验证的、模块化的架构之上,可以立刻开始构思你的核心玩法和内容创作。

这个模板非常适合两类开发者:一是刚接触 Godot、希望学习最佳实践的新手,它能帮你避开很多初期架构上的“坑”;二是需要快速启动原型或中小型项目的独立开发者或小团队,它能显著提升开发效率,让团队保持一致的代码风格和项目结构。接下来,我将深入拆解这个模板的核心设计、关键模块,并分享如何基于它进行二次开发和避坑经验。

2. 项目整体架构与设计哲学

2.1 为什么需要项目模板?

在深入代码之前,我们先聊聊“为什么”。很多个人开发者习惯了一个main.tscn走天下,所有脚本都挂在根节点下。对于超小型项目或原型,这没问题。但当项目规模稍微扩大,比如需要添加主菜单、设置界面、多个游戏关卡时,这种“面条式”的代码结构就会带来灾难:场景耦合严重、全局状态难以追踪、资源加载混乱、新增功能如履薄冰。

godot-start模板的设计哲学非常明确:关注点分离与模块化。它将游戏的不同职责划分到独立的、可复用的模块中。例如,UI管理与游戏逻辑分离,场景切换由专门的路由器处理,游戏状态由中心化的管理器控制。这种架构带来的直接好处是:

  1. 可维护性:每个模块职责单一,修改一个功能不会“牵一发而动全身”。
  2. 可测试性:独立的模块更容易进行单元测试。
  3. 团队协作:不同的开发者可以并行工作在各自的模块上,只要接口定义清晰。
  4. 可扩展性:当需要添加新功能(如成就系统、数据分析)时,可以很容易地以插件形式集成,而不必重构核心逻辑。

2.2 核心目录结构解析

下载并打开godot-start项目,第一印象就是其清晰、标准的目录结构。这不仅仅是看起来整洁,更是模块化思想的直接体现。

godot-start/ ├── addons/ # 第三方插件目录 ├── assets/ # 游戏资源(图片、音效、字体等) │ ├── audio/ │ ├── fonts/ │ └── graphics/ ├── autoloads/ # 自动加载的单例脚本(全局管理器) ├── scenes/ # 所有游戏场景 │ ├── game/ # 核心游戏场景 │ ├── ui/ # UI场景和控件 │ └── world/ # 世界/关卡场景 └── scripts/ # 通用工具脚本和类 ├── systems/ # 系统级脚本(如存档、事件总线) └── utils/ # 工具函数

关键目录解读:

  • autoloads/:这是 Godot 的“自动加载”功能目录,里面的脚本会在游戏启动时自动实例化,并作为单例存在于整个项目生命周期。模板通常在这里预置了GameManager(游戏状态管理)、EventBus(全局事件总线)、AudioManager(音频管理)等核心管理器。这是实现解耦的关键,其他场景通过访问这些单例来通信,而不是直接引用。
  • scenes/的子目录划分:将场景按功能类型分离。ui/目录下的场景通常只负责界面显示和用户输入响应,不包含核心游戏逻辑。game/world/则专注于游戏玩法。这种分离使得美术和策划可以相对独立地修改UI,而不影响程序员的逻辑代码。
  • scripts/systems/:这里存放着一些“系统”脚本,比如一个基于Resource的存档系统,或者一个观察者模式的事件系统。它们是比具体场景更高一层的抽象,为整个游戏提供基础服务。

注意:不要随意改动autoloads中脚本的加载顺序,除非你完全理解它们之间的依赖关系。Godot 会按照字母顺序加载,如果A单例依赖B单例,但A的脚本名字母序在B之前,就会导致初始化错误。一个常见的技巧是在脚本中使用@onready配合信号来延迟初始化依赖。

3. 核心模块深度拆解与实操

3.1 全局事件总线:实现彻底解耦的通信

在传统的 Godot 开发中,节点间通信主要靠直接引用($操作符获取节点)或信号(signal)。直接引用会导致紧密耦合,而信号如果需要在非父子节点甚至毫无关联的场景间传递,就会变得非常麻烦(需要层层传递)。

godot-start模板通常实现了一个全局事件总线。你可以把它想象成一个中央广播站。任何脚本都可以在这里“发布”一个事件(比如player_died,item_collected),而任何其他脚本都可以“订阅”这个事件,并在事件发生时执行相应的回调。发布者和订阅者彼此完全不知道对方的存在。

实操示例:实现一个简单的事件总线

autoloads/下创建EventBus.gd

# EventBus.gd extends Node # 定义事件信号。建议使用动词过去式或名词描述事件。 signal game_paused signal game_resumed signal player_health_changed(new_health: int) signal coin_collected(amount: int) # 通常事件总线不需要额外方法,其他脚本直接连接(connect)或发射(emit)这些信号即可。

如何使用?

  1. 发布事件(在某个游戏脚本中):
    # 玩家受伤时 EventBus.player_health_changed.emit(current_health) # 捡到金币时 EventBus.coin_collected.emit(10)
  2. 订阅事件(在UI脚本中):
    # 在 UI 控件的 _ready() 方法中连接信号 func _ready(): EventBus.player_health_changed.connect(_on_player_health_changed) EventBus.coin_collected.connect(_on_coin_collected) func _on_player_health_changed(new_health: int): # 更新血条UI $HealthBar.value = new_health func _on_coin_collected(amount: int): # 更新金币数量UI Global.coin_count += amount $CoinLabel.text = str(Global.coin_count)

优势与注意事项:

  • 优势:彻底解耦。游戏逻辑模块修改时,只要事件名称和参数不变,UI模块完全无需改动。新增一个需要响应金币收集的系统(比如音效、成就),只需在新的脚本中订阅同一个coin_collected事件即可。
  • 注意:要避免内存泄漏。如果一个节点订阅了事件总线的事件,但在被移除(queue_free())时没有断开连接,那么这个节点实例就不会被正确释放。Godot 4 提供了Node.unconnect(),但更推荐使用SignalCONNECT_ONE_SHOT标志,或在节点的_exit_tree()方法中手动断开所有连接。
  • 注意:事件命名要有全局唯一性和清晰的语义,建议在团队内建立命名规范(如名词_动词过去式领域_动作)。

3.2 场景管理与状态路由

Godot 本身没有官方的“场景路由器”概念,godot-start模板通常会封装一个SceneManager来统一管理场景的加载、切换和过渡动画。这比直接使用get_tree().change_scene_to_file()要强大和优雅得多。

核心功能设计:

  1. 场景栈:模拟类似移动应用或UI的导航栈。可以推入新场景、弹出当前场景、替换栈顶场景。这对于实现“设置界面-返回主菜单”这样的流程非常方便。
  2. 加载屏幕:在切换大型场景时,自动显示一个加载界面,并在后台线程完成资源的加载,避免游戏卡顿。
  3. 场景间数据传递:提供一种标准化的方式,将数据从当前场景传递到下一个场景。

简化版 SceneManager 实现思路:autoloads/下创建SceneManager.gd

# SceneManager.gd extends Node # 场景路径常量,集中管理,避免硬编码 const MAIN_MENU := "res://scenes/ui/main_menu.tscn" const GAME_WORLD := "res://scenes/world/level_01.tscn" const SETTINGS := "res://scenes/ui/settings_menu.tscn" var _scene_stack := [] # 用于存储场景实例的栈 var _current_scene: Node = null func _ready(): # 游戏启动时加载初始场景(如主菜单) goto_scene(MAIN_MENU) func goto_scene(path: String, data: Dictionary = {}): # 1. 可在此处触发加载界面显示 # emit_signal("loading_started") # 2. 异步加载新场景资源 var loader := ResourceLoader.load_threaded_request(path) # 3. 轮询加载状态(在实际项目中,这步通常在_process中处理,并更新加载进度条) while true: var status = ResourceLoader.load_threaded_get_status(path) if status == ResourceLoader.THREAD_LOAD_LOADED: break await get_tree().process_frame # 每帧检查一次,避免阻塞 # 4. 获取加载好的场景并实例化 var new_scene_res = ResourceLoader.load_threaded_get(path) var new_scene_instance = new_scene_res.instantiate() # 5. 将数据注入到新场景(如果新场景有特定的接收方法) if new_scene_instance.has_method("set_init_data"): new_scene_instance.set_init_data(data) # 6. 切换场景 get_tree().root.add_child(new_scene_instance) if _current_scene: _current_scene.queue_free() # 释放旧场景 _current_scene = new_scene_instance _scene_stack.push_back(new_scene_instance) # 7. 隐藏加载界面 # emit_signal("loading_finished") func go_back(): if _scene_stack.size() > 1: var previous_scene = _scene_stack[-2] # 获取上一个场景 # ... 切换回 previous_scene 的逻辑 _scene_stack.pop_back() # 移除当前场景

在实际的模板中,SceneManager会更加复杂,可能包含场景过渡动画、加载进度回调、场景预加载等功能。但核心思想是一致的:集中管理场景生命周期,提供流畅的切换体验

3.3 数据管理与持久化

游戏需要保存玩家的进度、设置、库存等信息。godot-start模板通常会提供一个基于Resource的、类型安全的存档系统。

为什么用 Resource?Resource是 Godot 的核心数据对象,它可以被编辑、被引用、被保存为.tres.res文件。用它来做存档有天然优势:

  1. 类型安全:存档中的每个字段都有明确的类型(int, String, Array 等)。
  2. 编辑器友好:你甚至可以创建一个临时的.tres文件作为默认存档,在编辑器中直接修改初始值。
  3. 易于扩展:可以嵌套其他Resource,构建复杂的数据结构。

实操:创建一个 SaveGame 资源scripts/resources/下创建save_game.gd

# save_game.gd class_name SaveGame extends Resource @export var player_name: String = "Player" @export var high_score: int = 0 @export var coins: int = 0 @export var unlocked_levels: Array[int] = [1] # 已解锁的关卡编号 @export var settings: Dictionary = { # 游戏设置 "master_volume": 1.0, "music_volume": 0.8, "sfx_volume": 0.8, "fullscreen": false }

配套的 SaveSystem(在autoloads/scripts/systems/下):

# save_system.gd extends Node const SAVE_PATH := "user://savegame.tres" var current_save: SaveGame func _ready(): load_game() func create_new_save(): current_save = SaveGame.new() # 可以设置一些默认值 current_save.player_name = "NewPlayer" save_game() func save_game(): if current_save: # 在保存前,可以触发一个信号,让其他系统(如UI)更新最后保存时间等 # EventBus.game_saved.emit() var error = ResourceSaver.save(current_save, SAVE_PATH) if error != OK: push_error("Failed to save game: %s" % error) func load_game(): if ResourceLoader.exists(SAVE_PATH): current_save = ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as SaveGame else: # 没有存档文件,创建新的 create_new_save() # 加载完成后,通知游戏其他部分(如UI、玩家状态)应用存档数据 EventBus.save_loaded.emit(current_save) func get_setting(key: String, default): return current_save.settings.get(key, default) func update_setting(key: String, value): current_save.settings[key] = value save_game() # 设置更改后自动保存

这个系统将存档数据封装成一个对象,任何需要读取或修改存档数据的地方,都通过SaveSystem.current_save这个单例来进行,保证了数据访问入口的唯一性和一致性。

4. 基于模板的二次开发与最佳实践

4.1 如何开始你的新项目

  1. 获取模板:从zfoo-project/godot-start的代码仓库(如 GitHub)克隆或下载项目。
  2. 重命名与清理:在 Godot 编辑器中打开项目。首先,在项目设置 -> 常规中修改“项目名称”和“项目目录”。然后,根据你的游戏主题,重命名核心目录(如scenes/world/可以改为scenes/levels/)。删除模板中你暂时不需要的示例场景和资源。
  3. 配置核心管理器:检查autoloads/下的各个管理器脚本,根据你的游戏类型调整。例如,如果是不需要关卡的游戏,可以简化SceneManager;如果是2D游戏,确保AudioManager使用的是AudioStreamPlayer2D
  4. 定义你的游戏事件:在EventBus.gd中,根据你的游戏设计,添加你需要的事件信号。这是规划游戏模块间通信的好时机。
  5. 定制你的数据模型:修改SaveGame资源类,添加你的游戏特有的字段,如玩家属性、任务进度、物品栏等。

4.2 模块化开发工作流

基于此模板,推荐采用以下工作流:

  • UI 开发:在scenes/ui/下创建新的场景。使用 Godot 强大的容器和主题系统构建界面。UI 脚本只负责显示和输入反馈,业务逻辑通过调用GameManager或发射EventBus信号来实现。
  • 游戏逻辑开发:在scenes/game/scenes/world/下开发核心玩法场景。通过EventBus订阅UI事件(如按钮点击),通过GameManager获取全局状态。
  • 系统开发:新的游戏系统(如任务系统、对话系统)可以作为独立模块,放在scripts/systems/下。它们通过EventBus与其他模块交互,并通过SaveSystem持久化数据。
  • 资源管理:将美术、音效资源规范地放入assets/的对应子目录。使用有意义的命名,并考虑使用 Godot 的Resource来打包相关资源(如一个角色所有动画的SpriteFrames资源)。

4.3 性能优化与调试技巧

  • 资源预加载:对于切换频繁的场景或大型资源,可以在SceneManager或专门的ResourceManager中实现预加载。使用ResourceLoader.load_threaded_request()在后台加载,使用时再通过ResourceLoader.load_threaded_get()获取。
  • 信号连接检查:大量使用事件总线后,在调试时可能会遇到信号未触发或触发多次的问题。可以在EventBus的每个信号发射前添加一个调试打印,或者在连接时使用CONNECT_DEFERRED标志来避免在复杂的回调链中修改节点树。
  • 存档版本控制:当你的游戏更新,需要修改SaveGame的数据结构时,旧版本的存档会无法加载。一个简单的解决方案是在SaveGame资源中添加一个version字段。在SaveSystem.load_game()时,检查版本号,如果版本过低,执行一个升级函数,将旧数据迁移到新格式。
  • 使用 Godot 的性能分析器:定期使用 Godot 编辑器自带的“调试器”面板中的“性能”和“监视器”标签页。特别关注physics_processprocess中耗时长的函数,以及动态对象(如粒子、实例化节点)的数量。

5. 常见问题与排查实录

在实际使用godot-start模板或类似架构进行开发时,你几乎一定会遇到下面这些问题。这里记录了我的排查过程和解决方案。

5.1 事件总线信号接收不到

问题现象:在脚本A中发射了EventBus.some_signal.emit(),但在脚本B中连接的回调函数从未被调用。

排查步骤:

  1. 检查连接时机:确保脚本B在_ready()或更早的时候连接了信号。如果脚本B是在场景切换后动态创建的,需要在创建后立即连接。
  2. 检查信号名称和参数:确保发射和订阅使用的是完全相同的信号名,并且参数数量和类型匹配。GDScript 是动态类型,但信号签名必须一致。
  3. 检查单例实例:确保EventBus已正确添加到项目的自动加载列表中(项目设置 -> 自动加载)。如果没加载,它只是一个普通的脚本类,发射和连接的对象不是同一个全局实例。
  4. 使用调试输出:在EventBus_ready()中打印信息,确认它已初始化。在发射信号前后添加打印,确认发射被执行了。

解决方案示例:

# 在 EventBus.gd 的 _ready 中 func _ready(): print("EventBus loaded and ready.") # 连接一个测试信号 self.some_signal.connect(_on_test_signal) func _on_test_signal(value): print("Test signal received with value: ", value) # 在其他脚本中,尝试发射 EventBus.some_signal.emit(123)

如果控制台打印了 “EventBus loaded and ready.” 但没有打印接收信息,说明连接可能有问题。如果连第一句都没打印,说明自动加载配置错误。

5.2 场景切换后资源未释放导致内存泄漏

问题现象:游戏在多次切换场景后,内存占用持续上升,甚至导致卡顿或崩溃。

排查步骤:

  1. 使用 Godot 的调试工具:在调试器 -> 性能 -> 对象计数器中,观察NodeResource等对象的数量在场景切换后是否持续增长。
  2. 检查引用残留:最常见的根源是未断开的信号连接。如果一个节点订阅了事件总线或另一个长生命周期节点的信号,但在被释放时没有断开,该节点就无法被垃圾回收。
  3. 检查静态变量或全局引用:是否有全局字典或数组仍然持有对已销毁节点或其内部资源的引用?

解决方案与最佳实践:

  • 规范信号连接:对于可能被销毁的节点,在连接信号时,使用connect()的第四个参数flags,设置CONNECT_ONE_SHOT(只触发一次)或确保在节点的_exit_tree()_notification(NOTIFICATION_PREDELETE)中手动断开所有连接。
    # 在可能被销毁的节点中 func _ready(): EventBus.some_signal.connect(_on_signal, CONNECT_ONE_SHOT) # 只接收一次 func _exit_tree(): # 安全起见,手动断开(如果之前没用 ONE_SHOT) if EventBus.some_signal.is_connected(_on_signal): EventBus.some_signal.disconnect(_on_signal)
  • 使用 WeakRef:如果必须存储对某个节点的引用以备后用,使用WeakRef
    var _target_ref: WeakRef func store_target(node: Node): _target_ref = weakref(node) func use_target(): var node = _target_ref.get_ref() if node: # 节点还存在,可以使用 node.do_something() else: # 节点已被释放,引用自动失效 print("Target node is gone.")
  • 彻底清理场景:在SceneManager切换场景时,确保对旧场景根节点调用queue_free(),而不仅仅是remove_child()。Godot 的queue_free()会在下一帧安全地释放节点及其所有子节点。

5.3 存档数据损坏或无法加载

问题现象:更新游戏后,旧存档无法读取,或者存档文件突然损坏。

排查步骤:

  1. 检查文件路径和权限user://目录在不同平台的位置不同,确保有读写权限。可以打印OS.get_user_data_dir()查看路径。
  2. 检查资源版本兼容性:如果你修改了SaveGame类的结构(增删字段、改变类型),旧版本的.tres文件将无法直接加载到新的类定义中。
  3. 检查序列化数据:如果存档包含自定义的复杂对象(非基本类型、非Resource),确保它们正确地实现了序列化。

解决方案:

  • 实现存档版本迁移:这是最稳健的方案。
    # 在 SaveGame 资源中 @export var save_version: int = 1 # 在 SaveSystem 的 load_game 中 func load_game(): if ResourceLoader.exists(SAVE_PATH): var loaded = ResourceLoader.load(SAVE_PATH) as SaveGame if loaded: migrate_save_data(loaded) # 迁移数据 current_save = loaded else: create_new_save() func migrate_save_data(save: SaveGame): if save.save_version < 2: # 从版本1迁移到版本2:假设新增了一个字段 `total_play_time` if not save.has("total_play_time"): save.total_play_time = 0.0 save.save_version = 2 if save.save_version < 3: # 从版本2迁移到版本3... pass # 保存迁移后的数据 var error = ResourceSaver.save(save, SAVE_PATH)
  • 备份机制:在保存前,将旧存档文件重命名为备份(如savegame_backup.tres)。如果新存档保存失败,可以尝试恢复备份。
  • 使用 JSON 作为中间格式:对于极度担心兼容性的情况,可以不直接保存Resource,而是将SaveGame对象的属性字典用JSON.stringify()保存为文本文件。加载时再解析并赋值给新的SaveGame对象。这样对字段变化的容忍度更高,但失去了Resource的部分便利性。

5.4 多语言支持与本地化集成

虽然基础模板可能不包含完整的本地化,但这是一个常见需求。Godot 有内置的本地化系统(TranslationServer),可以很好地与模板架构结合。

快速集成步骤:

  1. 准备翻译文件:使用 CSV 或 PO 格式创建翻译文件,例如translations_en.csv,translations_zh.csv。文件内容类似keys,en,zh"UI_START","Start","开始"
  2. 导入并设置:在 Godot 项目设置 -> 本地化中,添加这些翻译文件。设置默认语言和回退语言。
  3. 创建本地化管理器:在autoloads/下创建LocalizationManager.gd。它负责调用TranslationServer.set_locale()切换语言,并发射一个language_changed信号。
  4. UI 控件响应:所有需要本地化的 Label、Button 等控件,不要直接设置text,而是设置一个占位符(如[TEXT_KEY])。在 UI 场景的_ready()中,连接LocalizationManager.language_changed信号。当信号触发时,遍历场景树,查找所有有占位符的控件,用tr()函数获取对应语言的文本进行更新。
    # LocalizationManager.gd signal language_changed func set_language(locale: String): TranslationServer.set_locale(locale) language_changed.emit() # 通知所有UI更新 # 在某个UI脚本中 func _ready(): LocalizationManager.language_changed.connect(_update_texts) _update_texts() # 初始化文本 func _update_texts(): $StartButton.text = tr("UI_START") $TitleLabel.text = tr("UI_TITLE")
  5. 保存语言设置:将当前语言设置保存在SaveGame.settings字典中,并在游戏启动时通过LocalizationManager应用。

通过将本地化逻辑集中到管理器和事件驱动更新,你可以确保游戏内所有UI在切换语言时都能即时、正确地刷新,而无需手动修改每一个场景。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/11 0:57:59

基于RAG的AI知识库构建:从原理到实践的全栈指南

1. 项目概述&#xff1a;一个面向AI的知识库构建方案最近在折腾AI应用开发&#xff0c;特别是想把手头积累的文档、笔记、代码片段都喂给大语言模型&#xff0c;让它能基于我的私有知识库进行问答和创作。这听起来像是每个技术团队或个人开发者都想实现的“第二大脑”。在GitHu…

作者头像 李华
网站建设 2026/5/11 0:57:49

Instrukt框架:构建生产级AI代理的指令操作系统实践指南

1. 项目概述&#xff1a;一个为AI代理量身定制的“指令操作系统”最近在折腾AI代理&#xff08;Agent&#xff09;开发的朋友&#xff0c;估计都绕不开一个核心痛点&#xff1a;如何让这些智能体真正理解并执行复杂的、多步骤的指令&#xff1f;我们常常会遇到&#xff0c;一个…

作者头像 李华
网站建设 2026/5/11 0:55:52

从零到一:基于iSYSTEM winIDEA与IC5000的嵌入式程序烧写与调试实战指南

1. 环境准备&#xff1a;搭建你的嵌入式开发工作台 第一次接触iSYSTEM工具链时&#xff0c;我完全被各种专业术语搞懵了。后来才发现&#xff0c;只要把环境搭好&#xff0c;后面的操作就像拼乐高一样简单。这里我会手把手带你配置好winIDEA和IC5000调试器&#xff0c;避开那些…

作者头像 李华
网站建设 2026/5/11 0:53:51

ViGEmBus完全指南:轻松解决Windows游戏手柄兼容性难题

ViGEmBus完全指南&#xff1a;轻松解决Windows游戏手柄兼容性难题 【免费下载链接】ViGEmBus Windows kernel-mode driver emulating well-known USB game controllers. 项目地址: https://gitcode.com/gh_mirrors/vi/ViGEmBus 你是否曾经遇到过这样的困扰&#xff1a;在…

作者头像 李华
网站建设 2026/5/11 0:43:04

使用Curxy实现内网穿透:轻量反向代理与隧道工具实战指南

1. 项目概述与核心价值最近在折腾一些需要跨网络访问的服务时&#xff0c;遇到了一个挺普遍的问题&#xff1a;如何安全、便捷地访问部署在家庭内网或者公司内网的服务&#xff0c;比如NAS、树莓派上的Web应用&#xff0c;或者开发测试环境。直接暴露端口到公网风险太大&#x…

作者头像 李华