在真实的团队开发里,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 项目变成一条笔直干净的流水线。