请求日志: 显示响应体 + 新增 ShowFullUrl/ShowRunTime 配置(默认true)

This commit is contained in:
2026-08-18 02:43:11 +08:00
parent 3925d0d5ad
commit 8a5c34756b
8 changed files with 140 additions and 32 deletions
+12 -12
View File
@@ -1,19 +1,19 @@
version = 0 version = 0
[requires] [requires]
soulsoft_web_http = {version = "1.0.20260528"}
soulsoft_extensions_options_configuration = {version = "1.0.20260528"} soulsoft_extensions_options_configuration = {version = "1.0.20260528"}
soulsoft_web_routing = {version = "1.0.20260528"}
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"}
soulsoft_extensions_configuration = {version = "1.0.20260528"}
soulsoft_serialization = {version = "1.0.20260528"}
soulsoft_extensions_injection = {version = "1.0.20260528"}
soulsoft_identity_claims = {version = "1.0.20260528"}
soulsoft_extensions_options = {version = "1.0.20260528"} soulsoft_extensions_options = {version = "1.0.20260528"}
redis = {version = "1.0.20260627"} redis = {version = "1.0.20260627"}
soulsoft_serialization = {version = "1.0.20260528"}
soulsoft_identity_claims = {version = "1.0.20260528"}
soulsoft_web_cors = {version = "1.0.20260528"}
soulsoft_extensions_hosting = {version = "1.0.20260528"}
soulsoft_web_mvc = {version = "1.0.20260528"}
soulsoft_extensions_logging = {version = "1.0.20260528"}
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
soulsoft_extensions_configuration = {version = "1.0.20260528"}
soulsoft_web_routing = {version = "1.0.20260528"}
soulsoft_extensions_injection = {version = "1.0.20260528"}
soulsoft_web_hosting = {version = "1.0.20260528"} soulsoft_web_hosting = {version = "1.0.20260528"}
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"} soulsoft_web_mvc = {version = "1.0.20260528"}
soulsoft_web_http = {version = "1.0.20260528"} soulsoft_web_cors = {version = "1.0.20260528"}
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
soulsoft_extensions_logging = {version = "1.0.20260528"}
soulsoft_extensions_hosting = {version = "1.0.20260528"}
+11 -1
View File
@@ -16,10 +16,20 @@ public class SimApiRequestLogOptions {
public var showFullHeader: Bool = false public var showFullHeader: Bool = false
/** /**
* 是否打印完整的响应体。 * 是否打印完整的响应体(false 时截断到 200 字符,对齐 C#)
*/ */
public var showFullResponse: Bool = false public var showFullResponse: Bool = false
/**
* 请求地址是否显示完整 URL(false 时仅显示路径+查询串)。
*/
public var showFullUrl: Bool = true
/**
* 是否在请求行显示运行耗时(如 [POST] (12ms) http://...)。
*/
public var showRunTime: Bool = true
/** /**
* 请求字段显示最长长度(0 表示不截断)。 * 请求字段显示最长长度(0 表示不截断)。
*/ */
+9 -2
View File
@@ -16,6 +16,8 @@ package simapi.helpers
import std.collection.* import std.collection.*
import std.io.* import std.io.*
import std.reflect.* import std.reflect.*
import stdx.encoding.json.*
import stdx.serialization.serialization.*
import soulsoft_web_http.* import soulsoft_web_http.*
import soulsoft_web_mvc.annotations.* import soulsoft_web_mvc.annotations.*
import soulsoft_web_mvc.core.* import soulsoft_web_mvc.core.*
@@ -304,7 +306,12 @@ struct SimApiActionInvoker {
if (let Some(status) <- details.status) { if (let Some(status) <- details.status) {
context.response.statusCode = UInt16(status) context.response.statusCode = UInt16(status)
} }
context.response.writeAsJson(details) // 与 soulsoft writeAsJson 的 Serializable<T> 分支一致:serialize().toJson().toString()
// toJson 为 DataModel 接口扩展,需先上转);writeAsJson 内部会设置 contentType,此处补上
context.response.contentType = "application/json; charset=utf-8"
let dm: DataModel = details.serialize()
let jsonText = dm.toJson().toString()
SimApiResponseWriter.write(context, jsonText)
} }
} }
@@ -322,7 +329,7 @@ struct SimApiActionInvoker {
if (let s: String <- actionResult) { if (let s: String <- actionResult) {
// String 原样输出文本(对齐 C# string 返回直接写入) // String 原样输出文本(对齐 C# string 返回直接写入)
context.response.contentType = "application/json; charset=utf-8" context.response.contentType = "application/json; charset=utf-8"
context.response.write(s) SimApiResponseWriter.write(context, s)
} else { } else {
ObjectResult<Any>(actionResult).invoke(context) ObjectResult<Any>(actionResult).invoke(context)
} }
+3 -1
View File
@@ -6,6 +6,7 @@
package simapi.helpers package simapi.helpers
import soulsoft_web_http.* import soulsoft_web_http.*
import simapi_serialization.*
import simapi.communications.* import simapi.communications.*
/** /**
@@ -24,7 +25,8 @@ public class SimApiResponseFilter {
context => context =>
next(context) next(context)
if (!context.response.hasStarted) { if (!context.response.hasStarted) {
context.response.writeAsJson(SimApiBaseResponse()) context.response.contentType = "application/json; charset=utf-8"
SimApiResponseWriter.write(context, JsonSerializer.Serialize(SimApiBaseResponse()))
} }
} }
} }
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
import soulsoft_web_http.*
/**
* 响应写出工具:写出响应体并缓存文本,供请求日志中间件读取。
*
* 背景:soulsoft 的 HttpResponse.body 为只写流(read 抛 UnsupportedException),
* 无法像 C# 那样用 MemoryStream 替换 Body 捕获响应内容;
* 故在统一写出入口缓存文本,请求日志中间件直接从 context.items 读取。
*/
public class SimApiResponseWriter {
private init() {}
/**
* 响应体缓存键(写入 HttpContext.items)。
*/
public static let responseBodyCacheKey = "SimApi:ResponseBodyCache"
/**
* 写出响应文本并缓存(供请求日志中间件读取)。
* @param context HTTP 上下文。
* @param text 响应正文文本。
*/
public static func write(context: HttpContext, text: String): Unit {
context.items[responseBodyCacheKey] = text
context.response.write(text)
}
}
+2 -2
View File
@@ -42,9 +42,9 @@ public class SimApiResultWriter {
} }
} }
/// simapi_serialization 序列化后直接写响应体 /// simapi_serialization 序列化后直接写响应体(经 SimApiResponseWriter 缓存,供请求日志读取)
private static func writeJson(context: HttpContext, obj: Any): Unit { private static func writeJson(context: HttpContext, obj: Any): Unit {
context.response.contentType = "application/json; charset=utf-8" context.response.contentType = "application/json; charset=utf-8"
context.response.write(JsonSerializer.Serialize(obj)) SimApiResponseWriter.write(context, JsonSerializer.Serialize(obj))
} }
} }
+2 -1
View File
@@ -11,6 +11,7 @@ import simapi_serialization.*
import simapi.communications.* import simapi.communications.*
import simapi.exceptions.* import simapi.exceptions.*
import simapi.configurations.* import simapi.configurations.*
import simapi.helpers.*
/** /**
* 异常处理中间件:全异常捕获,统一输出 HTTP 200 + JSON 响应。 * 异常处理中间件:全异常捕获,统一输出 HTTP 200 + JSON 响应。
@@ -47,7 +48,7 @@ public class SimApiExceptionMiddleware <: IMiddleware {
if (!context.response.hasStarted) { if (!context.response.hasStarted) {
context.response.statusCode = 200 context.response.statusCode = 200
context.response.contentType = "application/json; charset=utf-8" context.response.contentType = "application/json; charset=utf-8"
context.response.write(responseJson(response)) SimApiResponseWriter.write(context, responseJson(response))
} }
} }
} }
+67 -13
View File
@@ -16,14 +16,16 @@ import simapi.configurations.*
import simapi.helpers.* import simapi.helpers.*
/** /**
* 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码、耗时与异常。 * 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码、响应体、耗时与异常。
* 对应 C# 的 SimApi.Middlewares.SimApiRequestLogMiddleware。 * 对应 C# 的 SimApi.Middlewares.SimApiRequestLogMiddleware。
* *
* 对齐说明: * 对齐说明:
* - 请求体按 JSON 字段级截断(对齐 C#:仅对超长字符串字段截断,保留结构) * - 请求体按 JSON 字段级截断(对齐 C#:仅对超长字符串字段截断,保留结构)
* - 捕获下游异常并记录,随后重抛(对齐 C# ExceptionDispatchInfo + edi.Throw * - 捕获下游异常并记录,随后重抛(对齐 C# ExceptionDispatchInfo + edi.Throw
* - 响应体:soulsoft HttpResponse.body 只读不可替换(C# 用 MemoryStream 替换捕获), * - 响应体:soulsoft HttpResponse.body 只读不可读回(read 抛 UnsupportedException),
* 此处以 Content-Length 作为替代信息;ShowFullResponse 选项因此暂不生效 * 无法像 C# 那样用 MemoryStream 替换捕获;改为在各统一写出入口
* SimApiResponseWriter)缓存响应文本,此处直接读取。ShowFullResponse=false 时截断到 200 字符(对齐 C#)
* - ShowFullUrl=false 时仅显示路径+查询串;ShowRunTime=true 时请求行显示 [POST] (xxxms)
*/ */
public class SimApiRequestLogMiddleware <: IMiddleware { public class SimApiRequestLogMiddleware <: IMiddleware {
private let _options: SimApiOptions private let _options: SimApiOptions
@@ -39,13 +41,11 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
*/ */
public func invoke(context: HttpContext, next: RequestDelegate): Unit { public func invoke(context: HttpContext, next: RequestDelegate): Unit {
let start = MonoTime.now() let start = MonoTime.now()
let fullUrl = context.request.getDisplayUrl() let options = _options.simApiRequestLogOptions
var sb = StringBuilder() var sb = StringBuilder()
sb.append("[${context.request.method}] ${fullUrl}\n")
// 请求头 // 请求头
if (_options.simApiRequestLogOptions.showFullHeader) { if (options.showFullHeader) {
sb.append("*( RequestHeaders [Full] ) =>\n") sb.append("*( RequestHeaders [Full] ) =>\n")
sb.append(serializeHeaders(context)) sb.append(serializeHeaders(context))
} else { } else {
@@ -67,20 +67,30 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
exception = Some(ex) exception = Some(ex)
} }
// 响应信息 // 请求行(耗时需在 next 之后计算,故最后拼装)
let elapsed = MonoTime.now() - start let elapsed = MonoTime.now() - start
let elapsedMs = elapsed / Duration.millisecond let elapsedMs = elapsed / Duration.millisecond
sb.append("*( Response [${context.response.statusCode}] ) => ${elapsedMs}ms") let url = if (options.showFullUrl) {
// 响应体捕获受 soulsoft 限制(body 只读不可替换),记录 Content-Length 作为替代 buildDisplayUrl(context)
if (let Some(len) <- context.response.contentLength) { } else {
sb.append(" (响应体长度: ${len})") context.request.path.toString() + context.request.queryString.toString()
} }
var sbHead = StringBuilder()
sbHead.append("[${context.request.method}]")
if (options.showRunTime) {
sbHead.append(" (${elapsedMs}ms)")
}
sbHead.append(" ${url}\n")
// 响应信息:状态码 + 响应体(经 SimApiResponseWriter 缓存读取)
sb.append("*( Response [${context.response.statusCode}] ) =>")
sb.append(readResponseBody(context))
sb.append("\n") sb.append("\n")
if (let Some(ex) <- exception) { if (let Some(ex) <- exception) {
sb.append("Exception: ${ex.toString()}\n") sb.append("Exception: ${ex.toString()}\n")
} }
_logger.info(sb.toString()) _logger.info(sbHead.toString() + sb.toString())
// 重抛原异常(对齐 C# edi?.Throw()),由外层 ExceptionMiddleware 处理 // 重抛原异常(对齐 C# edi?.Throw()),由外层 ExceptionMiddleware 处理
if (let Some(ex) <- exception) { if (let Some(ex) <- exception) {
@@ -88,6 +98,27 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
} }
} }
/// 构造完整请求 URL:stdx 服务端请求 URL 只有路径(无 scheme/host),需手动拼接。
/// 对齐 C# 的 {Scheme}://{Host}{Path}{QueryString}Host 优先取 Host 请求头。
private func buildDisplayUrl(context: HttpContext): String {
var sb = StringBuilder()
let scheme = context.request.scheme
sb.append(if (scheme.isEmpty()) { "http" } else { scheme })
sb.append("://")
let hostHeader = context.request.headers.get("Host") ?? ""
if (!hostHeader.isEmpty()) {
sb.append(hostHeader)
} else {
let host = context.request.host.toString()
if (!host.isEmpty()) {
sb.append(host)
}
}
sb.append(context.request.path.toString())
sb.append(context.request.queryString.toString())
sb.toString()
}
private func serializeHeaders(context: HttpContext): String { private func serializeHeaders(context: HttpContext): String {
var sb = StringBuilder() var sb = StringBuilder()
sb.append("{") sb.append("{")
@@ -134,6 +165,29 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
} }
} }
/// 响应体读取:从 SimApiResponseWriter 缓存取;未捕获到时回退 Content-Length
private func readResponseBody(context: HttpContext): String {
let body = match (context.items.get(SimApiResponseWriter.responseBodyCacheKey)) {
case Some(v) => if (let s: String <- v) { s } else { "" }
case None => ""
}
if (body.isEmpty()) {
// 未捕获到响应体(如 IActionResult 直写路径):记录 Content-Length 作为替代
if (let Some(len) <- context.response.contentLength) {
return " (响应体长度: ${len})"
}
return ""
}
if (_options.simApiRequestLogOptions.showFullResponse) {
return body
}
// 对齐 C#ShowFullResponse=false 时截断到 200 字符
if (body.size > 200) {
return body[0..200] + "...(${body.size})"
}
return body
}
/// 请求体截断:JSON 字段级截断(对齐 C#:仅对超长字符串字段截断),非 JSON 则整串截断 /// 请求体截断:JSON 字段级截断(对齐 C#:仅对超长字符串字段截断),非 JSON 则整串截断
private func truncateBody(body: String): String { private func truncateBody(body: String): String {
let maxLen = _options.simApiRequestLogOptions.requestStringLogLength let maxLen = _options.simApiRequestLogOptions.requestStringLogLength