SFTPコンテナからファイルをダウンロードする

## はじめに

先日、[golang.tokyo #39](https://golangtokyo.connpass.com/event/353988/?utm_campaign=event_message_to_selected_participant&utm_source=notifications&utm_medium=email&utm_content=title_link)に参加し、「SFTPコンテナからファイルをダウンロードする」というタイトルでLT発表をさせていただきました。

スライドの内容をこちらの記事でもまとめたいと思います。
こちらのスライドから詳細をご確認いただけます。

https://speakerdeck.com/dip/sftpkontenakarahuairuwodaunrodosuru




## 発表内容

### 発表の概要

今回の発表では、SFTPサーバをAPIサーバと接続しローカルにファイルをダウンロードする実装についてお話ししました。

### 実装した機能

開発するのは、サーバ上にあるバナー等のサイト画像をGUIで置き換える社内ツールです。
ユーザ用APIでファイルのダウンロードを行い、ユーザに返却する部分を実装しました。
（一部抜粋）

![infrastructure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616730/4fb1bd15-9fd8-42ba-8472-0736c3f9222a.png)


### 全体の処理の流れ

今回実装したシステムの全体の処理の流れは以下の通りです。

```
1. ユーザーからのリクエスト受信
   ↓
2. SFTPクライアントの初期化
   - SSH接続の確立
   - SFTPセッションの開始
   ↓
3. SFTPサーバーからのファイル取得
   - 指定されたファイルの存在確認
   - ファイルデータのダウンロード
   ↓
4. ファイルのZIP化処理
   - メモリ内でのZIPファイル作成
   - ディレクトリ構造の保持
   ↓
5. ユーザーへのレスポンス返却
   - 適切なHTTPヘッダーの設定
   - ZIPファイルの送信
```


### それぞれの処理

#### SFTPクライアントの初期化


まず、SSH接続するための秘密鍵を準備し、SSHのClientConfigに必要な設定値を入れていきます。
そして、実際にSSH接続を行います。最後に、SFTPクライアントを作成して準備完了です。

```go
// SFTPクライアントの初期化
func New(conf *ServerConfig) (*Client, error) {
	// 秘密鍵の準備
	keyName := fmt.Sprintf("%s_%s", conf.Host, conf.User)
	signer, err := getSigner(keyName)
	if err != nil {
		return nil, errors.Wrap(err, "getSigner faild")
	}

	// SSH接続設定
	addr := fmt.Sprintf("%s:%d", conf.Host, conf.Port)
	config := ssh.ClientConfig{
		User: conf.User,
		Auth: []ssh.AuthMethod{
			ssh.PublicKeys(signer),
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}

	// SSH接続の確立
	conn, err := ssh.Dial("tcp", addr, &config)
	if err != nil {
		return nil, errors.Wrap(err, "ssh.Dial faild")
	}

	// SFTPクライアントの作成
	sc, err := sftp.NewClient(conn)
	if err != nil {
		return nil, errors.Wrap(err, "sftp.NewClient faild")
	}

	return &Client{
		sc,
		conn,
	}, nil
}
```

#### SFTPクライアントの機能

2つのメインの処理を実装しました。

- ファイルダウンロード機能
- ファイルのZIP化機能

#### ファイルダウンロード機能

SFTPサーバー上のファイルをダウンロードするための機能です。
リモートファイルをバッファに読み込み、メモリ上で処理を行います。

```go
// ファイルダウンロード
func (c *Client) DownloadFile(remoteFilePath string) (*bytes.Buffer, error) {
	// リモートファイルの読み込み
	srcFile, err := c.sftpClient.OpenFile(remoteFilePath, (os.O_RDONLY))
	if err != nil {
		return nil, errors.Wrap(err, "sftpClient.ReadDir failed")
	}
	defer srcFile.Close()

	// バッファに読み込み
	var buffer bytes.Buffer
	_, err = io.Copy(&buffer, srcFile)
	if err != nil {
		return nil, errors.Wrap(err, "io.Copy failed")
	}

	return &buffer, nil
}
```


#### ファイルのZIP化機能

まず、メモリ上でZIPファイルを作成します。
バッファに保存したファイルをそのZIPファイルにコピーしていきます。最後に、元のファイル構造を維持したままディレクトリ構造を構築します。

```go
// ファイルをZipWriterに追加
func AddToZip(fileName, directlyPath, filePath string, buffer *bytes.Buffer, zipWriter *zip.Writer) error {
	// 第１階層にはコンテンツディレクトリパスを命名する、/は_に置き換え
	baseDir := strings.ReplaceAll(strings.TrimPrefix(directlyPath, "/"), "/", "_")

	remotePath := filepath.Join(directlyPath, filePath)
	
	// リモートディレクトリをもとにZIP内の相対パスを作成
	relativePath, err := filepath.Rel(directlyPath, remotePath)
	if err != nil {
		return errors.Wrap(err, "filepath.Rel failed")
	}

	// ZIP内のディレクトリ構成を作成
	zipPath := filepath.Join(fileName, baseDir, relativePath)

	// ZIP内にファイルを作成
	writer, err := zipWriter.Create(zipPath)
	if err != nil {
		return err
	}

	// ファイルの内容をZIPに書き込む
	_, err = io.Copy(writer, bytes.NewReader(buffer.Bytes()))
	if err != nil {
		return err
	}

	return nil
}
```

### ユースケース

今回実装したファイルダウンロード機能とZIP化機能は、以下のようなユースケースで使用しています。

1. **単一ファイルのダウンロード**
   - ユーザが1つのファイルを選択した場合
   - ダウンロード機能を使用して対象ファイルを取得
   - 元のファイル名とMIMEタイプを維持したままユーザに返却

2. **複数ファイルの一括ダウンロード**
   - ユーザが複数のファイルを選択した場合
   - 各ファイルをダウンロード機能で取得
   - ZIP化機能を使用して1つのZIPファイルにまとめる
   - ディレクトリ構造を保持したままユーザに返却

### 実装で難しかった点


#### 1. ZIP化とインメモリ処理

ZIP化の処理は以下から最適な方法を選択する必要があります。

- 一時ファイルを使用する方法

- インメモリで処理する方法

実際の実装では、インメモリ処理を選択しました。

```go
// ファイル取得が複数成功した場合はZIP化処理
} else if successCount > 1 {
	// ZIPファイル名の作成
	date := time.Now().Format("20060102_150405")
	strProjectID := strconv.Itoa(projectID)
	fileName := filepath.Base(date + "_" + strProjectID)

	// ヘッダー設定
	w.Header().Set("Content-Disposition", "attachment; filename= "+fileName+".zip")
	w.Header().Set("Content-Type", "application/zip")

	// ZIPファイル作成
	zipWriter := zip.NewWriter(w)
	defer zipWriter.Close()

	for _, file := range downloadedFiles {
		// ZIPに追加
		err = sftp.AddToZip(fileName,file.directlyPath, file.filePath, file.buffer, zipWriter)
		if err != nil {
			logger.UA15UnexpectedServerErrorOccurred(r.Context(), err)
			return nil, nil, xerrors.Errorf("zip化失敗: %s: %w", err, errtypes.ErrInternal)
		}
	}
}
```

#### 2. ヘッダーの設定
Content-Typeなどのヘッダー設定は、ユースケースごとに変更する必要があるため、注意が必要でした。

```go
// ファイル取得が1件成功した場合はファイルを返却
} else if successCount == 1 {
	// ファイル名の取得
	fileName := filepath.Base(postBody.Files[0].DirectoryPath)

	// ファイルの拡張子からMIMEタイプを取得
	ext := filepath.Ext(fileName)
	mimeType := mime.TypeByExtension(ext)
	if mimeType == "" {
		// MIME タイプが判定できない場合のデフォルト値
		mimeType = "application/octet-stream"
	}

	// ヘッダー設定
	w.Header().Set("Content-Disposition", "attachment; filename= "+fileName)
	w.Header().Set("Content-Type", mimeType)

	// バッファから直接レスポンスボディにコピー
	_, err = io.Copy(w, downloadedFiles[0].buffer)
	if err != nil {
		return nil, nil, xerrors.Errorf("ファイル送信エラー: %s: %w", err, errtypes.ErrInternal)
	}
```


このように、ファイルの数に応じて適切なレスポンス形式を選択し、サーバーからファイルをダウンロードできる実装を行いました。



## まとめ

社外でのLT発表は初めてだったため、とても貴重な経験をさせていただきました。

今後も、このような技術コミュニティでの発表機会を積極的に活用し、技術的な知見を共有していきたいと思います。

ここまで読んでくださりありがとうございました！