在真实的团队开发里,Django 项目往往不是一个人从零写到尾,而是好几个人、甚至好几个小组同时在一个仓库里改代码。如果你见过那种把所有功能都塞进一个 app 的项目,一定体验过改一个接口要翻半天文件、合并代码时冲突不断、新同事接手后完全不知道从哪下手的感觉。这其实就是项目结构没规划好。今天咱们就聊一聊,怎么把一个 Django 项目拆得整整齐齐,让一群人协作起来不那么痛苦。
一、为什么要为大型团队做模块化拆分
先说个场景:小张和小李同时在一个 views.py 文件里加功能,小张改了第 100 行,小李改了第 150 行,本来互不干扰,但 Git 合并时却因为文件太大、行号偏移而产生冲突。这种问题在单体式 Django 项目里特别常见。当项目变大了,models.py 可能有一千多行,views.py 可能有两千多行,urls.py 里塞了上百个路由,所有人都挤在一个文件里改,效率自然就下来了。
模块化拆分的好处很直接:每个人负责自己的模块,文件按业务边界划分清楚,谁动了哪个部分一目了然。哪怕某个模块要重写,也不会影响其他功能。而且新同事入职后,只需要看自己负责的那一小块代码就能快速上手,不用把整个项目全读一遍。
二、拆分的核心思想:按业务边界,而不是按技术类型
很多初学者拆 Django 项目时,喜欢按技术类型分:建一个 models.py 放所有数据库表,建一个 views.py 放所有页面逻辑,建一个 templates 放所有模板。这种拆法只能算浅层整理,依然解决不了多人协作问题。
正确的做法是 按业务模块拆分。比如一个电商系统,可以拆成 用户、商品、订单、支付、优惠券 等几个业务板块。每个板块对应一个 Django 的 app,这个 app 内部有自己的 models、views、urls、templates、tests。这样每个人负责一个或者几个 app,互不干扰。
Django 的 app 机制本身就是为模块化设计的。但是光有 app 还不够,大型项目还需要更细致的内部结构规划。下面我们一步步来看具体怎么做。
三、一个可落地的项目结构方案
假设我们要开发一个电商平台,项目名叫 my_mall。这是一个典型的中大型业务系统,包含前台用户浏览、后台管理、API 接口等多个入口。下面是一个推荐的分层结构。
# 项目根目录结构说明
my_mall/
├── config/ # 项目配置模块(替代原来的 project 目录)
│ ├── __init__.py
│ ├── settings/ # settings 拆分为多个文件
│ │ ├── __init__.py
│ │ ├── base.py # 公共配置
│ │ ├── dev.py # 本地开发配置
│ │ ├── prod.py # 生产环境配置
│ │ └── test.py # 测试配置
│ ├── urls.py # 根路由
│ ├── wsgi.py
│ └── asgi.py
├── apps/ # 存放所有业务 app 的统一目录
│ ├── __init__.py
│ ├── users/ # 用户模块
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── views.py
│ │ ├── urls.py
│ │ ├── admin.py
│ │ ├── serializers.py # 如果做 API,这里放序列化器
│ │ ├── services.py # 业务逻辑层,避免在 views 里堆代码
│ │ └── tests.py
│ ├── products/ # 商品模块
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── views.py
│ │ ├── urls.py
│ │ ├── admin.py
│ │ ├── services.py
│ │ └── tests.py
│ └── orders/ # 订单模块
│ ├── __init__.py
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ ├── admin.py
│ ├── services.py
│ └── tests.py
├── common/ # 跨模块共享的工具代码
│ ├── __init__.py
│ ├── pagination.py # 自定义分页器
│ ├── permissions.py # 自定义权限类
│ └── exceptions.py # 统一异常处理
├── templates/ # 全局模板(各 app 自己的模板放在 app/templates 下)
├── static/ # 静态文件
├── media/ # 用户上传文件
├── manage.py
├── requirements.txt # 依赖清单
└── README.md # 项目说明文档
看到这个结构,你可能会有几个疑问。
第一,为什么把原来的 settings.py 变成一个包?因为不同环境下的配置差别很大——本地调试要开调试模式,生产环境要关调试、配数据库、日志、缓存等。如果全写在一个文件里,最后就变成一堆 if DEBUG 判断,还得小心别把生产配置泄露到开发环境。拆开之后,每个环境一个文件,非常直观。
第二,为什么加一个 services.py?因为很多业务逻辑直接写在 views.py 里,会导致视图又长又难测。比如下单这个动作,涉及库存检查、价格计算、创建订单记录、扣减库存,这一套动作如果全塞在视图函数里,那视图可能有两百行。放在 services.py 里,视图只需要调用一个 create_order(...) 函数,逻辑清晰,也方便单元测试。
第三,common 这个目录是干什么用的?有些代码会被多个 app 使用,比如统一的分页类、权限判断、异常处理。这些不适合单独建一个 app,也不适合重复复制到每个业务模块里,所以放到一个共享目录里,大家按需导入。
下面我们一步一步来搭建这个结构。
四、第一步:创建项目骨架
先安装 Django,然后创建项目和必要的目录。
# 在虚拟环境下安装 Django(假设 Python 3.10+)
pip install django
# 创建项目,注意后面有一个点,表示在当前目录生成项目文件
django-admin startproject config .
# 创建统一存放业务 app 的目录
mkdir -p apps
# 创建一个基础 app 作为例子,后续可以继续创建其他 app
python manage.py startapp users apps/users
执行完命令之后,项目里会有一个 config 目录和一个 apps/users 目录。接下来我们要把 settings.py 改造成一个包。
# 删除原来的 settings.py 文件
rm config/settings.py
# 在 config 下创建 settings 包目录
mkdir -p config/settings
# 创建 __init__.py 和其他配置文件(用 touch 命令)
touch config/settings/__init__.py
touch config/settings/base.py
touch config/settings/dev.py
touch config/settings/prod.py
打开 config/settings/base.py,把原来 settings.py 里的基础内容放进去,并加入 apps 的路径识别。
# config/settings/base.py
# 这个文件存放所有环境都适用的配置
from pathlib import Path
# 项目根目录的绝对路径
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# 安全密钥(生产环境中通过环境变量覆盖,这里只写一个占位符)
SECRET_KEY = 'your-secret-key'
# 调试模式默认关掉,具体环境里再打开
DEBUG = False
# 允许访问的主机列表,生产环境改成真实域名
ALLOWED_HOSTS = []
# 注册 app 的时候,需要把我们自定义的 apps 目录也加入搜索路径
# 这样才能用 'users' 而不是 'apps.users' 来引用
import sys
sys.path.insert(0, str(BASE_DIR / 'apps'))
# 项目应用列表
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# 这里添加业务 app,后续创建好了以后
# 'users',
# 'products',
# 'orders',
]
# 中间件配置(默认就行,必要时再调整顺序)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
# 根路由配置
ROOT_URLCONF = 'config.urls'
# 模板配置
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
# WSGI 和 ASGI 入口
WSGI_APPLICATION = 'config.wsgi.application'
# 数据库配置,这里先用 SQLite 作为开发默认
# 生产环境在 prod.py 里换成 PostgreSQL 或 MySQL
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# 密码验证器(保持默认)
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# 国际化和时区配置
LANGUAGE_CODE = 'zh-hans' # 使用中文
TIME_ZONE = 'Asia/Shanghai' # 使用中国时区
USE_I18N = True
USE_TZ = True
# 静态文件目录
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
# 媒体文件目录
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# 默认主键类型
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
接着写开发环境配置文件:
# config/settings/dev.py
# 本地开发环境配置
from .base import * # 导入公共配置
# 开发环境打开调试模式
DEBUG = True
# 本地开发允许本机访问
ALLOWED_HOSTS = ['*']
# 开发环境下数据库继续用 SQLite,也可以改成本地 MySQL
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# 开发环境打印 SQL 日志,方便调试
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
},
},
}
生产环境配置:
# config/settings/prod.py
# 生产环境配置
from .base import *
import os
# 生产环境必须关闭调试
DEBUG = False
# 生产环境的域名列表,需要根据实际情况填写
ALLOWED_HOSTS = ['www.example.com', 'api.example.com']
# 从环境变量读取密钥,不要写在代码里
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
# 生产环境使用 PostgreSQL 数据库,通过环境变量读取连接参数
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'mydb'),
'USER': os.environ.get('DB_USER', 'myuser'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'mypassword'),
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
# 生产环境必须配置静态文件收集目录
STATIC_ROOT = BASE_DIR / 'staticfiles'
# 生产环境安全相关配置
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
最后在 config/settings/__init__.py 里做一个环境切换的逻辑:
# config/settings/__init__.py
# 根据 DJANGO_ENV 环境变量自动加载不同的配置
import os
# 默认使用开发环境
env = os.environ.get('DJANGO_ENV', 'dev')
if env == 'prod':
from .prod import *
elif env == 'test':
from .test import *
else:
from .dev import *
这样设计之后,我们在启动项目时只需要设置环境变量 DJANGO_ENV,就能一键切换配置。比如本地跑 python manage.py runserver 会用开发配置;部署服务器上设置 DJANGO_ENV=prod,就会加载生产配置,非常方便。
五、第二步:让每个业务 app 内部结构更清晰
光新建一个空 app 是不够的。我们以 users 为例,把内部结构补充完整。先看看默认生成的文件有哪些,然后新增 serializers.py、services.py、selectors.py 等文件。其中 selectors.py 的作用是查询数据,和 services.py 写数据形成分离,这样可以避免一个文件里同时混杂大量读写逻辑,代码更容易阅读。
我们先定义用户模型。假设我们要做一个自定义用户系统,用手机号作为登录账号。
# apps/users/models.py
# 用户模块的数据库模型
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils import timezone
class User(AbstractUser):
"""
自定义用户模型,直接用手机号作为唯一标识登录
继承 Django 自带的 AbstractUser,保留原有的权限系统
"""
# 手机号字段,唯一、不为空
phone = models.CharField(max_length=11, unique=True, verbose_name='手机号')
# 昵称字段,可空,默认空字符串
nickname = models.CharField(max_length=32, blank=True, default='', verbose_name='昵称')
# 头像地址字段,存图片 URL 或者路径
avatar = models.URLField(blank=True, verbose_name='头像地址')
# 注册时间,自动设置
created_at = models.DateTimeField(default=timezone.now, verbose_name='注册时间')
# 把 USERNAME_FIELD 指向 phone,登录时用手机号
USERNAME_FIELD = 'phone'
# 创建超级用户时必须提供手机号
REQUIRED_FIELDS = ['username'] # username 仍需要,只是登录用 phone
class Meta:
# 给模型起中文名
verbose_name = '用户'
verbose_name_plural = '用户'
def __str__(self):
# 打印对象时显示手机号,方便排查问题
return self.phone
然后写用户注册的业务逻辑,放在 services.py 里。为什么不在 views.py 里写?因为 services.py 只依赖模型和工具,不依赖 HTTP 请求对象,这样我们可以在单元测试里直接调用,而不用模拟整个请求对象。
# apps/users/services.py
# 用户相关的业务逻辑(写操作)
from django.db import transaction
from django.core.exceptions import ValidationError
from .models import User
def create_user_with_phone(phone: str, password: str, nickname: str = '') -> User:
"""
使用手机号创建一个新用户
参数:
phone: 手机号
password: 明文密码,内部会加密
nickname: 昵称,可选
返回:
创建好的 User 对象
异常:
如果手机号已被注册,抛出 ValidationError
"""
# 检查手机号是否唯一
if User.objects.filter(phone=phone).exists():
raise ValidationError('该手机号已经注册过')
# 使用事务保证不会出现半截数据
with transaction.atomic():
# 创建用户对象,username 暂时先用手机号占位
# 因为我们用手机号登录,username 不参与登录逻辑
user = User(phone=phone, username=phone, nickname=nickname)
# 设置密码,Django 会自动加密,不要给 password 字段直接赋值明文
user.set_password(password)
user.save()
return user
def change_user_password(user: User, old_password: str, new_password: str) -> None:
"""
修改用户密码
参数:
user: 当前登录的用户对象
old_password: 旧密码,用于校验
new_password: 新密码
异常:
旧密码不正确时抛 ValidationError
"""
# 校验旧密码是否一致
if not user.check_password(old_password):
raise ValidationError('旧密码不正确')
# 设置新密码
user.set_password(new_password)
user.save()
再写一个查询用的 selectors.py,负责从数据库里获取展示数据。
# apps/users/selectors.py
# 用户相关的查询逻辑(读操作)
from .models import User
def get_user_by_id(user_id: int) -> User | None:
"""
根据用户 ID 获取用户,找不到就返回 None
"""
try:
return User.objects.get(id=user_id)
except User.DoesNotExist:
return None
def search_users(keyword: str, page: int = 1, page_size: int = 20):
"""
模糊搜索用户,按昵称或手机号匹配
返回的是一个分页后的 queryset 和总数
"""
from django.core.paginator import Paginator
# 过滤条件:nickname 包含关键字,或者 phone 包含关键字
queryset = User.objects.filter(
models.Q(nickname__icontains=keyword) | models.Q(phone__icontains=keyword)
).order_by('-date_joined')
# 使用 Django 自带的分页器
paginator = Paginator(queryset, page_size)
page_obj = paginator.get_page(page)
return page_obj.object_list, paginator.count
视图里就可以直接调用这些函数,保持视图代码极简。
# apps/users/views.py
# 用户模块的视图
from django.contrib.auth import login
from django.shortcuts import render, redirect
from django.contrib import messages
from django.views import View
from django.http import JsonResponse
from .services import create_user_with_phone, change_user_password
from .selectors import get_user_by_id
class RegisterView(View):
"""
注册页:展示表单并处理 POST 请求
"""
def get(self, request):
# 如果已经登录,跳转到首页
if request.user.is_authenticated:
return redirect('index')
return render(request, 'users/register.html')
def post(self, request):
# 从 POST 数据里取参数
phone = request.POST.get('phone', '')
password = request.POST.get('password', '')
password2 = request.POST.get('password2', '')
# 简单校验两次密码是否一致
if password != password2:
messages.error(request, '两次密码输入不一致')
return render(request, 'users/register.html', {'phone': phone})
try:
# 调用 services 里的业务函数来创建用户,不在这里写复杂的逻辑
user = create_user_with_phone(phone=phone, password=password)
except Exception as exc:
# 捕获业务异常并提示给用户
messages.error(request, str(exc))
return render(request, 'users/register.html', {'phone': phone})
# 注册成功后自动登录
login(request, user)
return redirect('index')
class UserProfileView(View):
"""
用户个人中心页
"""
def get(self, request, user_id):
# 调用 selectors 查询用户,找不到就返回 404
user = get_user_by_id(user_id)
if user is None:
return render(request, '404.html', status=404)
return render(request, 'users/profile.html', {'profile_user': user})
class ChangePasswordView(View):
"""
修改密码视图
"""
def post(self, request):
# 需要用户已登录才能操作
if not request.user.is_authenticated:
return JsonResponse({'code': 401, 'message': '请先登录'}, status=401)
old_pw = request.POST.get('old_password', '')
new_pw = request.POST.get('new_password', '')
try:
change_user_password(request.user, old_pw, new_pw)
except Exception as exc:
return JsonResponse({'code': 400, 'message': str(exc)}, status=400)
# 修改成功
return JsonResponse({'code': 0, 'message': '修改成功'})
每个业务模块都有自己的 urls.py,路由只管理自己模块内部的路径。
# apps/users/urls.py
# 用户模块的路由配置
from django.urls import path
from . import views
# app_name 用于在模板里用命名空间引用 URL
app_name = 'users'
urlpatterns = [
# 注册页面
path('register/', views.RegisterView.as_view(), name='register'),
# 用户主页,需要传入用户 ID
path('profile/<int:user_id>/', views.UserProfileView.as_view(), name='profile'),
# 修改密码接口
path('change-password/', views.ChangePasswordView.as_view(), name='change_password'),
]
接下来在根路由 config/urls.py 里加入所有业务模块的路由前缀。这样做的优势是,当某个模块需要迁移到独立的微服务时,只需要把对应的 urls.py 和 app 复制出去,再改一改根配置就行。
# config/urls.py
# 根路由:负责把请求分发到各个业务模块
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
# Django 自带后台
path('admin/', admin.site.urls),
# 所有 users 模块的 URL 统一放在 /users/ 下面
path('users/', include('users.urls')),
# 将来如果有 products 模块,就加上下面这行
# path('products/', include('products.urls')),
# 如果有 orders 模块,就加上下面这行
# path('orders/', include('orders.urls')),
]
为了让 INSTALLED_APPS 能识别到 users,我们回到 config/settings/base.py 里把 'users' 加进去。
# config/settings/base.py 中的 INSTALLED_APPS 片段
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# 业务 app
'users', # 用户模块
]
同步数据库:
# 先生成迁移文件
python manage.py makemigrations users
# 然后执行迁移
python manage.py migrate
到这里,一个最基本的模块化骨架已经搭建完成。我们再看看如何让模块之间的依赖关系保持健康。
六、第三步:处理模块之间的依赖关系
大型项目中,模块之间难免要互相调用数据。比如 orders(订单)模块需要读取 users(用户)的信息,也可能会去读 products(商品)的库存。如果不加约束,就会出现 orders.models 直接 import products.models 的情况。短期内没问题,时间长了,模块之间就会纠缠不清,变成一团乱麻。
解决方案是 单向依赖。每个模块只能依赖更底层的模块,不能反向依赖。比如 users 属于最底层,任何模块都可以依赖它;products 依赖 users,但不能依赖 orders;orders 可以依赖 users 和 products。这样依赖方向固定,改动某一个模块时,只要它的下层模块没变,它就不会影响别人。
具体实现上,我们可以在 orders 的 services.py 里通过 selectors 来访问用户和商品数据,而不是直接操作对方的模型。比如先定义一个获取商品数据的接口,在 products.selectors 里写:
# apps/products/selectors.py
# 商品模块的查询逻辑
from .models import Product
def get_product_by_id(product_id: int) -> Product | None:
"""
获取单个商品,不存在时返回 None
"""
try:
return Product.objects.select_related('category').get(id=product_id)
except Product.DoesNotExist:
return None
def get_product_stock(product_id: int) -> int:
"""
获取商品剩余库存
"""
product = get_product_by_id(product_id)
if product is None:
return 0
return product.stock
然后在 orders.services 里调用这个接口,避免直接 import Product 模型:
# apps/orders/services.py
# 订单模块的业务逻辑
from django.db import transaction
from django.core.exceptions import ValidationError
from products.selectors import get_product_by_id, get_product_stock
from users.selectors import get_user_by_id
from .models import Order, OrderItem
def create_order(user_id: int, items: list) -> Order:
"""
创建订单
参数:
user_id: 下单用户的 ID
items: 列表,每个元素是 dict,包含 product_id 和 quantity
返回:
创建的订单对象
异常:
库存不足或者参数不合法时抛 ValidationError
"""
# 检查用户是否存在
user = get_user_by_id(user_id)
if user is None:
raise ValidationError('用户不存在')
# 在事务中创建订单和订单明细
with transaction.atomic():
# 计算订单总金额
total_amount = 0
order_items = []
# 遍历前端传来的每个商品
for item in items:
product_id = item['product_id']
quantity = item['quantity']
# 从商品模块的 selectors 里获取商品,不直接操作商品模型
product = get_product_by_id(product_id)
if product is None:
raise ValidationError(f'商品 {product_id} 不存在')
# 检查库存
stock = get_product_stock(product_id)
if stock < quantity:
raise ValidationError(f'商品 {product.name} 库存不足')
# 单件商品小计
subtotal = product.price * quantity
total_amount += subtotal
# 先暂存在内存里,订单保存后再统一写入
order_items.append((product, quantity, subtotal))
# 创建主订单表
order = Order.objects.create(user=user, total_amount=total_amount)
# 创建订单明细表
for product, quantity, subtotal in order_items:
OrderItem.objects.create(
order=order,
product=product,
quantity=quantity,
price=product.price, # 快照价格,防止以后商品改价
subtotal=subtotal,
)
# 让商品模块扣减库存,这个操作应该由商品模块提供服务函数
# 这里为了示例保持简单,假设 Product 有 stock 字段,直接减
from django.db.models import F
for product, quantity, subtotal in order_items:
# F 表达式避免并发下库存超卖
product.__class__.objects.filter(id=product.id).update(stock=F('stock') - quantity)
return order
这个例子展示了模块间调用的标准姿势:通过 selectors 进行只读查询,通过 services 进行写操作。如果后续商品模块的库存逻辑发生变化,只需要修改 products 模块内的代码,orders 模块外的调用方不需要改动。
七、第四步:共享的 common 模块
在很多项目中,多个模块都需要自定义分页结果、统一的异常响应、权限判断。我们可以把这些代码放在 common 目录下,每个模块按需导入。下面创建一个简单的统一响应工具。
# common/response.py
# 统一的成功和失败响应格式
from django.http import JsonResponse
def ok(data=None, message='success'):
"""
成功响应:返回 {"code": 0, "data": data, "message": message}
"""
return JsonResponse({'code': 0, 'data': data, 'message': message})
def fail(message='fail', code=1, status=200):
"""
失败响应:返回 {"code": code, "data": None, "message": message}
status 参数控制 HTTP 状态码,默认 200(业务错误用业务码表示)
"""
return JsonResponse({'code': code, 'data': None, 'message': message}, status=status)
再写一个自定义异常处理类,在视图层统一捕获业务异常。
# common/exceptions.py
# 业务异常基类和全局异常处理函数
from django.http import JsonResponse
class BusinessException(Exception):
"""
业务异常,当代码逻辑出现可预期的错误时抛出
例如:库存不足、用户已注册、参数校验失败等
"""
def __init__(self, message, code=1):
super().__init__(message)
self.message = message
self.code = code
# 全局异常处理函数,需要在根 urls.py 中注册
def custom_exception_handler(exc, context):
"""
当视图抛出 BusinessException 时,返回规范化的 JSON 格式
"""
if isinstance(exc, BusinessException):
return JsonResponse({
'code': exc.code,
'message': exc.message,
'data': None,
}, status=200)
# 其他异常继续交给 Django 自带机制处理
return None
然后在根路由里注册这个异常处理器:
# config/urls.py
# 根路由配置
from django.contrib import admin
from django.urls import path, include
from common.exceptions import BusinessException
handler400 = 'common.exceptions.custom_exception_handler'
handler403 = 'common.exceptions.custom_exception_handler'
handler404 = 'common.exceptions.custom_exception_handler'
handler500 = 'common.exceptions.custom_exception_handler'
urlpatterns = [
path('admin/', admin.site.urls),
path('users/', include('users.urls')),
]
当然这只是一个小示例。实际项目中,你可以把分页、限流、监控、日志等通用能力全部沉淀到 common 中,让业务模块只关注自己的需求。
八、模块化拆分的应用场景
这种结构最典型的应用场景是 中大型 Web 应用,尤其是下面几类:
- 电商平台:用户、商品、订单、支付、售后、营销等模块天然独立,非常适合按模块拆分。
- 企业管理系统:组织架构、权限、审批、报表、通知等模块之间边界清晰。
- 内容平台:创作者、文章、评论、关注、推送等模块可以独立迭代。
- 开放 API 平台:接口服务需要稳定,拆分后可以单独给第三方提供 SDK,每个模块版本独立升级。
如果你的项目只是一个几十行代码的 Demo,或者只是写个测试练习,那完全没必要拆成这么多层。拆分的初衷是为了应对复杂度和团队协作,不能为了拆而拆。
九、这种拆分方案的优缺点
先说优点。第一,团队并行效率高:每个人负责一个 app,修改范围被限制在自己的目录里,Git 冲突概率大大降低。第二,可测试性好:业务逻辑被隔离在 services.py 和 selectors.py 中,可以脱离 HTTP 层做单元测试。第三,代码可读性好:按业务命名模块,新人找代码非常快,不用在巨大的 models.py 和 views.py 里翻箱倒柜。第四,扩展性好:如果某个模块的访问量特别高,可以把这个 app 提取成独立的服务,改成微服务架构也很方便。第五,配置管理方便:不同环境使用不同的 settings 文件,避免了生产环境配置被意外带入本地。
再说缺点。第一,初始搭建成本高:需要花时间规划目录结构,约束团队规范,对于刚起步的团队来说可能显得笨重。第二,学习成本增加:新人需要理解 services、selectors、common 等额外概念,比直接往 views.py 里写代码多了一道门槛。第三,代码量增加:模块之间调用需要经过接口层,可能会多写一些透传代码,看起来没有原来那么直接。第四,过度拆分的风险:如果业务边界没划清楚,模块之间互相依赖,可能比不拆更痛苦。比如把强耦合的“购物车”和“订单”拆开,结果下单时需要同时改两个模块,反而增加了维护成本。
十、有哪些必须注意的坑
要注意的第一点是 不要把 apps 目录下的所有 app 都注册到 INSTALLED_APPS 中。有些内部工具模块不包含数据模型,也不需要被 Django 的 ORM 发现,如果你硬把它注册进 INSTALLED_APPS,Django 会执行它的 migrations,可能会产生无效的表。建议只注册真正需要被 Django 管理的业务模块。
第二点是 管理好模块之间的 import。在代码审查中,要特别留意 from apps.xxx.models import Xxx 这种导入。一旦发现订单模块引用了商品模块的内部函数,应该提醒开发者把该函数下沉到商品模块的 services 或 selectors 层。可以约定一个规则:跨模块只允许导入 services、selectors、common 里的内容,禁止直接导入对方的模型和视图。
第三点是 数据库迁移文件一定要跟着模块走。每个 app 的 migrations 目录只存放自己的迁移记录,不要手动删除或者合并,否则在团队协作时很容易出现迁移冲突。常用的做法是把迁移文件也纳入 code review 的检查范围,减少冲突。
第四点是 共享模板和静态文件的组织方式。虽然可以让每个 app 自带 templates 和 static,但要注意模板文件重名覆盖的问题。Django 搜索模板时,如果多个 app 都有相同路径的模板,顺序靠前的 app 会覆盖后面的。建议在模板子目录里用命名空间区分,比如 users/templates/users/profile.html,而不是直接叫 profile.html。
第五点是 设置文件中的秘密不要硬编码。尤其是 SECRET_KEY、数据库密码、API 密钥等,必须通过环境变量或密钥管理工具读取。我们上面的 prod.py 已经做了示范,从环境变量读取,避免不小心把密钥提交到仓库里。
第六点是 测试也要按模块组织。每个 app 的 tests.py 不要写一个超大的类,而是分文件:test_services.py、test_selectors.py、test_views.py。这样每个测试文件只负责测一个层面,失败时定位也快。
十一、从一个总入口到模块化项目改造的实战步骤
如果你手上已经有一个现有的 Django 项目,想改成这种模块化结构,不建议一次性推翻重来。按下面的步骤渐进式改造会稳妥得多。
第一步,先建 apps 目录,把你的业务按高内聚度分类,从最核心的业务开始,比如先把 users 抽出来。将原来 models.py 中关于用户且不依赖其他表的模型移过去,改好 apps/users/models.py,同步迁移。
注意:抽模型时,要处理外键引用。比如原来的订单表里写了 user = models.ForeignKey(User, ...),现在 User 被移到了新位置,你有两种处理方式:
- 保留原来的模型名,通过
settings.AUTH_USER_MODEL引用。这是推荐的方式。 - 如果之前没有用
AUTH_USER_MODEL,那你需要在订单模型里改成from django.conf import settings,然后使用settings.AUTH_USER_MODEL作为外键指向模型字符串。
我们这里不展开讲迁移的每一个细节,但核心思路是:先移动模型,再修改引用,再生成迁移测试,不要一次把七个 app 全拆完,那样出了错很难定位。
第二步,把公共配置从 settings.py 中拆分成 settings/base.py、dev.py、prod.py。拆分的技巧是:原来文件里所有以 # 注释的通用配置放到 base.py,和本地环境强相关的(比如 SQLite 路径、DEBUG 开关)放进 dev.py,和生产相关的放进 prod.py。最后别忘了在 __init__.py 里做环境判断。
第三步,把原来 urls.py 中的路由按照业务前缀拆分到各 app 的 urls.py。可以保留根路由做一个跳板,先把一个模块的路由复制过去,测试通过后再拆分下一个模块。
第四步,逐步把视图里的业务逻辑转移到 services.py 和 selectors.py。这一步可以按函数来做:每当你需要修改一个视图函数,先想想它里面哪些逻辑是“计算”和“存取”,把它们抽离。比如下单逻辑,抽成 create_order;列表查询,抽成 search_orders,视图里只保留请求参数预处理和响应渲染。
第五步,把测试从小而全改为小而有针对性。比如新建 modules/users/tests/test_services.py,专门测创建用户功能。运行测试时只跑这一文件,反馈速度快很多。
十二、文章总结
模块化拆分不是 Django 官方的强制要求,但它是一个人多的团队能否长期维持稳定开发效率的关键。说白了,核心就是三件事:按业务边界划分 app;每个 app 内部按职责分层;跨模块通过整齐的接口调用,而不是互相翻对方的模型。这样拆完之后,你不再需要担心改一个功能碰到别人的代码,也不需要在几千行的文件里做阅读理解。项目结构是为团队服务的,找到适合你们团队节奏的拆分深度,比追求完美更重要。希望这篇文章能给你提供一个可以落地的参考方案,从今天的小尝试开始,慢慢把一个乱七八糟的 Django 项目变成一条笔直干净的流水线。
评论
围绕“Django项目结构规范:适合大型团队协作的模块化拆分方案”参与讨论