第12, 13講 応用2

2023-07-20 (Thu) 10:30 までにcpコマンドのプログラムを作成し,提出してください. 以下のステップが完成するごとに提出してください.

ただし,ステップ2のプログラムは,ステップ1を含んでいなければいけません. 同じように,ステップ6はステップ1〜5の全てが実行できなければいけません.

この講で利用するプログラム

  1. ファイルをコピーする
  2. 複数ファイルをディレクトリへコピーする
  3. オプション解析を行う
  4. ファイルの上書きを確認する
  5. 出力先のファイルが新しい場合はコピーしない
  6. ディレクトリを再帰的にコピーする

第12, 13講 応用2のサブセクション

cpコマンドの実装

  1. ファイルをコピーする
  2. 複数ファイルをディレクトリへコピーする
  3. オプション解析を行う
  4. ファイルの上書きを確認する
  5. 出力先のファイルが新しい場合はコピーしない
  6. ディレクトリを再帰的にコピーする

1. ファイルをコピーする.

UNIXのcpコマンドのように,java Copy1 from_file to_file を 実行すると,from_fileの内容がto_fileにコピーされるようなコマンドCopy1を 作成してください.to_fileが存在している場合は上書きしてください (単純にFileWriterで書き込むと上書きすることになります).

コマンドライン引数に必要な数のファイルが指定されない場合,エラーメッセージを出力しましょう. コマンドライン引数で指定されたものは,ファイルであるとしても構いません.

ヒント

// 必要な import 文を書いてください.
public class Copy1{
    void run(String[] args) throws IOException{
        // コマンドライン引数に必要な分のファイルが指定されているか確認する.
        //     args.lengthが2より小さい場合,必要な引数が指定されていない旨を出力して終了する.
        // argsの0番目,1番目の要素からそれぞれFile型の実体を作成する.
        // copyメソッドを呼び出す.
    }
    void copy(File from, File to) throws IOException{
        // fromをBufferedReader(FileReader)で開く.
        // toをPrintWriter(FileWriter)で開く.
        //     fromから読み込んだ内容をtoに書き出す.
        //     ファイルの終わりまでこの処理を繰り返す.
        // from, to から開いたストリームを閉じる.
    }
    // mainメソッドは省略.
}

実行例

$ echo 'abcdef' > file1  # file1 を作成する.
$ ls file2               # file2 が存在しないことを確認する.
ls: file2: No such file or directory
$ java Copy1 file1 file2 # file1 を file2 にコピーする.
$ cat file2              # file2 と file1 の内容が同じであることを確認する.
abcdef
$ java Copy1             # コマンドライン引数がない場合にエラーを出して終了する.
cp: コマンドライン引数には,少なくとも,コピー元,コピー先を指定する必要があります.
$ java Copy1 file2       # コマンドライン引数が足りない場合にエラーを出して終了する.
cp: コマンドライン引数には,少なくとも,コピー元,コピー先を指定する必要があります.

参考

2. 複数ファイルをディレクトリへコピーする

1. ファイルをコピーするでは,コマンドライン引数は2つ,かつ両方がファイルであることが前提でした. ここでは,複数ファイルを一つのディレクトリにコピーするプログラムCopy2を作成します.

java Copy2 from_file1 from_file2 from_file3 directory

を実行すると,from_file1from_file2from_file3がディレクトリにコピーされます. すなわち,directory/from_file1directory/from_file2directory/from_file3が作成されます. このような挙動を行うプログラムを作成してください.

まず,directory/from_file1に書き込むためには,directory/from_file1を表すFile型の実体を作成しなければいけません. そのためにまず,File型の変数toが出力先ディレクトリであるdirectoryを表し, 出力先のファイル名であるfrom_file1File型の変数fromが表していると仮定します. このとき,new File(to, from.getName()) という新たなFileの実体を 作成することで,directory/from_file1を表すFile型の実体を作成できます.

コマンドライン引数の一番最後の要素がディレクトリであるか否かを判定してください. 一番最後の要素が存在し,ディレクトリである場合のみ,その前のコマンドライン引数が示すファイルの内容をディレクトリ以下にコピーしてください.

コマンドライン引数の一番最後の要素が存在しない場合,もしくは,ファイルであった場合は, 複数ファイルのコピーは行えませんので,エラーメッセージを出力し,終了してください.

ヒント

    void run(String[] args){
        // コマンドライン引数に必要な分のファイルが指定されているか確認する.
        //     args.lengthが2より小さい場合,必要な引数が指定されていない旨を出力して終了する.
        // 出力先は,コマンドライン引数の一番最後の要素である.
        File to = new File(args[args.length - 1]);
        if(.....){ // toが存在しない,もしくはファイルの場合.
            // args.length が 2 より大きい場合,
            //     複数ファイルを1つのファイルにコピーできない旨を出力し,終了する.
            // そうでない場合,copyメソッドを呼び出す.
        }
        else if(....){ // toがディレクトリの場合.
            for(....){ // argsを必要なだけ繰り返す.一番後ろの要素は省くことを忘れない.
                File from = new File(arg[i]);
                File toFile = new File(to, from.getName());
                // fromをtoFileにコピーするよう copy メソッドを呼び出す.
            }
        }
    }

実行例

$ for i in 1 2 3; do echo "file$i" > "file$i.txt" ; done # ファイルを作成する.
$ ls file?.txt
file1.txt    file2.txt    file3.txt
$ mkdir dir
$ java Copy2 file1.txt file2.txt dir
$ ls dir                         # dir の内容を確認する.
file1.txt    file2.txt
$ java Copy2 file3.txt file4.txt # file3.txt を file4.txt にコピーする.
$ cat file4.txt
file3
$ java Copy2 file2.txt file3.txt file5.txt
cp: 複数ファイルを一つのファイルにコピーできません.

参考

3. オプション解析を行う

オプション解析を行うようにプログラムを改良しCopy3を作成してください. オプションとは,コマンドに渡す -h などの-から始まる文字(文字列)です. オプションによってプログラムの挙動が少し変わります.

最終的に 6. ディレクトリを再帰的にコピーするを終えると, 次のようなオプションを含んだプログラムが完成することになります.

$ java Copy3 -h
Usage: java Copy3 [OPTIONS] from_file to_file
       java Copy3 [OPTIONS] from_file ... to_directory
OPTIONS
     -h: このメッセージを表示して終了する(help).
     -i: コピー先のファイルが存在していた時,ユーザに上書き確認を求める(interactive).
     -r: ディレクトリを再帰的にコピーする(recursive).
     -u: コピー先のファイルが存在しない場合,もしくはコピー元のファイルの方が新しい場合のみコピーする(update).
     -v: 実行内容を表示する(verbose).

ヒント1. オプション解析

オプション解析を行うために,Arguments.javaを作成してください.Arguments には,Boolean型の5つの変数,helpinteractiverecursiveupdate, そして,verboseと,ArrayList<String>型のlistの6つのフィールドを 持たせてください.listは宣言時に初期化を行うようにしてください(newを使いArrayList<String>の 実体を作成し,代入しておいてください).Boolean型の変数も 全て falseで初期化しておいてください.

次の parseメソッドを Argumentsに定義してください.

    void parse(String[] args){
        for(String arg: args){
            if(Objects.equals(arg, "-h")){
                this.help = true;
            }
            else if(Objects.equals(arg, "-i")){
                this.interactive = true;
            }
            else if(Objects.equals(arg, "-r")){
                this.recursive = true;
            }
            else if(Objects.equals(arg, "-u")){
                this.update = true;
            }
            else if(Objects.equals(arg, "-v")){
                this.verbose = true;
            }
            else{
                this.list.add(arg);
            }
        }
    }

ヒント2. オプション解析とヘルプの表示

次に,Copy3クラスのrunメソッドを次のように定義しましょう.

    void run(String[] args){
        Arguments arguments = new Arguments();
        arguments.parse(args);
        if(arguments.help){
            this.printHelp();
        }
        else{
            this.performCopy(arguments);
        }
    }

このメソッドのでは,-hオプションが指定された時,printHelpメソッドを呼び出すようになっています. 上記に示したヘルプメッセージを表示するようprintHelpメソッドの処理を書いてください. 単純に上に示したメッセージをSystem.out.printlnで出力すれば良いでしょう.

また,performCopyステップ2までの処理(run)を基本として 書き直してください.String型の配列であるargsではなく,ArrayList<String>から 要素を取り出す必要があります.

ヒント3. verboseオプションが指定された場合の処理

また,-vオプション(verbose)が指定された場合,どのファイルがどこにコピーされたのかを表示するようにしてください. 次のような処理になるでしょう.「コピー処理」部分はfromからtoへのコピーの処理に置き換えてください.

    void copy(File from, File to, Arguments args) throws IOException{
        // コピー処理
        // ...
        if(args.verbose){
            System.out.printf("%s -> %s%n", from.getPath(), to.getPath());
        }
    }

実行例

$ for i in 1 2 3 ; do echo "file$i" > "file$i.txt" ; done
$ java Copy3 -v file1.txt hoge.txt                 # 実行状況を確認してコピーする.
file1.txt -> hoge.txt
$ mkdir dir2
$ java Copy3 -v file1.txt file2.txt file3.txt dir2 # 実行状況を確認してコピーする.
file1.txt -> dir2/file1.txt
file2.txt -> dir2/file2.txt
file3.txt -> dir2/file3.txt

参考

4. ファイルの上書きを確認する.

ファイルの上書き確認を行うようプログラムを改良し,Copy4を作成しましょう. コピー先のファイルが存在し,ディレクトリでなく通常のファイルの場合(to.exists() && to.isFile()) ユーザに上書きして良いかの確認を取りましょう.

ヒント

このプログラムを実現するには,copyメソッドの最初に,isOverwriteメソッドを呼び出し, 上書きするかを判定します.isOverwriteメソッドでは,まずチェックすべきかどうか,すなわち, 出力先が存在しており,かつ,ファイルであるかを確認しています. その上で,チェックすべきである場合は,interactiveオプションが指定されており, ユーザが上書きしない場合に,isOverwritefalseを返すようにしています. なお,ユーザへの確認処理はisOverwriteAskToUserメソッドで行っています.

なお,SimpleConsole.java第9回目 練習問題2. 電話帳の作成と同じものを利用してください. SimpleConsole.javaはこの文章をクリックすることでもダウンロードできます

また,実際にコピーを行う処理をcopyメソッド内からdoCopyメソッドに移しています.

    Boolean isOverwriteAskToUser(File to) throws IOException{
        SimpleConsole console = new SimpleConsole();
        while(true){
            System.out.printf("%sを上書きしますか? (y/n [n]) ", to.getName());
            String line = console.readLine();
            line = line.trim();  // 入力された前後の空白を取り除く.
            if(Objects.equals(line, "y")){
                return true;
            }
            else if(Objects.equals(line, "") || Objects.equals(line, "n")){
                // nもしくは単に改行された場合
                System.out.println("上書きしません.");
                return false;
            }
            else{ // その他の値が入力された場合.
                System.out.println("yかnを入力してください.");
            }
        }
    }
    Boolean isOverwrite(File to, Arguments args) throws IOException{
        if(to.exists() && to.isFile()){
            if(args.interactive && !isOverwriteAskToUser(to)){
                return false;
            }
        }
        return true;
    }
    void copy(File from, File to, Arguments args) throws IOException{
        if(isOverwrite(to, args)){
            this.doCopy(from, to);
            // verboseの処理
        }
    }
    void doCopy(File from, File to){
        // コピー処理
        // ...
    }

実行例

$ for i in 1 2 3 ; do echo "file$i" > "file$i.txt" ; done
$ java Copy4 file1.txt file2.txt
$ cat file2.txt
file1
$ java Copy4 -i file1.txt file3.txt    # file3.txt が存在すれば上書き確認を行う.
file3.txtを上書きしますか? (y/n [n]) aaaa # 再入力可能かを確認するため,aaaa を入力する.
yかnを入力してください.
file3.txtを上書きしますか? (y/n [n]) n    # nを入力して,上書きしない.
上書きしません.
$ cat file3.txt                        # 内容が書き換わっていないことを確認する.
file3
$ java Copy4 -i -v file1.txt file3.txt
file3.txtを上書きしますか? (y/n [n]) y    # yを入力した.上書きコピーを実行する.
file1.txt -> file3.txt
$ cat file3.txt                        # 内容が書き換わったことを確認する.
file1

参考

5. 出力先のファイルが新しい場合はコピーしない

ファイルの更新日時を確認し,出力先のファイルの方が新しければコピーしないようにしましょう.Copy4を コピーしてCopy5を作成し,処理を追加しましょう. 先ほど作成したisOverwriteメソッドを修正して,この確認を行うようにしましょう.

ヒント

    Boolean isOverwrite(File from, File to, Arguments args) throws IOException{
        if(to.exists() && to.isFile()){
            Boolean overwriteFlag = true; // デフォルトは上書きする.
            if(args.update){              // updateオプションが指定された場合
                // toの方が新しい場合,上書きしない.
                overwriteFlag = ...       // 上書きしない.
            }
            // 上書き,かつ,インタラクティブであれば,ユーザに上書きの可否を問い合わせる.
            // ユーザが上書きを指示しなければ上書きしない.
            if(overwriteFlag && args.interactive && !isOverwriteAskToUser(to)){
                overwriteFlag = false;
            }
            return overwriteFlag;
        }
        return true;
    }

なお,Copy4で作成したisOverwriteメソッドに新しい条件を追加しています. 条件が複数になったため,overwriteFlagというBoolean型の変数で上書き確認を行なっています.

実行例

$ for i in 1 2 3 ; do echo "file$i" > "file$i.txt" ; done
$ touch file1.txt                         # file1.txt の最終更新日時を更新した.
$ java Copy5 -u -v file1.txt file3.txt    # file3.txt の方が古いため,コピーする.
file1.txt -> file3.txt
$ java Copy5 -u -v file2.txt file1.txt    # file1.txt の方が新しいため,コピーしない.
$ touch file2.txt
$ cat file2.txt
file2
$ java Copy5 -u -v -i file2.txt file1.txt # file1.txt の方が古いため,コピーする.
file1.txtを上書きしますか? (y/n [n]) n
上書きしません.
$ java Copy5 -u -v -i file2.txt file1.txt # file1.txt の方が古いため,コピーする.
file1.txtを上書きしますか? (y/n [n]) y
file2.txt -> file1.txt
$ cat file1.txt
file2

参考

6. ディレクトリを再帰的にコピーする

今までは,ファイルを出力先のファイル,もしくはディレクトリにコピーするコマンドを作成しました. このステップでは,ディレクトリをコピーする処理を追加します.Copy5をコピーし,Copy6を作成してください.

ステップ2のヒントで作成したrunメソッドでは, 出力先(to)が,存在しない,もしくはファイルの場合と,出力先がディレクトリに場合に場合分けを行い, それぞれで処理を実行していました.

ヒント

ディレクトリを再帰的にコピーするには,copyRecursiveメソッドを用意します. そこで,ディレクトリ内のファイル,ディレクトリを調べ,ファイルであれば, すでに作成済みであるcopyメソッドを呼び出します. ディレクトリであれば,copyRecursiveの再帰呼び出しを行います.

ここで,copyメソッドを呼び出すとき,出力先となるFile型の変数に気をつける必要があります. 例えば,fromaaaが指定され,その中のaaa/bbb/file.txtdest にコピーするとき,単純に new File(to, file) とすると,dest/aaa/bbb/file.txt に出力されることになります. 本来の出力は,dest/bbb/file.txt であるはずです.そのファイル名を取得しているのが,createToFileです.

    void performCopy(Arguments args){
        // コマンドライン引数に必要な文のファイルが指定されているか確認する.
        // 出力先は,コマンドライン引数の一番最後の要素である.
        File to = new File(args.list.get(args.list.size() - 1));
        if(.....){ // toが存在しない,もしくはファイルの場合.
            this.copyToFile(args, to);
        }
        else if(....){ // toがディレクトリの場合.
            this.copyToDirectory(args, to);
        }
    }
    void copyToDirectory(Arguments args, File to){
        for(....){ // argsを必要なだけ繰り返す.一番後ろの要素は省くことを忘れない.
            File from = new File(args.list.get(i));
            // fromがファイルの場合
                File toFile = new File(to, from.getName());
                // copyメソッドを呼び出し,コピーする.
            // コマンドライン引数に recursive が指定された場合.
                // copyRecursiveメソッドを呼び出す.
                this.copyRecursive(from, from, to, args);
            else{  // その他の場合
                System.out.printf("cp: %sはディレクトリです(コピーしません)%n", from.getName());
            }
        }
    }
    void copyToFile(Arguments args, File to){
        // args.list.size() が 2 より大きい場合,
        //     複数ファイルを1つのファイルにコピーできない旨を出力し,終了する.
        // そうでない場合,fromがディレクトリか否かを調べる.
        else{
            File from = new File(args.list.get(0));
            if(from.isDirectory()){  // from がディレクトリの場合.
                // toがファイルの場合
                    System.out.println("cp: ディレクトリをファイルにコピーできません.");
                // コマンドライン引数に recursive が指定された場合.
                    // copyRecursiveメソッドを呼び出す.
                    this.copyRecursive(from, from, to, args);
                else{  // その他の場合
                    System.out.printf("cp: %sはディレクトリです(コピーしません)%n", from.getName());
                }
            }
            else{  // fromがファイルの場合.
                this.copy(from, to, args);
            }
        }
    }
    ....
    void copyRecursive(File base, File from, File to, Arguments args) throws IOException{
        for(File file: from.listFiles()){
            if(file.isDirectory()){
                // copyRecursiveメソッドを再帰呼び出し.
            }
            else{
                File toFile = this.createToFile(base, file, to);
                // toFile の親ディレクトリ(File型)を取得する(toFile.getParentFile()).
                // toFile の親ディレクトリが存在しない場合作成する(parent.mkdirs()).
                this.copy(file, toFile, args);
            }
        }
    }
    File createToFile(File base, File from, File to){
        String basePath = base.getPath();
        String fromPath = from.getPath();
        String newPath = fromPath.substring(basePath.length() + 1);
        return new File(to, newPath);
    }

実行例

$ mkdir dir1
$ for i in 1 2 3 ; do echo "file $i in dir1" > dir1/file$i.txt ; done
$ ls dir1
file1.txt  file2.txt  file3.txt
$ ls dir2
ls: dir2: No such file or directory
$ java Copy6 dir1 dir2
cp: dir1はディレクトリです(コピーしません)
$ java Copy6 -r -v dir1 dir2
dir1/file1.txt -> dir2/file1.txt
dir1/file2.txt -> dir2/file2.txt
dir1/file3.txt -> dir2/file3.txt

参考