2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-06-23

はじめに

先日、golang.tokyo #39に参加し、「SFTPコンテナからファイルをダウンロードする」というタイトルでLT発表をさせていただきました。

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

発表内容

発表の概要

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

実装した機能

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

infrastructure.png

全体の処理の流れ

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

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

それぞれの処理

SFTPクライアントの初期化

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

// 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サーバー上のファイルをダウンロードするための機能です。
リモートファイルをバッファに読み込み、メモリ上で処理を行います。

// ファイルダウンロード
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ファイルにコピーしていきます。最後に、元のファイル構造を維持したままディレクトリ構造を構築します。

// ファイルをZipWriterに追加
func AddToZip(fileName, directlyPath, filePath string, buffer *bytes.Buffer, zipWriter *zip.Writer) error {
	// 第1階層にはコンテンツディレクトリパスを命名する、/は_に置き換え
	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化の処理は以下から最適な方法を選択する必要があります。

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

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

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

// ファイル取得が複数成功した場合は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などのヘッダー設定は、ユースケースごとに変更する必要があるため、注意が必要でした。

// ファイル取得が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発表は初めてだったため、とても貴重な経験をさせていただきました。

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

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?