前言
村里的老人常说:"工程未动,日志先行。"
有效的利用日志,能够显著提高开发/debug效率,否则程序运行出现问题时可能需要花费大量的时间去定位错误位置和出错原因。
然而一个复杂的项目往往需要打印日志的地方比较多,除了控制日志数量之外,
如何做到有效区分重要信息,以及帮助快速定位代码位置,也是衡量一个工程日志质量的重要标准。
效果图
废话不多说,先看看我们的日志长啥样儿:
(图1)
通常日志信息中,除了包含需要显示的文本,同时应该包含执行时间、代码调用位置信息等。
在我这套系统中,还允许通过颜色区分显示不同日志等级的信息,这个在日志过多时可以让你迅速找到重要信息。
由上面的图1可以看到,4种级别的日志分别采用了不同的颜色显示,并且调用位置显示为程序路径文件名,可以直接点击蓝色的文件名跳转 到相应的代码行。
是不是十分方便? :D
而下面的 HomePage 则展示了该日志模块的另一种用法:
(图2)
接口设计
我们先来看一下接口代码:
Dart
/// Simple Log
class Log {
static const int kDebugFlag = 1 << 0;
static const int kInfoFlag = 1 << 1;
static const int kWarningFlag = 1 << 2;
static const int kErrorFlag = 1 << 3;
static const int kDebug = kDebugFlag|kInfoFlag|kWarningFlag|kErrorFlag;
static const int kDevelop = kInfoFlag|kWarningFlag|kErrorFlag;
static const int kRelease = kWarningFlag|kErrorFlag;
static int level = kRelease;
static bool colorful = false; // colored printer
static bool showTime = true;
static bool showCaller = false;
static Logger logger = DefaultLogger();
static void debug(String msg) => logger.debug(msg);
static void info(String msg) => logger.info(msg);
static void warning(String msg) => logger.warning(msg);
static void error(String msg) => logger.error(msg);
}
根据多年的项目经验,一般的项目需求中日志可以分为4个等级:
- 调试信息 (仅 debug 模式下显示)
- 普通信息
- 警告信息
- 错误信息 (严重错误,应收集后定时上报)
其中"调试信息"通常是当我们需要仔细观察每一个关键变量的值时才会打印的信息,这种信息由于太过冗余,通常情况下应该关闭;
而"告警信息"和"错误信息"则是程序出现超出预期范围或错误时需要打印的信息,这两类信息一般不应该关闭,在正式发布的版本中,错误信息甚至可能需要打包上传到日志服务器,以便程序员远程定位问题。
考虑到项目环境等因素,这里将具体的打印功能代理给 Log 类对象 logger 执行(后面会介绍)。
另外,根据 Dart 语言特性,这里还提供了 MixIn(混入)方式调用日志的接口,
通过 MixIn,还可以在打印日志的时候额外输出当前类信息:
Dart
/// Log with class name
mixin Logging {
void logDebug(String msg) {
Type clazz = runtimeType;
Log.debug('$clazz >\t$msg');
}
void logInfo(String msg) {
Type clazz = runtimeType;
Log.info('$clazz >\t$msg');
}
void logWarning(String msg) {
Type clazz = runtimeType;
Log.warning('$clazz >\t$msg');
}
void logError(String msg) {
Type clazz = runtimeType;
Log.error('$clazz >\t$msg');
}
}
使用方法也很简单(如上图2所示),先在需要打印日志的类定义中增加 ```with Logging```,
然后使用上面定义的 4 个接口 logXxxx() 打印即可:
Dart
import 'package:lnc/log.dart';
// Logging Demo
class MyClass with Logging {
int _counter = 0;
void _incrementCounter() {
logInfo('counter = $_counter');
}
//...
}
开发应用
首先以你项目需求所期望的方式实现 ```Logger``` 接口:
Dart
import 'package:lnc/log.dart';
class MyLogger implements Logger {
@override
void debug(String msg) {
// 打印调试信息
}
@override
void info(String msg) {
// 打印普通日志信息
}
@override
void warning(String msg) {
// 打印告警信息
}
@override
void error(String msg) {
// 打印 or 收集需要上报的错误信息
}
}
然后在 app 启动之前初始化替换 ```Log.logger```:
Dart
void main() {
Log.logger = MyLogger(); // 替换 logger
Log.level = Log.kDebug;
Log.colorful = true;
Log.showTime = true;
Log.showCaller = true;
Log.debug('starting MyApp');
// ...
}
代码引用
由于我已提交了一个完整的模块代码到 pub.dev,所以在实际应用中,你只需要在项目工程文件 ```pubspec.yaml``` 中添加:
dependencies: lnc: ^0.1.2
然后在需要使用的 dart 文件头引入即可:
Dart
import 'package:lnc/log.dart';
只有当你需要修改日志行为(例如上报数据)的时候,才需要编写你的 MyLogger。
全部源码
Dart
/* license: https://mit-license.org
*
* LNC : Log & Notification Center
*
* Written in 2023 by Moky <albert.moky@gmail.com>
*
* =============================================================================
* The MIT License (MIT)
*
* Copyright (c) 2023 Albert Moky
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
* =============================================================================
*/
/// Simple Log
class Log {
static const int kDebugFlag = 1 << 0;
static const int kInfoFlag = 1 << 1;
static const int kWarningFlag = 1 << 2;
static const int kErrorFlag = 1 << 3;
static const int kDebug = kDebugFlag|kInfoFlag|kWarningFlag|kErrorFlag;
static const int kDevelop = kInfoFlag|kWarningFlag|kErrorFlag;
static const int kRelease = kWarningFlag|kErrorFlag;
static int level = kRelease;
static bool colorful = false; // colored printer
static bool showTime = true;
static bool showCaller = false;
static Logger logger = DefaultLogger();
static void debug(String msg) => logger.debug(msg);
static void info(String msg) => logger.info(msg);
static void warning(String msg) => logger.warning(msg);
static void error(String msg) => logger.error(msg);
}
/// Log with class name
mixin Logging {
void logDebug(String msg) {
Type clazz = runtimeType;
Log.debug('$clazz >\t$msg');
}
void logInfo(String msg) {
Type clazz = runtimeType;
Log.info('$clazz >\t$msg');
}
void logWarning(String msg) {
Type clazz = runtimeType;
Log.warning('$clazz >\t$msg');
}
void logError(String msg) {
Type clazz = runtimeType;
Log.error('$clazz >\t$msg');
}
}
class DefaultLogger with LogMixin {
// override for customized logger
final LogPrinter _printer = LogPrinter();
@override
LogPrinter get printer => _printer;
}
abstract class Logger {
LogPrinter get printer;
void debug(String msg);
void info(String msg);
void warning(String msg);
void error(String msg);
}
mixin LogMixin implements Logger {
static String colorRed = '\x1B[95m'; // error
static String colorYellow = '\x1B[93m'; // warning
static String colorGreen = '\x1B[92m'; // debug
static String colorClear = '\x1B[0m';
String? get now =>
Log.showTime ? LogTimer().now : null;
LogCaller? get caller =>
Log.showCaller ? LogCaller.parse(StackTrace.current) : null;
int output(String msg, {LogCaller? caller, String? tag, String color = ''}) {
String body;
// insert caller
if (caller == null) {
body = msg;
} else {
body = '$caller >\t$msg';
}
// insert tag
if (tag != null) {
body = '$tag | $body';
}
// insert time
String? time = now;
if (time != null) {
body = '[$time] $body';
}
// colored print
if (Log.colorful && color.isNotEmpty) {
printer.output(body, head: color, tail: colorClear);
} else {
printer.output(body);
}
return body.length;
}
@override
void debug(String msg) => (Log.level & Log.kDebugFlag) > 0 &&
output(msg, caller: caller, tag: ' DEBUG ', color: colorGreen) > 0;
@override
void info(String msg) => (Log.level & Log.kInfoFlag) > 0 &&
output(msg, caller: caller, tag: ' ', color: '') > 0;
@override
void warning(String msg) => (Log.level & Log.kWarningFlag) > 0 &&
output(msg, caller: caller, tag: 'WARNING', color: colorYellow) > 0;
@override
void error(String msg) => (Log.level & Log.kErrorFlag) > 0 &&
output(msg, caller: caller, tag: ' ERROR ', color: colorRed) > 0;
}
class LogPrinter {
int chunkLength = 1000; // split output when it's too long
int limitLength = -1; // max output length, -1 means unlimited
String carriageReturn = '↩️';
/// colorful print
void output(String body, {String head = '', String tail = ''}) {
int size = body.length;
if (0 < limitLength && limitLength < size) {
body = '${body.substring(0, limitLength - 3)}...';
size = limitLength;
}
int start = 0, end = chunkLength;
for (; end < size; start = end, end += chunkLength) {
_print(head + body.substring(start, end) + tail + carriageReturn);
}
if (start >= size) {
// all chunks printed
assert(start == size, 'should not happen');
} else if (start == 0) {
// body too short
_print(head + body + tail);
} else {
// print last chunk
_print(head + body.substring(start) + tail);
}
}
/// override for redirecting outputs
void _print(Object? object) => print(object);
}
class LogTimer {
/// full string for current time: 'yyyy-mm-dd HH:MM:SS'
String get now {
DateTime time = DateTime.now();
String m = _twoDigits(time.month);
String d = _twoDigits(time.day);
String h = _twoDigits(time.hour);
String min = _twoDigits(time.minute);
String sec = _twoDigits(time.second);
return '${time.year}-$m-$d $h:$min:$sec';
}
static String _twoDigits(int n) {
if (n >= 10) return "$n";
return "0$n";
}
}
// #0 LogMixin.caller (package:lnc/src/log.dart:85:55)
// #1 LogMixin.debug (package:lnc/src/log.dart:105:41)
// #2 Log.debug (package:lnc/src/log.dart:50:45)
// #3 main.<anonymous closure>.<anonymous closure> (file:///Users/moky/client/test/client_test.dart:14:11)
// #4 Declarer.test.<anonymous closure>.<anonymous closure> (package:test_api/src/backend/declarer.dart:215:19)
// <asynchronous suspension>
// #5 Declarer.test.<anonymous closure> (package:test_api/src/backend/declarer.dart:213:7)
// <asynchronous suspension>
// #6 Invoker._waitForOutstandingCallbacks.<anonymous closure> (package:test_api/src/backend/invoker.dart:258:9)
// <asynchronous suspension>
// #? function (path:1:2)
// #? function (path:1)
class LogCaller {
LogCaller(this.name, this.path, this.line);
final String name;
final String path;
final int line;
@override
String toString() => '$path:$line';
/// locate the real caller: '#3 ...'
static String? locate(StackTrace current) {
List<String> array = current.toString().split('\n');
for (String line in array) {
if (line.contains('lnc/src/log.dart:')) {
// skip for Log
continue;
}
// assert(line.startsWith('#3 '), 'unknown stack trace: $current');
if (line.startsWith('#')) {
return line;
}
}
// unknown format
return null;
}
/// parse caller info from trace
static LogCaller? parse(StackTrace current) {
String? text = locate(current);
if (text == null) {
// unknown format
return null;
}
// skip '#0 '
int pos = text.indexOf(' ');
text = text.substring(pos + 1).trimLeft();
// split 'name' & '(path:line:column)'
pos = text.lastIndexOf(' ');
String name = text.substring(0, pos);
String tail = text.substring(pos + 1);
String path = 'unknown.file';
String line = '-1';
int pos1 = tail.indexOf(':');
if (pos1 > 0) {
pos = tail.indexOf(':', pos1 + 1);
if (pos > 0) {
path = tail.substring(1, pos);
pos1 = pos + 1;
pos = tail.indexOf(':', pos1);
if (pos > 0) {
line = tail.substring(pos1, pos);
} else if (pos1 < tail.length) {
line = tail.substring(pos1, tail.length - 1);
}
}
}
return LogCaller(name, path, int.parse(line));
}
}
GitHub 地址:
https://github.com/dimchat/sdk-dart/blob/main/lnc/lib/src/log.dart
结语
这里展示了一个高效简洁美观的 Flutter 日志模块,其中包含了"接口驱动"、"代理模式"、"混入模式"等设计思想。
在这里重点推介"接口驱动"这种设计思想,就是当你准备开发一个功能模块的时候,
首先要充分理解需求,然后根据需求定义接口(这时千万不要过多的考虑具体怎么实现,而是重点关注需求);然后再将具体的实现放到别的地方,从而达到接口与内部执行代码完全分离。
而使用者则无需关心你的内部实现,只需要了解接口定义即可。
这种设计思想,村里的老人们更喜欢称之为"干湿分离",希望对你有所帮助。 ^_^
如有其他问题,可以下载登录 Tarsier 与我交流(默认通讯录里找 Albert Moky / 章北海)