七、集成 NoSQL数据库

环境准备
安装 elastic、Redis、Mongodb略
bash
(fastapi) root@simon:/FastAPI-Cookbook-main/Chapter07/streaming_platform#
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--reload
数据库
数据库连接
py
# db_connection.py
import logging
from elasticsearch import (
# Elasticsearch的异步Python客户端
AsyncElasticsearch,
# Elasticsearch操作时可能抛出的传输相关异常
TransportError,
)
# 异步MongoDB客户端
from motor.motor_asyncio import AsyncIOMotorClient
# 异步Redis客户端
from redis import asyncio as aioredis
# uvicorn会自动配置日志系统 直接进行获取即可
logger = logging.getLogger("uvicorn")
# 定义各服务客户端 首先确保这些服务已经在本地运行了
mongo_client = AsyncIOMotorClient(
"mongodb://localhost:27017"
)
es_client = AsyncElasticsearch("http://localhost:9200")
redis_client = aioredis.from_url("redis://localhost")
# 这几个ping方法将被main方法的异步上下文管理器 lifespan 调用
# mongodb 连接测试
async def ping_mongo_db_server():
try:
# 向admin数据库发送了个 ping 命令
await mongo_client.admin.command("ping")
logger.info("Connected to MongoDB")
except Exception as e:
logger.error(
f"Error connecting to MongoDB: {e}"
)
raise e
# Elasticsearch 连接测试
async def ping_elasticsearch_server():
try:
# 调用info()方法获取Elasticsearch服务器信息来测试连接
await es_client.info()
logger.info(
"Elasticsearch connection successful"
)
except TransportError as e:
logger.error(
f"Elasticsearch connection failed: {e}"
)
raise e
# Redis 连接测试
async def ping_redis_server():
try:
# 进行测试
await redis_client.ping()
logger.info("Connected to Redis")
except Exception as e:
logger.error(f"Error connecting to Redis: {e}")
raise e
数据库设置
py
# database.py(1)
from app.db_connection import es_client, mongo_client
# 定义了一个名为 beat_streaming 的数据库
# motor 库会自行检查或者创建
# Motor库重写了 __getattr__ 方法,实现了动态属性访问机制
database = mongo_client.beat_streaming
# 在main方法中被调用 返回当前的 database 对象,用于 main 中端点的依赖注入
def mongo_database():
return database
# 关于 Elasticsearch的部分 后续继续进行补充
songs_index_mapping = {
"mappings": {
"properties": {
"artist": {"type": "keyword"},
"views_per_country": {
"type": "object",
"dynamic": True,
},
}
}
}
async def create_es_index():
await es_client.options(
ignore_status=[400, 404]
).indices.create(
index="songs_index",
body=songs_index_mapping,
)
CRUD操作端点
py
# main.py
# 向集合中添加一首歌
@app.post("/song")
async def add_song(
# Body() 用于从HTTP请求体中提取JSON数据
song: dict = Body(
# example 参数提供API文档示例
example={
"title": "My Song",
"artist": "My Artist",
"genre": "My Genre",
},
),
# 进行依赖注入
mongo_db=Depends(mongo_database),
):
# 插入单个文档
await mongo_db.songs.insert_one(song)
return {
"message": "Song added successfully",
# main方法配置了 ENCODERS_BY_TYPE[ObjectId] = str
"id": song.get("_id"),
}
!NOTE
为什么要写
"id": song.get("_id"):原始的
song是一个普通的 Pythondict,例如:
json{ "title": "My Song", "artist": "My Artist", "genre": "My Genre" }执行:
pythonawait mongo_db.songs.insert_one(song)如果文档里没有
_id字段,PyMongo 会自动生成一个ObjectId;同时PyMongo会对个_id字段进行原地修改。insert_one调用完之后,内存里的song这个 dict 变成了类似:
python{ "_id": ObjectId("65a..."), "title": "My Song", "artist": "My Artist", "genre": "My Genre" }因此通过
python"id": song.get("_id")就能拿到刚生成的
_id。同时在
main方法中,有
pythonENCODERS_BY_TYPE[ObjectId] = str告诉 FastAPI:如果响应里出现
ObjectId类型,就用str()把它自动转成字符串 。所以即使song.get("_id")是ObjectId,最终返回给前端看到的是字符串形式的 id。!IMPORTANT
NoSQL 文档库和传统 SQL 表库在设计思路上的差异
接口直接接收「任意字典」而不是固定表结构
python# 请求体类型是 dict,只给了一个示例结构,并没有强制字段集合或字段类型 song: dict = Body( example={ "title": "My Song", "artist": "My Artist", "genre": "My Genre", }, )甚至可以在请求时写入
json{ "title": "Song A", "artist": "Someone", "genre": "Rock", "tags": ["live", "HD"], "extra_info": {"source": "yt", "duration": 123} }体现的 NoSQL 特性:
- 集合(collection)不需要预先定义严格 schema;
- 每条文档可以有不同字段集、不同嵌套结构,新增字段不需要迁移。
对比 SQL:
- SQL 中你必须先
CREATE TABLE songs (id INT, title VARCHAR(...), artist VARCHAR(...), ...);- 如果突然要加
extra_info这种 JSON 结构,通常需要:
- 改表(
ALTER TABLE),或者- 设计一个单独的扩展表,或者
- 用某些 JSON 类型字段(但依然受表结构约束)。
接口层通常会对应一个固定的 ORM / Pydantic 模型,而不是
dict。
文档是 JSON 风格的一整块,而不是拆成多表
示例里的文档结构:
json{ "title": "My Song", "artist": "My Artist", "genre": "My Genre" }在实际项目中,则可以是
json{ "title": "My Song", "artist": "My Artist", "genre": "My Genre", "album": { "name": "Album X", "release_year": 2020 }, "views_per_country": { "US": 1000, "CN": 500 } }所有数据在一条文档中,读一首歌就能拿到所有信息。
NoSQL 的典型特征:
- 倾向于把「一条业务实体」相关的属性 嵌套在一个文档中;
- 为读优化,避免频繁 join。
对比 SQL:
- 通常会拆成多张表:
songs、albums、song_views等;- 查询时通过 join/多次查询拼回来;
_id自动生成、自动补回文档插入部分:
pythonawait mongo_db.songs.insert_one(song) return { "message": "Song added successfully", "id": song.get("_id"), }
insert_one(song)会:
- 如果文档里没
_id,自动生成一个ObjectId;- 把
_id原地写回你传入的songdict。没有显式声明主键类型、序列,也不用在 Python 里管理自增 id。
NoSQL 的特点:
- 主键
_id是文档级的,自动生成、全局唯一;- 不需要在「建表」阶段定义主键类型和自增策略。
对比 SQL:
必须在建表时定义主键、类型、是否自增等:
sqlid SERIAL PRIMARY KEY应用代码通常通过 ORM 的模型来拿
id,模型结构和表字段紧耦合。
py
# main.py
# 检索单首歌的端点
@app.get("/song/{song_id}")
async def get_song(
song_id: str,
db=Depends(mongo_database),
):
song = await db.songs.find_one(
{
"_id": ObjectId(song_id)
if ObjectId.is_valid(song_id)
else None
}
)
if not song:
raise HTTPException(
status_code=404, detail="Song not found"
)
song.pop("album", None)
return song
# 更新歌曲端点
@app.put("/song/{song_id}")
async def update_song(
song_id: str,
updated_song: dict,
db=Depends(mongo_database),
):
result = await db.songs.update_one(
{
"_id": ObjectId(song_id)
if ObjectId.is_valid(song_id)
else None
},
{"$set": updated_song},
)
if result.modified_count == 1:
return {"message": "Song updated successfully"}
raise HTTPException(
status_code=404, detail="Song not found"
)
# 删除歌曲端点
@app.delete("/song/{song_id}")
async def delete_song(
song_id: str,
db=Depends(mongo_database),
):
result = await db.songs.delete_one(
{
"_id": ObjectId(song_id)
if ObjectId.is_valid(song_id)
else None
}
)
if result.deleted_count == 1:
return {"message": "Song deleted successfully"}
raise HTTPException(
status_code=404, detail="Song not found"
)
# 获取所有歌曲的端点
@app.get("/songs")
async def get_songs(
db=Depends(mongo_database),
):
songs = await db.songs.find().to_list(None)
return songs
嵌入与引用
关系型数据库具有固定的表结构(schema) ,在使用前必须通过 CREATE TABLE 明确定义字段名称、数据类型、主键、外键等。表与表之间的关系通常通过外键与 SQL 的 JOIN 操作 来表达 ;例如,用户表和订单表通过 user_id 字段建立关联,查询完整信息时往往需要将多张表 JOIN 在一起。这类关系是强约束的:外键机制可以在插入或删除数据时自动执行级联操作或拒绝不合规的操作,从而保障数据的一致性。
相比之下,MongoDB 等文档型 NoSQL 数据库没有固定的 schema ,集合(collection)不要求所有文档具有相同的字段结构,使得数据模型可以灵活地随业务需求演化。数据库层面不提供 JOIN 或外键约束 ,这意味着关系不能像在 SQL 中那样通过声明式语法自动维护。取而代之的是两种常见的建模方式:嵌入(Embedding) ,即将相关数据直接内嵌到同一个文档中;或引用(Referencing) ,即在一个文档中保存另一个文档的 ID,由应用程序在需要时发起额外查询来"手动"关联数据。MongoDB主要通过嵌入和引用进行建模。
嵌入就是把相关数据直接嵌套在同一个文档中,一起存、一块读。
json
{
"title": "歌曲标题",
"artist": "歌手名称",
"genre": "音乐类型",
"album": {
"title": "专辑标题",
"release_year": 2017
}
}
在 MongoDB 中,创建歌曲时可以直接在同一个 JSON 文档中嵌入完整的专辑信息,并通过 insert_one(song) 一次性写入。MongoDB 会原样保留你提供的嵌套结构,无需预先定义字段或关系。
这种模式特别适用于关系紧密且基本不变 的数据场景。例如,一首歌与其所属的专辑通常在专辑发布后就不再频繁变更,两者天然绑定;同时,这类数据往往读多写少且经常一起读取------比如在展示歌曲详情时,通常也需要一并显示专辑封面、发行年份等信息。
采用嵌入方式的主要优势在于性能与简洁性:只需一次读取操作就能获取完整的歌曲与专辑信息 ,显著提升读取性能;同时,MongoDB 保证单个文档内的更新是原子操作,避免了并发修改时的数据不一致问题。此外,嵌套文档的结构天然贴近前端使用的 JSON 模型,开发体验更加直观。
然而,嵌入设计也存在明显代价。最突出的问题是数据重复 :如果一张专辑包含十首歌,专辑信息就会被完整复制十次,造成存储冗余。当嵌入的内容体积较大或更新频繁时,问题会进一步放大;不仅文档整体变大,影响读写效率,还容易因更新遗漏导致多处副本内容不一致(例如修改了某首歌中的专辑名,却忘了同步其他歌曲)。
引用就是文档中只存 其他文档的 ID 列表,真正内容需要再查一次。
引用关系的典型用例可以是创建播放列表。一个播放列表包含多首歌曲,每首歌曲可以出现在不同的播放列表中。此外,播放列表经常被更改或更新,因此需要引用策略来管理关系。
python
# main.py
class Playlist(BaseModel):
name: str
songs: list[str] = []
@app.post("/playlist")
async def create_playlist(
playlist: Playlist = Body(
example={
"name": "我的播放列表",
"songs": ["song_id"],
}
),
db=Depends(mongo_database),
):
result = await db.playlists.insert_one(
# 将 Pydantic 模型 转换为标准 Python 字典
playlist.model_dump()
)
return {
"message": "播放列表创建成功",
# 和之前插入歌曲的部分进行对比 其实两种写法都是可以的
"id": str(result.inserted_id),
}
python
# 检索播放列表
@app.get("/playlist/{playlist_id}")
async def get_playlist(
playlist_id: str,
db=Depends(mongo_database),
):
# 先找到对应的播放列表
playlist = await db.playlists.find_one(
{
"_id": ObjectId(playlist_id)
if ObjectId.is_valid(playlist_id)
else None
}
)
if not playlist:
raise HTTPException(
status_code=404, detail="Playlist not found"
)
# 寻找歌曲
songs = await db.songs.find(
{
"_id": {
"$in": [
# 播放列表集合中的歌曲ID存储为字符串 所以在使用时需要将其转换成 ObjectId
ObjectId(song_id)
for song_id in playlist["songs"]
]
}
}
).to_list(None)
return {"name": playlist["name"], "songs": songs}
MongoDB中的索引
json
{
"_id": "695f4332c83e6608f6d78c1f",
"title": "Bohemian Rhapsody",
"artist": "Queen",
"genre": "classic rock",
// 检索使用的是"album"的"release_year"字段
"album": {
"title": "A Night at the Opera",
"release_year": 1975
},
"views_per_country": {
"US": 10000016,
"UK": 20000017,
"Germany": 15000018,
"Italy": 5000019
}
},
py
# 创建特定年份歌曲索引
# 在 main.py 的上文中我们创建了一个索引
# await db.songs.create_index(
# {"album.release_year": -1}
# )
#
@app.get("/songs/year")
async def get_songs_by_released_year(
year: int,
db=Depends(mongo_database),
):
query = db.songs.find({"album.release_year": year})
# 返回查查询的执行计划 用于输出一些调试
explained_query = await query.explain()
logger.info(
"Index used: %s",
explained_query.get("queryPlanner", {})
.get("winningPlan", {})
.get("inputStage", {})
.get("indexName", "No index used"),
)
songs = await query.to_list(None)
return songs
py
# main.py 上文中进行了这个索引的创建
# await db.songs.create_index({"artist": "text"})
@app.get("/songs/artist")
async def get_songs_by_artist(
artist: str, db=Depends(mongo_database)
):
# 使用前要先建立文本索引
query = db.songs.find(
# $text 操作符 用于在文本索引上执行全文搜索
# $search 参数 指定要搜索的文本内容
# artist 是搜索关键词
{"$text": {"$search": artist}}
)
explained_query = await query.explain()
logger.info(
"Index used: %s",
explained_query.get("queryPlanner", {})
.get("winningPlan", {})
.get("indexName", "No index used"),
)
songs = await query.to_list(None)
return songs
暴露敏感数据
数据掩码是隐藏、混淆或替换敏感数据的技术,使数据在非生产环境中保持安全,同时仍可用于开发、测试等用途。通过数据库聚合安全地查看数据,以便将其暴露给API的第三方消费者。
数据填充
py
# fill_users_in_mongo.py
# 向数据库中填充用户集合
import asyncio
from datetime import datetime
from app.db_connection import mongo_client
db = mongo_client.beat_streaming
users = [
{
"name": "John Doe",
"email": "johndoe@email.com",
"year_of_birth": 1990,
"country": "USA",
"actions": [
{
"action": "basic subscription",
"date": datetime.fromisoformat(
"2021-01-01"
),
"amount": 10,
},
{
"action": "unscription",
"date": datetime.fromisoformat(
"2021-05-01"
),
},
],
# 是否同意与第三方合作伙伴共享行为数据
"consent_to_share_data": True,
},
{
....剩余内容略
},
]
async def add_users():
await db.users.insert_many(users)
if __name__ == "__main__":
asyncio.run(add_users())
创建脱敏视图
py
# streaming_platform/create_aggregation_and_user_data_view.py
# 创建一个只用于第三方访问的脱敏视图
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017/")
pipeline_redact = {
# $redact 常用于基于条件整体保留/丢弃文档或子文档
"$redact": {
"$cond": {
"if": {
# consent_to_share_data == true 才保留
"$eq": ["$consent_to_share_data", True]
},
# 保留文档
"then": "$$KEEP",
# 直接丢弃
"else": "$$PRUNE",
}
}
}
# 去掉 email 和 name
# $unset:在聚合管道中删除字段
pipeline_remove_email_and_name = {"$unset": ["email", "name"]}
# 日期打码
obfuscate_day_of_date = {
"$concat": [
{
"$substrCP": [
"$$action.date",
0,
7,
]
},
"-XX",
]
}
rebuild_actions_elements = {
"input": "$actions",
"as": "action",
"in": {
"$mergeObjects": [
"$$action",
{"date": obfuscate_day_of_date},
]
},
}
# 映射新的日期字段
pipeline_set_actions = {
"$set": {
"actions": {"$map": rebuild_actions_elements},
}
}
pipeline = [
pipeline_redact,
pipeline_remove_email_and_name,
pipeline_set_actions,
]
# 在 beat_streaming数据库中创建 users_data_view 视图
if __name__ == "__main__":
client["beat_streaming"].drop_collection(
"users_data_view"
)
client["beat_streaming"].create_collection(
"users_data_view",
viewOn="users",
pipeline=pipeline,
)
独立端点
py
# third_party_endpoint.py
from fastapi import APIRouter, Depends
from app.database import mongo_database
# 在 main.py 中添加了这个路由
router = APIRouter(
prefix="/thirdparty",
tags=["third party"],
)
@router.get("/users/actions")
async def get_users_with_actions(
db=Depends(mongo_database),
):
users = [
user
# 查询条件 {} 不设置任何过滤条件 相当于 SQL 中的 SELECT * FROM table
# 返回集合中的所有记录
# 投影操作 {"_id": 0} 不返回 _id 字段
# {"name": 1, "email": 1} 则是只返回 name 和 email 字段
async for user in db.users_data_view.find(
{}, {"_id": 0}
)
]
return users
集成 Elasticsearch 与 Redis
db_connection.py 中已经完成了 Elasticsearch 异步客户端的定义以及与 Elasticsearch的连接检查函数;同时完成了 Redis 客户端的定义以及与 Redis的连接检查函数。
数据填充
py
from app.db_connection import es_client
from songs_info import songs_info
mapping = {
"mappings": {
"properties": {
"artist": {"type": "keyword"},
"views_per_country": {
"type": "object",
"dynamic": True,
},
}
}
}
async def create_index():
await es_client.options(
ignore_status=[400, 404]
).indices.create(
index="songs_index",
body=mapping,
)
await es_client.close()
async def fill_elastichsearch():
for song in songs_info:
await es_client.index(
index="songs_index", body=song
)
await es_client.close()
async def delete_all_indexes():
await es_client.options(
ignore_status=[400, 404]
).indices.delete(index="*")
await es_client.close()
async def main():
await delete_all_indexes()
await create_index()
await fill_elastichsearch()
if __name__ == "__main__":
import asyncio
asyncio.run(main())
构建查询
py
# es_queries.py
def top_ten_artists_query(country) -> dict:
views_field = f"views_per_country.{country}"
query = {
"bool": {
"filter": [
{"exists": {"field": views_field}}
],
}
}
aggs = {
"top_ten_artists": {
"terms": {
"field": "artist",
"size": 10,
"order": {"views": "desc"},
},
"aggs": {
"views": {
"sum": {
"field": views_field,
"missing": 0,
}
}
},
}
}
return {
"index": "songs_index",
"size": 0,
"query": query,
"aggs": aggs,
}
def top_ten_songs_query(country) -> dict:
views_field = f"views_per_country.{country}"
query = {
"bool": {
"must": {"match_all": {}},
"filter": [
{"exists": {"field": views_field}}
],
}
}
sort = {views_field: {"order": "desc"}}
source = [
"title",
views_field,
"album.title",
"artist",
]
return {
"index": "songs_index",
"query": query,
"size": 10,
"sort": sort,
"source": source,
}
建立端点
py
import json
import logging
from elasticsearch import BadRequestError
from fastapi import APIRouter, Depends, HTTPException
from fastapi_cache.decorator import cache
from app.db_connection import es_client, redis_client
from app.es_queries import (
top_ten_artists_query,
top_ten_songs_query,
)
logger = logging.getLogger("uvicorn")
router = APIRouter(
prefix="/search",
tags=["search"],
)
def get_elasticsearch_client():
return es_client
def get_redis_client():
return redis_client
@router.get("/top/ten/artists/{country}")
async def top_ten_artist_by_country(
country: str,
es_client=Depends(get_elasticsearch_client),
redis_client=Depends(get_redis_client),
):
cache_key = f"top_ten_artists_{country}"
cached_data = await redis_client.get(cache_key)
if cached_data:
logger.info(
f"Returning cached data for {country}"
)
return json.loads(cached_data)
logger.info(
f"Getting top ten artists for {country}"
)
try:
response = await es_client.search(
**top_ten_artists_query(country)
)
except BadRequestError as e:
logger.error(e)
raise HTTPException(
status_code=400,
detail="Invalid country",
)
artists = [
{
"artist": record.get("key"),
"views": record.get("views", {}).get(
"value"
),
}
for record in response["aggregations"][
"top_ten_artists"
]["buckets"]
]
await redis_client.set(
cache_key, json.dumps(artists), ex=3600
)
return artists
@router.get("/top/ten/songs/{country}")
@cache(expire=60)
async def get_top_ten_by_country(
country: str,
es_client=Depends(get_elasticsearch_client),
):
try:
response = await es_client.search(
**top_ten_songs_query(country)
)
except BadRequestError as e:
logger.error(e)
raise HTTPException(
status_code=400,
detail="Invalid country",
)
songs = []
for record in response["hits"]["hits"]:
song = {
"title": record["_source"]["title"],
"artist": record["_source"]["artist"],
"album": record["_source"]["album"][
"title"
],
"views": record["_source"]
.get("views_per_country", {})
.get(country),
}
songs.append(song)
return songs
main方法
py
import logging
from asyncio import gather
from contextlib import asynccontextmanager
from bson import ObjectId
from fastapi import (
Body,
Depends,
FastAPI,
HTTPException,
)
from fastapi.encoders import ENCODERS_BY_TYPE
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from pydantic import BaseModel
from app import main_search, third_party_endpoint
from app.database import mongo_database
from app.db_connection import (
ping_elasticsearch_server,
ping_mongo_db_server,
ping_redis_server,
redis_client,
)
logger = logging.getLogger("uvicorn")
# 全局配置 将MongoDB的ObjectId转换为字符串
ENCODERS_BY_TYPE[ObjectId] = str
# 建立一个异步上下文管理器 在启动和关闭时执行特定代码
@asynccontextmanager
# 当前并没有添加异常处理 也就是说只要有一个出现了问题 启动就会失败
async def lifespan(app: FastAPI):
# gather 并发执行多个异步任务,并等待所有任务都完成后才返回
await gather(
ping_mongo_db_server(),
ping_elasticsearch_server(),
ping_redis_server(),
)
db = mongo_database()
# 删除现有的索引
await db.songs.drop_indexes()
# 创建特定年份歌曲索引
await db.songs.create_index(
{"album.release_year": -1}
)
# 创建艺术家文本索引
await db.songs.create_index({"artist": "text"})
FastAPICache.init(
RedisBackend(redis_client),
prefix="fastapi-cache",
)
yield
# 将声明周期上下文管理器作为参数传递给FastAPI对象
app = FastAPI(lifespan=lifespan)
# 添加第三方路由
app.include_router(third_party_endpoint.router)
try:
app.include_router(main_search.router)
except Exception:
pass