概述
发布动态这个功能要动两样东西:一个 movement 集合、一个 POST /movements 接口。这里把这两样一次讲透,字段为什么这么设计、媒体数据为什么嵌进文档而不是拆表、带文件上传的表单接口和之前见过的纯 JSON 接口差在哪。
纲要
- 数据模型
movement集合:Movement实体完整代码- 字段逐一分析:pid / userId / textContent / medias / locationName / longitude / latitude / state / created
medias数组嵌入 vs 单独建 media 表的取舍pid与_id:业务键 ≠ 主键- 接口文档
POST /movements:multipart 表单,第三种接口形态- 请求参数表 + 请求示例
- 发布流程概述(细节留给代码实现篇)
- 原型还原:发动态页面线框图
- 坑与验证:孤儿文件、文档 16MB 上限、mongosh 样例验证
这里在圈子系列中的位置
圈子的整体表设计(为什么选 MongoDB、四张表怎么分工)在圈子:表设计方案里已经讲过,点赞、评论、喜欢这些互动数据的存储后面互动章节会单独展开。这里只聚焦一件事:动态主表 movement 的字段设计,以及「发动态」这一个接口的定义 。时间线表 movement_timeline、好友表 friend 只在流程图里露个脸,不展开。
movement 集合:动态主表
实体定义
工程实读 mf-model 模块下的 com.mf.model.mongo.Movement,这是一个 MongoDB 实体,用 @Document 声明集合名:
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
//动态详情表
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "movement")
public class Movement implements java.io.Serializable {
private ObjectId id; //主键id
//redis实现,使用Mongodb实现
private Long pid; //Long类型,用于推荐系统的模型(自动增长)
private Long created; //发布时间
private Long userId;
private String textContent; //文字
private List<String> medias; //媒体数据,图片或小视频 url
private String longitude; //经度
private String latitude; //纬度
private String locationName; //位置名称
private Integer state = 0;//状态 0:未审(默认),1:通过,2:驳回
}
几个实读细节值得先说清:
- 没有继承
BasePojo。项目里部分 Mongo 实体会抽公共字段(created/updated)到父类,Movement没这么做,created直接平铺在实体里,取值是毫秒时间戳,由 API 层在保存前手动赋值。 - 没有
updated字段。动态发出去之后几乎不改(编辑朋友圈都不支持),所以只留创建时间。 - 没有用
@Field注解 。Java 字段名和文档字段名一一对应(textContent就存成textContent),不需要改名映射。 id字段没加@Id,但 Spring Data MongoDB 对名为id的ObjectId字段会自动识别为主键,落库时若为 null 会自动生成。_class字段是 Spring Data 自动写入的类型标识,代码里看不到,查询文档时能看到。
字段逐个看设计理由
| 字段 | 类型 | 含义 | 设计理由 |
|---|---|---|---|
id |
ObjectId | MongoDB 主键 | 集合默认主键,自动生成,全局唯一 |
pid |
Long | 发布唯一标识(业务 id) | 给推荐系统用的数字自增 id,底层用 MongoDB findAndModify + $inc 操作 sequence 集合保证递增(工程早期注释写「redis实现」,实读 IdWorker 已切到 Mongo 实现)。推荐模型算相似度时需要一个纯数字、有序的标识,ObjectId 那串十六进制不好使 |
userId |
Long | 发布者 id | 关联 user_info 表。存 id 不存昵称头像,用户改昵称不用回刷所有动态 |
textContent |
String | 文字内容 | 原型约束 140 字以内,纯文本存储 |
medias |
List<String> | 媒体地址数组 | 只存 URL,图片本体在 OSS。0 到 6 个元素,图片和小视频混用同一字段 |
longitude |
String | 经度 | 冗余存位置,为「附近的人/同城动态」预留 |
latitude |
String | 纬度 | 同上 |
locationName |
String | 位置名称 | 客户端定位后传上来的中文描述,如「中国上海市奉贤区人民路445弄」,直接展示用 |
state |
Integer | 审核状态 | 默认 0(未审),1 通过,2 驳回。内容审核是异步链路,先落库再审 |
created |
Long | 发布时间(毫秒) | API 层保存前用 System.currentTimeMillis() 赋值 |
按一条真实文档看更直观(讲义样例,_class 那行是 Spring Data 自动写的类型标识):
json
{
"_id": ObjectId("5e82dc416401952928c211d8"),
"pid": NumberLong("10064"),
"userId": NumberLong("6"),
"textContent": "最悲伤却又是最痛苦的谎言,就是我还好,没有关系。",
"medias": [
"https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/7/1.jpg",
"https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/7/1564567349498.jpg",
"https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/7/1564567352977.jpg"
],
"longitude": "121.588627",
"latitude": "30.935781",
"state": NumberInt("0"),
"locationName": "中国上海市奉贤区人民路445弄",
"created": NumberLong("1585634369493"),
"_class": "com.mf.dubbo.server.pojo.Publish"
}
顺带指个教材里的坑:样例里 _class 写的是 com.mf.dubbo.server.pojo.Publish,那是另一版项目工程的类名残留;这里配套代码的实体是 com.mf.model.mongo.Movement,字段名两者一致,别被类名带偏。
medias 用数组嵌入,而不是单独建 media 表
这是 movement 设计里最值得说的决定。一条动态最多 6 张图,图片和动态是典型的「包含关系」,图片离开动态没有独立意义,也不会被单独查询、单独分页。放到关系型数据库里你可能习惯拆一张 movement_media 表,用外键关联;在 MongoDB 里直接把 URL 数组嵌进文档。
两种方案对比:
| 维度 | 数组嵌入(实际方案) | 单独建 media 集合 |
|---|---|---|
| 读取一条动态 | 一次查询,文字图片全拿到 | 查动态 + 按 movementId 查媒体,两次查询 |
| 写入 | 与动态同文档原子写入 | 两次写入,需要自己保证一致性 |
| 查「包含某张图的动态」 | 没这需求,无所谓 | 天然支持 |
| 数组膨胀风险 | 最多 6 个 URL,可控 | 无 |
| 文档大小 | 每个 URL 约 70 字节,6 个不到 1KB | 无 |
有人会拿 MongoDB 文档 16MB 上限说事,那条限制是针对整个文档的,但这里只存 URL 不存图片本体 ,图片二进制放在 OSS。就算存满 6 张图的 URL 加上正文,离 16MB 还差好几个数量级。嵌入的前提是「子数据小、有界、只随父文档一起读写」,medias 三条全占,所以放心嵌。
这也呼应了体系结构篇说过的原则:嵌入式文档表达包含关系,引用表达关联关系 。图片「属于」动态,嵌;用户「发布」动态但用户是独立实体,只存 userId 引用。
位置信息为什么冗余存三个字段
longitude、latitude、locationName 三个字段一起存,看起来重复,经纬度算一下不就有位置了?反过来才对:经纬度能算出距离,算不出「中国上海市奉贤区人民路445弄」这个给人看的名字,逆地理编码是客户端定位时就已经做好的,直接传上来存住,展示时零成本。
更重要的是,经纬度留在文档里是为后面「附近的人」「同城动态」铺路,MongoDB 支持地理位置索引(2dsphere),建了索引就能按距离检索动态。这里一句点到,检索怎么建索引、怎么算距离,留给附近的人那章。
pid 与 _id:业务键 ≠ 主键
id(ObjectId)是存储层主键,pid 是业务层的发布唯一标识,两套并存。这个套路前面反复出现过:业务需要一个有业务含义、有序、数字化的标识时,单独造一个业务键,别污染主键。pid 的生成在 API 层保存动态前通过 IdWorker(底层 Redis 自增)取号:
java
movement.setPid(idWorker.getNextId("movement"));
movement.setCreated(System.currentTimeMillis());
mongoTemplate.save(movement);
为什么推荐系统不直接用 _id?ObjectId 虽然宏观递增,但它是 12 字节的十六进制串,做向量相似度、排序模型输入时不如一个干净的 Long 好处理,而且 pid 语义上是「第几条发布的动态」,数字大小直接反映先后顺序。
接口文档:POST /movements
第三种接口形态
到这篇为止,项目里出现了三种接口形态,值得放一起对照:
| 形态 | 代表接口 | Content-Type | 服务端接收方式 |
|---|---|---|---|
| 纯 JSON | 登录 POST /login(登录篇) |
application/json | @RequestBody 绑定 |
| 纯文件 | 头像上传 POST /users/header(头像篇) |
multipart/form-data | 单个 MultipartFile |
| JSON 字段 + 文件混合 | 发动态 POST /movements |
multipart/form-data | 实体对象 + MultipartFile[] |
发动态要同时提交文字、位置这些结构化字段和若干张图片文件,于是整个请求走 multipart 表单:文本字段和文件都是表单里的普通 part。注意一个容易踩的点,既然是表单提交,Controller 就不能加 @RequestBody 了 (它只认 JSON 请求体),Spring MVC 会按表单字段名自动绑定到 Movement 对象的同名属性上。
接口定义
- 请求路径:
POST /movements - 请求方式:POST,multipart/form-data
- 请求参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| textContent | String | 是 | 动态文字内容,140 字以内 |
| longitude | String | 否 | 经度 |
| latitude | String | 否 | 纬度 |
| locationName | String | 否 | 位置的中文描述 |
| imageContent | MultipartFile\[\] | 否 | 图片文件列表,客户端一次上传多张(或视频),服务端用 MultipartFile[] 接收 |
- 响应:发布成功返回状态码 200,响应体为空(
ResponseEntity.ok(null));内容为空时抛业务异常,返回统一错误结构。
请求示意
用 Postman 或前端 FormData 发一个典型请求长这样:
http
POST /movements HTTP/1.1
Host: mf-server
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="textContent"
今天天气不错
------WebKitFormBoundary
Content-Disposition: form-data; name="longitude"
121.588627
------WebKitFormBoundary
Content-Disposition: form-data; name="latitude"
30.935781
------WebKitFormBoundary
Content-Disposition: form-data; name="locationName"
中国上海市奉贤区人民路445弄
------WebKitFormBoundary
Content-Disposition: form-data; name="imageContent"; filename="a.jpg"
Content-Type: image/jpeg
<二进制图片数据>
------WebKitFormBoundary
Content-Disposition: form-data; name="imageContent"; filename="b.jpg"
Content-Type: image/jpeg
<二进制图片数据>
------WebKitFormBoundary--
多张图片用同名参数 imageContent 重复出现 ,Spring MVC 会把它们收进 MultipartFile[] 数组。前端对应的 FormData 写法是对同一个 key 多次 append("imageContent", file)。
发布流程概述
接口背后的完整链路,为代码实现篇铺垫:
#mermaid-svg-AKTMy4oKEgym3IGW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AKTMy4oKEgym3IGW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AKTMy4oKEgym3IGW .error-icon{fill:#552222;}#mermaid-svg-AKTMy4oKEgym3IGW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AKTMy4oKEgym3IGW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AKTMy4oKEgym3IGW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AKTMy4oKEgym3IGW .marker.cross{stroke:#333333;}#mermaid-svg-AKTMy4oKEgym3IGW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AKTMy4oKEgym3IGW p{margin:0;}#mermaid-svg-AKTMy4oKEgym3IGW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AKTMy4oKEgym3IGW .cluster-label text{fill:#333;}#mermaid-svg-AKTMy4oKEgym3IGW .cluster-label span{color:#333;}#mermaid-svg-AKTMy4oKEgym3IGW .cluster-label span p{background-color:transparent;}#mermaid-svg-AKTMy4oKEgym3IGW .label text,#mermaid-svg-AKTMy4oKEgym3IGW span{fill:#333;color:#333;}#mermaid-svg-AKTMy4oKEgym3IGW .node rect,#mermaid-svg-AKTMy4oKEgym3IGW .node circle,#mermaid-svg-AKTMy4oKEgym3IGW .node ellipse,#mermaid-svg-AKTMy4oKEgym3IGW .node polygon,#mermaid-svg-AKTMy4oKEgym3IGW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AKTMy4oKEgym3IGW .rough-node .label text,#mermaid-svg-AKTMy4oKEgym3IGW .node .label text,#mermaid-svg-AKTMy4oKEgym3IGW .image-shape .label,#mermaid-svg-AKTMy4oKEgym3IGW .icon-shape .label{text-anchor:middle;}#mermaid-svg-AKTMy4oKEgym3IGW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AKTMy4oKEgym3IGW .rough-node .label,#mermaid-svg-AKTMy4oKEgym3IGW .node .label,#mermaid-svg-AKTMy4oKEgym3IGW .image-shape .label,#mermaid-svg-AKTMy4oKEgym3IGW .icon-shape .label{text-align:center;}#mermaid-svg-AKTMy4oKEgym3IGW .node.clickable{cursor:pointer;}#mermaid-svg-AKTMy4oKEgym3IGW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AKTMy4oKEgym3IGW .arrowheadPath{fill:#333333;}#mermaid-svg-AKTMy4oKEgym3IGW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AKTMy4oKEgym3IGW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AKTMy4oKEgym3IGW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AKTMy4oKEgym3IGW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AKTMy4oKEgym3IGW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AKTMy4oKEgym3IGW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AKTMy4oKEgym3IGW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AKTMy4oKEgym3IGW .cluster text{fill:#333;}#mermaid-svg-AKTMy4oKEgym3IGW .cluster span{color:#333;}#mermaid-svg-AKTMy4oKEgym3IGW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AKTMy4oKEgym3IGW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AKTMy4oKEgym3IGW rect.text{fill:none;stroke-width:0;}#mermaid-svg-AKTMy4oKEgym3IGW .icon-shape,#mermaid-svg-AKTMy4oKEgym3IGW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AKTMy4oKEgym3IGW .icon-shape p,#mermaid-svg-AKTMy4oKEgym3IGW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AKTMy4oKEgym3IGW .icon-shape .label rect,#mermaid-svg-AKTMy4oKEgym3IGW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AKTMy4oKEgym3IGW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AKTMy4oKEgym3IGW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AKTMy4oKEgym3IGW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端提交表单
Controller 接收 Movement + MultipartFile 数组
校验 textContent 非空
逐张上传 OSS 换取 URL
组装 Movement: userId / medias
API 层: 生成 pid / created
movement 集合
异步写入好友时间线
三步主干:图片先传 OSS 拿 URL → 组装 Movement 对象 → mongoTemplate.save 落库。落库之后还要往每个好友的时间线表里写一条记录,这一步项目里已经改成了 @Async 异步执行,异步改造的前因后果在发布圈子动态:代码优化(异步处理的改造)一篇里展开,这里不重复。
服务端接收侧的核心代码(Controller 签名部分):
java
@RestController
@RequestMapping("/movements")
public class MovementController {
@Autowired
private MovementService movementService;
/**
* 发布动态
*/
@PostMapping
public ResponseEntity movements(Movement movement,
MultipartFile imageContent[]) throws IOException {
movementService.publishMovement(movement, imageContent);
return ResponseEntity.ok(null);
}
}
原型还原:发动态页面
从原型(圈子.html)还原发布入口和发布页两屏:
doc
+----------------------------------+
| <- 返回 发动态 |
+----------------------------------+
| |
| +----------------------------+ |
| | 请填写动态(140字以内) | |
| | | |
| | | |
| +----------------------------+ |
| |
| [图1] [图2] [图3] |
| [图4] [图5] [+] |
| |
| @ 定位 |
| |
| [ 上传 ] |
| |
+----------------------------------+
doc
+----------------------------------+
| 选择照片 返回 |
+----------------------------------+
| |
| [img] [img] [img] |
| [img] [img] [img] |
| [img] [img] [img] |
| |
| 最多可选6张 |
| |
+----------------------------------+
要点:
- 文案取自原型原文:「请填写动态(140字以内)」、「选择照片」、「上传」、「最多可选6张」。
- 点击九宫格的「+」进入相册选择列表,一次最多可以选择6张照片,超过6张后就不能点击了 ,这个 6 正是服务端
medias数组长度上限的产品侧来源。 - 关于字数,原型的说法是「内容不进行字数限制,但是不能超过140字」,听起来矛盾,实际含义是服务端不做强制截断,140 字是前端提示口径。
- 「定位」控件取到经纬度和地名,对应表里的
longitude/latitude/locationName三字段。
坑表
| 坑 | 说明 |
|---|---|
前端传参与 medias 的对应 |
前端对同名 key imageContent 多次 append,服务端才收得到数组;名字对不上,MultipartFile[] 就是 null,循环上传时直接 NPE |
| 文本与图片的事务边界 | 图片先传 OSS 成功、随后落库失败,OSS 上就留下没人引用的孤儿文件。MongoDB 单文档写入是原子的,但「OSS + MongoDB」跨存储没有事务,只能靠兜底清理或容忍孤儿文件。头像篇已经遇到过同款问题,思路一致 |
| 位置信息隐私 | 经纬度是用户实时位置,属于敏感数据;接口层面这部分参数是选填,客户端要给「不显示位置」的选项,存储和展示都要考虑脱敏(动态页只显示「距离你 20km」这类模糊值) |
| 文档大小上限 | 单个 MongoDB 文档上限 16MB。medias 只存 URL,大小无忧;但如果哪天有人想直接把图片 Base64 塞进文档,6 张图轻松超限,红线是「媒体只存 URL」 |
讲义样例的 _class |
讲义文档样例里 _class 是旧工程类名 Publish,以这里实体 Movement 为准,字段定义两者一致 |
验证要点
集合不用手动建,mongoTemplate.save 首次落库时自动创建。想提前验证字段设计,可以手动插一条样例再查回来:
javascript
// mongosh
use mf
// 插入一条样例动态
db.movement.insertOne({
pid: NumberLong("10001"),
userId: NumberLong("106"),
textContent: "测试动态",
medias: [
"https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/106/1.jpg"
],
longitude: "121.588627",
latitude: "30.935781",
locationName: "中国上海市奉贤区人民路445弄",
state: NumberInt("0"),
created: NumberLong("1585634369493")
})
// 查询确认
db.movement.find({ userId: NumberLong("106") }).pretty()
查询结果里 _id 已由 MongoDB 自动补上 ObjectId,_class 也自动写入,这两点正是「主键交给存储层、业务键自己管」的直观体现。
API 速览
| 接口 | 方法 | 参数形态 | 说明 |
|---|---|---|---|
| /movements | POST | multipart/form-data | 发布动态;表单字段绑定 Movement,imageContent 接收多文件;成功返回 200 空体 |
官方文档
- Spring Data MongoDB - Mapping:
@Document、ObjectId主键映射、_class字段的官方说明 - MongoDB 文档大小限制:16MB 上限的权威出处
常见误解澄清
| 误解 | 澄清 |
|---|---|
| 图片存在 MongoDB 里 | medias 只存 OSS 的 URL,图片二进制根本不进数据库 |
| medias 该拆成单独的媒体表 | 媒体与动态是包含关系、数量有界(≤6)、只随动态一起读写,嵌入数组是最优解;拆表反而多一次查询 |
_id 就是动态 id,够了 |
推荐系统需要数字有序的 pid,_id 是 ObjectId,两套并存各司其职 |
| 发布接口是 JSON 提交 | 带文件上传,整个请求是 multipart 表单,Controller 不能用 @RequestBody |
movement 的 created 来自 BasePojo |
实读实体没有继承 BasePojo,created 平铺在实体里、API 层手动赋值 |
| 没有审核概念,发出去就能刷到 | 有 state 字段(0 未审 / 1 通过 / 2 驳回),内容审核是独立异步链路 |
总结
movement集合是动态主表:pid(业务自增 id,给推荐系统)、textContent、medias(URL 数组)、位置三字段、state审核状态、created毫秒时间戳。medias数组嵌入是这个设计的题眼:子数据小、有界、只随父读写,嵌进文档一次查询拿全;图片只存 URL,16MB 上限形同虚设。- 位置三字段冗余存,经纬度为地理索引预留,
locationName为直接展示省掉逆地理编码。 POST /movements是项目里第三种接口形态:multipart 表单同时装文本字段和多文件,字段名imageContent对应MultipartFile[]。- 发布链路主干:传 OSS → 组装对象 → 落库;时间线写入已异步化,细节见异步改造篇。
下一篇发布圈子动态:业务流程和代码步骤分析把发布链路拆成一步步的代码动作,Controller、Service、API 三层怎么协作,那里见。