docker-compose.yaml 與 dockerfile 差別

這兩者雖然都跟 Docker 有關,但分工非常明確。簡單來說:Dockerfile 負責做出一台電腦(映像檔),而 docker-compose.yaml 負責把多台電腦連成一個系統

Dockerfile:單一機器的製作說明書

Dockerfile 是一個純文字檔,裡面記錄了如何從無到有建構出一個環境。例如你要把一個 Node.js 專案打包,Dockerfile 會寫:

  1. 從哪個作業系統開始(例如 Ubuntu 或輕量化的 Alpine)。
  2. 要安裝哪些軟體(例如 Node.js、ffmpeg)。
  3. 要把哪些程式碼檔案複製進去。
  4. 最後啟動程式的指令是什麼。

它的產出是一個 Image(映像檔)。這就像是你把整台裝好環境的電腦做成了「壓縮檔」,不管拿到誰的電腦上執行,環境都會一模一樣。

docker-compose.yaml:整個系統的配置藍圖

當你的專案變大,不再只有一個 Node.js 程式,還需要資料庫(MySQL)、快取(Redis)或網頁伺服器(Nginx)時,如果你一台一台手動啟動會非常痛苦。這時候就需要 docker-compose.yaml

它會定義:

  1. 服務 (Services): 這裡有 Node.js 程式、那裡有資料庫。
  2. 網路 (Networks): 讓這些服務可以互相溝通(例如讓 Node.js 找到資料庫)。
  3. 磁碟 (Volumes): 讓資料庫的資料可以保存在硬碟裡,不會因為容器關掉就消失。

它的作用是管理多個容器。只要執行一個 docker-compose up 指令,它就會根據這份藍圖,把所有需要的電腦(容器)通通開起來並連上網路。


兩者的差異對照表

特性Dockerfiledocker-compose.yaml
核心目標建立單個 Image(映像檔)啟動並管理多個 Container(容器)
關注點軟體安裝、環境變數、程式啟動服務之間的連線、連接埠對應、資料保存
指令範例docker builddocker-compose up
比喻某一道菜的食譜整桌酒席的菜單與座位安排

實際開發中的搭配

通常你會為你的 React 或 Node.js 專案寫一個 Dockerfile,確保程式能在任何地方跑起來。然後再寫一個 docker-compose.yaml,把你的程式跟資料庫串在一起。這樣你在換新電腦開發時,只要下一個指令,整套開發環境(包含資料庫)就會在幾秒鐘內架設完成。


一個同時包含 Node.js 跟資料庫的 docker-compose.yaml 範例,了解它們是怎麼互相溝通的

這是一個常見的開發環境組合:一個 Node.js 後端程式配上一個 MariaDB 資料庫。透過 docker-compose.yaml,你不需要在電腦上安裝資料庫軟體,Docker 會自動幫你處理好。

專案配置範例

假設你的專案資料夾裡已經有一個寫好的 Dockerfile(用來跑 Node.js),接著你會在同一個地方建立這份 docker-compose.yaml

YAML

version: '3.8'

services:
  # 後端程式服務
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DB_HOST=db
      - DB_USER=max
      - DB_PASSWORD=secret
    depends_on:
      - db

  # 資料庫服務
  db:
    image: mariadb:10.6
    restart: always
    environment:
      - MARIADB_ROOT_PASSWORD=root_pass
      - MARIADB_DATABASE=my_project
      - MARIADB_USER=max
      - MARIADB_PASSWORD=secret
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

關鍵設定解析

在上面的 YAML 檔中,有幾個非常聰明的設計:

  • depends_on: 這告訴 Docker 必須先讓資料庫跑起來,再啟動 Node.js 程式,避免程式因為找不到資料庫而當掉。
  • 服務名稱即網址: 在 Node.js 的程式碼裡,你連接資料庫的網址不需要寫 IP,直接寫 db(就是服務名稱)即可。Docker 內部有一套自動電話簿,會幫你轉接到正確的容器。
  • Volumes (資料持久化): 資料庫的資料預設是存在容器裡的虛擬硬碟,如果容器刪除資料就沒了。我們透過 volumes 把容器裡的資料夾對應到你電腦的實體硬碟,這樣即便重開機,資料也還會在。

啟動與管理指令

寫好這份檔案後,你只需要在終端機輸入:

Bash

# 啟動所有服務(加上 -d 會在背景執行)
docker-compose up -d

# 查看目前有哪些容器在跑
docker-compose ps

# 停止並移除所有容器(但保留資料庫內容)
docker-compose down

這套流程對於全端工程師來說非常強大,因為你可以確保「我的電腦跑得起來,客戶的伺服器也一定跑得起來」。


case study:

react 寫的web, 有 attachment 要放到 azure 的 blob storage, database 要使用 azure sql server, docker-compose 如何寫, 在本地端 localhost 的 docker 如何進行功能的驗證與測試.

在本地端模擬這一套雲端架構,核心做法是利用 Docker 容器來運行資料庫,並使用模擬器來替代雲端存儲空間。

專案架構與 Dockerfile

首先,你需要為 React 前端與 Node.js 後端(用於處理 Azure Blob 與資料庫連線)準備環境。

Dockerfile

# 後端 Dockerfile 範例
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

docker-compose.yaml 配置

為了在 localhost 驗證 Azure 功能,我們引入 Azurite 作為 Blob Storage 的模擬器,並使用 Microsoft 提供的 SQL Server 映像檔。

YAML

version: '3.8'

services:
  # 後端 API
  server:
    build: ./server
    ports:
      - "3000:3000"
    environment:
      - AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xBDfhYFl6480lEy9XRSdg57xvE4NMpVq0n7Slsraw==;BlobEndpoint=http://azurite:10001/devstoreaccount1;
      - DB_SERVER=sqlserver
      - DB_USER=SA
      - DB_PASSWORD=YourStrongPassword123
    depends_on:
      - azurite
      - sqlserver

  # Azure Blob 模擬器
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
      - "10001:10001"

  # Azure SQL 模擬器 (MS SQL Server)
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_SA_PASSWORD=YourStrongPassword123
    ports:
      - "1433:1433"

本地端功能驗證與測試

部署好容器後,你可以透過以下方式驗證功能是否正常:

  1. 資料庫連線驗證你可以使用 VS Code 的 SQL Server 擴充功能,連線到 localhost:1433。帳號為 SA,密碼為你在 YAML 檔設定的那組。如果能成功建立資料表,代表後端程式碼中的 mssql 套件也能正常運作。
  2. 檔案上傳驗證 (Blob)當 React 送出附件到後端,後端透過 Azure SDK 上傳至 Azurite 時,你可以安裝 Azure Storage Explorer。這個工具可以連線到本地的 Azurite,讓你直接看到檔案是否有成功被放入容器(Container)中。
  3. 環境變數切換在本地測試時,連線字串指向的是 azurite 與 localhost;當你部署到 Azure 時,只需要透過環境變數將連線字串換成真正的 Azure Blob Endpoint 與 Azure SQL 位置,程式碼完全不需要修改。
  4. 網路偵錯因為 React 是在瀏覽器跑,Node.js 是在容器跑。請確保 React 是呼叫 localhost:3000,而 Node.js 容器內部呼叫資料庫時,主機名稱要寫服務名稱 sqlserver 而非 localhost。

使用 Azurite 模擬器進行本機 Azure 儲存體開發

Azurite 開放原始碼模擬器提供免費的本機環境,用於測試雲端式應用程式。 當您對應用程式在本機運作方式感到滿意時,請切換至使用雲端中的 Azure 儲存體帳戶。

Azurite 是以 JavaScript 撰寫的輕量型儲存體模擬器,並在模擬 Azure 儲存體服務以進行本機開發的 Node.js 上執行。 它支援 Blob、佇列和資料表儲存體服務,並在 Windows、Linux 和 macOS 上提供跨平台支援。 如需安裝和執行 Azurite 的說明,請參閱 安裝並執行 Azurite 模擬器

Azurite 還允許開發人員針對本地儲存環境運行測試,模擬 Azure 的行為,這對於整合和端到端測試至關重要。 若要深入瞭解如何使用 Azurite 進行自動化測試,請參閱 使用 Azurite 執行自動化測試

Azurite 取代 Azure 儲存體模擬器,並持續更新以支援最新版本的 Azure 儲存體 API。

Azurite 和 Azure 儲存體之間的差異

Azurite 的本機執行個體與雲端中的 Azure 儲存體帳戶之間存在功能差異。

 重要

Azurite 僅支援 Blob、佇列和資料表儲存體服務。 它不支援 Azure 檔案儲存體或 Azure Data Lake Storage Gen2,但在 Windows、Linux 和 macOS 上提供跨平臺支援。

端點和連線 URL

Azurite 的服務端點與 Azure 儲存體帳戶的端點不同。 本機電腦不會執行網域名稱解析,因此需要 Azurite 端點是本機位址。

當您在 Azure 儲存體帳戶中處理資源時,帳戶名稱是 URI 主機名稱的一部分。 要定址的資源是 URI 路徑的一部分:

<http|https>://<account-name>.<service-name>.core.windows.net/<resource-path>

下列 URI 是 Azure 儲存體帳戶中 Blob 的有效位址:

https://myaccount.blob.core.windows.net/mycontainer/myblob.txt

IP 樣式 URL

由於本機電腦不會解析網域名稱,因此帳戶名稱是 URI 路徑的一部分,而不是主機名稱。 對 Azurite 中的資源使用下列 URI 格式:

http://<local-machine-address>:<port>/<account-name>/<resource-path>

下列位址可用來存取 Azurite 中的 Blob:

http://127.0.0.1:10000/myaccount/mycontainer/myblob.txt

生產樣式 URL

或者,您可以修改主機檔案,以存取具有 生產樣式 URL 的帳戶。

首先,將一行或多行新增至主機檔案。 例如:

127.0.0.1 account1.blob.localhost
127.0.0.1 account1.queue.localhost
127.0.0.1 account1.table.localhost

接下來,設定環境變數以啟用自訂的儲存體帳戶和金鑰:

set AZURITE_ACCOUNTS="account1:key1:key2"

您可以新增更多帳戶。 請參閱連線到 Azurite 文章的自訂儲存體帳戶和金鑰一節。

啟動 Azurite 並使用自訂連接字串存取您的帳戶。 在下列範例中,連接字串會假設使用預設連接埠。

DefaultEndpointsProtocol=http;AccountName=account1;AccountKey=key1;BlobEndpoint=http://account1.blob.localhost:10000;QueueEndpoint=http://account1.queue.localhost:10001;TableEndpoint=http://account1.table.localhost:10002;

請勿使用 Azure 儲存體總管以這種方式存取預設帳戶。 儲存體總管一律會在 URL 路徑中新增帳戶名稱,導致失敗。

依預設,將 Azurite 搭配生產樣式 URL 使用時,帳戶名稱應該是完整網域名稱中的主機名稱,例如 http://devstoreaccount1.blob.localhost:10000/container。 若要在 URL 路徑 http://foo.bar.com:10000/devstoreaccount1/container中使用具有帳戶名稱的生產樣式 URL,例如 ,請務必在啟動 Azurite 時使用參數 --disableProductStyleUrl 。

如果您使用 host.docker.internal 作為要求 Uri 主機 (例如: http://host.docker.internal:10000/devstoreaccount1/container),Azurite 會從要求 Uri 路徑取得帳戶名稱。 無論您在啟動 Azurite 時是否使用參數 --disableProductStyleUrl ,此行為都是正確的。

調整和效能

Azurite 不支援大量連線的用戶端。 沒有效能保證。 Azurite 旨在用於開發和測試目的。

錯誤處理

Azurite 與 Azure 儲存體錯誤處理邏輯一致,但有差異。 例如,錯誤訊息可能不同,而錯誤狀態碼則一致。

RA-GRS

Azurite 支援讀取存取異地備援複寫 (RA-GRS)。 對於儲存資源,請附加 -secondary 至帳戶名稱來存取次要位置。 例如,下列位址可用來使用 Azurite 中的唯讀次要存取 Blob:

http://127.0.0.1:10000/devstoreaccount1-secondary/mycontainer/myblob.txt

表格支援

對 Azurite 中表格的支援目前處於預覽階段。 如需詳細資訊,請參閱 Azurite V3 表格 專案。

對持久函數的支援需要資料表。

 重要

Azurite 對 Table Storage 的支援目前處於 預覽階段。 請參閱 Microsoft Azure 預覽版增補使用規定,以了解適用於 Azure 功能 (搶鮮版 (Beta)、預覽版,或尚未正式發行的版本) 的法律條款。

Azurite 是開源的

歡迎對 Azurite 做出貢獻和建議。 移至 Azurite GitHub 專案 頁面或 GitHub 問題 ,以取得我們正在追蹤即將推出的功能和錯誤修正的里程碑和工作專案。 詳細的工作專案也會在 GitHub 中追蹤。

工作區結構

初始化 Azurite 時,可能會在工作區位置中建立下列檔案和資料夾。

  • __blobstorage__ – 包含 Azurite Blob 服務保存二進位資料的目錄
  • __queuestorage__ – 包含 Azurite 佇列服務保存二進位資料的目錄
  • __tablestorage__ – 包含 Azurite 資料表服務保存二進位資料的目錄
  • __azurite_db_blob__.json – Azurite Blob 服務中繼資料檔案
  • __azurite_db_blob_extent__.json – Azurite Blob 服務範圍中繼資料檔案
  • __azurite_db_queue__.json – Azurite 佇列服務中繼資料檔案
  • __azurite_db_queue_extent__.json – Azurite 佇列服務範圍 meta 資料檔
  • __azurite_db_table__.json – Azurite 資料表服務中繼資料檔案
  • __azurite_db_table_extent__.json – Azurite 表格服務範圍中繼資料檔案

若要清理 Azurite,請刪除檔案和資料夾並重新啟動模擬器。

後續步驟


在 React 中使用 Azure SDK 直接從瀏覽器安全地把附件上傳到 Blob

在 React 中直接從瀏覽器上傳檔案到 Azure Blob Storage,最常見且安全的方法是使用 SAS (Shared Access Signature) 權杖。這能避免在前端暴露你的帳戶金鑰,同時又能讓使用者直接將檔案傳給 Azure,減輕後端伺服器的負擔。

安全上傳流程

  1. 請求授權: React 前端向 Node.js 後端申請上傳權限。
  2. 核發 SAS 權杖: 後端驗證使用者身分後,向 Azure 申請一個限時(例如 5 分鐘)且限權限(只能寫入)的臨時網址。
  3. 直接上傳: React 拿到網址後,使用 Azure SDK 將檔案(Attachment)直接推送到 Blob Storage。

React 前端實作碼

首先需要安裝 SDK:npm install @azure/storage-blob

JavaScript

import { BlockBlobClient } from '@azure/storage-blob';

async function uploadFile(file) {
  // 1. 向你的後端取得臨時的 SAS 網址 (這部分需實作 API)
  const response = await fetch('/api/get-sas-url?fileName=' + file.name);
  const { sasUrl } = await response.json();

  // 2. 建立客戶端並執行上傳
  const blobClient = new BlockBlobClient(sasUrl);
  
  try {
    await blobClient.uploadBrowserData(file, {
      onProgress: (ev) => console.log(`上傳進度: ${ev.loadedBytes}`),
      blobHTTPHeaders: { blobContentType: file.type }
    });
    alert('上傳成功!');
  } catch (error) {
    console.error('上傳失敗', error);
  }
}

本地開發的注意事項

在本地端使用 Azurite 模擬器進行測試時,有兩個關鍵點需要設定:

  • CORS 設定: 瀏覽器基於安全性會阻擋跨來源請求。你需要對 Azurite 執行命令來開啟 CORS,或是透過 Azure SDK 在後端初始化 Container 時,設定允許來自 http://localhost:5173 (React 預設埠) 的存取。
  • 連接字串: 誠如前面提到的,本地測試時 SAS 產生的網址會指向 http://127.0.0.1:10001。請確保你的 React 環境能存取到這個位置。

Azure SQL 與 Blob 的關聯

通常的做法是:檔案成功上傳到 Blob 後,你會拿到一個檔案路徑(URL)。接著,React 會再發一個請求給 Node.js,將這個 URL 與相關資訊(如上傳者、日期)存入 Azure SQL Database。這樣之後要讀取附件時,只需從 SQL 查出網址,前端就能直接顯示圖片或提供下載連結。


後端 Node.js 如何產生安全的 SAS 權杖程式碼

產生 SAS 權杖(Shared Access Signature)是保護雲端資源的核心技術。後端 Node.js 會扮演守門員的角色,利用帳戶金鑰運算出一串具備時效性的簽名,交給前端 React 使用。

Node.js 產生 SAS 權杖的程式碼

你需要安裝 @azure/storage-blob 套件。以下是一個簡單的 API 邏輯範例:

JavaScript

import { 
  BlobServiceClient, 
  StorageSharedKeyCredential, 
  generateBlobSASQueryParameters, 
  BlobSASPermissions 
} from "@azure/storage-blob";

export async function getSasUrl(fileName) {
  const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME;
  const accountKey = process.env.AZURE_STORAGE_ACCOUNT_KEY;
  const containerName = "attachments";

  const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey);
  
  // 設定權限:允許寫入 (Create/Write),且 5 分鐘後過期
  const sasOptions = {
    containerName,
    blobName: fileName,
    permissions: BlobSASPermissions.parse("cw"), 
    startsOn: new Date(),
    expiresOn: new Date(new Date().valueOf() + 5 * 60 * 1000), 
  };

  const sasToken = generateBlobSASQueryParameters(sasOptions, sharedKeyCredential).toString();
  
  // 回傳完整的帶權杖網址給前端
  return `https://${accountName}.blob.core.windows.net/${containerName}/${fileName}?${sasToken}`;
}

在本地端 Azurite 測試的調整

在本地使用 Azurite 模擬器時,因為連線協議是 HTTP 而非 HTTPS,且 AccountKey 是固定的測試金鑰,產生 SAS 的方式完全相同,但網址會變成 http://127.0.0.1:10001/...

功能驗證與資料庫紀錄

當 React 拿到這個網址並成功上傳後,下一步通常是把檔案資訊寫入 Azure SQL。你會在 Node.js 中使用 mssql 套件來執行類似這樣的操作:

JavaScript

// 範例:將 Blob 網址存入 SQL Server
const request = new sql.Request();
await request.query(`
  INSERT INTO Attachments (FileName, BlobUrl, UploadTime)
  VALUES ('${fileName}', '${cleanUrl}', GETDATE())
`);

如何在 localhost 進行完整測試

  1. 啟動 Docker: 執行 docker-compose up 啟動 Azurite 與 SQL Server。
  2. 建立 Container: 第一次執行時,你可以寫一個簡單的腳本確保 Azurite 裡已經建立名為 attachments 的容器。
  3. 前端測試: 在 React 介面選取檔案,觀察瀏覽器的 Network 標籤。你應該會看到先發一個請求到 Node.js 拿 SAS 網址,接著 React 直接發一個 PUT 請求到 127.0.0.1:10001
  4. 檢查結果: 使用 Azure Storage Explorer 查看 Azurite 是否多了檔案,並用 SQL 管理工具查看資料表是否多了紀錄。

檔案上傳後的預覽與權限管理,是提升使用者體驗與系統安全的重要環節。我們通常會根據檔案類型(如圖片或 PDF)來決定處理方式。

檔案預覽的處理方式

當檔案上傳到 Blob Storage 後,React 前端可以透過以下兩種方式展示預覽:

  • 上傳前預覽 (Client-side): 在檔案還沒傳到雲端前,利用瀏覽器的 URL.createObjectURL(file) 產生一個暫時的本地網址。這可以讓使用者立刻看到圖片,確認沒選錯檔案。
  • 上傳後預覽 (Server-side): 當使用者下次回到頁面時,React 會從 Azure SQL 讀取檔案的網址。如果你的 Blob Container 設定為「私有」(Private),你就必須再次向後端請求一個「唯讀」的 SAS 權杖,前端才能顯示這張圖片。

下載權限管理

為了安全,我們通常不會將 Blob Container 設為公開。管理下載權限的標準做法如下:

  1. 動態產生下載連結: 當使用者點擊「下載」按鈕時,前端呼叫後端 API。後端確認該使用者有權限查看此附件後,產生一個有效期僅 1 分鐘的 SAS 權杖網址並回傳。
  2. 強制瀏覽器下載: 在產生 SAS 權杖時,後端可以設定 content-disposition 參數。這樣當瀏覽器打開該網址時,會自動彈出「另存新檔」視窗,而不是直接在視窗中開啟檔案。

縮圖處理 (Thumbnail)

如果你的網頁需要一次顯示大量圖片預覽(例如相簿),直接讀取原圖會讓網頁變得很慢。專業的作法是利用 Azure Functions。當檔案一傳進 Blob Storage,會觸發一個自動程式,將圖片縮小並存到另一個名為 thumbnails 的容器中。React 則優先讀取縮圖,等使用者點擊後才載入原圖。

本地端測試預覽功能

在 localhost 測試時,Azurite 同樣支援這些行為。你可以試著在本地端產生一個過期時間只有 10 秒的 SAS 網址,然後在網頁上重新整理,觀察圖片是否在 10 秒後就因為權限失效而無法顯示。這能幫助你驗證系統的安全性是否符合預期。

這套流程串聯了 React 的前端互動、Node.js 的邏輯控制、以及 Azure 的儲存服務。你想了解如何在 Azure SQL 中設計這張附件資料表,好讓它能關聯到你的使用者或專案資料嗎?


除了 Azure Blob Storage,針對不同的應用場景,Azure 還提供幾種存放附件或檔案的替代方案。你可以根據檔案的大小、存取頻率以及是否需要被多台伺服器共享來選擇。

Azure Files (檔案分享)

這與 Blob Storage 的主要區別在於它支援標準的 SMB 與 NFS 協定。簡單來說,你可以把它想像成一個雲端網路硬碟(網路芳鄰)。

  • 使用場景: 如果你的後端程式(Node.js)是跑在多個 Docker 容器或多台虛擬機上,且這些程式需要像存取本地磁碟一樣讀寫檔案,Azure Files 非常適合。
  • 優點: 支援掛載為磁碟機,現有的程式碼不需要改寫成 SDK 調用,直接用檔案路徑就能讀寫。

Azure Data Lake Storage Gen2

這是在 Blob Storage 之上構建的專門用於大數據分析的服務。

  • 使用場景: 如果你的附件不僅僅是拿來下載,之後還需要進行複雜的資料分析、機器學習,或是附件數量多達數百萬、數千萬等級。
  • 優點: 擁有階層式命名空間(真正的資料夾結構),在處理大量檔案搬移或更名時,效能遠高於一般的 Blob。

資料庫直接存放 (不推薦但可行)

你可以將檔案轉成 Base64 字串或二進位格式(VARBINARY),直接存入 Azure SQL Database 的欄位中。

  • 使用場景: 檔案極小(例如不到 100KB 的小圖示或簽名),且你希望備份資料庫時能連同檔案一起備份,不想管理兩套系統。
  • 缺點: 會大幅增加資料庫的負擔與儲存成本,也會讓資料庫備份變得非常巨大且緩慢。通常我們還是建議「資料庫存路徑,Blob 存檔案」。

Azure App Service 內的磁碟空間

如果你的 Node.js 是部署在 Azure App Service,它本身也有一小塊磁碟空間(D 槽或 /home)。

  • 使用場景: 極小規模的測試專案,或檔案只是暫存性質(例如上傳後處理完就刪除)。
  • 風險: 當 App Service 重新啟動、更新或擴充(Scale out)時,這些檔案可能會消失。

該如何選擇?

需求場景推薦方案
最通用、成本最低、React 直接上傳Azure Blob Storage
多台伺服器掛載同一個網路硬碟Azure Files
超大規模數據、需要資料夾權限管理Data Lake Gen2
檔案極小且不想分開管理Azure SQL (VARBINARY)

在本地端驗證時,Azure Files 也有對應的 Docker 模擬器或可以用本地資料夾掛載(Volume)來模擬。你想了解如何把 Azure SQL 中的附件資料表與這些儲存路徑做關聯設計嗎?

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *