解決什麼問題

  • 快速定位日誌
  • 降低記錄成本
  • 提高代碼可讀性

日誌框架是項目開始階段應該最先搭建好的內容之一,有助於極大地節約以後解決問題的時間和成本。但這也是最讓我頭疼的問題之一,因為記日誌不光包括用什麼記、怎麼記,也包括記什麼內容,這恰恰是最容易被忽略的問題。

一條好的日誌需要做到能讓問題的跟蹤者快速定位它在程序中的位置且包含關鍵數據。工作中不乏這樣的團隊成員,在移交接口問題的時候沒有主動提供關鍵信息的意識,所謂關鍵信息,是指像問題發生的環境、接口名、傳遞的實參和返回結果這樣的內容,使得面向契約編程本來是很好的開發模式,卻得不到最好的應用。這時我們可以從自己接口的日誌中得到一些彌補。

但是日誌的記錄不應該佔用很大的代碼量,一來降低了代碼的可讀性,二來會耗費太多的時間精力。

本文目的在於討論一種日誌記錄的最佳實踐方式,使得可以兼顧以上這些問題。

實踐

這裡針對Laravel實現一個日誌工具類,實現以下功能:

  • 兼容Laravel自己的日誌系統
  • 自動記錄關鍵的業務數據
  • 自動記錄日誌所屬的接口名
  • 自動記錄接口的實參
  • 記錄接口的返回值
  • 生成簡潔規範的日誌內容

代碼:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<?php
namespace Ox3f\LaravelUtils\Log;
use Illuminate\Support\Facades\Log as LaravelLog;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
/**
* Class Log
* @author donie
*/
class Log
{
private static $instance;
private $id; // Identity of the log, username by default.
private $referer; // Request path for RESTful APIs, method name for ordinary class methods.
private $isHttp; // True for RESTful APIs, otherwise, false.
private $callStackParsed; // Whether call stack has been parsed.
private function __construct() {
$user = Auth::user();
$this->id = !empty($user->name) ? $user->name : 'anonymous';
}
private function __clone() {}
public static function getInstance() {
if (!self::$instance) {
self::$instance = new self;
}
return self::$instance;
}
/**
* Parse the call stack
*
* @return void
*/
private function parseCallStack() {
$traceInfo = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT|DEBUG_BACKTRACE_IGNORE_ARGS, 5);
$this->referer = '';
$this->isHttp = false;
foreach ($traceInfo as $callInfo) {
if ($callInfo['class'] != __CLASS__) {
if (preg_match('/Controller$/', $callInfo['class'])) {
$this->referer = Request::path();
$this->isHttp = true;
} else {
$this->referer = $callInfo['class'].$callInfo['type'].$callInfo['function'];
}
break;
}
}
$this->callStackParsed = true;
}
/**
* Wrapper of the laravel log facade
*
* @return void
*/
public static function __callStatic($name, $args)
{
if (!self::getInstance()->callStackParsed)
self::getInstance()->parseCallStack();
$id = self::getInstance()->id;
$referer = self::getInstance()->referer;
$msg = !empty($args) ? $args[0] : '';
LaravelLog::$name("{$id} | {$referer} | {$msg}");
self::getInstance()->callStackParsed = false;
}
/**
* Save parameters of the request or arguments of the method to log at debug level
*
* @param mixed $args Empty for HTTP calls, needed for ordinary class methods
* @return void
*/
public static function saveInput($args=null)
{
self::getInstance()->parseCallStack();
if (self::getInstance()->isHttp) $args = Request::except('_url');
self::debug('Input:'.json_encode($args));
}
/**
* Save the output to log at debug level
*
* @param mixed $result Result to be saved
* @return void
*/
public static function saveOutput($result)
{
self::getInstance()->parseCallStack();
self::debug('Output:'.json_encode($result));
}
}

這是一個單例類,核心在於parseCallStack()方法,通過debug_backtrace()函數獲取日誌所在的接口,對於REST接口,得到HTTP請求的路徑,對於接口類的方法,得到包含類名的接口名。此外,日誌中還會記錄當前的用戶名,方便定位和復現問題。

saveInput()saveOutput()是在此基礎上封裝的兩個高級方法,用於記錄接口的輸入和輸出,對於界定問題是否出在自己的接口或復現問題都有很重要的作用。對於REST接口,saveInput()可以自動獲取請求中的參數,而對於接口類的方法,出於性能和內存佔用考慮,沒有允許debug_backtrace()返回參數信息,需要用戶手動指定要記錄的數據。

使用方法

安裝

1
composer require xbot/laravel-utils

使用

1
2
3
4
5
6
use Ox3f\LaravelUtils\Log\Log;
Log::saveInput(); // REST接口中自動保存請求數據
Log::saveInput(func_get_args()); // 接口類的方法中保存實參
Log::error('This is an error.'); // 記錄一條錯誤日誌
Log::saveOutput($result); // 保存接口返回值

日誌示例

1
2
[2017-04-25 06:46:11] local.DEBUG: donie | users/groups/33 | Input:{"check":"1"}
[2017-04-25 06:46:11] local.ERROR: donie | users/groups/33 | This is an error.

待討論的問題

最佳實踐需要持續改進,以下問題有待討論:

關鍵業務數據中是否應該包含Request ID?

是否有必要對每次請求生成一個ID?這樣可以很簡單地過濾出一次請求中所有的日誌。