require 的工作方式和 Lua 的标准全局变量 package 有很强的关系。 package 是 require 函数对外曝露的信息与可配置项。关于 package 的说明会在后面补充。
从宏观流程上来说,require 加载模块的步骤可以分为以下:
- 搜索
- 加载
- 缓存并返回
搜索
require 会按顺序调用各个搜索器(searcher)来寻找模块。
--- modname:模块名
--- module:如果成功,返回所加载的内容,如果失败则为 nil
--- errmsg:第二个返回值,失败时返回,错误信息,通常是搜索过的路径
local module, errmsg = searcher(modname)
在 require 中,如果所有搜索器都没有返回模块,则会直接通过 error() 抛出错误。
搜索器
搜索器总共有四个:
- preload:预设加载器,它排在最前
- path:搜索
package.path(也就是 LUA_PATH)的搜索器
- cpath:搜索
package.cpath(也就是 LUA_CPATH)的搜索器
- cpath (sub-module):cpath 子模块搜索器,专门针对 C 模块中的子模块。
预加载在本质上是直接查询 package.preload 中的加载器。这个表中默认没有内容,它通常是用来修改指定模块的加载方式:例如说一个被静态到 Lua 解释器中的模块,那么可以将它的模块名提前放到 package.preload 中,对应一个加载器(loader),调用时返回一个模块。
path 搜索会将所有的 . 换成系统的路径分隔符,替换掉 LUA_PATH 中的 ?,以 ; 为分割,在路径列表里一个个地搜索。
这个路径的搜索函数是通用的,就是 package.searchpath,它传入两个字符串参数:1. 模块名,2. 路径列表。如果找到,则返回最终的文件名,如果找不到,则返回 nil 以及错误信息,一般为搜索过的路径历史。
例如搜索路径如下:
> package.path
./?.lua;./?/init.lua;/opt/homebrew/share/lua/5.4/?.lua;/opt/homebrew/share/lua/5.4/?/init.lua;/opt/homebrew/lib/lua/5.4/?.lua;/opt/homebrew/lib/lua/5.4/?/init.lua
结果:
> package.searchpath('cqueues', package.path)
/opt/homebrew/luarocks/share/lua/5.4/cqueues.lua
cpath 搜索与 path 搜索流程基本一致。在找到对应的 C 动态库后,lua 会拿出其中以 luaopen_ + 模块名 为名的函数。
加载
当 path 搜索找到文件之后,便调用 loadfile() 加载对应的文件,得到一个函数,调用并将返回值写入 package.loaded 后返回。这里有个小细节:如果它调用没有返回任何内容,对应的 key 值可能会被设置为 true,表示已经加载过了。
但因为 C 的函数名不能包含 .,在加载 C 语言子模块的时候,候补的 cpath 子模块搜索器会将第一个点之前的名称抽出,将模块名的 . 换成 _,再拼凑出新的函数名来搜索。
- 假设
local modname = 'cqueues.promise'
- 抽出主模块名
local main_name = 'cqueues'
- 搜索 C 模块
local modpath = package.searchpath('cqueues', package.cpath),得到模块路径
- 转换模块名,也就是得到
cqueues_promise
- 加载 C 函数
local loader = loadlib(modpath, 'luaopen_cqueues_promise')
调用得到的加载函数时,require 会将模块名作为第一个参数传入。
缓存
在 package.loaded 中存储着已经加载了的模块(module)。搜索的前置步骤会从这个表里找模块名。如果找到,便直接返回其中的模块,搜索流程不再会被触发。(这和表的 __index 元方法是同质的)
local module = loader(modname)
package.loaded[modname] = module
成功加载后,模块会被写入 package.loaded 中,下次再用相同的模块名调用 require 会因为 package.loaded 中已存在同名值,直接返回。所以,如果想要强制一个模块被重新加载,直接将其中对应的值设为 nil 即可。
模块名
模块名全小写是一种习惯,require 并没有对此作限制。而在搜索的时候,Lua 默认的文件系统搜索器也只是将 . 替换成配置好的路径分隔符。而当目的模块是 C 模块时,将子模块的 . 换成 _ 并调用对应的 C 函数。
所以,模块名能用什么字符,取决于下层的搜索器的特性。
回到 require 函数
require 函数本质上就是个普通的 Lua 函数,辅助对 loadfile 和 package.loadlib 的调用。
在需要更复杂的加载逻辑时,通常有这些方式来实现:
- 修改
package.searchers 的内容,写自己的搜索器。
- 使用一个新的函数包裹
require,在最后才回落到标准的 require。
- 重新写一个
重新写一个的场景,通常是因为要控制所加载的模块的全局变量表(但从 Lua 5.2 开始,没有全局,只有上值了)。
标准的 require 函数,其上值是启动时的 _ENV,也就是 _G,它已经在构造 require 的时候作为上值封入 require 中了,我们拿到的只是对它的引用,这是无法修改的。当然,对这个默认的上值设置元方法,把它改成一个代理表(proxy)也不是不行。