项目开发心得
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
3type: integer
format: int64
example: 1052101482 - 比如数据库表中的字段类型是UUID,在openapi中可以这样设置
1
2
3
4leyan_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
4SubsystemSeller:
type: object
x-response: Seller
x-request: Seller如果说OpenAPI中定义的某些component和Seller Model不完全对应,只有其中某些字段,那么也可以用这种方法:1
val seller = Seller.newRecord(requestBody)
此时,假设传入的requestBody中包含了这个SubsystemToken,可以用1
2
3
4SubsystemToken:
type: object
x-request: Seller
required: [ access_token, app_name ]update
来更新Seller Model实现更新操作。当然,不管是那种方式,最后都需要1
seller.update(requestBody.SubsystemToken)
seller.store()
将其应用到数据库中。response
同样的,当我从数据库中拿到了一些记录,但他们是Model类型,如何将其以OpenAPI规定的格式传出?
此时我们可以定义x-response
,正如上面所说的那样。对于数据库中处理得到的loadedSellerOrders,对其执行toResponse操作转换成OpenAPI所定义的格式1
val responses = loadedSellerOrders.map { it.includes(fields).toResponse(OpenApiSellerOrder) }
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
class SuperbookController {
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和数据库进行交互。那么find
和findBy
有什么区别?
简单来说,find
只能根据主键进行查询,所以直接传入主键值即可。但是1
Seller.find(updatedFields.leyanSellerId)
findBy
的参数是一个条件,可以查询任意字段值此外,还有一个差别,我们看一下二者的底层实现:1
2Seller.findBy( Seller.SELLERS.LEYAN_SELLER_ID.eq(installSystem.subsystemSeller.leyanSellerId),
)1
2fun findBy(vararg conditions: Condition) = where(*conditions).fetchOne()
fun find(value: T) = where(pk.eq(value)).fetchSingle()
- fetchOne():
- 如果查询结果没有记录,返回 null。
- 如果查询结果返回多于一条记录,则抛出异常(例如
NonUniqueResultException
)。 - 适用于“可选”结果场景,即允许结果为空。
- fetchSingle():
对于更新、插入等方法的测试,在测试插入之后,需要绕过接口直接查数据库并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
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 | /* |
然后比如我们进行了一个测试,返回404/400,我们不仅要判断这个状态码,还需要对返回值进行判断。比如说:1
2
3
4
5
6
7
8
9
10
11
12
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
2assertEquals(Instant.parse("2030-01-01T00:00:00Z"),
seller.expiresAt?.toInstant(), "expiresAt 应该匹配")
来parse一下