feat(debug): 增加 HTTP 请求响应日志
This commit is contained in:
1 parent
dc4b60270d
commit
7a588898fa
6 files changed
+534
No files matched your search
@@ -1,8 +1,11 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptors.dart';
|
||||
import 'network_log_interceptor.dart';
|
||||
|
||||
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
||||
export 'api_exception.dart';
|
||||
@@ -39,6 +42,7 @@ class ApiClient {
|
||||
deviceIdReader: deviceIdReader,
|
||||
),
|
||||
const EnvelopeInterceptor(),
|
||||
NetworkLogInterceptor(),
|
||||
]);
|
||||
if (adapter != null) {
|
||||
_dio.httpClientAdapter = adapter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'network_log_interceptor.dart';
|
||||
|
||||
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
||||
class ApiRequestFlags {
|
||||
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
final options = response.requestOptions;
|
||||
if (NetworkLogInterceptor.isEnabled) {
|
||||
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
|
||||
response.data;
|
||||
}
|
||||
final shell = _decodeShell(response.data);
|
||||
final code = shell['code'] as int? ?? -1;
|
||||
final message = shell['message'] as String?;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 调试构建下输出 Dio 请求、响应和异常详情。
|
||||
class NetworkLogInterceptor extends Interceptor {
|
||||
/// 在调试时关闭日志的本地常量开关。
|
||||
static const bool enabled = true;
|
||||
|
||||
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
|
||||
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
|
||||
|
||||
/// 请求、响应体单条日志最大字符数。
|
||||
static const int maxBodyLength = 3000;
|
||||
|
||||
static const String _truncatedSuffix = '…(truncated)';
|
||||
static const String _stopwatchKey = 'app.http.stopwatch';
|
||||
static const String _startedAtKey = 'app.http.startedAt';
|
||||
static const String _emptyBody = '<empty>';
|
||||
static const String _redacted = '***';
|
||||
|
||||
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
|
||||
NetworkLogInterceptor({void Function(String)? logger})
|
||||
: _logger = logger ?? debugPrint;
|
||||
|
||||
final void Function(String) _logger;
|
||||
|
||||
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
|
||||
static bool get isEnabled => kDebugMode && enabled;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(options);
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
options.extra[_stopwatchKey] = stopwatch;
|
||||
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
|
||||
final body = _formatRequestBody(options.data);
|
||||
final message = StringBuffer()
|
||||
..writeln('[HTTP] → ${options.method} ${options.uri}')
|
||||
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
|
||||
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
|
||||
..writeln('[HTTP] → body:$body');
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(response);
|
||||
}
|
||||
final options = response.requestOptions;
|
||||
final elapsed = _elapsedMilliseconds(options);
|
||||
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
|
||||
final body = _formatResponseBody(
|
||||
rawBody,
|
||||
response.headers.value(Headers.contentTypeHeader),
|
||||
);
|
||||
final message = StringBuffer()
|
||||
..writeln(
|
||||
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
|
||||
'${options.method} ${options.uri}',
|
||||
)
|
||||
..writeln('[HTTP] ← body:$body');
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(err);
|
||||
}
|
||||
final options = err.requestOptions;
|
||||
final message = StringBuffer()
|
||||
..writeln(
|
||||
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
|
||||
'${options.uri} ${err.message ?? ''}',
|
||||
)
|
||||
..write(_errorBodyLine(err));
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
int _elapsedMilliseconds(RequestOptions options) {
|
||||
final stopwatch = options.extra[_stopwatchKey];
|
||||
if (stopwatch is Stopwatch) {
|
||||
return stopwatch.elapsedMilliseconds;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
String _errorBodyLine(DioException error) {
|
||||
final rawBody =
|
||||
error.requestOptions.extra[rawResponseDataExtraKey] ??
|
||||
error.response?.data;
|
||||
if (rawBody == null) {
|
||||
return '';
|
||||
}
|
||||
final body = _formatResponseBody(
|
||||
rawBody,
|
||||
error.response?.headers.value(Headers.contentTypeHeader),
|
||||
);
|
||||
return '\n[HTTP] ✗ body:$body';
|
||||
}
|
||||
|
||||
String _formatHeaders(Map<String, dynamic> headers) {
|
||||
if (headers.isEmpty) {
|
||||
return '{}';
|
||||
}
|
||||
final entries = headers.entries.toList()
|
||||
..sort((left, right) => left.key.compareTo(right.key));
|
||||
final safe = <String, String>{
|
||||
for (final entry in entries)
|
||||
entry.key: _safeHeaderValue(entry.key, entry.value),
|
||||
};
|
||||
return _truncate(jsonEncode(safe));
|
||||
}
|
||||
|
||||
String _safeHeaderValue(String name, Object? value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (name.toLowerCase() == 'authorization') {
|
||||
return _maskAuthorization(text);
|
||||
}
|
||||
if (name.toLowerCase() == 'x-device-id') {
|
||||
return text;
|
||||
}
|
||||
return _redacted;
|
||||
}
|
||||
|
||||
String _maskAuthorization(String value) {
|
||||
if (value.length <= 8) {
|
||||
return _redacted;
|
||||
}
|
||||
return '${value.substring(0, math.min(8, value.length))}...';
|
||||
}
|
||||
|
||||
String _formatRequestBody(Object? body) {
|
||||
if (body == null) {
|
||||
return _emptyBody;
|
||||
}
|
||||
final json = _tryPrettyJson(body);
|
||||
if (json != null) {
|
||||
return _truncate(json);
|
||||
}
|
||||
final length = switch (body) {
|
||||
FormData data => data.length,
|
||||
List<int> bytes => bytes.length,
|
||||
_ => utf8.encode(body.toString()).length,
|
||||
};
|
||||
return '${body.runtimeType} ($length bytes)';
|
||||
}
|
||||
|
||||
String _formatResponseBody(Object? body, String? contentType) {
|
||||
if (body == null) {
|
||||
return _emptyBody;
|
||||
}
|
||||
if (body is List<int>) {
|
||||
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
|
||||
if (_isBinary(contentType)) {
|
||||
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
|
||||
}
|
||||
final text = utf8.decode(bytes, allowMalformed: true);
|
||||
return _formatText(text);
|
||||
}
|
||||
return _formatText(body.toString());
|
||||
}
|
||||
|
||||
String _formatText(String text) {
|
||||
if (text.isEmpty) {
|
||||
return _emptyBody;
|
||||
}
|
||||
return _truncate(_tryPrettyJson(text) ?? text);
|
||||
}
|
||||
|
||||
String? _tryPrettyJson(Object? body) {
|
||||
try {
|
||||
final value = body is String
|
||||
? jsonDecode(body)
|
||||
: body is List<int>
|
||||
? jsonDecode(utf8.decode(body, allowMalformed: true))
|
||||
: body;
|
||||
if (value is! Map && value is! List) {
|
||||
return null;
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBinary(String? contentType) {
|
||||
final type = contentType?.toLowerCase() ?? '';
|
||||
return type.startsWith('image/') ||
|
||||
type.startsWith('audio/') ||
|
||||
type.startsWith('video/') ||
|
||||
type.startsWith('font/') ||
|
||||
type.contains('application/octet-stream') ||
|
||||
type.contains('application/pdf') ||
|
||||
type.contains('application/zip');
|
||||
}
|
||||
|
||||
String _truncate(String value) {
|
||||
if (value.length <= maxBodyLength) {
|
||||
return value;
|
||||
}
|
||||
final end = maxBodyLength - _truncatedSuffix.length;
|
||||
return '${value.substring(0, end)}$_truncatedSuffix';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user