• Lua 模块与包


    一、什么是模块

    模块就是一些代码(可以是 Lua 编写的,也可以是 C 语言编写的),这些代码可以通过函数 require 加载,然后创建和返回一个表,这个表就类似命名空间。

    所有的标准库都是模块,例如 mathstring 模块

    使用表来承载模块,有很显著的优点,可以像操作普通表一样操作模块,而且能利用 Lua 语言的所有功能实现额外的功能。

    例如引入 math 模块

    -- 两种书写方式都可以使用
    local math = require "math"
    --local math = require("math")
    
    -- sin 使用的是弧度,不是角度
    print(math.sin(3.14))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    也可以直接引入模块中的一个函数,例如以下代码

    直接引入模块中的函数,实际上只是省去了模块这一中间变量,从加载的模块 table 中获取相应的 value

    -- 引用模块中的某个函数
    -- 等同于 require("math").sin
    -- 此时 require("math") 获取到了引入表,`.sin` 即从表中获取了对应的值 value ,此时为一个函数
    local sin = require "math".sin
    print(sin(3.14))
    
    • 1
    • 2
    • 3
    • 4
    • 5

    二、require(modname)

    Lua 通过 require(modname) 函数进行加载模块,modname 为需要加载的模块名(字符串类型)。

    0、require 函数加载模块的流程

    require 会先从 package.loaded 中获取,如果没有找到相应模块,则进入根据搜索器列表 package.searches 中设置的搜索器按顺序进行查找。

    package.searches 默认内置了 4 个搜索器,按顺序分别为 预加载搜索器Lua 搜索器C 标准库搜索器C 库子模块搜索器

    假设我们使用了 require('A') 进行加载 A 模块,会进行以下加载步骤:

    1、第一步:会在 package.loaded 中检查模块 “A” 是否已经存在,如果存在则会将其返回,不存在则进入第二步骤

    package.loaded 是一个 table , 存储着加载成功的模块,以模块名为 key ,模块返回结果为 value 的形式存放。

    如果 package.loaded 不存在对应的模块,则会进入到后续的步骤进行搜索,无论后续的哪一步骤让模块加载成功,都会将模块的返回值(该返回值类型可以是 function 、 table 等数据类型)作为 value 和加载的模块名(例如这里的 A )为 key ,以 key-value 的形式存放到 package.loaded table 中。如果模块没有返回值,则会用 true 代替返回值,从而达到不会每次加载相同模块都需要运行一次加载流程。

    举个例子

    加载两个模块,然后通过打印 package.loaded 查看已经加载的模块

    print("package.loaded 已经加载的模块:")
    
    -- 获取当前 lua 的文件夹路径
    local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
    -- 设置加载模块路径
    package.path = package.path .. ";" .. currentPath .. "../?.lua"
    
    require("一个合理的模块")
    require("module.sub")
    
    for path, package in pairs(package.loaded) do
        print("----- 模块【" .. path .. "】包含的属性:-----")
        if type(package) == "table" then
            for key, value in pairs(package) do
                print(key, "---", value)
            end
        else
            print(path, "---", package)
        end
        print("--------------------------------")
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    打印的内容会比较多,因为 Lua 默认加载的函数也会在其中,但在众多的输出中,可以找到加载的 “一个合理的模块” 和 “module.sub” 模块(见下图)

    “一个合理的模块” 模块会返回一个 table ,所以会将 table 存储在 package.loaded 表中(这里输出的便是存储的内容)。

    具体代码可以进入 github 查看 https://github.com/zincPower/lua_study_2022/blob/master/11%20%E6%A8%A1%E5%9D%97%E5%92%8C%E5%8C%85/%E4%B8%80%E4%B8%AA%E5%90%88%E7%90%86%E7%9A%84%E6%A8%A1%E5%9D%97.lua

    “module.sub” 模块则没有返回值,所以 Lua 会默认返回 true ,将其存储在 pacakge.loaded 表中(从输出的内容也可以验证这一点)。

    具体代码可以进入 github 查看 https://github.com/zincPower/lua_study_2022/blob/master/11%20%E6%A8%A1%E5%9D%97%E5%92%8C%E5%8C%85/module/sub.lua

    2、第二步:在 “预加载搜索器” 中使用 package.preload 查找是否有对应加载函数,如果有则会将加载函数返回,否则进入第三步骤

    package.preload 也是一个 table ,只是他的 value 必须是一个加载函数。

    会根据 require 传入的模块名,在 preload 中查询,如果找到对应的 key ,则调用 value(是一个函数),会将请求的模块名和加载的来源(这里是通过预加载器,即 preload)传递给 value 函数,最后会将该函数的返回值作为模块的返回值存储在第一步提到的 package.loaded 中, 方便后续加载相同的模块。

    可以运行代码,通过调用 showLoadedModule 函数,感受这一过程

    print("package.preload:")
    
    local function showLoadedModule()
        for path, package in pairs(package.loaded) do
            print("----- 模块【" .. path .. "】包含的属性:-----")
            if type(package) == "table" then
                for key, value in pairs(package) do
                    print(key, "---", value)
                end
            else
                print(path, "---", package)
            end
            print("--------------------------------")
        end
    end
    
    package.preload["testModule"] = function(name, source)
        print("加载函数", name, source)
        return { name = "江澎涌" }
    end
    
    do
        for k, v in pairs(package.preload) do
            print(k, "-->", v)
        end
    end
    --> package.preload:
    --> testModule	-->	function: 0x6000015f4de0
    
    --showLoadedModule()
    
    --- 会调用到 preload 的加载函数,加载完会将 testModule 的返回值放入到 loaded 中
    require("testModule")
    --> 加载函数	testModule	:preload:
    
    --showLoadedModule()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    3、第三步:在 “Lua 搜索器” 中会使用 package.path 查找对应的加载模块文件,如果找到则会使用 loadfile 对其加载,否则进入第四步

    package.path 是一个字符串,字符串内部由一个个路径拼凑而成,这些路径表明在哪里进行查找我们需要的 Lua 文件

    我们可以通过下面代码进行输出 package.path 路径

    print("package.path: ", package.path)   --> package.path: 	/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua
    
    • 1

    具体的如何使用 package.path 进行搜索,看下面的 “搜索路径” 小节

    举个例子:

    我们要加载两个名为 “一个合理的模块” 和 “module.sub” 自己编写的模块,则需要将他们存放的目录路径设置给 package.path 否则会查找不到

    如何编写一个合理的模块,可以查看下面的 “模块的编写” 小节。

    debug.getinfo(1, "S").source:sub(2):match("(.*/)") 是为了获取当前执行的 Lua 文件所在的文件夹路径。debug 的使用后续会有详细的文章分享。

    可以运行代码,通过调用 showLoadedModule 函数,感受这一过程

    print("package.path:")
    
    local function showLoadedModule()
        for path, package in pairs(package.loaded) do
            print("----- 模块【" .. path .. "】包含的属性:-----")
            if type(package) == "table" then
                for key, value in pairs(package) do
                    print(key, "---", value)
                end
            else
                print(path, "---", package)
            end
            print("--------------------------------")
        end
    end
    
    local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
    package.path = package.path .. ";" .. currentPath .. "../?.lua"
    
    --showLoadedModule()
    
    require("一个合理的模块")
    -- 会拆解为 module/sub
    require("module.sub")
    
    --showLoadedModule()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    4、第四步:在 “C 标准库搜索器” 中会使用 package.cpath 搜索对应的 C 标准库,如果查找到了,则会使用 package.loadlib 进行加载,底层函数会查找名为 luaopen_modname 的函数

    package.cpath 是一个字符串,字符串内部由一个个路径拼凑而成,这些路径表明在哪里进行查找我们需要的 C 标准库

    print("package.cpath: ", package.cpath)     --> package.cpath: 	/usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.so
    
    • 1

    具体如何使用,后续 " C++ 中使用 Lua 的系列文章" 会进行分享

    5、第五步:在 “C 库子模块搜索器” 中会使用 package.cpath 搜索对应的 C 标准库

    和第四步的差异在于,“C 库子模块搜索器” 用于处理加载包含子模块的情况,具体的规则可以查看最后的 “子模块” 小节,如何使用在后续的 " C++ 中使用 Lua 的系列文章" 会进行分享

    值得注意的是第四步和第五步都是对于 C 库的处理,只是考虑的情况不同。

    6、一图胜千言

    针对这一流程,我手绘了一下看看是否能达到一图胜千言了

    三、模块

    1、入参

    模块文件会接收到两个参数,可以通过 ... 获取

    • 第一个入参:模块名
    • 第二个入参:该文件所在的路径

    例如加载一个模块为 被加载的模块.lua 的文件

    --- 注意不要有 `.lua`
    require("被加载的模块")
    
    • 1
    • 2

    在模块中,输出 ... 便可看到入参

    print(...)            --> 被加载的模块	/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/调用模块/被加载的模块.lua
    
    • 1

    Lua 的模块加载不提供传递自定义参数,所以如果需要同一模块不同表现,就需要在模块内部自行处理,例如暴露函数初始化。

    2、返回值

    require 的本质是将模块的结果以模块名为键放到 package.loaded 表中,这样做的目的是:为了下一次获取统一模块时可以返回同样的返回值,而且也标记了已经加载过了(未加载为 nil)。

    有两种方式可以将设置模块的返回值:

    第一种: 在模块最末尾用 return 的方式,将结果返回,一般是一个表结构(当然也可以是其他的类型)。require 函数会将结果存到 package.loaded 表中。

    第二种: 直接将结果存入 package.loaded 表中,表的 key 就用模块名(通过 ... 获取),所以简单方式就是 package.loaded[...] (会舍弃掉 ... 的第二个参数)。用这种方式,就可以不用 return 了,但是模块会默认返回 true ,作为模块已经被加载过的标记(因为并不是每个模块都会自行设置 package.loaded[...])。但是值得注意的是 required 在返回最终的值时会检测 package.loaded[模块名] 是否已经有值了,有的话则直接放回我们在前自己手动设置的值,而舍弃 true 这一默认值;否则保存 true 这样就可以标记该模块已经被加载了。

    返回值的表述会比较绕,可以移步代码,运行一下 调用模块.lua 代码,对比一下加载 被加载的模块.lua被加载没有返回值的模块.luapackage.loaded 表现就很清楚。

    举个例子

    下面两种方式效果是一样的

    -- 第一种方式
    return {
        name = name,
        foo = foo
    }
    
    -- 第二种方式
    package.loaded[...] = {
        name = name,
        foo = foo
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    3、删除已加载模块

    从模块加载流程中得知,已加载的模块结果会被存放在 package.loaded 中,而 package.loaded 是一个表,key 就是模块名。从之前分享的 “ Lua 数据类型 —— 表” 一文中,知道删除一个元素就是将其赋值为 nil 。

    所以综上所述,我们就可以用这样的方法删除已经加载的模块

    package.loaded.modname = nil
    
    -- 或
    package.loaded[modname] = nil
    
    • 1
    • 2
    • 3
    • 4

    4、搜索路径

    Lua 的所有搜索路径中,都是一组模版,每个模版间用 “;” 连接。

    每个模版都会使用 “模块名” 替换 ? ,然后检查文件是否存在,如果不存在,就检查下一个模版,直到所有的模版都被检查完,如果还没有找到相应文件就会返回两个值 “nil” 和 “错误信息(已经搜索过的路径)”

    举个例子:

    假设我们的搜索路径是如下内容

    ?;?.lua;/usr/local/lua/?/?.lua
    
    • 1

    此时调用 require "user" , 则会在以下路径中查询相应的文件:

    user
    user.lua
    /usr/local/lua/user/user.lua
    
    • 1
    • 2
    • 3
    4-1、package.path 和 package.cpath 的区别

    经过第一小节,聪明的你其实已经知道他们的区别了,上面的规则适用于这两种路径

    • Lua 文件的搜索路径是 package.path

    • C 标准库的搜索路径是 package.cpath

    4-2、搜索路径的初始化

    在 package 模块初始化后,Lua 会从几个地方尝试赋值 package.path :

    1. 会先检查是否有环境变量 LUA_PATH_5_4(后面的是版本,因为我现在使用的的版本是 lua 5.4.4 ),如果有则会将其值复制给 package.path,如果没有则执行第二点;
    2. 检查环境变量 LUA_PATH 是否存在,有的话则赋值给 package.path,否则 Lua 会使用一个编译时定义的默认路径。

    对于 package.cpath 也是一样的逻辑,只是是从 LUA_CPATH_5_4LUA_CPATH 中获取。值得注意对于 C 库,不同平台的后缀会有不同。 例如在 POSIX 使用的是 .so 后缀,而 Windows 使用的是 .dll 后缀。

    在使用终端的交互模式中,如果想要使用默认路径,可以使用 lua -E 来启动一个交互模式。

    在环境变量的设置中,可以使用 ;; 表示默认路径, 例如 model/?.lua;; 则最后会表示为在 model/?.lua 和默认路径中进行搜索。

    5、搜索器

    require 函数内部其实是通过一个个搜索器来实现的,而所有的搜索器存储在 package.seachers 中。

    Lua 内置了四个搜索器,按顺序依次是:

    1. 预加载搜索器,从 package.preload 的表中搜索,这个表存储的是 “模块名->加载函数” 。能够为要加载的模块定义任意的加载函数,提供了一种通用的方式。
    2. Lua 文件搜索器
    3. C 标准库搜索器
    4. C 库子模块搜索器
    --- 第一个是预加载搜索器
    --- 第二个是 Lua 搜索器
    --- 第三个是 C 搜索器
    --- 第四个是 C 库子模块搜索器
    for k, v in pairs(package.searchers) do
        print(k, "-->", v)
    end
    --> 1	-->	function: 0x600003ac44e0
    --> 2	-->	function: 0x600003ac4510
    --> 3	-->	function: 0x600003ac4540
    --> 4	-->	function: 0x600003ac4570
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    如果所有的搜索器都被调用完还找不到加载函数,则 require 会抛出异常

    5-1、自定义搜索器

    搜索器其实是一个以模块名为参数,以对应模块的加载器或 nil(如果找不到加载器)为返回值的简单函数。

    举个例子

    自定义一个搜索器,这里无论加载什么模块都是返回同一个加载器。搜索器内部都会加载 “被搜索器加载的文件.lua” 文件。

    -- 设置自定义搜索器
    package.searchers[#package.searchers + 1] = function(moduleName)
        print("moduleName: ", moduleName)
        local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
        return loadfile(currentPath .. "被搜索器加载的文件.lua")
    end
    
    local module = require("不能存在的模块")
    print(module, module.name)
    --> moduleName: 	不能存在的模块
    --> ==================================
    --> 进入模块
    --> 模块入参:	不能存在的模块	nil
    --> table: 0x60000362cb80	江澎涌
    
    local module1 = require("不能存在的模块2")
    print(module1, module1.name)
    --> moduleName: 	不能存在的模块2
    --> ==================================
    --> 进入模块
    --> 模块入参:	不能存在的模块2	nil
    --> table: 0x60000362ccc0	江澎涌
    
    -- 因为 “不能存在的模块2” 模块在上面已经加载过了,所以就不会在加载了,可以拿到上面加载的结果直接运行
    local module2 = require("不能存在的模块2")
    print(module2, module2.name)
    --> table: 0x60000362ccc0	江澎涌
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    自定义搜索器可以实现一些特殊模块的搜索规则,例如在 zip 中的模块。

    加载器可以理解为一个函数,通过他可以获取到模块的结果,进而保存至 package.loaded 中,达到缓存模块结果,不需要多次加载同一个模块。(这期间的编译是很消耗性能)

    6、模块重命名

    加载模块中,难免会遇到命名冲突的问题。

    如果是 Lua 文件的话,比较好处理,只需要重新命名一下避开就行。

    如果是 C 标准库的话,就没办法去改 luaopen_xxx 的函数,所以提供了一种 “连字符” 处理方式。就是一个模块包含连字符的话,require 函数只会用连字符之前的内容来创建 luaopen_xxx 的名称。所以只要将其中一个模块名称更改为携带版本即可,在寻找 luaopen_xxx 则会将版本去除后进行查找。

    举个例子:

    如果模块名为 mod-v1.0 ,执行 require "mod-v1.0" 打开的是 luaopen_mod 函数,而不是 luaopen_mod-v1.0

    四、package.searchpath(name, path, sep, rep)

    在给定路径 path 中搜索给定名称 name 。

    路径是一个字符串,包含一个由分号分隔的模版(规则和 “搜索路径” 一样),尝试打开处理后的文件名。

    参数:

    • name:要搜索的模块名
    • path:搜索的路径
    • sep:模块名中需要被替换的字符,默认为 “.”
    • rep:替换字符所用的字符,默认为系统分隔符

    返回值:

    • 如果找到了就会返回查找到的文件的完整路径
    • 如果没有找到返回两个值 nil 和没有成功的错误信息

    举个例子:

    如果路径是字符串 “./?.lua;./?.lc;/usr/local/?/init.lua” 搜索名称 foo.a 将 尝试按顺序打开文件./foo/a.lua./foo/a.lc/usr/local/foo/a/init.lua

    local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
    local path = "?.lua;"..currentPath .. "?.lua"
    
    print(package.searchpath("module!sub", path, "!", "@"))
    --> nil	no file 'module@sub.lua'
    -->	    no file '/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/module@sub.lua'
    
    print(package.searchpath("module.sub", path))
    --> /Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/module/sub.lua
    
    print(package.searchpath("模块", path))
    --> nil	no file '模块.lua'
    --> 	no file '/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/模块.lua'
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    五、模块的编写

    定义一个合理的模块可以遵循以下几点:

    1. 将模块内部的变量和函数都声明为 local ,这样可以达到类似 java、kotlin 的 private 变量或函数,避免和全局的冲突或为后续的代码带来问题
    2. 模块的返回一般为 table ,然后将需要给外部调用的函数设置在 table 中,可以达到 java、kotlin 的 public 变量或函数

    模块不是规定要返回 table ,可以选择任意的数据类型,也可以没有返回值

    举个例子:

    创建一个 “一个合理的模块.lua” 的文件,内容如下:

    local man = {}
    
    function man.sayHello()
        print("Hello.")
    end
    
    man.name = "jiang pengyong"
    
    local age = 29
    man.age = age
    
    local function showInfo()
        print("My name is " .. man.name .. "." ..
                "I'm " .. man.age .. " years old.")
    end
    man.showInfo = showInfo
    
    --- 第一种返回值的方式
    return man
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    然后加载这个文件:

    package.path = package.path..";/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/?.lua"
    local module = require("一个合理的模块")
    print(module.name)      --> jiang pengyong
    module.showInfo()       --> My name is jiang pengyong.I'm 29 years old.
    
    • 1
    • 2
    • 3
    • 4

    模块的返回值并不一定要通过 return 来返回,可以通过 package.loaded[...] = xxx 进行设置返回值。(上面 “返回值” 一节中提及) 因为前面有提及到,模块会有两个入参,第一个就是模块名称,第二个是加载函数所在文件的名称,这种方式则是直接给 loaded 表设置返回值,而 [...] 则是取第一个参数,即模块名称。

    所以使用第二种返回值的方式如下所示:

    local man = {}
    
    function man.sayHello()
        print("Hello.")
    end
    
    man.name = "jiang pengyong"
    
    local age = 29
    man.age = age
    
    local function showInfo()
        print("My name is " .. man.name .. "." ..
                "I'm " .. man.age .. " years old.")
    end
    man.showInfo = showInfo
    
    --- 第二种返回值的方式
    package.loaded[...] = man
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    六、子模块

    1、如何搜索 Lua 编写的子模块

    Lua 支持具有层次结构的模块名,通过点来分隔名称中的层次。

    例如 module.submodule 的子模块,而多个模块组成的树则叫做包。

    当我们 require("module.sub") 搜索一个带有子模块的文件时,Lua 会进行以下步骤:

    1. 直接使用 module.sub 作为 key ,在 package.loadedpackage.preload 中搜索是否有对应的 value(注意此时 “module.sub” 的 “.” 不会被转换为其他的字符)。
    2. 如果 1 没有搜索到,则会将 module.sub. 转为系统对应的目录分隔符(如果是 mac )则转为 /,此时就变为 module/sub ,然后进行 “搜索路径” 小节的规则进行替换,然后进行查找文件。

    这个分隔符的替换,是在 Lua 编译时配置的,可以是任意字符串。

    值得注意的是,如果子模块加载成功,在 package.loaded 其保存的 key 值是 module.sub , 而不是 module 不是 sub 也不是 module/sub

    2、如何搜索 C 编写的子模块

    如果是 C 编写的子模块,因为 C 函数不能带有 . ,则在调用 luaopen 函数时,则会将 . 转为 _

    所以在经过了 “Lua 搜索器” 和 “C 搜索器” 都加搜索不到相应的文件时,会进入到 “第四个搜索器 —— C 库子模块搜索器” 中。

    如要通过 require("module.sub") 加载 C 模块,“C 库子模块搜索器” 会在 package.cpath 中搜索是否有 module 的 C 标准库,如果找到了对应的库,则会搜索是否存在 luaopen_module_sub 函数,有则进行执行,然后将结果存储。

    这里也就回应了上面 “第五步” 的问题了。

    3、子模块的关联

    对于同一包而言,加载一个子模块并不会将整个包的模块都加载,如果子模块有需要,该模块需要自己去创建这种联系。

    七、写在最后

    Lua 项目地址:Github传送门 (如果对你有所帮助或喜欢的话,赏个star吧,码字不易,请多多支持)

    如果觉得本篇博文对你有所启发或是解决了困惑,点个赞或关注我呀

    公众号搜索 “江澎涌”,更多优质文章会第一时间分享与你。

  • 相关阅读:
    C#使用ICSharpCode.TextEditor制作代码编辑器
    MySQL常用函数
    mysql command not found 找不到mysql命令
    研究告诉你,晨练好,还是晚练好?看看你运动的方式是正确的吗
    vivo 基于 JaCoCo 的测试覆盖率设计与实践
    nginx 同一个端口支持http和https配置
    GEEM2引擎微端架设基本教程
    记一次服务器异常掉电,导致HBase Master is initializing 问题处理
    专利变更需要哪些材料
    【iOS开发】—— weak底层原理
  • 原文地址:https://blog.csdn.net/weixin_37625173/article/details/132631999