📷

PowerShellで写真整理(リファクタリング版)

2023/06/24に公開

概要

Windows PowerShellで指定フォルダ配下の写真を日付別フォルダに移動する。
あえてクラスを作成してリファクタリングを試みる。
PowerShellで写真整理を読んでいる事前提で記載する。
https://zenn.dev/modokisealsky/articles/94fee9a06bf5a9

制限事項・仕様

変更なし。
リファクタリングなので変わらない。

クラス化の利点

  • 再利用がしやすい
  • 処理の修正がしやすい

パラメータと実行手順が同じであれば、呼び出し元が別であっても使える。
例えば、フォルダ選択の方法を変更したとしても写真整理処理の修正が不要。
また、写真整理処理のロジックを修正しても呼び出し元の修正が不要。

ソース

処理部分がいかに置き換わる。

$processer = [PhotoMove]::new()
$processer.setSrcFolderPath($srcFolderPath)
$processer.setDestFolderPath($destFolderPath)
$processer.setLogFilePath($logFilePath)
$processer.exec()
  1. 処理クラスをインスタンス化
  2. 整理元親フォルダパスを設定
  3. 整理先親フォルダパスを設定
  4. ログ出力先パスを設定
  5. 処理実行

クラス図

せっかくなのでファイルを移動する処理、ファイルをコピーする処理、の2種類のクラスを作成した。
とは言っても、移動とコピーで処理の違いは軽微すぎるので共通処理を親クラスにしている。
外から実行できる処理を明示するためにインターフェースを定義した。
上のソースでインスタンス化しているのはクラス図左下の写真移動クラス。

plantuml
@startuml

interface 写真整理 {
    +void 移動元フォルダパス指定(フォルダパス文字列)
    +void 移動先フォルダパス指定(フォルダパス文字列)
    +void ログ出力先パス指定(ファイルパス文字列)
    +void 処理実行()
}

abstract class 写真整理処理 implements 写真整理 {
    #String 移動元フォルダパス
    #String 移動先フォルダパス
    #String ログファイルパス
    +void 移動元フォルダパス指定(フォルダパス文字列)
    +void 移動先フォルダパス指定(フォルダパス文字列)
    +void ログ出力先パス指定(ファイルパス文字列)
    +void 処理実行()
    #void ログ出力(出力文字列)
    #Date 撮影日時取得(ファイルパス)
    #Strng フォルダパス生成(撮影日時)
    #void フォルダ生成(フォルダパス)
    {abstract}#void ファイル処理(移動元ファイルパス, 移動先ファイルパス)
}

class 写真移動 extends 写真整理処理 {
    #ファイル処理(移動元ファイルパス, 移動先ファイルパス)
}

class 写真コピー extends 写真整理処理 {
    #ファイル処理(移動元ファイルパス, 移動先ファイルパス)
}

@enduml 

デザインパターン的な話

今回のリファクタリングされたソース内でやったこと、やってないことを記述する。

アダプター

これはやってないこと。
インターフェースや抽象クラスでラップして扱う、という概念がPowerShellには…
そもそもインターフェースが無いし。

ビルダー

これはやっていないこと。
上の例では末端クラスを直接インスタンス化している。
各種パラメーターを設定するメソッドを持ち、最後に処理クラスのインスタンスを返すクラスを用意する。
この場合はインスタンスを取得する部分のソースは以下の様になる。

$builder = [PhotoArrangementBuilder]::new();
$builder.setType("MOVE")
$builder.setSrcFolderPath($srcFolderPath)
$builder.setDestFolderPath($destFolderPath)
$builder.setLogFilePath($logFilePat)
$processer = $builder.build();

文字列をセットしていくだけなのでビルダーパターンとは言うのは大げさ。
生成するクラスが内部で利用する他クラスのインスタンを設定するような構造の時が出番。
インプットストリーム、アウトプットストリーム、ロガーの各インスタンスを設定、みたいなのなら、ビルダーパターンっぽく作れる。

ただし、インターフェースや抽象クラスでラップができないので、PowerShellで使うようなデザインパターンではないかな…

蛇足:
setterの戻り値をビルダークラス自身のインスタンスにするとメソッドチェーンができる。
powershellで複数行にまたがる記述は好みが別れるので(個人の感想)、使っていない。
1行がやたら長い記述にもしない。

テンプレートメソッド

これはやっていること。

処理実行メソッドは親クラスに実装されている。
親クラスは写真を移動するかコピーするかは関与しないので、ファイル処理メソッドをabstractとしている。
写真移動クラスと写真コピークラスでは該当メソッドを実装する。

以上を踏まえて、親クラスの処理実行メソッドは以下の様になる。

[void]exec() {
    # ファイル整理ループ
    $files = Get-ChildItem -Path $this.srcFolderPath -Include *.jpg,*.JPG -Recurse
    foreach($file in $files) {
        # 撮影日時取得
        $shootingDate = $this.getShootingDate($file)
        # フォルダ作成
        $deteFolderPath = $this.makeFolderPath($shootingDate)
        $this.makeFolder($deteFolderPath)
        # ファイル整理
        $destFilePath = Join-Path $deteFolderPath $file.Name
        $this.execProcess($file.FullName, $destFilePath)
    }

    $this.outputLog("処理終了")
}

$thisで自インスタンスのいろんなものを利用している。
「execProcess」が各クラスで実装するメソッド。
写真移動クラスなら、写真移動クラスの「execProcess」が実行される。
写真移動クラスで他のメソッドをオーバーライドしていなければ、他はすべて親クラスの処理が実行される。

各クラスのソース

これを踏まえて実装したソース全文を以下に記載する。
例によって、クラス図から見えなかった処理が入ってたりするのはご愛敬。

写真整理処理(親クラス)

PhotoProcess.psm1
<##
 # 写真ファイル移動処理クラス
 #>
class PhotoProcess {
    # 処理元フォルダパス
    hidden $srcFolderPath = $null
    # 処理先フォルダパス
    hidden $destFolderPath = $null
    # ログファイルパス
    hidden $logFilePath = $null

    <##
     # コンストラクタ
     #>
    PhotoProcess() {
        Add-Type -AssemblyName System.Drawing
    }

    <##
     # 処理元フォルダパス指定
     # @param srcFolderPath フォルダパス文字列
     #>
    [void]setSrcFolderPath([String]$srcFolderPath) {
        $this.srcFolderPath = $srcFolderPath
    }

    <##
     # 処理先フォルダパス指定
     # @param destFolderPathフォルダパス文字列
     #>
    [void]setDestFolderPath([String]$destFolderPath) {
        $this.destFolderPath = $destFolderPath
    }

    <##
     #ログ出力先パス指定
     # @param logFilePath ファイルパス文字列
     #>
    [void]setLogFilePath([String]$logFilePath) {
        $this.logFilePath = $logFilePath
    }

    <##
     # 処理実行
     #>
    [void]exec() {
        $this.outputLog("処理開始")
        $this.outputLog("処理元:" + $this.srcFolderPath)
        $this.outputLog("処理先:" + $this.destFolderPath)
        $this.outputLog("ログ先:" + $this.logFilePath)

        # 処理元、処理先、どちらかが空の場合は終了
        if ([String]::IsNullOrEmpty($this.srcFolderPath) -or
            [String]::IsNullOrEmpty($this.destFolderPath)) { 
            $this.outputLog("処理終了(未実施)")
            return
        }

        # ファイル整理ループ
        $files = Get-ChildItem -Path $this.srcFolderPath -Include *.jpg,*.JPG -Recurse
        foreach($file in $files) {
            # 撮影日時取得
            $shootingDate = $this.getShootingDate($file)
            # フォルダ作成
            $deteFolderPath = $this.makeFolderPath($shootingDate)
            $this.makeFolder($deteFolderPath)
            # ファイル整理
            $destFilePath = Join-Path $deteFolderPath $file.Name
            $this.execProcess($file.FullName, $destFilePath)
        }

        $this.outputLog("処理終了")
    }

    <##
     # ファイル処理
     # @param $srcFilePath 処理元ファイルパス
     # @param $destFilePath 処理先ファイルパス
     #>
    hidden [void]execProcess([String]$srcFilePath, [String]$destFilePath) {
        # 各クラスで実装
    }

    <##
     # ログ出力
     # @param logStr 出力文字列
     #>
    [void]outputLog([String]$logStr) {
        Write-Host $logStr
        if (![String]::IsNullOrEmpty($this.logFilePath)) {
            echo $logStr >> $this.logFilePath
        }
    }

    <##
     # 撮影日時取得
     # @param $filePath ファイル
     # @return 撮影日時(EXIFが無い場合はファイル作成日時)
     #>
    [DateTime]getShootingDate([System.IO.FileSystemInfo]$file) {
        $fileFullName = $file.FullName
        $shootingTime = $file.CreationTime
        if ($shootingTime -gt $file.LastWriteTime) {
            # 作成日と更新日を比較し、古い方を使用する
            $shootingTime = $file.LastWriteTime
        }
        $img = $null
        try {
            # EXIFから撮影日時取得
            $img = New-Object Drawing.Bitmap($fileFullName)
            $byteAry = ($img.PropertyItems | Where-Object{$_.Id -eq 36867}).Value
            $byteAry[4] = 47
            $byteAry[7] = 47
            $shootingTime = [datetime][System.Text.Encoding]::ASCII.GetString($byteAry)
        } catch {
            $this.outputLog("EXIF無:$fileFullName")
        } finally {
            if ($img -ne $null) {
                $img.Dispose()
                $img = $null
            }
        }
        return $shootingTime
    }

    <##
     # フォルダ生成
     # 存在しない場合はフォルダを作成する
     # @param dateFolderPath フォルダパス
     #>
    [void]makeFolder([String]$dateFolderPath) {
        if (!(Test-Path $dateFolderPath -PathType Container)) {
            New-Item $dateFolderPath -ItemType Directory
        }
    }

    <##
     # 日付フォルダパス生成
     # @param shootingDateTime
     # @return フォルダパス(処理先フォルダ/年/年月/年月日)
    #>
    hidden [String]makeFolderPath([DateTime]$shootingDateTime) {
        $yearPath = (Join-Path $this.destFolderPath  $shootingDateTime.toString("yyyy"))
        $monthPath = (Join-Path $yearPath  $shootingDateTime.toString("yyyy_MM"))
        $datePath = (Join-Path $monthPath  $shootingDateTime.toString("yyyy_MM_dd"))
        return $datePath
    }

    <##
     # 別名パス取得
     # ファイル名末尾に連番を付与する。
     # 別名もすでに存在する場合は処理不可能としてnullを返す。
     # @param destFilePath 処理先フルパス
     # @return 別ファイル名のフルパス(別名も存在する場合はnull)
     #>
    hidden [String]getAtherFileNamePath([String]$destFilePath) {
        for ($i = 1; $i -lt 10; $i++) {
            $p = [System.IO.Path]::GetDirectoryName($destFilePath)
            $n = [System.IO.Path]::GetFileNameWithoutExtension($destFilePath)
            $t = (Join-Path $p "$n-$i.jpg")
            if (!(Test-Path $t -PathType Leaf)) {
                return $t
            }
        }
        return $null
    }

    <##
     # 同一ファイル確認
     # @param srcFilePath
     # @param destFilePath
     # @return 同一の場合はtrue
     #>
    hidden [Boolean]isDuplicateFile([String]$srcFilePath, [String]$destFilePath) {
        [void](cmd /C "comp $srcFilePath $destFilePath /M & exit ERRORLEVEL;")
        return $LASTEXITCODE -eq 0
    }
}

写真移動処理

PhotoMove.psm1
using module ".\PhotoProcess.psm1"

<##
 # 写真移動処理クラス
 #>
class PhotoMove : PhotoProcess {
    <##
     # ファイル処理
     # @param $srcFilePath 移動元ファイルパス
     # @param $destFilePath 移動先ファイルパス
     #>
    hidden [void]execProcess([String]$srcFilePath, [String]$destFilePath) {
        # 移動元と移動先が同じ場合はスキップ
        if ($srcFilePath -eq $destFilePath) {
            $this.outputLog("移動不要:$srcFilePath")
            return
        }

        # 移動処理
        if (!(Test-Path $destFilePath -PathType Leaf)) {
            # 移動先に同一名が無い場合は移動
            $this.outputLog("移動:$srcFilePath -> $destFilePath")
            Move-Item $srcFilePath $destFilePath
            return
        }

        # 同一ファイル確認
        if ($this.isDuplicateFile($srcFilePath, $destFilePath)) {
            $this.outputLog("同一ファイル:$srcFilePath = $destFilePath")
            return
        }

        # 別名で移動
        $atherFileNamePath = $this.getAtherFileNamePath($destFilePath)
        if ([String]::IsNullOrEmpty($atherFileNamePath)) {
            $this.outputLog("移動不可:$srcFilePath")
        } else {
            $this.outputLog("移動:$srcFilePath -> $atherFileNamePath")
            Move-Item $srcFilePath $atherFileNamePath
        }
    }        
}

写真コピー処理

PhotoCopy.psm1
using module ".\PhotoProcess.psm1"

<##
 # 写真ファイルコピー処理クラス
 #>
class PhotoCopy : PhotoProcess {
    <##
     # ファイル処理
     # @param $srcFilePath コピー元ファイルパス
     # @param $destFilePath コピー先ファイルパス
     #>
    hidden [void]execProcess([String]$srcFilePath, [String]$destFilePath) {
        # コピー元とコピー先が同じ場合はスキップ
        if ($srcFilePath -eq $destFilePath) {
            $this.outputLog("コピー不要:$srcFilePath")
            return
        }

        # コピー処理
        if (!(Test-Path $destFilePath -PathType Leaf)) {
            # コピー先に同一名が無い場合はコピー
            $this.outputLog("コピー:$srcFilePath -> $destFilePath")
            Copy-Item $srcFilePath $destFilePath
            return
        }

        # 同一ファイル確認
        if ($this.isDuplicateFile($srcFilePath, $destFilePath)) {
            $this.outputLog("同一ファイル:$srcFilePath = $destFilePath")
            return
        }

        # 別名でコピー
        $atherFileNamePath = $this.getAtherFileNamePath($destFilePath)
        if ([String]::IsNullOrEmpty($atherFileNamePath)) {
            $this.outputLog("コピー不可:$srcFilePath")
        } else {
            $this.outputLog("コピー:$srcFilePath -> $atherFileNamePath")
            Copy-Item $srcFilePath $atherFileNamePath
        }
    }        
}

補足

写真移動と写真コピーのメソッド、中身がほとんど同じでは?
もっと共通化できるのでは?
実は、ログ出力の文字列が違うという理由だけで、同じ処理をそれぞれに実装している。
片方だけ作った後に複製して作って直したから同じになっているわけではないのです。
ここを共通化するために親クラスのログ出力メソッドに手を入れるというのものね…

なぜ作ったか

記述量が増えたくせにできることが変わっていないので無駄では?
処理を直していくうちにfunctionだらけになっていくのが気に入らなかった。
別ファイルに分けてしまうと、関数名がかぶってないかを気にしないといけなくなる。
classにしてしまえば、クラス名がかぶらなければメソッド名かぶっても平気。
ついでだから移動処理とコピー処理を別クラスにして継承関係も付けてみよう。
という自己満足。
修正に強くなったのかもしれないが、過剰実装にしか見えない気がする。

Discussion