はじめに
先日、golang.tokyo #39に参加し、「SFTPコンテナからファイルをダウンロードする」というタイトルでLT発表をさせていただきました。
スライドの内容をこちらの記事でもまとめたいと思います。
こちらのスライドから詳細をご確認いただけます。
発表内容
発表の概要
今回の発表では、SFTPサーバをAPIサーバと接続しローカルにファイルをダウンロードする実装についてお話ししました。
実装した機能
開発するのは、サーバ上にあるバナー等のサイト画像をGUIで置き換える社内ツールです。
ユーザ用APIでファイルのダウンロードを行い、ユーザに返却する部分を実装しました。
(一部抜粋)
全体の処理の流れ
今回実装したシステムの全体の処理の流れは以下の通りです。
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つのファイルを選択した場合
- ダウンロード機能を使用して対象ファイルを取得
- 元のファイル名とMIMEタイプを維持したままユーザに返却
-
複数ファイルの一括ダウンロード
- ユーザが複数のファイルを選択した場合
- 各ファイルをダウンロード機能で取得
- 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発表は初めてだったため、とても貴重な経験をさせていただきました。
今後も、このような技術コミュニティでの発表機会を積極的に活用し、技術的な知見を共有していきたいと思います。
ここまで読んでくださりありがとうございました!