[Day 12] 实作 API Response 及 i18n Response Message

定义 API Response 格式

API Response 的格式没有标准答案,网路上已经有许多范例可以参考,我认为不管格式为何,重点是团队成员有共识,而且相同类型的 API 格式要固定,否则会造成串接方的困扰。如果你还没决定好格式,不妨可以参考我的作法。

基本的 ResponseDTO 包含以下栏位

  • ResponseCode
    • name: 方便理解的名称
    • value: 固定不变的回应码
    • type: 回应码类型 (ResponseCodeType)
    • httpStatusCode: HTTP 状态码
  • message: 给使用者看的讯息文字,支援 i18n
  • data: 回应资料 (json)

简单的成功格式有 CodeResponseDTODataResponseDTO,CodeResponseDTO 只回应 ResponseCode,DataResponseDTO 则是有回应资料 data

至於错误格式的 ErrorResponseDTO 多了以下栏位

  • detail: 给前端开发者看的详细错误讯息
  • reqId: 请求ID (如果发生错误,我们可以拿这个 id 去搜寻 error log)
  • errors: 如果有多个详细错误讯息需要回应给前端

例如使用者登入帐密错误,此时回应 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>()

支援多专案的 i18n Response Message

我习惯 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个子专案定义各自的 OpsResponseCodeClubResponseCode。要注意所有 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.ConfigwithFallback 方法实现,这也是我为什麽采用 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 ApplicationCallApplicationRequest 类别定义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。


<<:  环境建置(2)

>>:  EP05 - 从零开始,在 AWS 建置 Gitlab 使用 Terraform

为什麽要使用VPN?综合3款VPN推荐给大家

最近有朋友回中国内地工作,刚好问我有什麽VPN推荐一下给他,需要在内地能翻墙,连脸书就可以了,了解他...

JAVA - JAVA Maven 错误处理

JAVA - JAVA Maven 错误处理 参考资料 参考资料1:命令行mvn打包的时候报错:No...

【day11】争厚厚切牛排

位於南港车站附近的争厚厚切牛排 也是我们常去的口袋名单之一 平日营业时间是两段式的 但像今天周日的话...

a连结标签基础方法

<a href="https://www.w3.org/" target=...

【Day6】情蒐阶段的小工具 ─ 另外那篇

哈罗~ 昨天我们介绍了线上可用的情蒐工具, 今天再来介绍几个情蒐工具,请先安装Kali Linux或...