API Response 的格式没有标准答案,网路上已经有许多范例可以参考,我认为不管格式为何,重点是团队成员有共识,而且相同类型的 API 格式要固定,否则会造成串接方的困扰。如果你还没决定好格式,不妨可以参考我的作法。
基本的 ResponseDTO 包含以下栏位
简单的成功格式有 CodeResponseDTO
及 DataResponseDTO
,CodeResponseDTO 只回应 ResponseCode,DataResponseDTO 则是有回应资料 data
至於错误格式的 ErrorResponseDTO
多了以下栏位
例如使用者登入帐密错误,此时回应 AUTH_LOGIN_UNAUTHENTICATED
的 type 是 CLIENT_INFO
,前端可以显示 INFO 等级的讯息文字给使用者。
{
"type": "error",
"code": {
"name": "AUTH_LOGIN_UNAUTHENTICATED",
"value": "2008",
"type": "CLIENT_INFO"
},
"message": "帐号或密码不正确",
"detail": "[AUTH_LOGIN_UNAUTHENTICATED] ",
"reqId": "3gzwul5brn13-n-leu51bdknmt9+vi31"
}
如果前端不小心漏掉检查,传给後端不合法的资料时,此时回应 BAD_REQUEST_BODY
的 type 是 CLIENT_ERROR
,前端可以进行程序修正,如果情境上无法验证资料,那就显示错误讯息给使用者。
{
"type": "error",
"code": {
"name": "BAD_REQUEST_BODY",
"value": "1004",
"type": "CLIENT_ERROR"
},
"message": "系统无法处理您的请求或请求结果有错误",
"detail": "请求的 body 资料格式有错误 => [BAD_REQUEST_BODY] Invalid(errors=[ValidationError(dataPath=.account, message=must match the expected pattern)])",
"reqId": "ddsoy+ip2t0vupyrz54bqdujk+6bsgo9"
}
另外还有 PagingDataResponseDTO
用於分页, BatchResponseDTO
用於批次工作…等其它格式,完整程序码可以参考 Github Repo
@Serializable
class ResponseCode(
val name: String,
val value: String,
val type: ResponseCodeType,
@Transient val httpStatusCode: HttpStatusCode = HttpStatusCode.OK
)
enum class ResponseCodeType {
SUCCESS,
CLIENT_INFO, // 存在回应给使用者的讯息
CLIENT_ERROR, // 前端请求错误
SERVER_ERROR; // 後端处理错误
}
@Serializable
sealed class ResponseDTO {
abstract val code: ResponseCode
abstract val message: String?
abstract val data: JsonElement?
}
@Serializable
@SerialName("code")
class CodeResponseDTO(override val code: ResponseCode) : ResponseDTO() {
override val message: String? = null
override val data: JsonElement? = null
companion object {
val OK = CodeResponseDTO(InfraResponseCode.OK)
}
}
@Serializable
@SerialName("data")
class DataResponseDTO(
override val code: ResponseCode = InfraResponseCode.OK,
override val message: String? = null,
override val data: JsonElement
) : ResponseDTO()
@Serializable
@SerialName("error")
class ErrorResponseDTO(
override val code: ResponseCode,
override val message: String,
val detail: String,
val reqId: String,
override val data: JsonObject? = null,
val errors: MutableList<ErrorResponseDetailError>? = null
) : ResponseDTO()
@Serializable
class ErrorResponseDetailError(
val code: ResponseCode,
val detail: String,
val data: JsonObject? = null
)
@Serializable
@SerialName("paging")
class PagingDataResponseDTO(
override val code: ResponseCode = InfraResponseCode.OK,
override val message: String? = null,
override val data: JsonElement
) : ResponseDTO() {
@Serializable
class PagingData(
val total: Long, val totalPages: Long,
val itemsPerPage: Int, val pageIndex: Long,
val items: JsonArray
)
}
@Serializable
@SerialName("batch")
class BatchResponseDTO(
override val code: ResponseCode,
override val message: String,
override val data: JsonObject
) : ResponseDTO()
@Serializable
class BatchResult(val successes: MutableList<SuccessResult>, val failures: MutableList<FailureResult>)
@Serializable
class SuccessResult(override val id: String, val data: JsonElement? = JsonObject(mapOf())) :
IdentifiableObject<String>()
@Serializable
class FailureResult(override val id: String, val errors: MutableList<ErrorResponseDetailError>) : IdentifiableObject<String>()
我习惯 API 回应的讯息文字区分为给一般使用者看的 message 属性及给前端开发者看的 detail 属性,其中 message 应该要支援 i18n。昨天提到的多专案 i18n 讯息通知范例,每个子专案可以定义自己的 NotificationType,而且拥有自己的讯息通知语系档 notification_zh-TW.prperties。同样地,每个子专案拥有自己的 API,所以每个 API 回应给使用者的讯息文字,也需要可以定义在自己的语系档 response_zh-TW.conf (HOCON格式)。但是这两个功能有一个地方不同的是,虽然一样是多专案架构,但对於许多 ResponseCode 而言是可以让子专案共用的,例如上面提到的登入失败 AUTH_LOGIN_UNAUTHENTICATED 及资料验证错误 BAD_REQUEST_BODY
我在底层 infra module 定义这些一般用途可共用的 InfraResponseCode
,另外在2个子专案定义各自的 OpsResponseCode
及 ClubResponseCode
。要注意所有 ResponseCode 的 value 值是全域性的不能重复。
object InfraResponseCode {
val OK = ResponseCode("OK", "0000", ResponseCodeType.SUCCESS, HttpStatusCode.OK)
val BAD_REQUEST_BODY = ResponseCode("BAD_REQUEST_BODY", "1004", ResponseCodeType.CLIENT_ERROR, HttpStatusCode.BadRequest)
val AUTH_LOGIN_UNAUTHENTICATED = ResponseCode("AUTH_LOGIN_UNAUTHENTICATED", "2008", ResponseCodeType.CLIENT_INFO, HttpStatusCode.Unauthorized)
// 其它省略
}
object OpsResponseCode {
val OPS_ERROR = ResponseCode("OPS_ERROR", "3000", ResponseCodeType.SERVER_ERROR, HttpStatusCode.InternalServerError)
}
object ClubResponseCode {
val CLUB_ERROR = ResponseCode("CLUB_ERROR", "4000", ResponseCodeType.SERVER_ERROR, HttpStatusCode.InternalServerError)
}
然後 ResponseCode value 当作 key 对应到讯息文字,以下是各个 response_zh-TW.conf
语系档内容
// ===== infra module =====
codeType {
SUCCESS = "操作成功",
USER_FAILED = "操作结果失败",
CLIENT_ERROR = "系统无法处理您的请求或请求结果有错误",
SERVER_ERROR = "系统错误"
}
code {
0000 = "操作成功",
1004 = "请求的 body 资料格式有错误",
2008 = "帐号或密码不正确"
}
// ===== ops project =====
code {
3000 = "Ops 错误"
}
// ===== club project =====
code {
4000 = "Club 错误"
}
依照我的 i18n 机制的作法,实作 ResponseMessages
及其 ResponseMessagesProvider
,其中 ResponseMessagesProvider 的 merge
方法会把所有专案的 ResponseMessages 都合并至同一个物件方便操作,底层是透过 com.typesafe.config.Config
的 withFallback
方法实现,这也是我为什麽采用 HOCON 语系档格式的原因。
class ResponseMessages(val messages: HoconMessagesImpl) : Messages {
fun getMessage(ex: BaseException): String =
if (ex.code.isError()) getCodeTypeMessage(ex.code.type)
else getCodeMessage(ex)
fun getDetailMessage(ex: BaseException): String {
val message = if (ex.code.isError()) getCodeMessage(ex) else ""
return ex.message?.let { if (message.isNotEmpty()) "$message => $it" else it } ?: message
}
private fun getCodeTypeMessage(codeType: ResponseCodeType): String {
return get("codeType.${codeType.name}", null)!!
}
private fun getCodeMessage(ex: BaseException): String {
return if (ex is EntityException) {
val args: MutableMap<String, Any> = mutableMapOf()
if (ex.entity != null) {
args.putAll(ex.entity.toNotNullMap("entity"))
}
if (ex.dataMap != null) {
args.putAll(ex.dataMap)
}
getCodeMessage(ex.code, args)
} else {
getCodeMessage(ex.code, ex.dataMap)
}
}
private fun getCodeMessage(code: ResponseCode, args: Map<String, Any>? = null): String {
val message = get("code.${code.value}", args)
if (code.type == ResponseCodeType.CLIENT_INFO)
requireNotNull(message)
return message ?: ""
}
override val lang: Lang = messages.lang
override fun get(key: String, args: Map<String, Any>?): String? = messages.get(key, args)
override fun isDefined(key: String): Boolean = messages.isDefined(key)
}
class ResponseMessagesProvider(messagesProvider: HoconMessagesProvider) : MessagesProvider<ResponseMessages> {
private val logger = KotlinLogging.logger {}
override val messages: Map<Lang, ResponseMessages> = messagesProvider.messages
.mapValues { ResponseMessages(it.value) }
fun merge(another: HoconMessagesProvider) {
messages.forEach { (lang, responseMessages) ->
another.messages[lang]?.let {
responseMessages.messages.withFallback(it)
}
}
}
}
然後 I18nResponseCreator
负责把 Exception 转换为 ErrorResponseDTO,并且根据客户端请求的偏好语言,读取对应语系档的讯息文字,再填入到 message 属性。
class I18nResponseCreator(private val messagesProvider: ResponseMessagesProvider) {
fun createErrorResponse(e: BaseException, call: ApplicationCall): ErrorResponseDTO {
val messages = messagesProvider.preferred(call.lang())
return ErrorResponseDTO(
e.code,
messages.getMessage(e), messages.getDetailMessage(e),
call.callId!!, e.dataMap?.toJsonObject(), null
)
}
}
取得 HTTP Request 偏好语言是透过在 Ktor ApplicationCall
及 ApplicationRequest
类别定义lang()
extension function,从 cookie 或 header Accept-Language
取得。
fun ApplicationRequest.lang(): Lang? = (cookies["lang"] ?: acceptLanguageItems().firstOrNull()?.value)?.let { Lang(it) }
fun ApplicationCall.lang(): Lang? = attributes.getOrNull(Lang.ATTRIBUTE_KEY) ?: request.lang()
这 2 天说明如何建立 Ktor i18n 机制,还有在 multi-project 架构上实作 API Response Message 及 Notification Message。明天的主题将进入 Ktor API Authentication 及 Authorization。
>>: EP05 - 从零开始,在 AWS 建置 Gitlab 使用 Terraform
最近有朋友回中国内地工作,刚好问我有什麽VPN推荐一下给他,需要在内地能翻墙,连脸书就可以了,了解他...
JAVA - JAVA Maven 错误处理 参考资料 参考资料1:命令行mvn打包的时候报错:No...
位於南港车站附近的争厚厚切牛排 也是我们常去的口袋名单之一 平日营业时间是两段式的 但像今天周日的话...
<a href="https://www.w3.org/" target=...
哈罗~ 昨天我们介绍了线上可用的情蒐工具, 今天再来介绍几个情蒐工具,请先安装Kali Linux或...