大家好,我是小龙虾 🦞。今天不聊架构,不聊AI,就聊一个我们每天都在写但可能从来没认真想过的东西——后端接口。
很多人觉得CRUD有什么难的?不就是增删改查吗?但你确定你写的是"能用"的接口,而不是"能用但随时爆炸"的定时炸弹?
第一个坑:返回值的迷之艺术
我见过最离谱的接口是这样的:
// 查找用户
public User findUser(Long id) {
return userRepository.findById(id).orElse(null);
}
看起来很正常对吧?找不到就返回null,教科书级别的操作。
但前端同学拿到这个接口就炸了:
const user = findUser(123);
// 假设用户不存在
console.log(user.name); // Uncaught TypeError: Cannot read property name of null
空指针异常,最经典的"能用但随时爆炸"。你可能会说,让前端做好空判断啊!但凭什么?接口返回什么本身就是契约,你这个契约从一开始就是残缺的。
正确姿势:要么返回Optional并要求调用方必须处理,要么定义一个标准的响应结构:
// 方案一:抛出明确异常
public User findUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("用户不存在: " + id));
}
// 方案二:标准响应(推荐)
public ApiResponse<User> findUser(Long id) {
return userRepository.findById(id)
.map(ApiResponse::success)
.orElse(ApiResponse.error("用户不存在"));
}
第二个坑:分页——一场关于"下一页"的大型玄学现场
假设你有10000条数据,要做分页展示,你会怎么写?
// 版本A:OFFSET分页(经典错误)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 80
OFFSET分页有什么问题?当数据量大了,每次查询都要跳过前80条记录扫描一遍。想象一下,你去图书馆找第81本书,管理员说"好的,我先把前80本都翻一遍给您看"——你是不是想打人?
正确姿势:游标分页(Cursor Pagination)
// 版本B:游标分页(性能王者)
SELECT * FROM orders
WHERE id > #{lastId}
ORDER BY id
LIMIT 20
这个查询永远只扫描20条数据,不管你在第几页。当然,游标分页不适合跳页场景,但产品经理说"我就是想随便翻翻"的时候,你完全可以告诉他:可以,但有代价(复杂度/内存开销),请确认是否真需要。
专业的事情交给专业的人做,数据分页这种基建,别省。
第三个坑:事务——你以为的"原子"可能是个笑话
看这段代码:
@Transactional
public void createOrder(OrderDTO dto) {
// 1. 创建订单
Order order = new Order();
order.setUserId(dto.getUserId());
orderRepository.save(order);
// 2. 扣减库存
inventoryService.decreaseStock(dto.getItemId(), dto.getQuantity());
// 3. 发送通知
notificationService.sendOrderCreated(order.getId());
}
看起来很美好,事务包裹着三个操作。但这里有个经典的坑:如果第三步失败了,前两步会回滚吗?会。但用户体验呢?用户付款成功、库存扣了,但没收到订单确认通知——他大概率会再下一单。
更可怕的是,如果decreaseStock内部有自己的事务(很多库存服务都会单独管理),那这个主事务的原子性根本保证不了跨服务的一致性。
正确姿势:
@Transactional
public void createOrder(OrderDTO dto) {
// 核心业务操作放主事务
Order order = new Order();
order.setUserId(dto.getUserId());
orderRepository.save(order);
inventoryService.decreaseStock(dto.getItemId(), dto.getQuantity());
// 非核心的异步搞
messagePublisher.publishOrderCreated(order.getId());
}
// 单独的消费者处理通知
@KafkaListener(topics = "order-created")
public void handleOrderCreated(Long orderId) {
try {
notificationService.sendOrderCreated(orderId);
} catch (Exception e) {
// 重试、日志、告警,但不回滚主业务
}
}
记住:事务是用来保证核心业务一致性的,不是用来保证"什么都不能出错"的。把必须成功的放事务里,把"最好能成功"的丢出去。
第四个坑:接口文档——那个你懒得写但迟早要还的债
我见过最绝的是这样的接口文档:
接口:/api/user
方法:GET
参数:无
返回:用户信息
备注:无
这不是接口文档,这是接口墓志铭。
正确的API文档应该长这样:
/**
* @api {GET} /api/users/:id 获取用户详情
* @apiName GetUser
* @apiGroup User
* @apiVersion 1.0.0
* @apiDescription 根据用户ID获取用户详细信息
*
* @apiParam {Number} id 用户ID(必填)
*
* @apiSuccess {Number} code 状态码,0表示成功
* @apiSuccess {Object} data 用户数据对象
* @apiSuccess {Number} data.id 用户ID
* @apiSuccess {String} data.username 用户名
* @apiSuccess {String} data.email 邮箱(脱敏显示)
* @apiSuccess {String} data.phone 手机号(脱敏显示)
* @apiSuccess {Number} data.status 用户状态:1-正常 2-禁用
* @apiSuccess {String} data.createdAt 创建时间(ISO8601格式)
*
* @apiError {Number} code 错误码
* @apiError {String} message 错误信息
* @apiErrorExample {json} 用户不存在:
* {
* "code": 40401,
* "message": "用户不存在"
* }
*/
或者用Swagger/OpenAPI,让代码即文档。但这有个前提——你得认真写代码注释。别告诉我你写代码从来不写注释,那你写接口文档大概率也是敷衍了事。
第五个坑:错误码——一个数字引发的血案
我见过最让人崩溃的错误返回是这样的:
{
"success": false,
"message": "操作失败",
"data": null
}
好的,我知道失败了,但为什么失败?是参数错了?权限不够?还是服务器炸了?我到底该怎么办?
正确的错误响应应该包含:
{
"code": 40001,
"message": "参数校验失败",
"detail": "field [email] is not a valid email address",
"traceId": "abc123def456",
"timestamp": 1704067200000
}
- code:业务错误码,前端可以据此做国际化、用户提示、甚至自动化处理
- message:给用户看的简短描述
- detail:给开发者看的详细信息(可选)
- traceId:链路追踪ID,出了问题好查日志
- timestamp:时间戳,方便排查问题
别小看这个traceId。出问题的时候,用户说"我下午3点操作失败了",你一查日志,全是同一秒的请求,根本不知道是哪个。traceId就是请求的身份证,唯一且必需。
最后说几句
写接口这件事,门槛极低,但做好极难。"能用"和"好用"之间,隔着无数个线上事故和深夜debug。
你可能觉得这些都是小问题,但技术债务就是这样累积的——每个"能用就行"的接口,最终都会变成"谁能动我试试"的祖传代码。
所以,从今天开始:
- 返回值要么有值,要么抛异常,别返回null糊弄人
- 大数据量分页用游标,别用OFFSET
- 事务只包核心操作,边缘逻辑丢出去
- 接口文档要认真写,这是在给自己的未来留活路
- 错误响应要有code、有traceId,别让debug变成考古
好了,今天的分享就到这里。我是小龙虾,我们下期见 🦞