REST API 设计与 DTO:没有包装的礼物很危险

“咦?为什么密码能看见?”

刚入职那阵子,我在前端和后端之间来回切换,忙得不可开交。有一天,我正在把和我同期入职的同事写的“会员列表查询 API”接到前端页面上。

为了确认数据有没有正常返回,我打开了 Chrome 开发者工具(F12)的 Network 标签页。可当我看到响应数据的瞬间,我简直不敢相信自己的眼睛。

[
  {
    "id": 1,
    "username": "tester",
    "password": "$2a$10$D...", // 加密密碼外洩
    "ssn": "900101-1...",      // 身分證字號外洩
    "createdAt": "2024-01-01"
  }
]

我吓了一跳,马上问同事:“喂,这个 API 的响应里怎么连密码和身份证号都能看到?”

同事却很淡定地回答:“啊,那个? 反正页面上只显示名字嘛。我嫌麻烦,就直接把 userRepository.findAll() 的结果原样返回了,有问题吗?”

我后背一阵发凉。同事只是图省事,但结果却等于埋下了一颗严重安全事故的种子。

“不显示在页面上,不代表就安全!”

浏览器的 Network 标签页是谁都能看的。如果恶意攻击者来调用这个 API,会怎么样?

把实体原样暴露出去,和把自家卧室做成整面透明玻璃墙没什么区别。

里面装什么都一清二楚的透明袋,对小偷来说就是邀请函。

DTO:数据的“包装纸”

“实体(Entity)”就像工厂内部才会处理的“原材料”。它上面往往粘着很多不该给客户看的内容,比如机密信息(密码),还有内部管理字段(创建时间、修改时间、删除标记)。

而“DTO(Data Transfer Object)”则像是为了交付给客户而精心包装好的“成品”。

直接返回实体,就像餐厅不是给客人端上煎好的牛排,而是把带血的生肉直接扔到盘子里。我们必须经过“转换(Mapping)”这一步。

[Code Verification] 无限循环的泥潭(循环引用)

比安全问题更能把开发者逼疯的,其实是“循环引用(Circular Reference)”问题。还记得上一节学过的 JPA 双向映射 User <-> Team 吗?

如果把 User 实体原样转成 JSON 返回,会发生什么?

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne // 使用者隸屬於一個隊伍
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team") // 一個隊伍擁有多位使用者
    private List<User> users = new ArrayList<>();
}

在这种状态下返回 User,JSON 序列化器(Jackson)就会像下面这样工作。

结果: 服务器会伴随着 StackOverflowError 壮烈牺牲。实体之间的关系会互相追着尾巴跑,如果不先把这条链条切断就直接暴露出去,就会掉进“无限循环”。这也是为什么必须使用 DTO,只保留像 teamName 这样的必要字段。

如果不用 DTO 把关系切开,数据就会永远转个不停。

好 API 的条件:RESTful

如果已经用 DTO 把数据包装好了,接下来就要给这个包裹选一个合适的“地址”。这就是所谓的“REST API 设计”。

大学那会儿,我给 URL 起名完全是想到什么写什么。

joinupdate 这样的动词写进 URL 里并不好。RESTful 设计的原则是:把“资源(名词)”放进 URL,把“行为(动词)”交给 HTTP Method。

这样设计之后,光看 URL 就能知道:“啊,这是在处理用户资源。” 而且和前端开发者沟通起来也会轻松很多。这是全世界开发者之间默认遵守的共同约定。

实战建议:名字决定一半(DTO 命名策略)

现在打开公司项目代码时,我有时都会先叹一口气。UserDtoMemberVoInfoParamResultData……前任开发者随手命名,导致你在真正翻代码之前,根本不知道它到底是请求对象还是响应对象。

DTO 是数据的“容器”。如果容器的用途没有写在名字里,就会出现像把饭装进汤碗、把水倒进饭碗那样的混乱。在实务里,我最推荐的命名规则如下。

public class UserDto {
    // 註冊請求
    public static class SignUpRequest {
        private String email;
        private String password;
    }

    // 會員資料回應
    public static class Response {
        private String name;
        private String email;
    }
}

系列 2 结束语:房子现在算是盖起来了

至此,[Monolith Builder] 系列就结束了。我们用 Spring Boot(DI)打好了地基,完成了前后端分离(CORS),学会了用 JPA 操作数据库,也掌握了通过 DTO 和 REST API 进行协作的方式。

现在,我们已经具备了“一个人也能做出像样 Web 服务”的能力。但开发者的旅程并不会在这里结束。如果我做出来的服务只在自己的电脑,也就是 Localhost 上跑得好,又有什么意义? 想让真实用户看到,就必须把它部署到“服务器”上。

可服务器并不是 Windows,而是只有黑色终端画面的 Linux。没有鼠标的地方,我们到底该怎么安装和运行程序?

在下一套系列 [Works on My Machine] 中,我们就一起进入 Linux 和 Docker 的世界,去解决开发者永恒的难题:“在我电脑上明明能跑,为什么到了服务器上就不行?”

發佈留言