项目开发心得

项目开发心得

Git

  • 查看该文档
  • 每天早上上班的时候,需要跟master保持一致。否则很可能会conflict, SOP 如下
    • git checkout master
    • git pull
    • git checkout xxx
    • git merge master
    • git push -u origin xxx

数据库篇

  • postgres一般使用TEXT而不是用VARCHAR
  • Primary Key就不需要NOT NULL 了
  • 对于一些字段,我们要求他可以为空,但是不能为空字符串。此时null 表示“没有提供数据”,而空字符串则被认为是无效或错误的输入。比如一些枚举类,它们一旦有值,通常需要用于后续业务逻辑或者系统校验,如果保存了空字符串,可能引发逻辑错误或数据不一致。
    • access_token TEXT CHECK (access_token IS NULL OR access_token <> '')
  • 对于其他一些字段,我们会要求它不能为空,但是可以为字符串。比如默认配置项、必要文本输入等 seller_id BIGINT NOT NULL
  • 对于一些Unique的值 ,我们要在创建数据表格的时候,就将其约束起来,不要等到后续再去维护。要向上推
    • 比如CREATE UNIQUE INDEX sellers_seller_id_store_id_idx ON public.sellers (seller_id);
    • 要知道数据库是最后一层保障,很多判断应该在应用层做判断,如果数据库报错一定是代码写错了。
  • 时间格式:expires_at TIMESTAMP WITH TIME ZONE = TIMESTAMPTZ

    OpenAPI

    OpenAPI 与数据库表类型匹配

    mig25-codegen的下载和基本操作
    我们要编写符合mig25的openapi.yaml , 最好是要将openapi中定义的component中的字段和数据库表中的字段匹配起来。
  • 比如数据库表中字段类型为long,在openapi中可以这样设置:
    1
    2
    3
    type: integer  
    format: int64
    example: 1052101482
  • 比如数据库表中的字段类型是UUID,在openapi中可以这样设置
    1
    2
    3
    4
    leyan_seller_id:  
    type: string
    format: uuid
    example: '44cfdf24-6a22-4ba8-dedc-55e62aeadd85'

    OpenAPI component 设置 DTO TO VO

    由于我们使用了mig25-codegen 这个插件,因此他可以根据OpenAPI的定义生成出相关VO和DTO的转换逻辑。这样就方便了对请求格式和返回格式的处理。
    假设我们的 Model 名称为Seller,可以通过它对数据库进行增删改查的工作。

    request

    那么当requestBody 中传入了json格式的seller,如何将其直接转换为可以操控数据库的类型Seller?
    首先需要在OpenAPI的component中找到定义的seller,然后添加x-request 属性
    1
    2
    3
    4
    SubsystemSeller:  
    type: object
    x-response: Seller
    x-request: Seller
    然后在方法中:
    1
    val seller = Seller.newRecord(requestBody)
    如果说OpenAPI中定义的某些component和Seller Model不完全对应,只有其中某些字段,那么也可以用这种方法:
    1
    2
    3
    4
    SubsystemToken:  
    type: object
    x-request: Seller
    required: [ access_token, app_name ]
    此时,假设传入的requestBody中包含了这个SubsystemToken,可以用update 来更新Seller Model实现更新操作。
    1
    seller.update(requestBody.SubsystemToken)
    当然,不管是那种方式,最后都需要 seller.store() 将其应用到数据库中。

    response

    同样的,当我从数据库中拿到了一些记录,但他们是Model类型,如何将其以OpenAPI规定的格式传出?
    此时我们可以定义x-response ,正如上面所说的那样。
    1
    val responses = loadedSellerOrders.map { it.includes(fields).toResponse(OpenApiSellerOrder) }
    对于数据库中处理得到的loadedSellerOrders,对其执行toResponse操作转换成OpenAPI所定义的格式

    Controller

    我们的目标是
  • [!] 尽量不去手动的做VO和DTO之间的转换,而是交给codegen去完成。
  • [!] 尽量使用codegen生成的api进行操作,如find,findBy,update,store,delete等。代码要包含两方面:1. 要做什么 2.怎么去做。好的代码只体现1,2对developer是透明的,尽量交给框架去完成。这也涉及到命令式编程和声明式编程的区别,命令式倾向于如何做,如C,Java等;而声明式则倾向于做什么,更关注与描述期望得到的结果和状态,而不必关心如何一步步实现这一结果。如我们在项目中使用的mig25+jooq 技术栈

    Exception Mapper

    对于很多方法,可能都会抛出同一类错误,比如说NOT FOUND ,那么我们有没有什么合适的方法去同意管理起来,而不是在每个方法中写一模一样的错误处理方法?
    这时我们就需要用到@ServerExceptionMapper 注解,我们在Controller类中配置,统一处理特定类型的错误,比如说:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Path("/superbook/subsystem")  
    class SuperbookController {
    @ServerExceptionMapper
    fun handle(e: NoDataFoundException): Response {
    // 返回 JSON 格式的错误信息
    val body =
    mapOf(
    "code" to 404,
    "error" to "NOT_FOUND",
    "message" to (e.message ?: "Resource not found"),
    )

    return Response
    .status(Response.Status.NOT_FOUND)
    .type(MediaType.APPLICATION_JSON)
    .entity(body)
    .build()
    }
    //...
    }

    mig25-codegen : find和findBy

    当我们使用mig25生成的models的时候,可以使用它提供的API和数据库进行交互。那么findfindBy 有什么区别?
    简单来说,find 只能根据主键进行查询,所以直接传入主键值即可。
    1
    Seller.find(updatedFields.leyanSellerId)
    但是findBy 的参数是一个条件,可以查询任意字段值
    1
    2
    Seller.findBy(  Seller.SELLERS.LEYAN_SELLER_ID.eq(installSystem.subsystemSeller.leyanSellerId),  
    )
    此外,还有一个差别,我们看一下二者的底层实现:
    1
    2
    fun findBy(vararg conditions: Condition) = where(*conditions).fetchOne()  
    fun find(value: T) = where(pk.eq(value)).fetchSingle()
  • fetchOne()
    • 如果查询结果没有记录,返回 null
    • 如果查询结果返回多于一条记录,则抛出异常(例如 NonUniqueResultException)。
    • 适用于“可选”结果场景,即允许结果为空。
  • fetchSingle()
    • 要求查询必须返回恰好一条记录
    • 如果查询结果为空,则抛出异常(例如 NoDataFoundException 或者其他与“没有数据”相关的异常);如果结果超过一条,也会抛出异常。
    • 用于预期必须存在一个记录的场景,并且希望在不存在时立即获得错误提示。

      Delete软删除

      在写delete方法的时候,通常使用软删除,将一些重要信息置空,而不是用delete将整行record删除。

      Test

      用Assert包住错误

对于更新、插入等方法的测试,在测试插入之后,需要绕过接口直接查数据库并Assert状态是否已经更新。而且需要Assert所有字段。这样比较容易发现bug,当我们发现bug的时候,需要写一个尽可能详细的test去把这个bug的边界给囊括住,这样可以更好的帮助修复。
如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Test  
fun `test enableSubsystem updates existing seller if already exists`() {
val subsystemName = ...
// 使用 fixture 中已存在的 sellerOne val existingId = sellerOne.leyanSellerId!!

// 构造示例请求体 InstallSystem
val subsystemInstallSystem = ...

Given {
...
} When {
...
} Then {
...
}
//需要从数据库fetch出来注意比较
val seller = Seller.find(existingId)

assertNotNull(seller, "Seller 应该存在")
assertEquals(existingId, seller.leyanSellerId, "leyan_seller_id 应该匹配")
assertEquals(1052101482, seller.sellerId, "seller_id 应该匹配")
assertEquals(425920014, seller.storeId, "store_id 应该匹配")
assertEquals("updated_seller_nick", seller.sellerNick, "seller_nick 应该匹配")
assertEquals("更新后的店铺标题", seller.storeTitle, "store_title 应该匹配")
assertEquals("taobao", seller.platform, "platform 应该匹配")
assertEquals("5c4f37e8-2447-4dd6-b31e-9d678904b051", seller.organizationId.toString(), "organization_id 应该匹配")
assertEquals("淘宝", seller.sellerType, "seller_type 应该匹配")
// bugfix: 这里是框架性错误,第二次update失效,expected accessToken:"updated_access_token"
assertEquals("updated_access_token", seller.accessToken, "access_token 应该匹配")
// bugfix: 这里是框架性错误,第二次update失效,expected appName:"tb_lyzr"
assertEquals("tb_lyzr", seller.appName, "app_name 应该匹配")
// bugfix: 这里是框架性错误,第二次update失效,expected:"Instant.parse("2030-01-01T00:00:00Z") "
assertEquals(Instant.parse("2030-01-01T00:00:00Z"), seller.expiresAt?.toInstant(), "expiresAt 应该匹配")
}

处理错误:Status + Message

在Controller中,端口出错需要统一处理,比如需要请求的资源不存在,请求格式不对等。都可以使用quarkus提供的ServerExceptionMapper 注解,这里给出一些常用的

  • 如果接口是内部接口,那么返回信息可以简单一点,文字即可
  • 如果接口是外部接口,有很多人需要用,那么返回信息需要更复杂,内容更细致
  • 对于返回message,需要说明我要什么,但你给的是什么。可读性更强
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/*
请求资源不存在,用NoDataFoundException接收
*/
@ServerExceptionMapper
fun handle(e: NoDataFoundException): Response {
// 返回 JSON 格式的错误信息
val body =
mapOf(
"code" to 404,
"error" to "NOT_FOUND",
"message" to ("The target leyan_seller_id does not exist in the database"),
)

return Response
.status(Response.Status.NOT_FOUND)
.type(MediaType.APPLICATION_JSON)
.entity(body)
.build()
}

/*
请求体json格式不对,用JsonMappingException
*/
@ServerExceptionMapper
fun handle(e: JsonMappingException): Response {
val body =
mapOf(
"code" to 400,
"error" to "BAD_REQUEST",
"message" to "Json Mapping Exception",
)
return Response.status(Response.Status.BAD_REQUEST)
.type(MediaType.APPLICATION_JSON)
.entity(body)
.build()
}

/*
请求参数不符合限制条件,用ConstraintViolationException
*/

@ServerExceptionMapper
fun handle(e: ConstraintViolationException): Response {
// 构造自定义错误信息,你可以遍历 e.constraintViolations 获取详细信息
val errors =
e.constraintViolations.map { violation ->
mapOf(
"field" to violation.propertyPath.toString(),
)
}

val body =
mapOf(
"code" to 400,
"error" to "BAD_REQUEST",
"message" to "Invalid Query Parameter",
"violations" to errors,
)

return Response.status(Response.Status.BAD_REQUEST)
.type(MediaType.APPLICATION_JSON)
.entity(body)
.build()
}

然后比如我们进行了一个测试,返回404/400,我们不仅要判断这个状态码,还需要对返回值进行判断。比如说:

1
2
3
4
5
6
7
8
9
10
11
12
@Test  
fun `test disableSubsystem returns 400 when leyan_seller_id is blank`() {

When {
..
} Then {
//判断状态
statusCode(400)
//判断message
body("message", equalTo("Invalid Query Parameter"))
}
}

bugfix

此外,当发现bug的时候,我们如何上报,也是需要标准的处理方式
比如这里我发现了一个框架性的错误,无法解决。

  • 先在原来的分支编写 错误的、但是可以通过的测试
  • 再checkout一条 bugfix 分支,编写 正确的、但是无法通过的测试
  • 这样leader修改完之后,直接测试bugfix分支,通过以后,将bugfix分支merge到原先分支,就会得到正确的、且可以通过的测试

对于时间的判断

对于时间戳,本地数据库和ci可能存在某些配置上的问题,导致存储的时间戳格式不一致。此时不要简单得用字符串判断是否相等。
可以用这样的方法:

1
2
assertEquals(Instant.parse("2030-01-01T00:00:00Z"),
seller.expiresAt?.toInstant(), "expiresAt 应该匹配")

来parse一下

-------------本文结束,感谢您的阅读-------------