きり丸の技術日記

技術・エンジニアのイベント・資格等はこちらにまとめる予定です

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

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

なお、ソースコードをガンガン載せていきますので、携帯ではなく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転送元。
    • リモート
  • 既にローカルに転送対象ファイルを実体化している

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

リスト


  • PassiveModeでの通信する
    • TODO: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

【Docker】クライアントからプライベートリポジトリへのアクセス時に発生するX509エラーを回避する

社内に構築しているプライベートリポジトリに対して、Dockerでログインしようとしたが掲題のエラーが発生したので、回避した時のメモ。

ゴール

次のコマンドでログインが成功するようにする。

docker login private-repository

次のエラーが出たら失敗。

Error response from daemon: Get https://private-repository/v2/: x509: certificate signed by unknown authority

環境

社内で構築しているプライベートリポジトリはJFrogを使用しています。参考程度に。 jfrog.com

方法

Dockerデーモンにinsecure-registriesを設定し、プライベートリポジトリが安全であることを認識させます。

※ private-repositoryは適宜、読み替えてください。

  "insecure-registries": ["private-repository"]

GUIで設定


Settings または Preferences -> DockerEngine

に上の設定を追加してください。

※ 画像はイメージです。 f:id:nainaistar:20210107163200p:plain

設定後、自動でDockerが再起動します。

CUIで設定


daemon.jsonを修正します。

daemon.jsonはデフォルトでは次のURLに存在します。

修正後、dockerを再起動してください。

終わりに

今回の方法は「社内の」「自分が管理している」等々の安全が確認できるプライベートリポジトリに限った対応と考えてください。

本来であれば、プライベートリポジトリに証明書が入っていれば発生しないエラーです。ただ、開発用のプライベートリポジトリへのアクセスだという割り切り方をするのであれば、今回の対応でいいと思います。

こういう表現をしていいかはわかりませんが、サーバのメンテナンスもタダではありませんしね。

社内のサーバであれば、こういうことも起こります。


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

参考

敬称略。

Docker-docs-ja:安全ではないレジストリ docs.docker.jp

Docker-docs-ja:Docker デーモンの設定 docs.docker.jp

f:id:nainaistar:20201228233250p:plain

LAPRAS SCOREをPythonで取得し、GitHub ActionsでGitHub Profileを更新する

私は自分の力を客観的に判断するために、LAPRAS様を利用しています。LAPRAS様でLAPRAS SCOREなるエンジニアの能力を数値化したものがあり、そちらを利用してGitHub Profileに貼り付けていましたが、自分でキャプチャして画像化したものなので一定期間更新していないと情報が古くなってしまいます。

LAPRASのプロフィールリンクを踏んでもらえれば画像化する必要はないものの、わざわざアクセスしてくれる人は少ないので画像としてアピールしたかったです。

なんとか画像化を自動化しようとしていたところ、LAPRAS様の公開リンクでTwitter共有するとOGP画像で、目的の画像が出力されていることに気付きました。

そこでLAPRAS様のOGP画像を利用し、そのままGitHub Profileへの更新を自動化するための記録を残しているのが、この記事となります。

※OGP画像とは。

SNS投稿した際に表示される画像のこと。 f:id:nainaistar:20210110182349p:plain

言葉の定義

LAPRAS SCORE


転職サイトのLAPRAS様が出しているエンジニアとしての能力を数値化したもの。

f:id:nainaistar:20210110182405p:plain

GitHub Profile


GitHubのプロフィールページのこと。

私の場合は、次のリンク先のページが該当します。

hirotoKirimaru (kirimaru) · GitHub

ゴール

  • LAPRAS SCORE画像のリンクを取得するアプリケーションを作る
  • GitHub Profileに記載しているLAPRAS SCORE画像のリンクを更新する
  • GitHub Actionsで実行できるようにする

書かないこと

環境

  • アプリケーション
    • Python 3.9
    • Pipenv
    • Beautifulsoup4
      • 取得したHTMLを加工する
    • Requests
      • HTTP通信を行う
  • CI
  • GitHub

アプリケーション編

初期構築を行う


Pythonの環境構築をします。

pipenv --python 3.9
pipenv install requests
pipenv install beautifulsoup4

置換用の文言を入力します。アプリケーションを実行したらすぐに書き換えるので、リンクは適当でもいいですが、必ず何かを入力してださい。

ファイル名:path.txt

https://media.lapras.com/media/public_setting/XXXXXXXX.png

ファイル名:README.MD

![lapras_score](https://media.lapras.com/media/public_setting/XXXXXXXX.png)

Pipfileに実行スクリプトを書く


Pipenvの起動スクリプトを記載します。

[scripts]
start = "python main.py"

LAPRASの公開URLを取得する


LAPRASの公開URLをWebページから取得します。

f:id:nainaistar:20210110182456p:plain

公開URLから画像リンクを取得する


上で取得した公開URLにHTTP通信を行うと、TwitterのOGP画像が取得できます。

metaタグのnameが「twitter:image」のcontentが目的の画像リンクです。

<meta name="twitter:image" content="https://media.lapras.com/media/public_setting/JFCUKEW/6b2122adc53449dcb4c69a33d5364954.png" />

main.pyにロジックを書いていきます。

  1. HTTP通信を行うためのrequestsと、HTML構造を取得するためのBeautifulSoupをimportします。

  2. requests.getに上で取得した公開ページのリンクを渡します。

  3. その後、取得できたHTMLからmetaタグのみを取得します。

  4. metaタグのcontentに「https://media.lapras.com/media/public_setting」を含んでいれば画像のリンクとみなします。

※ 本当はmetaタグのnameで検索したかったのでが、BeautifulSoupの仕様でnameは検索できないようでした。

import requests
from bs4 import BeautifulSoup
# Webページを取得して解析する

# 自分のURLを入力する
load_url = "https://lapras.com/public/JFCUKEW"
html = requests.get(load_url)
soup = BeautifulSoup(html.content, "html.parser")

content = ""
for tag in soup.find_all(name = "meta"):
    content = tag['content']
    if "https://media.lapras.com/media/public_setting" in content:
        break

画像リンクが前回と同一だった場合は更新しない


GitHubのコミットを最小限にしたいので、リンクが前回と同一だった場合は更新しないようにします。

import sys

read = ""
with open("path.txt", "r") as path:
    read = path.read()
    if read in content:
        sys.exit()

以降の処理は、リンクが前回と異なっている前提です。

GitHub Profile(README.MD)を更新する


GitHub Profile(README.MD)に記載していた画像のリンクを、新しいものに書き換えます。同様のタイミングで、path.txtに記載していた画像リンクを更新します。

after = ""
with open("README.md", "r") as readme:
    after = readme.read().replace(read, content)

with open("README.md", "w") as readme:
    readme.write(after)

with open("path.txt", "w") as path:
    path.write(content)

GitにPushする


シェルでGitにPushします。特に大事なのはemailとnameです。無ければGitHub Actionsで更新できません。

git config --local user.email 'GitHubのメールアドレス'
git config --local user.name '更新用のユーザ'
import os

os.system("git config --local user.email 'GitHubのメールアドレス''")
os.system("git config --local user.name '更新用のユーザ'")
os.system("git add .")
os.system("git commit -m '[Auto] Update'")
os.system("git push")

GitHub Actions編

こちらはPipenvが実行できればいいので、難しいことはしていません。

手動実行や、スケジュールを指定して定期実行できるような仕組みにしているといいでしょう。

name: Python application

on:
  workflow_dispatch: # 手動実行用
    branches: [ master ]
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python 3.9
      uses: actions/setup-python@v2
      with:
        python-version: 3.9
    - name: Install Pipenv
      run: |
        python -m pip install --upgrade pip
        pip install pipenv
    - name: Install dependencies
      run: |
        pipenv sync
    - name: update
      run: |
        pipenv run start

ソースコード

github.com

終わりに

HTML構造を取得するスクリプトを素振りしたかったので、いい機会でした。

今回取得したいLAPRAS SCOREはあくまでLAPRASのOGP画像に左右されるので、生成タイミングによっては最新の成績と一致しないことありますが、可能な限り最新な状態で維持できるでしょう。

自分のように、最新のLAPRAS SCOREを取得したい、かつCIで自動化したいという人の参考になれば幸いです。


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

参考

Beautiful Soupのリファレンス https://www.crummy.com/software/BeautifulSoup/bs4/doc/#

f:id:nainaistar:20210110183238p:plain