6.1 require 与模块加载 本节摘要:模块是 Lua 组织代码的单位——一张带公共接口的表。require 的加载协议四步:查 package.loaded 缓存、按 package.path 搜索、执行模块块、登记缓存并返回。缓存防止重复初始化,也天然挡住"重载"——热更新要动的第一颗螺丝就是它。本节讲透协议、给出模块的标准写法,并从寄宿视角看模块边界:宿主如何预载模块、如何用路径配置划分脚本领地。 拼装术 脚本大到一定程度就要拆文件。Lua 的拆法极简:一个模块文件 = 一个块 + 一张返回的表。先看最短的模块: 使用方 require 它: 执行模块文件、拿到它 return 的表,交给调用方。
本节摘要:模块是 Lua 组织代码的单位——一张带公共接口的表。require 的加载协议四步:查 package.loaded 缓存、按 package.path 搜索、执行模块块、登记缓存并返回。缓存防止重复初始化,也天然挡住"重载"——热更新要动的第一颗螺丝就是它。本节讲透协议、给出模块的标准写法,并从寄宿视角看模块边界:宿主如何预载模块、如何用路径配置划分脚本领地。
脚本大到一定程度就要拆文件。Lua 的拆法极简:一个模块文件 = 一个块 + 一张返回的表。先看最短的模块:
-- 假设这是模块文件 metric.lua 的全部内容 local M = {} M.K = { inch = 2.54, foot = 30.48, mile = 160934.4 } function M.to_cm(n, unit) return n * (M.K[unit] or error("unknown unit: " .. tostring(unit), 2)) end return M
使用方 require 它:
local metric = require("metric") print(metric.to_cm(3, "foot")) --> 91.44
require 执行模块文件、拿到它 return 的表,交给调用方。模块内部用 local M 聚拢公共接口、全部函数挂 M 上、私有辅助函数保持 local——公共面一张表,私有面全 local,这就是 Lua 模块范式的全部。没有 export 关键字、没有包声明、没有目录强制约定,文件即模块、返回即接口。
模块命名约定用点分层级:utils.net、resty.http。点不是文件分隔符的转义——require 会把点按搜索规则展开(通常是目录层级),require("utils.net") 大致找 utils 目录下的 net 文件。宿主可以重定义这个展开(OpenResty 把点映射到目录、点开头视为内嵌模块),机制后面讲。
四步里值得展开的是缓存与搜索。
缓存:package.loaded 是一张"模块名到返回值"的表。第一次 require 之后,后续 require 同名模块直接走缓存——模块只执行一次,这是语言级保证。设计上的含义:模块顶部的代码是天然的"单次初始化区",连接池、配置加载、常量字典放这里;反过来,想"每次拿新实例"就不要依赖 require,写显式的工厂函数(类模式的 new 就是这么来的)。
手动操作缓存是允许的,也是热更新的钥匙:
local m1 = require("policy") -- 首次加载并执行 package.loaded["policy"] = nil -- 摘除缓存 local m2 = require("policy") -- 重新执行:新代码(若文件已变) print(m1 == m2) -- false(两张不同的表)
搜索:package.path 是一串模板,分号分隔,问号是模块名占位符。默认值在常见环境下形如:
./?.lua;/usr/local/share/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?.lua
require("a.b") 会把点换成目录分隔、逐个模板套问号找文件。C 扩展走 package.cpath(.so 或 .dll)。环境变量 LUA_PATH 与 LUA_CPATH 可以改默认值——部署时不用改代码就能重定位脚本目录。找到的若是源码,读入并 load;若是 C 库,找 luaopen_模块名 入口调用(第 9 章 C API 一节接上)。
preload:package.preload[名字] = 函数,require 命中它就用函数造模块——不读文件、不走搜索。宿主用它预置内嵌模块:OpenResty 的 resty.core 一类点开头模块就是"虚拟文件"经 preload 或内嵌搜索器提供的;测试时也常用 preload 替身掉外部依赖。
军规一:local M 开局,return M 收尾。绝不用全局变量当模块接口(1.3 节的老账)。两个变体按需选:
-- 变体A:表当命名空间(本章主推) local M = {} function M.fn(...) ... end return M -- 变体B:单一函数模块(模块即工厂) return function(opts) ... end
军规二:依赖全部显式 require 到顶部。模块的依赖图要一眼可读:
local str = require("string") -- 标准库其实也能 require(不必要但合法) local net = require("app.net") local log = require("app.log")
循环依赖(A require B、B 又 require A)在 Lua 里有部分容忍——后加载的一方拿到的是"尚未 return 的未完成模块表"(缓存里登记的是执行中的占位),能引用到已定义的字段,引用未定义的是 nil。能解耦就解耦,实在循环就把公共部分抽到第三个模块。
军规三:模块只放定义,不放副作用。顶部执行连接、打日志、读环境的模块,会在 require 顺序里埋雷(谁先谁后行为不同)。初始化逻辑收进显式的 M.init(...) 函数,由入口调用——测试与热更新都因此受益。
⚠️ 常见坑:改了模块文件却"不生效"——十有八九是 package.loaded 缓存还在。开发期在入口加一行环境变量开启自动重载(交互模式的 REPL 每行新块不受影响,脚本形态要显式清缓存),线上则交给第 9 章的热更新机制统一处理。
寄宿视角看 require,三个现场各有一套玩法。
OpenResty:在配置里声明脚本搜索路径(lua_package_path 指令),worker 启动时把用到的模块加载进内存,之后每请求零 IO 调用模块函数——"常驻模块加每请求上下文"是它高性能的根基之一。点开头的模块名(如 ngx 内部的一批)由宿主内嵌提供,不落盘。
游戏引擎:脚本目录随资源包分发,require 的搜索路径指向资源包内的虚拟文件系统。MOD 场景更进一步:第三方 MOD 的模块名加前缀隔离(mod_a.hook 与 mod_b.hook 互不干扰),前缀隔离靠的就是 package.loaded 的键名天然分域。
Redis:干脆没有开放 require——EVAL 沙箱里脚本是自包含的,函数库靠 SCRIPT LOAD 预载整段脚本实现复用。"要不要给脚本模块能力"本身就是宿主的授权决策:给了灵活性,就要多管一条文件 IO 与加载顺序的边界。第 6.3 节的沙箱清单会再见到这笔账。
最后是一个诊断小工具,打印当前模块的加载来源,排查"到底加载了哪份文件":
local function where(modname) if package.loaded[modname] then return "cache" end if package.preload[modname] then return "preload" end local searched = {} local name = (modname:gsub("%.", "/")) for template in package.path:gmatch("[^;]+") do searched[#searched + 1] = template:gsub("%?", name) end return table.concat(searched, "\n") end print(where("app.policy")) -- 列出将按序尝试的每个候选文件路径(或 cache / preload)
require 之外还有两个加载入口,三者语义差异值得列表记牢:
| 维度 | require | dofile | load |
|---|---|---|---|
| 返回值 | 模块的返回值(缓存共享) | 块的执行结果 | 函数(调用才执行) |
| 缓存 | package.loaded 记账 | 无 | 无 |
| 搜索 | path/cpath 规则 | 显式路径 | 无(源码或字节码字符串) |
| 出错行为 | 抛出(含找不到) | 抛出 | 返回 nil 加错误串 |
local cfg = dofile("env-specific.lua") -- 每次都读盘执行,适合开发期配置 local f, err = load("return 1 + 1") -- 只编译不执行 print(f()) --> 2
分工很清晰:require 管正式依赖(缓存、单次、可管理),dofile 管显式文件(开发期反复加载、按环境挑文件),load 管动态代码(配置表达式、热更校验、沙箱装配)。三者在宿主手里各有妙用:Redis 的 EVAL 通道本质是"文本经 load 编译后执行";9.4 节热更的第一步"试编译校验"用的就是 load 的"只编译不执行"。
"模块只执行一次"意味着模块表天然是单例。要在同一模块里同时提供单例服务与可实例化类,把类做成模块表的字段:
-- pool.lua:模块单例 + 类多实例并存 local Pool = {} Pool.__index = Pool Pool.default = nil -- 模块级单例(惰性建) function Pool.new(size) return setmetatable({ size = size, items = {}, n = 0 }, Pool) end function Pool:get() return table.remove(self.items) -- 从池尾取一个 end function Pool:put(obj) if #self.items < self.size then self.items[#self.items + 1] = obj end end local M = { Pool = Pool } return M
使用方两个层级的取用方式:local pool_mod = require("pool") 拿单例世界,pool_mod.Pool.new(16) 造自己的实例。这是 Lua 库的常见布局——模块即命名空间、类是命名空间里的工厂(第 5.3 节末尾"类与模块同构"的实用版)。热更新时这个布局还有个好处:重载模块只换命名空间内容,已造出的实例挂在旧类表上不受影响,迁移窗口可控(9.4 节会回到这一点)。
模块化最直接的测试红利:依赖可以整体替换。package.preload 让"替身模块"在搜索真文件之前就位:
-- 测试环境装配:把外部依赖换成可控桩 package.preload["app.net"] = function() local calls = {} local M = {} function M.send(host, msg) calls[#calls + 1] = { host = host, msg = msg } return true, #calls end M._calls = calls -- 测试探针 return M end -- 被测代码(不知道自己拿到的是桩) local net = require("app.net") local ok = net.send("10.0.0.8", "ping") assert(ok and #net._calls == 1)
被测模块 require 依赖时命中 preload,拿到桩表——不打网络、不碰文件、行为可断言。同一手法用于"版本模拟"(preload 一个旧版接口测兼容层)与"故障注入"(桩按脚本返回错误)。配套的注意点:preload 登记要在被测代码加载之前完成;测试结束记得清掉登记(package.preload["app.net"] = nil),避免测试间串味——模块缓存与 preload 都是全局状态,测试隔离要自己负责。
项目大了以后"谁依赖谁"靠人脑记不住。require 的钩子位(package.loaded 的写入点)旁路一个记录器,就能自动生成依赖清单:
local loaded_order = {} local real_require = require -- 保存原函数(有的实现里 require 可直接换) -- 更稳的做法:包装搜索器或记录 loaded 表的变更 local function snapshot_deps() local list = {} for name in pairs(package.loaded) do if type(name) == "string" and not name:match("^package") then list[#list + 1] = name end end table.sort(list) return list end -- 启动完成后打一次清单,对比上次差异即可发现"悄悄加的依赖" local deps = snapshot_deps() print(table.concat(deps, "\n"))
轻量版只列"当前已加载"(如上),完整版记录"谁触发加载"(在模块顶部插桩 require("dep-tracker").mark(...))。CI 里对比两次构建的依赖清单,多出来的模块要么是合理的要么是泄漏——依赖图的可自省性,是第 9 章热更新判断"替换影响面"的输入。
下一节讲边界上的另一件大事:出了错,谁来兜底。