はじめに
こんにちは。あきどんです。
正月っていいですね。一生続いてほしいな〜って思ったりします。お餅が美味しい。
突然ですが最近、Mac miniを購入しました。
M4チップ、メモリ:32 GB、ストレージ:256 GB
月6450円の24回払い ナリ
ただ、Mac miniを購入してすぐにMacBookを買えばよかった。。。と後悔しました。
最近思っていた以上に外出することが多くなり、Mac miniを持ち運びするわけにもいかなかったからです。
Mac mini を選んだのは、MacBookだと予算オーバーだったからですが
そんな後悔を吹き飛ばすためにMac miniならではの使い道を考えました。
思いついたのが「自宅写真サーバー」です。
iCloud や Google Photos に毎月課金するのをやめて、自分だけのプライベートサーバーを作る。しかも Swift で。iOS / macOS 開発者なら馴染みの言語でサーバーサイドも書けるなんて、最高じゃないですか。
この記事では、Swift の Web フレームワーク Vapor を使って写真管理 API を構築し、Docker でデプロイするまでを解説します。
Mac miniを買ってよかったー!と思うためにも頑張りましょう。
0. 目指すもの
Google Photos のようなiOSアプリケーションから写真をアップロード・閲覧できる仕組みを構築します。
こんな感じで、Mac miniの外部ストレージに保存できるようにする。
1. Server Side Swift とは
Swift は Apple によって開発された汎用プログラミング言語です。元々は iOS や macOS 向けのアプリケーション開発のために設計されましたが、システムプログラミングからモバイル、デスクトップアプリケーション、そしてサーバーサイド開発に至るまで、幅広い用途に対応できるよう設計されています。
Apple 自身も Server Side Swift を推進しており、Swift.org には公式の Swift Server Workgroup が存在します。
なぜ Swift をサーバーで使うのか?
Swift on Server は、サーバーサイドコードを記述するためのモダンで安全かつ効率的な選択肢です。ハイレベル言語のシンプルさと読みやすさ、そしてコンパイル言語の性能と安全性を兼ね備えています。
メリット
1. 高いパフォーマンス
Swift は高速な性能と低いメモリ消費を提供します。 自動参照カウント(ARC) を使用してリソースを正確に管理するため、トレースガベージコレクションを行う他の言語(Java, Go など)と比較して、リソースの利用効率が高くなります。
この特性により、Swift はクラウドサービスの分野で強みを発揮します。
2. 高速な起動時間
Swift ベースのアプリケーションは、ほとんどウォームアップ操作を必要としないため、迅速に起動します。このため、AWS Lambda や Google Cloud Functions などのサーバーレスアプリケーションでも優れた適合性を持ち、「コールドスタート」時間が短いというメリットがあります。
3. 表現力と安全性
Swift は、型の安全性やオプショナル、メモリの安全性を強制する機能を持っており、プログラミングエラーを防ぎ、コードの信頼性を向上させます。
// 型安全:コンパイル時にエラーを検出
func fetchUser(id: UUID) async throws -> User {
guard let user = try await database.find(User.self, id) else {
throw Abort(.notFound) // nil の場合は明示的にハンドリング
}
return user
}
Swift の並行処理モデル(async/await, Actor)は、スケーラブルで応答性の高いサーバーアプリケーションの開発に適しています。
4. iOS/macOS と同じ言語でバックエンドが書ける
開発者は Swift を使用してエンドツーエンドのソリューションを構築できます。モデルの共有や、ロジックの再利用も可能です。
// iOS でも Server でも同じ構造体が使える
struct User: Codable {
let id: UUID
let name: String
let email: String
}
5. サポートされたエコシステム
Swift のエコシステムには、サーバーサイド開発に特化した多くのライブラリやツールが含まれています。これにより、高速でスケーラブル、かつセキュアなバックエンドサービスを構築する可能性が広がります。
引用元:Swift on Server
主要フレームワーク比較
| フレームワーク | GitHub Stars | 特徴 |
|---|---|---|
| Vapor | 25.8k+ | 最も人気で代表的なフレームワーク。ドキュメント充実 |
| Hummingbird | 1.6k+ | 軽量・高速。Swift Concurrency ネイティブ |
| Smoke | 1.4k+ | AWS 製。Lambda に最適化 |
| Kitura | 7.6k | IBM 製。メンテナンス停止 |
Server Side Swift のフレームワークはいくつかありますが、今回は Vapor を選びました。
Vapor は Swift Concurrency への対応も早く、ドキュメントも充実、GitHubのスター数も多く獲得しています。
また、Server Side Swiftのカンファレンス ServerSide.swift のオーガナイザーである 0xTim がコアメンテナーであることも安心材料の一つです。「とりあえず始める」には最適な選択肢だと思い採用しました。
2. プロジェクト概要:home-photo-server
今回作るもの
自宅の Mac mini で動かせる、シンプルな写真管理サーバーを作ります。
設計方針
- Docker で完結: Linux コンテナで全て動作
- PostgreSQL でメタデータ管理: Fluent ORM で型安全なクエリ
- 外部サービス不要: ローカルストレージに保存(S3 契約不要)
機能一覧
| 機能 | 説明 |
|---|---|
| 写真アップロード | JPEG, PNG, HEIC, WebP 対応(最大50MB) |
| サムネイル自動生成 | アップロード時に300px サムネイル作成 |
| EXIF 抽出 | カメラ情報、GPS、撮影日時を取得 |
| 重複チェック | SHA256 で同一ファイルを検出 |
| 一覧・詳細・削除 | 基本的な CRUD 操作 |
| ページネーション | 年/月フィルタ、ソート対応 |
構成図
3. 環境構築 & Hello World
公式 Docs に従って Vapor をインストールしたら、プロジェクトを作ります。
$ vapor new <project-name> -n
チュートリアル通りに起動したら、とりあえず以下を叩きましょう。
$ curl -v http://127.0.0.1:8080
レスポンスが返ってきたら、もう勝ちです。おめでとうございます。
Package.swift
プロジェクトの依存関係です。Fluent(ORM)と PostgreSQL ドライバを含みます。
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "HomePhotoServer",
platforms: [
.macOS(.v13)
],
dependencies: [
// Vapor 本体
.package(url: "https://github.com/vapor/vapor.git", from: "4.120.0"),
// SwiftNIO(Vapor が依存)
.package(url: "https://github.com/apple/swift-nio.git", from: "2.92.0"),
// SHA256 チェックサム計算用
.package(url: "https://github.com/apple/swift-crypto.git", from: "4.2.0"),
// Fluent ORM
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
// PostgreSQL driver for Fluent
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
],
targets: [
.executableTarget(
name: "HomePhotoServer",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
]
),
]
)
4. ルーティングとコントローラー
ルート登録
API バージョン (api/v1) は routes.swift で一元管理します。各コントローラーはリソース名のみを定義。
// routes.swift
import Vapor
func routes(_ app: Application) throws {
// API バージョンを一元管理
let api = app.grouped("api", "v1")
// Photo API
try api.register(collection: PhotoController())
// Health API
try api.register(collection: HealthController())
}
PhotoController
RouteCollection を使って、写真 API のルートをまとめます。
import Vapor
/// 写真 API コントローラー
struct PhotoController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
let photos = routes.grouped("photos")
// 読み取り
photos.get(use: list) // GET /api/v1/photos
photos.get(":id", use: get) // GET /api/v1/photos/:id
photos.get(":id", "download", use: download) // GET /api/v1/photos/:id/download
photos.get(":id", "thumbnail", use: thumbnail) // GET /api/v1/photos/:id/thumbnail
// 書き込み
photos.post(use: upload) // POST /api/v1/photos
photos.delete(":id", use: delete) // DELETE /api/v1/photos/:id
}
/// 写真一覧取得
///
/// - Endpoint: `GET /api/v1/photos`
/// - Query Parameters:
/// - `page`: ページ番号 (デフォルト: 1)
/// - `perPage`: 1ページあたりの件数 (デフォルト: 20, 最大: 100)
/// - `sortBy`: ソート項目 (`createdAt` | `takenAt`)
/// - `order`: ソート順 (`asc` | `desc`)
/// - `year`: 年でフィルタ (例: 2025)
/// - `month`: 月でフィルタ (例: 1-12)
/// - Response: `PaginatedResponse<Photo>` (200 OK)
@Sendable
func list(req: Request) async throws -> PaginatedResponse<Photo> { ... }
/// 写真アップロード
///
/// - Endpoint: `POST /api/v1/photos`
/// - Content-Type: `multipart/form-data`
/// - Request Body: `file` (JPEG, PNG, HEIC, WebP)
/// - Response: `PhotoUploadResponse` (200 OK)
/// - Errors:
/// - 400 Bad Request: サポートされていないファイル形式
/// - 409 Conflict: 同一ファイルが既に存在
@Sendable
func upload(req: Request) async throws -> PhotoUploadResponse { ... }
}
5. モデル設計
Photo(API レスポンス用)
クライアントに返す用のモデルです。Vapor が用意する Content プロトコルに準拠させると、自動で JSON シリアライズされます。
import Foundation
import Vapor
/// API レスポンス用の写真モデル
///
/// クライアントに返却する写真情報を表現する。
/// 内部管理用の `PhotoMetadata` から必要なフィールドのみを公開する。
///
/// ## JSON レスポンス例
/// ```json
/// {
/// "id": "550e8400-e29b-41d4-a716-446655440000",
/// "filename": "IMG_0001.jpg",
/// "mimeType": "image/jpeg",
/// "size": 2048576,
/// "width": 4032,
/// "height": 3024,
/// "createdAt": "2025-01-01T12:00:00Z",
/// "takenAt": "2025-01-01T10:30:00Z",
/// "checksum": "sha256:abc123..."
/// }
/// ```
struct Photo: Content, Sendable {
/// 写真の一意識別子 (UUID v4)
let id: UUID
/// オリジナルファイル名
let filename: String
/// MIME タイプ (例: `image/jpeg`, `image/png`, `image/heic`)
let mimeType: String
/// ファイルサイズ (バイト)
let size: Int64
/// 画像の幅 (ピクセル)。取得できない場合は `nil`
let width: Int?
/// 画像の高さ (ピクセル)。取得できない場合は `nil`
let height: Int?
/// サーバーへのアップロード日時
let createdAt: Date
/// 撮影日時 (EXIF から取得)。取得できない場合は `nil`
let takenAt: Date?
/// ファイルの SHA256 チェックサム (重複検出に使用)
let checksum: String
/// `PhotoMetadata` から `Photo` を生成
init(from metadata: PhotoMetadata) { ... }
}
PhotoMetadata(内部管理用)
内部で使うメタデータです。ストレージパスなど、API には公開しない情報も含みます。
import Foundation
/// 内部管理用のメタデータモデル
///
/// サーバー内部で写真を管理するための完全なメタデータ。
/// ストレージパスや EXIF データなど、クライアントに公開しない情報も含む。
///
/// ## ストレージ構造
/// ```
/// {basePath}/
/// ├── originals/
/// │ └── {id}.{ext} <- storagePath
/// ├── thumbnails/
/// │ └── {id}.jpg <- thumbnailPath
/// └── metadata/
/// └── {id}.json <- このモデルを JSON で保存
/// ```
struct PhotoMetadata: Codable, Sendable {
/// 写真の一意識別子 (UUID v4)
let id: UUID
/// アップロード時のオリジナルファイル名
let originalFilename: String
/// MIME タイプ (例: `image/jpeg`, `image/png`, `image/heic`, `image/webp`)
let mimeType: String
/// ファイルサイズ (バイト)
let size: Int64
/// 画像の幅 (ピクセル)
let width: Int?
/// 画像の高さ (ピクセル)
let height: Int?
/// サーバーへのアップロード日時 (UTC)
let createdAt: Date
/// 撮影日時 (EXIF の DateTimeOriginal から取得)
let takenAt: Date?
/// ファイルの SHA256 チェックサム (重複検出用、一意制約)
let checksum: String
/// オリジナル画像の保存パス (相対パス)
let storagePath: String
/// サムネイル画像の保存パス (相対パス)
let thumbnailPath: String?
/// EXIF メタデータ (カメラ情報、GPS 等)
let exifData: ExifData?
}
6. メタデータ管理(PostgreSQL + Fluent)
なぜ PostgreSQL か
| DB | 特徴 | 用途 |
|---|---|---|
| PostgreSQL(Fluent推奨) | 本番向け。クラウド対応が容易 | 本番環境 |
| SQLite | 軽量。インメモリ対応 | 開発・テスト |
| MySQL | 広く普及。情報が多い | レガシー連携 |
最初は iOS の Core Data の永続化レイヤとしても使われている SQLite で始めましたが、Dockerを使った運用面での課題が多く、最終的に Fluent が推奨する PostgreSQL に切り替えました。
ボリュームマウントの設定が面倒
コンテナ再起動でデータが消えないよう、適切なマウント設定が必要
PostgreSQL でも volume 設定自体は必要ですが、
公式イメージがデータディレクトリや権限管理を担ってくれるため、
アプリ側で意識すべきことがほとんどありません。
複数コンテナからのアクセス問題
SQLite はファイルロックに依存した排他制御のため、
複数コンテナ構成では同時書き込みがボトルネックになりやすく、
サーバ型 DB のようなスケールアウトには不向きだと感じました。
クラウド環境(AWS など)での運用が難しい
SQLite はローカルファイルに依存するため、
タスクの再起動やスケール時にデータの所在を安定して保証するのが困難です。
永続化のために共有ストレージを利用する選択肢もありますが、
アプリケーション側でストレージ構成や運用を意識する必要があり、
構成が一気に複雑になりそうと考えました。
また、SQLite は DB が単一のファイルとして存在するため、
クラウド環境ではデータを確認するだけでも
コンテナ内部に入る、DB ファイルをローカルにコピーするといった作業が発生しがちです。
その結果、GUI ツールを用いた手軽なデータ確認や障害調査が難しく、
運用時の負担が大きくなります。
一方、PostgreSQL であれば DBeaver などの GUI クライアントから安全に接続でき、
データ確認や障害調査をスムーズに行えると感じました。
PostgreSQL 実装(FluentMetadataStore)
Fluent ORM を使った PostgreSQL 向け実装です。
Fluent モデル
import Fluent
import Foundation
/// Fluent モデル: `photo_metadata` テーブル
///
/// PostgreSQL に写真メタデータを永続化するための Fluent ORM モデル。
/// DTO (`PhotoMetadata`) と相互変換可能。
final class PhotoMetadataModel: Model, @unchecked Sendable {
/// テーブル名
static let schema = "photo_metadata"
/// 主キー (UUID)
@ID(key: .id)
var id: UUID?
/// オリジナルファイル名
@Field(key: "original_filename")
var originalFilename: String
/// MIME タイプ (例: `image/jpeg`)
@Field(key: "mime_type")
var mimeType: String
/// ファイルサイズ (バイト)
@Field(key: "size")
var size: Int64
/// 画像の幅 (ピクセル)
@OptionalField(key: "width")
var width: Int?
/// 画像の高さ (ピクセル)
@OptionalField(key: "height")
var height: Int?
/// レコード作成日時 (自動設定)
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
/// 撮影日時 (EXIF から取得)
@OptionalField(key: "taken_at")
var takenAt: Date?
/// SHA256 チェックサム (UNIQUE 制約)
@Field(key: "checksum")
var checksum: String
/// オリジナル画像の保存パス
@Field(key: "storage_path")
var storagePath: String
/// サムネイル画像の保存パス
@OptionalField(key: "thumbnail_path")
var thumbnailPath: String?
/// 関連する EXIF データ (1:1)
@OptionalChild(for: \.$photo)
var exifData: ExifDataModel?
init() {}
/// DTO から Fluent モデルを生成
convenience init(from dto: PhotoMetadata) { ... }
/// Fluent モデルを DTO に変換
func toDTO(exifData: ExifData?) -> PhotoMetadata { ... }
}
ExifData Fluent モデル
import Fluent
import Foundation
/// Fluent モデル: `exif_data` テーブル
///
/// EXIF メタデータを PostgreSQL に永続化するための Fluent ORM モデル。
/// `PhotoMetadataModel` と 1:1 のリレーションを持つ。
///
/// ## テーブル定義
/// ```sql
/// CREATE TABLE exif_data (
/// id UUID PRIMARY KEY,
/// photo_id UUID NOT NULL REFERENCES photo_metadata(id) ON DELETE CASCADE,
/// camera_make VARCHAR(100),
/// camera_model VARCHAR(100),
/// ...
/// );
/// ```
///
/// ## 注意事項
/// - 親レコード (`photo_metadata`) 削除時に CASCADE で自動削除される
/// - すべてのフィールドは NULL 許容 (EXIF 情報がない場合)
final class ExifDataModel: Model, @unchecked Sendable {
/// テーブル名
static let schema = "exif_data"
/// 主キー (UUID)
@ID(key: .id)
var id: UUID?
/// 親の写真メタデータへの参照 (外部キー)
@Parent(key: "photo_id")
var photo: PhotoMetadataModel
/// カメラメーカー
@OptionalField(key: "camera_make")
var cameraMake: String?
/// カメラモデル
@OptionalField(key: "camera_model")
var cameraModel: String?
// ... 他のフィールドも同様にドキュメント付き
init() {}
/// DTO から Fluent モデルを生成
convenience init(from dto: ExifData, photoID: UUID) { ... }
/// Fluent モデルを DTO に変換
func toDTO() -> ExifData { ... }
}
マイグレーション
import Fluent
/// photo_metadata テーブルを作成するマイグレーション
struct CreatePhotoMetadata: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("photo_metadata")
.id()
.field("original_filename", .string, .required)
.field("mime_type", .string, .required)
.field("size", .int64, .required)
.field("width", .int)
.field("height", .int)
.field("created_at", .datetime, .required)
.field("taken_at", .datetime)
.field("checksum", .string, .required)
.field("storage_path", .string, .required)
.field("thumbnail_path", .string)
.unique(on: "checksum")
.create()
}
func revert(on database: Database) async throws {
try await database.schema("photo_metadata").delete()
}
}
FluentMetadataStore 実装
import Fluent
import Foundation
/// PostgreSQL (Fluent) ベースのメタデータストア
final class FluentMetadataStore: MetadataStore, Sendable {
private let database: any Database
init(database: any Database) {
self.database = database
}
func loadAll() async throws -> [PhotoMetadata] {
let models = try await PhotoMetadataModel.query(on: database)
.with(\.$exifData)
.all()
return models.map { model in
model.toDTO(exifData: model.exifData?.toDTO())
}
}
func get(id: UUID) async throws -> PhotoMetadata? {
guard let model = try await PhotoMetadataModel.query(on: database)
.filter(\.$id == id)
.with(\.$exifData)
.first() else {
return nil
}
return model.toDTO(exifData: model.exifData?.toDTO())
}
func save(_ metadata: PhotoMetadata) async throws {
let model = PhotoMetadataModel(from: metadata)
try await model.save(on: database)
if let exifDTO = metadata.exifData {
let exifModel = ExifDataModel(from: exifDTO, photoID: metadata.id)
try await exifModel.save(on: database)
}
}
func delete(id: UUID) async throws {
try await PhotoMetadataModel.query(on: database)
.filter(\.$id == id)
.delete()
}
}
7. 画像処理サービス
Docker (Linux) 環境で動作するため、libvips でサムネイル生成、exiftool で EXIF 抽出を行います。
| 機能 | ツール |
|---|---|
| サムネイル生成 | libvips (vipsthumbnail) |
| EXIF 抽出 | exiftool |
| チェックサム | swift-crypto (SHA256) |
VipsImageProcessor 実装
libvips でサムネイル生成、exiftool で EXIF 抽出を行います。
import Foundation
import Crypto
final class VipsImageProcessor: ImageProcessingService, Sendable {
private let thumbnailMaxSize: Int
init(thumbnailMaxSize: Int = 300) {
self.thumbnailMaxSize = thumbnailMaxSize
}
func extractExifData(from data: Data) async throws -> ExifData? {
// 一時ファイルに書き出し
let tempFile = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".jpg")
defer { try? FileManager.default.removeItem(at: tempFile) }
try data.write(to: tempFile)
// exiftool で JSON 形式で EXIF 情報を取得
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/exiftool")
process.arguments = ["-json", "-n", tempFile.path]
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
let outputData = pipe.fileHandleForReading.readDataToEndOfFile()
guard let jsonArray = try? JSONSerialization.jsonObject(with: outputData) as? [[String: Any]],
let exifDict = jsonArray.first else {
return nil
}
return ExifData(
cameraMake: exifDict["Make"] as? String,
cameraModel: exifDict["Model"] as? String,
// ... 他のフィールドも同様
)
}
func generateThumbnail(from data: Data, maxSize: Int) async throws -> Data {
// vipsthumbnail でサムネイル生成
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/vipsthumbnail")
process.arguments = [
inputPath,
"-s", "\(maxSize)x\(maxSize)",
"--rotate", // EXIF に基づいて回転
"-o", outputPath + "[Q=80]"
]
// ...
}
}
configure.swift での初期化
PostgreSQL への接続と各サービスの初期化を行います。
import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) async throws {
app.routes.defaultMaxBodySize = "50mb"
// ストレージ設定
let basePath = StorageConfig.defaultBasePath()
let config = StorageConfig(basePath: basePath)
app.storageConfig = config
// 画像処理サービス初期化
let imageProcessor = VipsImageProcessor(thumbnailMaxSize: 300)
app.imageProcessingService = imageProcessor
// PostgreSQL 設定 (必須)
guard let dbConfig = DatabaseConfig.fromEnvironment() else {
fatalError("Database configuration required. Set DATABASE_* env vars.")
}
app.databases.use(
.postgres(
configuration: .init(
hostname: dbConfig.hostname,
port: dbConfig.port,
username: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
tls: .prefer(try .init(configuration: .clientDefault))
)
),
as: .psql
)
// マイグレーション登録・実行
app.migrations.add(CreatePhotoMetadata())
app.migrations.add(CreateExifData())
try await app.autoMigrate()
// メタデータストア初期化
let metadataStore = FluentMetadataStore(database: app.db)
app.metadataStore = metadataStore
// 写真ストレージサービス初期化
let photoService = LocalPhotoStorageService(
basePath: config.photosPath,
thumbnailsPath: config.thumbnailsPath,
metadataStore: metadataStore,
imageProcessor: imageProcessor
)
app.photoStorageService = photoService
try routes(app)
}
DatabaseConfig
import Foundation
import Vapor
/// データベース設定
struct DatabaseConfig: Sendable {
let hostname: String
let port: Int
let username: String
let password: String
let database: String
/// 環境変数からデータベース設定を取得
static func fromEnvironment() -> DatabaseConfig? {
guard let hostname = Environment.get("DATABASE_HOST"),
let username = Environment.get("DATABASE_USERNAME"),
let password = Environment.get("DATABASE_PASSWORD"),
let database = Environment.get("DATABASE_NAME") else {
return nil
}
let port = Environment.get("DATABASE_PORT").flatMap(Int.init) ?? 5432
return DatabaseConfig(
hostname: hostname,
port: port,
username: username,
password: password,
database: database
)
}
}
8. 写真 API の実装
アップロード処理フロー
LocalPhotoStorageService
import Foundation
final class LocalPhotoStorageService: PhotoStorageService, Sendable {
private let basePath: String
private let thumbnailsPath: String
private let metadataStore: any MetadataStore
private let imageProcessor: any ImageProcessingService
func uploadPhoto(
filename: String,
data: Data,
mimeType: String
) async throws -> Photo {
// 1. チェックサム計算
let checksum = imageProcessor.calculateChecksum(from: data)
// 2. 重複チェック
if let existing = try await findByChecksum(checksum) {
throw AppError.duplicatePhoto(existing.id)
}
// 3. 画像情報抽出
let dimensions = try await imageProcessor.getImageDimensions(from: data)
let exifData = try await imageProcessor.extractExifData(from: data)
// 4. ID生成とパス決定(年/月でサブディレクトリ)
let id = UUID()
let fileExtension = getFileExtension(from: filename, mimeType: mimeType)
let storagePath = generateStoragePath(id: id, extension: fileExtension)
// 例: "2025/01/{uuid}.jpg"
// 5. オリジナルファイル保存
let fullPath = "\(basePath)/\(storagePath)"
try saveFile(data: data, to: fullPath)
// 6. サムネイル生成・保存
let thumbnailData = try await imageProcessor.generateThumbnail(from: data, maxSize: 300)
let thumbnailPath = "\(id.uuidString).jpg"
try saveFile(data: thumbnailData, to: "\(thumbnailsPath)/\(thumbnailPath)")
// 7. メタデータ作成・保存
let metadata = PhotoMetadata(
id: id,
originalFilename: filename,
mimeType: mimeType,
size: Int64(data.count),
width: dimensions?.width,
height: dimensions?.height,
createdAt: Date(),
takenAt: exifData?.dateTimeOriginal,
checksum: checksum,
storagePath: storagePath,
thumbnailPath: thumbnailPath,
exifData: exifData
)
try await metadataStore.save(metadata)
return Photo(from: metadata)
}
private func generateStoragePath(id: UUID, extension ext: String) -> String {
let date = Date()
let calendar = Calendar.current
let year = calendar.component(.year, from: date)
let month = calendar.component(.month, from: date)
return "\(year)/\(String(format: "%02d", month))/\(id.uuidString).\(ext)"
}
}
curl でテスト
# アップロード
$ curl -X POST http://localhost:8080/api/v1/photos \
-F "file=@/path/to/photo.jpg"
# 一覧取得(ページネーション)
$ curl "http://localhost:8080/api/v1/photos?page=1&perPage=10"
# サムネイル取得
$ curl http://localhost:8080/api/v1/photos/{id}/thumbnail -o thumb.jpg
# 削除
$ curl -X DELETE http://localhost:8080/api/v1/photos/{id}
10. まとめ
Server Side Swift はまだ発展途上ですが、Swift 好きな開発者にとっては魅力的な選択肢です。
余った Mac mini を活用して、自宅写真サーバー。クラウドに依存しないプライベートな写真管理が実現できます。
ぜひ、あなたも Swift でサーバー側を書いてみてください。
サンプルリポジトリ: akidon0000/swift-home-photo
開発サポート: GANGAN
拡張アイデア
- 認証機能: JWT / Basic Auth の追加
- リモートアクセス対応: 独自ドメイン+HTTPS(Let’s Encrypt)
- 顔認識・タグ付け: 写真の自動分類
- WebUI & MacOS App: フロントエンドの追加



