如何在Firestore事务中实现getOrCreate逻辑以构建引用收藏服务?
我正在开发一个引用收藏服务,数据结构如下:
quote集合包含quote、userId、authorId、sourceId四个字段;author集合包含name、userId两个字段;source集合包含name(例如“Star Wars”)、type(如book、film)、userId三个字段。
我的需求是:当用户保存引用时,通过Firestore事务完成以下操作:
- 检查作者是否存在(按name+userId查询,存在则返回authorId,不存在则创建作者);
- 对来源(name+type+userId)执行同样逻辑,获取对应的sourceId;
- 最后创建包含authorId和sourceId的quote对象。
但我发现Firestore的事务仅支持getDocument方法,无法使用whereField()进行查询。我了解到可以通过规则强制创建,并用try/catch捕获错误后执行getDocument,但不确定是否可行。如果只能依赖getDocument,是否需要将author/source集合的ID设为类似“userId + 哈希(作者/来源名称)”的复合键?请问该如何实现这一需求,Firestore是否支持此类场景?
附上我的伪代码:
Firestore.transaction { // Get or create author let author = Author.getOrCreate("Yoda", userId) // Get or create source let source = Source.getOrCreate("Star Wars", "film", userId) // Save quote let quote = Quote.create({ quote: "Do or do not. There is no try", authorId: author.id, sourceId: source.id, userId: userId }) }
解决方案:基于复合文档ID的Get-Or-Create模式
Firestore确实不支持在事务中执行查询(比如whereField)——事务只能针对已知的文档ID进行读写操作,这是为了保证事务的原子性和性能。所以你的思路是对的:要实现事务内的get-or-create,必须提前确定文档的唯一ID,也就是把“用户唯一标识+内容标识”组合成文档ID,避免重复。
1. 设计复合文档ID
对于author集合,每个用户的作者名称是唯一的(同一个用户不会重复创建同名作者),所以可以把文档ID设为:
`${userId}_${authorName.toLowerCase().replace(/\s+/g, '-')}`
或者更稳妥的方式是对作者名称做哈希(比如SHA-1),避免特殊字符或过长的问题:
`${userId}_${hash(authorName)}`
同理,source集合的文档ID需要包含userId、sourceName和type,因为同一个用户可能有同名但不同类型的来源(比如叫“Dune”的书和电影),所以ID可以是:
`${userId}_${hash(sourceName)}_${type}`
这样每个“用户-作者”和“用户-来源-类型”组合都对应唯一的文档ID,事务里可以直接通过ID获取文档。
2. 事务内的Get-Or-Create实现
在事务中,你可以先尝试获取指定ID的文档:
- 如果文档存在,直接拿到它的ID;
- 如果文档不存在,就在事务内创建这个文档,然后使用新生成的ID。
以JavaScript为例,实现getOrCreateAuthor的逻辑大概是这样:
async function getOrCreateAuthor(transaction, userId, authorName) { const authorDocId = `${userId}_${crypto.createHash('sha1').update(authorName.toLowerCase()).digest('hex')}`; const authorRef = firestore.collection('author').doc(authorDocId); const authorDoc = await transaction.get(authorRef); if (authorDoc.exists) { return { id: authorDoc.id, ...authorDoc.data() }; } else { // 创建新作者文档 await transaction.set(authorRef, { name: authorName, userId: userId }); return { id: authorDocId, name: authorName, userId }; } }
getOrCreateSource的逻辑类似,只是文档ID要包含type:
async function getOrCreateSource(transaction, userId, sourceName, type) { const sourceDocId = `${userId}_${crypto.createHash('sha1').update(sourceName.toLowerCase()).digest('hex')}_${type}`; const sourceRef = firestore.collection('source').doc(sourceDocId); const sourceDoc = await transaction.get(sourceRef); if (sourceDoc.exists) { return { id: sourceDoc.id, ...sourceDoc.data() }; } else { await transaction.set(sourceRef, { name: sourceName, type: type, userId: userId }); return { id: sourceDocId, name: sourceName, type, userId }; } }
然后把这些方法整合到事务里:
async function createQuote(userId, quoteText, authorName, sourceName, sourceType) { const firestore = getFirestore(); return firestore.runTransaction(async (transaction) => { // 获取或创建作者 const author = await getOrCreateAuthor(transaction, userId, authorName); // 获取或创建来源 const source = await getOrCreateSource(transaction, userId, sourceName, sourceType); // 创建引用 const quoteRef = firestore.collection('quote').doc(); await transaction.set(quoteRef, { quote: quoteText, userId: userId, authorId: author.id, sourceId: source.id }); return { quoteId: quoteRef.id, author, source }; }); }
3. 关于try/catch+规则的方案可行性
你提到的“用规则强制创建,try/catch捕获错误后get”的方案其实也能工作,但不推荐在事务中使用:因为try/catch的方式本质是先尝试写入,失败(比如文档已存在)再读取,这会增加额外的网络请求,而且在高并发场景下可能出现多次重试,性能不如直接用复合ID的方式稳定。
不过如果你的业务场景中用户重复创建同一作者/来源的概率极低,这种方式也可以作为备选,但要注意Firestore的写入冲突重试机制——事务本身会自动重试,所以try/catch需要和事务的重试逻辑配合好,避免逻辑混乱。
4. Firestore对这类场景的支持
Firestore完全支持这种基于复合ID的get-or-create模式,这也是官方推荐的在事务中处理“唯一约束”场景的方案。因为事务能保证整个操作的原子性:要么作者、来源、引用都创建成功,要么全部失败,不会出现中间状态。
内容的提问来源于stack exchange,提问作者Mickey Cheong

