3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Server Side Swift で自宅写真サーバーを作る

Posted at

はじめに

こんにちは。あきどんです。

正月っていいですね。一生続いてほしいな〜って思ったりします。お餅が美味しい。

突然ですが最近、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アプリケーションから写真をアップロード・閲覧できる仕組みを構築します。

スクリーンショット 2026-01-05 0.24.19.png
スクリーンショット 2026-01-05 0.24.31.png
スクリーンショット 2026-01-05 0.24.38.png

こんな感じで、Mac miniの外部ストレージに保存できるようにする。

スクリーンショット 2026-01-05 0.18.57.png

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: フロントエンドの追加

参考リンク

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?