6.1 require 与模块加载


文档摘要

6.1 require 与模块加载 本节摘要:模块是 Lua 组织代码的单位——一张带公共接口的表。require 的加载协议四步:查 package.loaded 缓存、按 package.path 搜索、执行模块块、登记缓存并返回。缓存防止重复初始化,也天然挡住"重载"——热更新要动的第一颗螺丝就是它。本节讲透协议、给出模块的标准写法,并从寄宿视角看模块边界:宿主如何预载模块、如何用路径配置划分脚本领地。 拼装术 脚本大到一定程度就要拆文件。Lua 的拆法极简:一个模块文件 = 一个块 + 一张返回的表。先看最短的模块: 使用方 require 它: 执行模块文件、拿到它 return 的表,交给调用方。

6.1 require 与模块加载

本节摘要:模块是 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.netresty.http。点不是文件分隔符的转义——require 会把点按搜索规则展开(通常是目录层级),require("utils.net") 大致找 utils 目录下的 net 文件。宿主可以重定义这个展开(OpenResty 把点映射到目录、点开头视为内嵌模块),机制后面讲。

require 的四步协议

四步里值得展开的是缓存搜索

缓存: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.hookmod_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、dofile、load:三种加载的三种语义

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 节会回到这一点)。

用 preload 做测试替身

模块化最直接的测试红利:依赖可以整体替换。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 章热更新判断"替换影响面"的输入

本节要点回顾

  • 模块范式:一个文件一个块、local M 聚拢公共面、私有全 local、return M 收接口;没有 export 与包声明;
  • require 四步:loaded 缓存 → preload → path/cpath 搜索 → 执行并登记;模块保证只执行一次,顶部是天然单次初始化区;
  • package.loaded 可手动摘除,清缓存再 require 即重载——热更新的第一颗螺丝;
  • 搜索规则:path 模板分号分隔、问号占位、点分层级;LUA_PATH 环境变量重定位;C 扩展走 cpath 的 luaopen 入口;
  • 三种加载三语义:require 管依赖(缓存单次)、dofile 管显式文件(每次执行)、load 管动态代码(只编译);
  • 单例与类并存:模块即命名空间、类是其中的工厂——热更迁移窗口因此可控;
  • 三条军规:local M 开局、依赖显式置顶、模块不放副作用;循环依赖能解耦就解耦;
  • 宿主的模块领地:OpenResty 常驻模块零请求 IO、游戏靠虚拟文件系统与 MOD 前缀隔离、Redis 直接没收 require——授权程度就是寄宿深度。

下一节讲边界上的另一件大事:出了错,谁来兜底。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U