[lua][openresty]程式碼覆蓋率檢測的解決方式

葛雲飛發表於2018-12-20

廢話在前

什麼是程式碼覆蓋率

來自百度百科

程式碼覆蓋(Code coverage)是軟體測試中的一種度量,描述程式中原始碼被測試的比例和程度,所得比例稱為程式碼覆蓋率。

開發人員為何關注?

在我們的開發過程中,經常要用各種方式進行自測,或是各種 xUnit 系列,或是 postman,或是直接curl,在我們的程式碼交給 QA 同學之前,我們有必要知道自己的自測驗過了多少內容,在這種情況,程式碼覆蓋率就是一個重要的衡量指標。

openresty 中的程式碼覆率解決方案

我們如果想得到每一次執行的程式碼覆率,需要搞定兩件事情:

  1. 可以外在的記錄每一行的程式碼
  2. 在記錄的同時,可以知道這一行的程式碼上下文是什麼
  3. 更重要的是,我們需要儘可能的不動現有業務程式碼

對於第一點,lua的debug庫中有一個非常神奇的鉤子函式sethook,其官方文件如下:

debug.sethook ([thread,] hook, mask [, count])

Sets the given function as a hook. The string mask and the number count describe when the hook will be called. The string mask may have any combination of the following characters, with the given meaning:

  • 'c': the hook is called every time Lua calls a function;
  • 'r': the hook is called every time Lua returns from a function;
  • 'l': the hook is called every time Lua enters a new line of code.Moreover, with a count different from zero, the hook is called also after every count instructions.

When called without arguments, debug.sethook turns off the hook.
When the hook is called, its first argument is a string describing the event that has triggered its call: "call" (or "tail call"), "return", "line", and "count". For line events, the hook also gets the new line number as its second parameter. Inside a hook, you can call getinfo with level 2 to get more information about the running function (level 0 is the getinfo function, and level 1 is the hook function).

其中文翻譯大體如下:

將給定的方法設定為鉤子,引數maskcount決定了什麼時候鉤子方法被呼叫.引數mask可以是下列字元的組合:

  • 'c' 當lua開始執行一個方法時呼叫;
  • 'r' 當lua執行一個方法在返回時呼叫;
  • 'l' 當lua每執行到一行程式碼時呼叫.即lua從0開始執行一個方法的每一行時,這個鉤子都會被呼叫.

如果呼叫時不傳任何引數,則會移除相應的鉤子.當一個鉤子方法被呼叫時,第一個參數列明了呼叫這個鉤子的事件:"call"(或"tail call"),"return","line""count".對於執行程式碼行的事件,新程式碼的行號會作為第二個引數傳入鉤子方法,可以用debug.getinfo(2)得到其他上下文資訊.

在這個官方的說明裡,lua已經貼心的告訴我們使用方式————配合debug.getinfo,那麼debug.getinfo是什麼?其實我們在之前討論錯誤輸出時已經使用過這個方法,其官方文件如下:

debug.getinfo ([thread,] f [, what])

Returns a table with information about a function. You can give the function directly or you can give a number as the value of f, which means the function running at level f of the call stack of the given thread: level 0 is the current function (getinfo itself); level 1 is the function that called getinfo (except for tail calls, which do not count on the stack); and so on. If f is a number larger than the number of active functions, then getinfo returns nil.

The returned table can contain all the fields returned by lua_getinfo, with the string what describing which fields to fill in. The default for what is to get all information available, except the table of valid lines. If present, the option 'f' adds a field named func with the function itself. If present, the option 'L' adds a field named activelines with the table of valid lines.

For instance, the expression debug.getinfo(1,"n").name returns a name for the current function, if a reasonable name can be found, and the expression debug.getinfo(print) returns a table with all available information about the print function.

這個API的說明中文含義大體如下:

以table的形式返回一個函式的資訊,可以直接呼叫這個方法或是傳入一個表示呼叫堆疊深度的引數f,0表示當前方法(即getinfo本身),1表示呼叫getinfo的方法(除了最頂層的呼叫,即不在任何方法中),以此類推。如果傳入的值比當前堆疊深度大,則返回nil.

返回的table內欄位包含由lua_info返回的所有欄位。預設呼叫會除了程式碼行數資訊的所有資訊。當前版本下,傳入'f'會增加一個func欄位表示方法本身,傳入'L'會增加一個activelines欄位返回函式所有可用行數。

例如如果當前方法是一個有意義的命名,debug.getinfo(1,"n").name可以得到當前的方法名,而debug.getinfo(print)可以得到print方法的所有資訊。

OK,有了這兩個方法,我們的思路就變得很清析了:

  1. 在生命週期開始時註冊鉤子函式.
  2. 將每一次呼叫情況記錄彙總.

這裡有一個新的問題,就是,我們的彙總是按呼叫累加還是隻針對每一次呼叫計算,本著實用的立場,我們是需要進行累加的,那麼,需要使用ngx.share_dict 來儲存彙總資訊.

基於以上考慮,封裝一個libs/test/hook.lua檔案,內容如下:

local debug = load "debug"
local cjson = load "cjson"
local M = {}
local mt = { __index = M }
local sharekey = 'test_hook'
local cachekey = 'test_hook'
function M:new()
    local ins = {}
    local share = ngx.shared[sharekey]
    local info ,ret = share:get(cachekey)
    if info then
        info = cjson.decode(info)
    else
        info = {}
    end
    ins.info = info
    setmetatable(ins,mt)
    return ins
end
function M:sethook ()
    debug.sethook(function(event,line)
        local info = debug.getinfo(2)
        local s = info.short_src
        local f = info.name
        local startline = info.linedefined
        local endline = info.lastlinedefined
        if  string.find(s,"lualib") ~= nil then
            return
        end
        if self.info[s] == nil then
            self.info[s]={}
        end
        if f == nil then
            return 
        end
        if self.info[s][f] ==nil then
            self.info[s][f]={
                start = startline,
                endline=endline,
                exec = {},
                activelines = debug.getinfo(2,'L').activelines
                }
        end
        self.info[s][f].exec[tostring(line)]=true

    end,'l')
end
function M:save()
     local share = ngx.shared[sharekey]
     local ret = share:set(cachekey,cjson.encode(self.info),120000)
end
function M:delete()
     local share = ngx.shared[sharekey]
     local ret = share:delete(cachekey)
     self.info = {}
end
function M:get_report()
    local res = {}
    for f,v in pairs(self.info) do
        item = {
            file=f,
            funcs={}
        }
        for m ,i in pairs(v) do
                local cover = 0
                local index = 0
                for c,code in pairs(i.activelines) do
                    if i.activelines[c] then
                        index = index + 1
                    end
                    if i.exec[tostring(c)] or i.exec[c] then
                        cover = cover +1
                    end
                end
                item.funcs[#item.funcs+1] = { name = m ,coverage=   string.format("%.2f",cover / index*100 ) .."%"}
        end
        res[#res+1]=item
   end
   return res
end
return M

這樣,我們只需要在content_by_lua的最開始加上:

local hook = load "libs.test.hook"
local test = hook:new()
test:sethook()
--other code ..

在最末加上:

test:save()

即可統計程式碼覆蓋率。

是的,沒錯,我們至今只增加了4行業務程式碼

但是統計了,應該怎麼進行輸出呢?

加個介面好了:)

因為現在lor用的多,所以,乾脆加個lor的路由檔案(libs/test/lorapi.lua):

local hook = require 'libs.test.hook'
local router =  lor:Router ()
local M = {}
router:get('/test/coverage/json-report',
function(req,res,next)
    local t = hook:new()
    res:json(t:get_report())
end)
router:get('/test/coverage/txt-report',
function(req,res,next)
    local t = hook:new()
    local msg = "Report"
    local rpt = t:get_report()
    for i ,v in pairs(rpt) do
        msg =msg.."\r\n"..v.file
        for j,f in pairs(v.funcs) do
            msg = msg .."\r\n\t function name:" .. f.name .."\tcoverage:"..f.coverage
        end
    end
    msg =msg .."\r\nEnd"
    res:send(msg)
end)
return router

這樣,在我們的lor路由檔案里加個requre,加個use,兩行改動,而且是增加!!就達到我們的需求,檢查程式碼的覆蓋率.

相關文章