きり丸の技術日記

技術検証したり、資格等をここに残していきます。

JavaでFTPを使ったアップロードを実装してテストも行う(commons-net)

JavaでFTPを使ったアップロード機能を実装したので、未来の自分のため残しておきます。ダウンロード機能も簡単に実装できるようですが、仕事では扱わなかったので、必要なときにまた実装します。要望があったら頑張って書きます。

なお、ソースコードをガンガン載せていきますので、携帯ではなくPCでの閲覧を推奨します。

環境

  • Java
    • 11
  • org.Apache.commons.net.ftp
    • 3.7.2
  • org.mockftpserver
    • 2.7.1

build.gradleの記述

dependencies {
    implementation 'commons-net:commons-net:3.7.2'
    testImplementation 'org.mockftpserver:MockFtpServer:2.7.1'
}

TODOリスト

前提

  • 言葉の定義

    • ローカル
      • Javaが動作する環境。FTP転送元。
    • リモート
      • FTP転送先
  • 既にローカルに転送対象ファイルを実体化している

  • ローカルはWin、Mac、Linuxで動くようにする
    • File.separatorがWinの"\"でも、Linuxの"/"でも動くようにする
  • リモートはUNIX
  • リモートでのログインユーザはどこにでもアクセスできる
    • 確認大事です。FTPアカウントがホームディレクトリより親ディレクトリに移動できないと聞いていたのに、全ディレクトリ自由に扱えたせいで変なところに配置できてしまいました。
  • リモートはホームディレクトリ配下にディレクトリを掘って、ファイルを転送する
  • アスキーモードではなく、バイナリモードで転送する
    • 改行コードをクライアント都合で変更するかしないかの違いです。基本はバイナリモードでいいでしょう。

リスト

  • PassiveModeでの通信する
  • リモートディレクトリの構造をローカルディレクトリにも生成する
      • ローカルルート:C:\tmp\transfers
      • ローカル:C:\tmp\transfers\blog\2020\11\18\01.png
      • リモートルート:/home/kirimaru
      • リモート:/home/kirimaru/blog/2020/11/18/01.png
  • 複数のファイルを転送できる
  • 安全にCloseするためにAutoClosableを実装する
  • 安全にCloseするためにtry-with-resourcesを使う
    • FTPクライアントを直接扱うクラスと、それを扱うクラスの2ファイル使用する

実装

FTPクライアントを直接扱うクラス(FtpFileTransmitter.java)


記載するのは、だいたいcommons-netの使い方になります。

クラスとコンストラクタ

try-with-resourcesを使いたいので、AutoCloseableを実装します。

なお、私はフィールド変数をfinalにするのが好きなので、コンストラクタに全部設定しました。FTPClientはともかく、ホームディレクトリは最初に取得して設定しておかないと後で取得できなかったし、変なタイミングで設定されるのは嫌なのでfinalがいいです。

public class FtpFileTransmitter implements AutoCloseable { // AutoCloseableを実装
  private final FtpConfiguration configuration; // 接続情報
  private final FTPClient ftp; // FTPClient(commons-net)
  private final String home; // ユーザのホームディレクトリ
  public FtpFileTransmitter(FtpConfiguration configuration) throws IOException{
    this.configuration = configuration;
    this.ftp = new FTPClient();
    connect(); // 接続
    this.home = ftp.printWorkingDirectory();
  }
}

接続メソッド

最低限必要なメソッドだけ実装しています。

タイムアウト等の設定は非必須なので、細かいところは私のGitHubのソースを見てください。

  private void connect() throws IOException {
    ftp.connect(configuration.getHost(), configuration.getPort()); // サーバへ接続
    ftp.login(configuration.getUsername(), configuration.getPassword()); // ユーザでログイン
    ftp.enterLocalPassiveMode(); // PassiveModeに変更
    ftp.setFileType(FTP.BINARY_FILE_TYPE); // バイナリモードに変更
  }

なお、FTPClientのconnectメソッドに関しては戻り値がvoid型なので直接結果が分かりませんが、下記メソッドで適宜最新の実行結果を取得できます。

    int reply = ftp.getReplyCode();
    if (!FTPReply.isPositiveCompletion(reply)) {
      throw new RuntimeException("サーバに接続できませんでした"); // エラーハンドリング
    }

Closeメソッド

確実にdisconnectするようにしましょう。AutoClosableを実装しているので、確実にcloseを呼んでくれます。

職場では、FTPClientにnullを設定して確実にgc対象にしていましたが、そうやるとfinal属性を付与できないのでGitHubソースでも外しています。

  @Override // AutoClosableを実装するため
  public void close() throws IOException {
    if (ftp == null || !ftp.isConnected()) {
      return;
    }
    ftp.disconnect();
  }

ディレクトリを作成しつつ、作成したディレクトリに移動するメソッド

地味に苦労したメソッドです。

最初はパラメータがString型にして、File.separatorをパラメータにしたsplitメソッドで分割していました。ただし、Windows環境ではFile.separatorが"\"になってしまい、エスケープ文字と判断されて動きません。Windows環境ではエスケープ文字をエスケープするために"\\"を入れるべきですが、それを行ってしまうとMac,Linux環境では動きません…。

なので、その辺の環境差異を吸収してくれるPath型をパラメータにしています。Path型はIteratorを継承しているので、forEachRemainingメソッドを呼べば、ディレクトリ作成とディレクトリ移動を簡単にできます。

GitHubのソースコードでは、既にmakeDirectoryをしているとWARNエラーが出てしまうので、回避する処理を入れています。

  public void ftpCreateDirectoryTree(Path remoteDirectory) {
    final Iterator<Path> iterator = remoteDirectory.iterator();

    iterator.forEachRemaining(
        dir -> {
          try {
            ftp.makeDirectory(dir.toString());
            ftp.changeWorkingDirectory(dir.toString());
          } catch (IOException e) {
            throw new RuntimeException(e);
          }
        }
    );
  }

ファイルを転送するメソッド

ftp.storeFileメソッドにファイル名と、InputStreamを渡すだけ。

  public void putFileToPath(Path path) throws IOException {
    ftp.storeFile(path.getFileName().toString(), Files.newInputStream(path));
  }

ホームディレクトリに移動するメソッド

今回は、ホームディレクトリを元にファイルを転送する仕様なので、ホームディレクトリに移動しなければどんどんディレクトリを深く掘ってしまいます。

FTPFileTransmitterクラスの生成時にホームディレクトリを準備しているので、ホームディレクトリに戻ります。

  public void changeHomeDirectory() throws IOException {
    ftp.changeWorkingDirectory(home);
  }

FTPクライアントを直接扱うクラスを扱うクラス(FtpClientImpl.java)


try-with-resourcesでFTPFileTransmitterクラスを生成します。

ローカルに生成しておいたファイルを、リモートに転送します。

ローカルから"C:\"等の不要なパスを除いたうえで、ディレクトリの作成、ファイル転送、ホームディレクトリの移動を行っています。

  public void ftp(Path localRootPath, List<Path> paths) {

    try (FtpFileTransmitter ftp = new FtpFileTransmitter(ftpConfiguration)) {
      for (Path path : paths) {
        transfer(ftp, localRootPath, path);
      }
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  private void transfer(FtpFileTransmitter ftp, Path localRootPath, Path path) throws IOException {
    ftp.ftpCreateDirectoryTree(Path.of(createRemoteDirectory(localRootPath, path))); 
    ftp.putFileToPath(path);
    ftp.changeHomeDirectory();
  }

  private String createRemoteDirectory(Path localRootPath, Path localPath) {
    String remoteDirectoryIncludeFileName = localPath.toString().replace(localRootPath.toString(), "");
    return remoteDirectoryIncludeFileName.replace(localPath.getFileName().toString(), "");
  }

テスト(FtpClientImplTests.java)

FakeFtpServerを使用してテストします。

ログインユーザ用のUserAccountの作成、FileSystemの環境の指定、ディレクトリの作成等ができます。一応、ディレクトリのアクセス権限とか厳格に設定することもできそうですが、私は最初のテストの段階では行わないほうがいいと考えています。というのも、結局本物じゃない以上は環境一致する保証が無いからです。下手に厳しくしてしまうと、相手のサーバ構成がちょっと変わっただけで役に立たないテストになってしまいかねません。

本番で動くコードが正義です。あくまで最初に厳しくしたくないというだけで、本番接続で不具合があったときに、徐々に厳しくしていくのはいいと考えています。

テストの準備


  FtpClientImpl target;
  public static final String HOME = "/user";
  FakeFtpServer server = new FakeFtpServer();

  public FtpClientImplTests() {
    server.setServerControlPort(0); // 0を設定するとランダムポートになる。ランダムになると並列化できるからうれしい。
    server.addUserAccount(new UserAccount("user", "pass", HOME));

    FileSystem fileSystem = new UnixFakeFileSystem(); // Unix指定。WindowsFakeFileSystemもある。
    fileSystem.add(new DirectoryEntry(HOME)); // ホームディレクトリ作成
    server.setFileSystem(fileSystem);
  }

  @BeforeEach
  void setup() {
    server.start(); // サーバ起動

    // テスト対象クラスの生成
    FtpConfiguration ftpConfiguration = new FtpConfiguration();
    ftpConfiguration.setUsername("user");
    ftpConfiguration.setPassword("pass");
    ftpConfiguration.setHost("localhost");
    ftpConfiguration.setPort(server.getServerControlPort());

    target = new FtpClientImpl(ftpConfiguration);
  }

  @AfterEach
  void tearDoiwn() {
    server.stop(); // サーバ終了
  }

テストの実施


FakeFtpServerにファイルが転送されていることを確認します。

getFileSystemメソッドでserverのディレクトリやファイルの存在を確認できるので、その確認をしています。

なお、読みやすいテストとして「準備/実行/検証」を意味するArrange-Act-Assertという3Aがありますが、私はGherkinのGiven-When-Thenの方が好みです。

  @Test
  void test_02() throws IOException {
    // Given 事前準備
    // ローカルにファイルを作成する
    Files.createDirectories(Paths.get(TMP_ROOT_PATH.toString(), EXPECTED_FILE_PATH.toString()));
    List<Path> paths = List.of(
        Files.createFile(Paths.get(TMP_ROOT_PATH.toString(), EXPECTED_FILE_PATH.toString(), EXPECTED_FILE_ONE)),
        Files.createFile(Paths.get(TMP_ROOT_PATH.toString(), EXPECTED_FILE_PATH.toString(), EXPECTED_FILE_TWO))
    );
    // When 実行
    target.ftp(TMP_ROOT_PATH, paths);

    // Then 検証
    SoftAssertions softly = new SoftAssertions();
    softly.assertThat(server.getFileSystem().exists(HOME + "/" + EXPECTED_FILE_PATH.toString())).isTrue();
    softly.assertThat(server.getFileSystem().exists(HOME + "/" + EXPECTED_FILE_PATH.toString() + "/" + EXPECTED_FILE_ONE)).isTrue();
    softly.assertThat(server.getFileSystem().exists(HOME + "/" + EXPECTED_FILE_PATH.toString() + "/" + EXPECTED_FILE_TWO)).isTrue();
    softly.assertThat(Files.exists(Paths.get(TMP_ROOT_PATH.toString()))).isTrue();
    softly.assertAll();;
  }

ソースコード

終わりに

純粋にFTPのロジックだけ見たいんだ!という方にとってはちょっと冗長なブログになっています。

できるだけ具体的にコードを載せることで、悪い点を指摘してくれないかなぁ、とかそういう狙いもあります。

初心者にとっては単純なFTPのロジックだけよりもかなり見やすい情報が揃っているとは考えているので、ぜひ参考にしてください。

悪い点はプルリクエスト、待っています!


この記事がお役に立ちましたら、各種SNSでのシェアや、今後も情報発信しますのでフォローよろしくお願いします。

f:id:nainaistar:20201013111905p:plain