2025年6月25日 星期三

使用 Django、Channels 和 Docker 打造一個即時聊天應用!

 

🚀 從零開始:使用 Django、Channels 和 Docker 打造一個即時聊天應用!

一、引言

您是否厭倦了傳統網頁的延遲刷新?想讓您的應用程式動起來,即時反應使用者的互動嗎?即時通訊是現代網路應用不可或缺的一部分,從線上遊戲到協作工具,無不依賴其快速響應的能力。

本文將帶您深入探索如何打造一個功能完整的即時聊天室,並整合多項尖端技術。我們將從無到有建立一個基於 Django 的即時聊天應用,並利用 Django Channels 實現 WebSocket 通訊,透過 Redis 作為高性能的頻道層,最後使用 DockerDocker Compose 簡化開發環境建置與部署。

透過這篇文章,您將學到如何整合這些技術,理解 WebSocket 的運作原理及其在即時應用中的重要性,並學會如何使用 Docker 管理多個服務。

點這裡前往 GitHub 專案

二、專案概覽與核心特色

本專案旨在展示一個功能完善的即時聊天應用程式,讓使用者能夠在不同的聊天室中即時交流。

核心功能列表:

  • 多個獨立的聊天室:使用者可以進入不同的聊天室進行對話,各聊天室的訊息互不干擾。

  • 即時消息傳遞(WebSocket 支援):當使用者發送訊息後,所有在該聊天室的線上用戶會立即收到並顯示訊息,無需刷新頁面。

  • 聊天記錄持久化:所有發送的訊息都會被儲存到資料庫中,確保聊天記錄的完整性。

  • REST API 接口:提供一個外部接口,允許其他應用程式或服務透過 HTTP 請求向聊天室發送訊息。

  • 簡單直觀的前端界面:基於 HTML/CSS/JavaScript 的輕量級前端,方便使用者操作。

  • 環境變數配置:使用 python-decouple 安全管理敏感配置。

  • Docker 化部署:利用 Docker 和 Docker Compose,簡化了開發環境的設定,實現服務的快速啟動與部署。

為什麼選擇這些技術?

  • Django:作為穩固的後端骨架,提供強大的 ORM、用戶認證、管理後台等功能,加速開發進程。

  • Django Channels:為 Django 引入了非同步功能和 WebSocket 支援,突破了傳統 WSGI 伺服器無法處理長連線的限制,是實現即時通訊的關鍵。

  • Redis:高性能的記憶體資料庫,在此專案中作為 Django Channels 的「頻道層 (Channel Layer)」。它負責處理消息佇列和發布/訂閱 (Pub/Sub) 模式,確保不同進程或執行緒間的消息能夠高效廣播和傳遞。

  • Docker:解決了「在我機器上可以跑」的問題。它將應用程式及其所有依賴打包在獨立的容器中,確保開發、測試、生產環境的一致性,極大簡化了部署和環境管理。

專案架構示意圖:



架構說明

  • 客戶端瀏覽器:用戶透過此界面與應用互動,發送 HTTP 請求或建立 WebSocket 連線。

  • Django 伺服器:處理傳統 HTTP 請求(如網頁渲染、API 請求),將其路由到相應的 views.py 處理。

  • Daphne 伺服器:作為 ASGI 伺服器,專門負責處理 WebSocket 連線,將其路由到 ChatConsumer

  • urls.py:Django 的 URL 路由器,將請求導向正確的視圖或消費者。

  • views.py:處理 HTTP 請求的 Django 視圖,負責渲染模板或提供 RESTful API 響應。

  • ChatConsumer:Django Channels 的核心,處理 WebSocket 的連接、斷開、消息接收,並與頻道層互動。

  • Redis 頻道層:一個基於 Redis 的消息中介層,所有需要廣播的即時消息都會先發送至此,再由它分發給所有訂閱的消費者。

  • 資料庫:儲存聊天記錄(ChatMessage 模型)以及用戶資訊等持久化數據。

  • SendMessageAPI:一個特殊的 REST API 端點,允許外部系統透過 HTTP 發送消息,這些消息也會被傳遞到 Redis 頻道層,實現即時廣播。

三、核心技術詳解

這部分將深入探討專案中各個關鍵技術的實作細節與程式碼片段。

1. Django Channels 的魔力:WebSocket 通訊

WebSocket 協定提供了持久的雙向連線,與傳統 HTTP 的請求/回應模式不同,它允許伺服器在有新數據時主動推送給客戶端,是實現即時通訊的基石。Django Channels 則為 Django 帶來了 ASGI (Asynchronous Server Gateway Interface) 支援,使得 Django 應用能夠處理 WebSocket 連線。

在我們的專案中,ChatConsumer 是處理 WebSocket 連線的核心。

chat/consumers.py - ChatConsumer 核心片段:

import json
import logging
import re # 用於後端驗證房間名稱
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async
from django.contrib.auth.models import User
from django.utils import timezone
from .models import ChatMessage

logger = logging.getLogger(__name__)

class ChatConsumer(AsyncWebsocketConsumer):
    # 當 WebSocket 連線建立時被呼叫
    async def connect(self):
        # 從 URL 獲取房間名稱
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        # 為該房間建立一個唯一的群組名稱
        self.room_group_name = f'chat_{self.room_name}'

        # 後端驗證房間名稱格式,防止無效的房間名連線
        valid_room_pattern = re.compile(r'^[a-zA-Z0-9_]+$') # 允許字母、數字、底線
        if not valid_room_pattern.match(self.room_name):
            logger.warning(f"Consumer: 檢測到無效房間名稱格式: {self.room_name},拒絕連線。")
            await self.close(code=4000) # 使用自定義關閉碼
            return

        # 將此 WebSocket 連線加入到房間群組中
        # 這是異步操作,所以使用 await
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        # 獲取當前用戶名,如果未認證則顯示為“未登入用戶”
        self.user = self.scope['user']
        username = await sync_to_async(lambda: self.user.username)() if self.user.is_authenticated else "未登入用戶"
        logger.info(f"用戶 '{username}' 連線到房間: {self.room_name}")

        # 接受 WebSocket 連線
        await self.accept()

    # 當 WebSocket 連線斷開時被呼叫
    async def disconnect(self, close_code):
        # 將此 WebSocket 連線從房間群組中移除
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
        username = await sync_to_async(lambda: self.user.username)() if self.user.is_authenticated else "未登入用戶"
        logger.info(f"用戶 '{username}' 從房間斷開: {self.room_name} 代碼: {close_code}")

    # 當從 WebSocket 接收到消息時被呼叫 (客戶端發送消息到伺服器)
    async def receive(self, text_data):
        try:
            text_data_json = json.loads(text_data)
            message = text_data_json.get('message')

            # 再次驗證消息內容是否有效
            if not message or not isinstance(message, str) or not message.strip():
                logger.warning("收到空消息或無效消息。")
                await self.send(text_data=json.dumps({"error": "消息內容為空或格式無效。"}))
                return

            username = await sync_to_async(lambda: self.user.username)() if self.user.is_authenticated else "未登入用戶"
            current_timestamp = timezone.now()

            # 將消息存儲到數據庫 (同步操作,需要 sync_to_async 包裝)
            try:
                if self.user.is_authenticated:
                    await sync_to_async(ChatMessage.objects.create)(
                        room_name=self.room_name,
                        sender=self.user,
                        content=message,
                        timestamp=current_timestamp
                    )
                else:
                    await sync_to_async(ChatMessage.objects.create)(
                        room_name=self.room_name,
                        content=message,
                        timestamp=current_timestamp
                    )
                logger.debug(f"消息 '{message}' 已儲存到資料庫。")
            except Exception as e:
                logger.error(f"保存消息到數據庫時發生錯誤: {e}")

            # 將消息廣播到房間群組 (異步操作)
            # 這會觸發同一群組內所有其他 ChatConsumer 實例的 chat_message 方法
            await self.channel_layer.group_send(
                self.room_group_name,
                {
                    'type': 'chat_message', # 事件類型,對應下面的方法名
                    'message': message,
                    'user': username,
                    'timestamp': current_timestamp.isoformat(), # 使用 ISO 格式方便前端解析
                }
            )
        except json.JSONDecodeError:
            logger.error("收到非 JSON 格式的數據。")
            await self.send(text_data=json.dumps({"error": "Invalid JSON format."}))
        except Exception as e:
            logger.error(f"處理消息時發生錯誤: {e}")
            await self.send(text_data=json.dumps({"error": "Server error processing message."}))

    # 當頻道層發送 'chat_message' 類型的事件時被呼叫 (接收廣播消息)
    async def chat_message(self, event):
        message = event['message']
        user = event['user']
        timestamp = event.get('timestamp', '時間未知')

        # 將消息發送回客戶端 (透過 WebSocket)
        await self.send(text_data=json.dumps({
            'message': message,
            'user': user,
            'timestamp': timestamp,
        }))

2. Redis:即時通訊的心臟

Redis 在此專案中作為 channels_redis 的後端,實現了高效的頻道層。當 ChatConsumerSendMessageAPI 需要廣播一條消息時,它們會將消息發送到 Redis。Redis 則利用其發布/訂閱 (Pub/Sub) 機制,將這條消息即時地推送給所有訂閱了相關頻道的 ChatConsumer 實例。這使得多個運行中的 Django 進程能夠無縫地共享消息,是即時廣播功能的關鍵。

3. 異步與同步的橋樑:async_to_sync & sync_to_async

Django ORM(物件關係映射)和大多數 Django 函式庫是同步的,這表示它們會阻塞執行緒直到操作完成。然而,Channels 的 Consumer 是異步的,運行在事件迴圈中。為了在這兩種環境之間無縫互動,asgiref 提供了兩個關鍵的包裝器:

  • sync_to_async(sync_function):將同步函數包裝成異步函數。在異步上下文中呼叫它時,它會自動在單獨的執行緒中運行同步程式碼,從而避免阻塞主事件迴圈。

    • 應用場景:在 ChatConsumerreceive 方法中,當我們需要將消息存儲到資料庫 (ChatMessage.objects.create()) 時,就使用了 await sync_to_async(ChatMessage.objects.create)(...)

  • async_to_sync(async_function):將異步函數包裝成同步函數。這允許您在同步上下文中呼叫異步程式碼。

    • 應用場景:在 SendMessageAPI(一個同步的 Django APIView)的 post 方法中,我們需要向異步的 Channels 頻道層發送消息 (channel_layer.group_send()),因此使用了 async_to_sync(channel_layer.group_send)(...)

4. REST API 整合:擴展通訊維度

除了 WebSocket,專案還提供了一個 RESTful API 端點,允許外部系統透過標準 HTTP 請求發送消息到聊天室。

chat/views.py - SendMessageAPI 核心片段:

import json
import logging
import re # 用於後端驗證房間名稱
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated # 需要認證
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from django.utils import timezone
from .models import ChatMessage

logger = logging.getLogger(__name__)

class SendMessageAPI(APIView):
    # 設定權限類:只有已認證的用戶才能呼叫此 API
    permission_classes = [IsAuthenticated] 

    def post(self, request, room_name, *args, **kwargs):
        """
        接收 HTTP POST 請求,將消息發送到指定房間的 WebSocket 頻道層。
        """
        # 後端驗證房間名稱格式
        valid_room_pattern = re.compile(r'^[a-zA-Z0-9_]+$') # 允許字母、數字、底線
        if not valid_room_pattern.match(room_name):
            logger.warning(f"API: 檢測到無效房間名稱格式: {room_name}。")
            return Response({"error": "房間名稱格式無效。"}, status=status.HTTP_400_BAD_REQUEST)

        message_content = request.data.get('message')
        if not message_content or not isinstance(message_content, str) or not message_content.strip():
            logger.warning("API 收到空消息或無效消息。")
            return Response({"error": "消息內容為必填項且不能為空。"}, status=status.HTTP_400_BAD_REQUEST)

        # 獲取頻道層實例
        channel_layer = get_channel_layer()
        room_group_name = f'chat_{room_name}'
        
        # 獲取發送者名稱
        user_display_name = request.user.username if request.user.is_authenticated else "API 發送者"
        current_timestamp = timezone.now()

        # 將消息存儲到數據庫 (ChatMessage 模型已啟用)
        try:
            # 由於此 API 需要認證,sender 欄位可以直接使用 request.user
            ChatMessage.objects.create(
                room_name=room_name, 
                sender=request.user, 
                content=message_content, 
                timestamp=current_timestamp
            )
            logger.debug(f"API 發送消息 '{message_content}' 已儲存到資料庫。")
        except Exception as e:
            logger.error(f"API 保存消息到數據庫時發生錯誤: {e}")
            # 即使資料庫儲存失敗,仍嘗試發送到 WebSocket,保證即時性

        # 使用 async_to_sync 將異步的 channel_layer.group_send 轉為同步執行
        # 這會觸發 ChatConsumer 中的 chat_message 方法,將消息廣播給所有 WebSocket 連線
        async_to_sync(channel_layer.group_send)(
            room_group_name,
            {
                'type': 'chat_message', 
                'message': message_content,
                'user': user_display_name,
                'timestamp': current_timestamp.isoformat(), # 使用 ISO 格式方便前端解析
            }
        )
        return Response({"status": "消息已成功發送到 WebSocket 頻道。"}, status=status.HTTP_200_OK)

5. Docker 化:簡化開發與部署

Docker 讓開發環境的設定變得輕而易舉,並保證了各環境的一致性。

Dockerfile - 定義 Django 應用程式映像檔:

# realtime_chat_project/Dockerfile

# 使用 Python 官方映像檔作為基礎,選擇一個穩定的版本
FROM python:3.10-slim-buster

# 設定容器內的工作目錄
WORKDIR /app

# 設定環境變數,讓 Python 輸出直接顯示,不緩衝
ENV PYTHONUNBUFFERED 1

# 複製 requirements.txt 到工作目錄
COPY requirements.txt /app/

# 安裝 Python 依賴
# 使用 --no-cache-dir 避免生成緩存,減少映像檔大小
RUN pip install --no-cache-dir -r requirements.txt

# 複製專案程式碼到工作目錄
COPY . /app/

# 收集靜態文件 (建議在構建時執行)
RUN python manage.py collectstatic --noinput

# 只需暴露 Daphne 將運行的 8000 埠
EXPOSE 8000

# 預設啟動命令改為 daphne,處理 ASGI 請求 (WebSocket 和 HTTP)
# CMD 指令的內容在 Docker Compose 中會被覆蓋
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "realtime_chat_project.asgi:application"]

docker-compose.yml - 定義多服務應用程式:

# realtime_chat_project/docker-compose.yml

# Docker Compose 版本
version: '3.9'

services:
  # Django/Daphne 服務 (處理 HTTP 和 WebSocket)
  web:
    build:
      context: . # 指定 Dockerfile 的路徑 (當前目錄)
      dockerfile: Dockerfile # 指定 Dockerfile 名稱
    container_name: realtime_chat_web # 容器名稱
    # 簡化後的命令:只啟動 daphne。ASGI 應用程式已能同時處理 HTTP 和 WebSocket。
    command: daphne -b 0.0.0.0 -p 8000 realtime_chat_project.asgi:application
    volumes:
      - .:/app # 將主機當前目錄掛載到容器的 /app,方便開發時代碼熱重載
    ports:
      - "8000:8000" # 映射容器的 8000 埠到主機的 8000 埠
    env_file:
      - .env # 載入 .env 文件中的環境變數
    depends_on:
      - redis # 確保 redis 服務先啟動
    networks:
      - chat_network # 將服務加入自定義網路

  # Redis 服務 (作為 Channels 的頻道層)
  redis:
    image: redis/redis-stack-server:latest # 使用 Redis Stack 映像檔,包含 RedisInsight
    container_name: realtime_chat_redis # 容器名稱
    ports:
      - "6379:6379" # 映射 Redis 埠
    command: redis-server --appendonly yes # 啟動 Redis 並啟用 AOF 持久化
    volumes:
      - redis_data:/data # 持久化 Redis 數據到具名數據卷
    networks:
      - chat_network # 將服務加入自定義網路

# 定義一個自定義網路,讓服務可以透過服務名 (如 'redis') 互相通訊
networks:
  chat_network:
    driver: bridge

# 定義數據卷,用於持久化 Redis 數據
volumes:
  redis_data:

四、專案建置與運行

現在,讓我們動手運行這個即時聊天應用!

1. 環境要求

  • Python 3.10+ (用於本地運行或生成 SECRET_KEY)

  • Git

  • Docker 和 Docker Compose (強烈推薦,這是最簡單的運行方式)

2. 使用 Docker Compose 運行 (推薦方式)

這是最推薦的運行方式,它會自動處理環境和服務間的依賴。

  1. 克隆儲存庫

    git clone https://github.com/BpsEason/realtime_chat_project.git
    cd realtime_chat_project
    
  2. 建立 .env 檔案:

    在 realtime_chat_project/ 目錄中建立一個名為 .env 的檔案,內容如下:

    SECRET_KEY='你的實際密鑰,至少50個隨機字符' # 請替換為一個強密鑰!
    DEBUG=True
    ALLOWED_HOSTS='127.0.0.1,localhost'
    REDIS_URL='redis://redis:6379' # 在 Docker Compose 環境中,'redis' 會被解析為 Redis 服務的內部 IP
    

    (您可以透過執行 python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' 來生成一個強 SECRET_KEY。在 PowerShell 中,單引號內的指令可能需要雙引號。)

  3. 構建並啟動 Docker 服務:

    這會構建 Django 應用程式的映像檔並啟動所有服務(web (Django/Daphne) 和 redis)。

    docker compose up --build -d
    
    • --build: 如果 Dockerfile 或程式碼有任何變動,會重新構建映像檔。

    • -d: 在後台運行容器。

  4. 執行資料庫遷移 (在容器內執行):

    docker compose exec web python manage.py migrate
    
  5. 創建超級用戶 (可選,用於訪問 Django Admin 後台:http://127.0.0.1:8000/admin/):

    docker compose exec web python manage.py createsuperuser
    
  6. 訪問應用程式

    • 瀏覽器訪問:http://127.0.0.1:8000/chat/

    • WebSocket 連線:前端會自動連接 ws://127.0.0.1:8000/ws/chat/your_room_name/

3. 常用 Docker Compose 命令:

  • 查看運行中的服務docker compose ps

  • 查看服務日誌docker compose logs -f web (查看 web 服務的實時日誌)

  • 停止服務docker compose stop

  • 停止並移除容器和網路 (保留數據卷)docker compose down (這是安全的,Redis 數據會被保留)

  • 停止並移除所有內容 (包括數據卷)docker compose down -v (警告:這會刪除 redis_data 卷,導致 Redis 數據永久丟失,請謹慎使用!)

4. 本地開發環境運行 (不使用 Docker,如果需要)

如果您不使用 Docker,也可以手動設定和運行:

  1. 進入專案目錄cd realtime_chat_project

  2. 建立並激活虛擬環境python3 -m venv .venv && source .venv/bin/activate (Windows 使用 .\.venv\Scripts\activate)

  3. 安裝 Python 依賴pip install -r requirements.txt

  4. 設定環境變數:在專案根目錄建立 .env 檔案,內容同 Docker 部分的 .env,但 REDIS_URL 應為 redis://127.0.0.1:6379

  5. 安裝並啟動 Redis 服務

    • Ubuntu/Debian: sudo apt update && sudo apt install redis-server

    • macOS (使用 Homebrew): brew install redis && brew services start redis

    • Windows: 可以考慮使用 WSL2 或安裝適用於 Windows 的 Redis 版本。

    • 確保 Redis 服務正在運行在 127.0.0.1:6379

  6. 執行資料庫遷移python manage.py migrate (可選:創建超級用戶 python manage.py createsuperuser)

  7. 啟動 ASGI 伺服器 (處理 WebSocket 和 HTTP):daphne -b 0.0.0.0 -p 8000 realtime_chat_project.asgi:application

  8. 訪問 http://127.0.0.1:8000/chat/ 進行測試。 (請注意,如果只運行 Daphne,所有請求都會走 8000 埠)

五、前端交互與用戶體驗

前端部分設計簡潔,主要由兩個 HTML 模板和內嵌 JavaScript 構成。

  • index.html: 用於讓使用者輸入或選擇要進入的聊天室名稱。

  • room.html: 實際的聊天界面,負責:

    • 建立 WebSocket 連線到 Daphne 伺服器。

    • 發送使用者輸入的消息。

    • 接收來自伺服器的即時消息並動態添加到聊天記錄中。

    • 提供基本的連線狀態提示和自動重連機制。

chat/templates/chat/room.html - JavaScript 核心片段:

// ... (HTML 和 CSS 部分) ...
<body>
    <div class="chat-container">
        <h1>聊天室: {{ room_name }}</h1>
        <div id="chat-log">
            {% for message in messages %}
                <div>
                    <span class="message-user">{{ message.sender.username|default:"未登入用戶" }}</span>
                    <span class="message-timestamp">[{{ message.timestamp|date:"Y-m-d H:i:s" }}]:</span>
                    {{ message.content }}
                </div>
            {% endfor %}
        </div>
        <div class="input-area">
            {% if request.user.is_authenticated %}
                <input id="chat-message-input" type="text" placeholder="輸入您的消息..."/>
                <input id="chat-message-submit" type="button" value="發送"/>
            {% else %}
                <input id="chat-message-input" type="text" placeholder="請登入後發言..." disabled/>
                <input id="chat-message-submit" type="button" value="發送" disabled/>
                <p style="margin-top: 10px; color: #dc3545;">您需要<a href="/admin/login/?next={{ request.path }}">登入</a>才能發言。</p>
            {% endif %}
        </div>
        <div id="status-message" class="status-message status-connecting">狀態:正在連接...</div>
    </div>

    <script>
        var roomName = "{{ room_name }}";
        var chatLog = document.querySelector('#chat-log');
        var chatMessageInput = document.querySelector('#chat-message-input');
        var chatMessageSubmit = document.querySelector('#chat-message-submit');
        var statusMessage = document.querySelector('#status-message');

        // 加載歷史消息時滾動到底部
        chatLog.scrollTop = chatLog.scrollHeight;

        var webSocket;
        // 動態決定 WebSocket URL (ws:// 或 wss://)
        var wsUrl = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
                    window.location.host +
                    '/ws/chat/' + encodeURIComponent(roomName) + '/'; // 確保房間名已編碼

        var reconnectAttempts = 0;
        var maxReconnectAttempts = 10; // 最大重連次數

        // 更新連線狀態顯示
        function setStatus(message, type) {
            statusMessage.textContent = '狀態:' + message;
            statusMessage.className = 'status-message status-' + type;
        }

        // 將新消息添加到聊天記錄中
        function appendMessage(user, message, timestamp) {
            var messageDiv = document.createElement('div');
            var userSpan = document.createElement('span');
            userSpan.className = 'message-user';
            userSpan.textContent = user;

            var timestampSpan = document.createElement('span');
            timestampSpan.className = 'message-timestamp';
            // 嘗試解析 ISO 格式的時間戳,否則使用原始值
            try {
                const date = new Date(timestamp);
                if (!isNaN(date)) { // 檢查是否是有效日期
                    timestampSpan.textContent = '[' + date.toLocaleString() + ']:';
                } else {
                    timestampSpan.textContent = '[' + timestamp + ']:';
                }
            } catch (e) {
                timestampSpan.textContent = '[' + timestamp + ']:';
            }

            var contentText = document.createTextNode(' ' + message);

            messageDiv.appendChild(userSpan);
            messageDiv.appendChild(timestampSpan);
            messageDiv.appendChild(contentText);

            if (user === '[系統]') {
                messageDiv.classList.add('system-message');
            }

            chatLog.appendChild(messageDiv);
            chatLog.scrollTop = chatLog.scrollHeight; // 自動滾動到底部
        }

        // 建立 WebSocket 連線的核心函數
        function connectWebSocket() {
            if (webSocket && (webSocket.readyState === WebSocket.OPEN || webSocket.readyState === WebSocket.CONNECTING)) {
                return; // 如果已經連接或正在連接,則不重複操作
            }

            if (reconnectAttempts >= maxReconnectAttempts) {
                setStatus('重連失敗次數過多,請手動刷新頁面。', 'error');
                console.error('Max reconnect attempts reached. Please refresh.');
                return;
            }

            setStatus('正在連接...', 'connecting');
            console.log('嘗試連接 WebSocket:', wsUrl);
            webSocket = new WebSocket(wsUrl);

            // 連線成功
            webSocket.onopen = function(e) {
                appendMessage('[系統]', '連線成功!', new Date().toLocaleString());
                setStatus('已連接', 'connected');
                reconnectAttempts = 0; // 重置重連計數器
                console.log('WebSocket opened:', e);
            };

            // 收到消息
            webSocket.onmessage = function(e) {
                try {
                    var data = JSON.parse(e.data);
                    var message = data['message'];
                    var user = data['user'];
                    var timestamp = data['timestamp'] || new Date().toLocaleString(); 
                    appendMessage(user, message, timestamp);
                } catch (jsonError) {
                    console.error('接收到無效的 JSON 消息:', e.data, jsonError);
                    appendMessage('[系統]', '收到無效消息格式。', new Date().toLocaleString());
                }
            };

            // 連線斷開
            webSocket.onclose = function(e) {
                appendMessage('[系統]', '連線斷開!', new Date().toLocaleString());
                setStatus('連線斷開,嘗試重連...', 'disconnected');
                console.error('WebSocket closed unexpectedly:', e);

                reconnectAttempts++;
                // 指數退避重連策略,最大延遲 30 秒
                var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); 
                console.log('嘗試在 ' + (delay / 1000) + ' 秒後重連...');
                setTimeout(connectWebSocket, delay); 
            };

            // 連線錯誤
            webSocket.onerror = function(e) {
                appendMessage('[系統]', '連線錯誤!', new Date().toLocaleString());
                setStatus('連線錯誤!', 'error');
                console.error('WebSocket error:', e);
                webSocket.close(); // 觸發 onclose 事件,以便重連邏輯處理
            };
        }

        connectWebSocket(); // 初始連接嘗試

        // 綁定輸入框 Enter 鍵和發送按鈕點擊事件
        chatMessageInput.focus();
        chatMessageInput.onkeyup = function(e) {
            if (e.key === 'Enter') {
                chatMessageSubmit.click();
            }
        };

        chatMessageSubmit.onclick = function(e) {
            var message = chatMessageInput.value.trim();
            if (message && webSocket.readyState === WebSocket.OPEN) {
                webSocket.send(JSON.stringify({
                    'message': message
                }));
                chatMessageInput.value = ''; // 清空輸入框
            } else if (!message) {
                alert('消息不能為空!'); // 建議替換為更現代的 UI 提示
            } else {
                alert('WebSocket 連線未建立或已斷開,請稍候或刷新頁面。'); // 建議替換為更現代的 UI 提示
            }
        };
    </script>
</body>
<!-- ... (結束 HTML 標籤) -->

六、常見面試問題 (FAQ for Interviewers)

這是一個將專案作為作品集展示時,面試官可能提出的問題及其建議回答。

1. 為什麼選擇 Django Channels 而不是傳統的 HTTP 請求來實現聊天功能?

  • 回答:傳統的 HTTP 請求是無狀態的,通常採用請求/回應模型。這對於即時通訊效率低下,因為客戶端需要不斷輪詢伺服器以獲取新消息。Django Channels 則基於 WebSocket 協定,提供了持久的雙向連線。這使得伺服器可以在新消息可用時立即推送給所有連接的客戶端,實現真正的即時性,同時顯著減少了網路負擔和延遲。

2. 在這個專案中,Redis 扮演了什麼角色?

  • 回答:在這個即時聊天應用程式中,Redis 作為 Django Channels 的「頻道層 (Channel Layer)」。它允許不同的 Django 進程(例如運行在不同 Daphne 實例上的 Consumers)之間互相通訊和廣播消息。當一個用戶在某個聊天室發送消息時,該消息會被發送到 Redis 頻道層,然後 Redis 會將其廣播給所有訂閱了該聊天室頻道的消費者,從而確保所有在線用戶都能即時收到消息。Redis 的高速特性對於這種消息中介非常關鍵。

3. 如何處理 WebSocket 連線的認證與授權?

  • 回答:在這個專案中,WebSocket 連線透過 channels.auth.AuthMiddlewareStack 處理認證。它會自動將來自標準 Django 會話的用戶訊息附加到 WebSocket 連線的 scope 中,使得 ChatConsumer 可以直接透過 self.scope['user'] 訪問當前登入的用戶對象。這樣,我們就可以根據用戶的登入狀態來決定是否允許發送消息或顯示用戶名。對於更複雜的授權,可以在 connectreceive 方法中添加額外的權限檢查邏輯。

4. ChatConsumer 中的 sync_to_asyncasync_to_sync 有什麼作用?

  • 回答sync_to_asyncasync_to_syncasgiref 庫提供的工具,用於在 Django 的同步程式碼(例如 ORM 數據庫操作)和 Channels 的異步程式碼之間進行橋接。

    • sync_to_async:允許在異步函數(如 ChatConsumerreceive 方法)內部呼叫同步的 Django ORM 操作(如 ChatMessage.objects.create())。這是必要的,因為 Django ORM 本身是同步的,直接在異步環境中呼叫會阻塞事件迴圈。

    • async_to_sync:允許在同步函數(例如 REST API 的 SendMessageAPI 中的 post 方法)中呼叫異步的 Channels 頻道層操作(如 channel_layer.group_send())。這樣即使 HTTP 請求是同步的,也能將消息發送到異步的 WebSocket 頻道層。

5. 專案中的 REST API (SendMessageAPI) 有什麼用途?它與 WebSocket 有何不同?

  • 回答SendMessageAPI 是一個 RESTful API 端點,允許外部系統或服務透過標準 HTTP POST 請求向指定聊天室發送消息。

    • 用途:例如,您可以建立一個排程任務、一個Webhook 接收器,或者一個第三方應用程式,透過呼叫此 API 來自動化發送通知或消息到聊天室。

    • 與 WebSocket 的不同

      • 連線方式:REST API 透過 HTTP/HTTPS 協議進行一次性的請求/回應,每次發送消息都需要建立新的連線。WebSocket 則建立一個持久的、雙向的連線,允許即時的數據流動。

      • 即時性:WebSocket 是為即時通訊設計的,消息幾乎沒有延遲。REST API 雖然可以發送消息,但它本身不提供即時接收功能,如果客戶端要接收消息,仍需要輪詢或依賴 WebSocket 連線。

      • 用途:WebSocket 主要用於客戶端(瀏覽器)和伺服器之間的即時互動。REST API 則更適用於異步操作、批次處理、或與其他非即時服務的整合。

6. 如何將此專案部署到生產環境?

  • 回答:部署到生產環境時,需要考慮幾個關鍵點:

    • 數據庫:將預設的 SQLite 替換為更可靠的數據庫系統,如 PostgreSQL 或 MySQL。

    • Web 伺服器:使用 Nginx 或 Apache 作為反向代理伺服器,處理靜態文件、負載均衡和 SSL 加密。

    • ASGI 伺服器:使用像 Gunicorn 搭配 Uvicorn worker 這樣的生產級 ASGI 伺服器來運行 Daphne 應用程式,以確保穩定性和性能。

    • 環境變數:在生產環境中嚴格管理環境變數,例如 SECRET_KEYDEBUG 必須設定為 False

    • 日誌:配置適當的日誌記錄,以便監控應用程式的運行狀態和排查問題。

    • SSL/TLS:為所有 HTTP 和 WebSocket 連線啟用 SSL/TLS 加密,以保護數據傳輸安全。

    • 持久化:確保 Redis 數據卷和數據庫數據都被適當持久化。

    • Docker Compose:可以利用 Docker Compose 在生產環境中協調所有服務(應用程式、數據庫、Redis),使其部署更為簡潔和可移植。

七、挑戰與解決方案

在開發此即時聊天應用程式時,我們遇到了一些常見的挑戰,並透過設計選擇和特定技術解決方案加以克服:

  1. 即時通訊的挑戰:傳統的 HTTP 無法滿足即時數據推送的需求。

    • 解決方案:引入 Django ChannelsWebSocket。Channels 將 Django 從同步的 WSGI 框架轉變為支援非同步 ASGI,使得伺服器能與客戶端建立持久的雙向連線,實現消息的即時推送。

  2. 狀態管理與消息廣播:如何確保多個同時連接的客戶端(可能運行在不同的伺服器進程上)都能收到相同的即時消息?

    • 解決方案:使用 Redis 作為頻道層 (Channel Layer)。Redis 的 Pub/Sub 機制允許一個消息發布者將消息推送到頻道,所有訂閱該頻道的消費者都能接收。這確保了消息在不同 ChatConsumer 實例之間的有效廣播。

  3. 異步與同步代碼的協作:Django 的大部分 ORM 和視圖是同步的,而 ChatConsumer 是異步的。直接混合使用會導致阻塞問題。

    • 解決方案:利用 asgiref 庫提供的 sync_to_asyncasync_to_sync 包裝器。它們在異步和同步代碼之間建立橋樑,確保同步操作在獨立的執行緒池中運行,不阻塞事件迴圈,同時允許同步代碼調用異步資源。

  4. 環境設定與部署複雜性:依賴多個服務 (Django, Redis),手動配置和部署容易出錯且耗時。

    • 解決方案:全面採用 Docker 和 Docker Compose。透過 Dockerfiledocker-compose.yml 將應用程式及其依賴打包成容器,實現「一次構建,隨處運行」。這極大簡化了開發環境的設定和生產環境的部署流程。

  5. 安全性與濫用防範:允許匿名用戶發送消息可能導致聊天室被惡意灌水。

    • 解決方案:在後端 ChatConsumerSendMessageAPI 中實施用戶認證檢查,要求用戶登入才能發言。同時,前端也對未登入狀態下的輸入框進行禁用提示,引導用戶登入。對於房間名稱,也在後端進行了格式驗證。

八、結論

本專案不僅展示了一個功能完整的即時聊天應用,更是一個整合了 Django、Channels、Redis 和 Docker 等現代 Web 技術的綜合實踐。它解決了即時通訊中的核心挑戰,如消息廣播、異步處理和環境管理,為您構建更複雜的即時應用奠定了基礎。

希望這篇文章能幫助您深入理解這些技術的原理和實踐,並啟發您在自己的專案中應用它們。

完整的程式碼和更多資訊,請查看我的 GitHub 儲存庫:[您的 GitHub 儲存庫連結 (例如:https://github.com/BpsEason/realtime_chat_django)]

如果您有任何問題或建議,歡迎在評論區留言!

沒有留言:

張貼留言

網誌存檔