できない.dev

HTTPでファイルをダウンロードするには

HTTP でファイルを取得してディスクに保存する基本形を各言語で示す。
保存する前にステータスコードを確かめること、本文をまとめてメモリに読み込まずストリームのまま書き出すこと、書き終わるまでは一時ファイルに書いてから本来の名前に置き換えることまでを扱う。

公開:

各言語見出しの横のバッジは検証状態を表す。実行確認済みはコードを実際に実行して確認したもの、静的確認は構文と公式 API ドキュメントで確認したものである。

Python 実行確認済み

import os
import shutil
import urllib.request
 
url = "https://github.com/python/cpython/raw/main/LICENSE"
dest = "LICENSE.txt"
tmp = dest + ".part"
 
# 404 や 500 は HTTPError になるので、エラーページがファイルとして残らない
with urllib.request.urlopen(url, timeout=30) as res, open(tmp, "wb") as f:
    # 少しずつ読んで書くので、大きなファイルでもメモリに全部載せない
    shutil.copyfileobj(res, f)
 
# 書き終わってから本来の名前に置き換える
os.replace(tmp, dest)
print(res.status, res.url)  # -> 200 https://raw.githubusercontent.com/python/cpython/main/LICENSE
print(os.path.getsize(dest) > 0)  # -> True

urlopen が返すレスポンスはファイルのように読めるので、shutil.copyfileobj でバイナリモードのファイルへ少しずつ写す。
urlopen は 4xx / 5xx を HTTPError として投げるため保存処理まで進まず、リダイレクトも自動で追う。
一時ファイルに書いてから os.replace で置き換えるのは、途中で失敗したときに壊れたファイルが本来の名前で残らないようにするためである。

JavaScript 実行確認済み

import { createWriteStream } from "node:fs";
import { rename } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
 
const url = "https://github.com/python/cpython/raw/main/LICENSE";
const dest = "LICENSE.txt";
const tmp = `${dest}.part`;
 
const res = await fetch(url);
// fetch は 404 や 500 でも reject しないので、保存する前に必ず確かめる
if (!res.ok) {
  throw new Error(`download failed: ${res.status}`);
}
 
// 本文をストリームのままファイルへ流し込み、全体をメモリに載せない
await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
await rename(tmp, dest);
console.log(res.status, res.redirected); // -> 200 true

Node.js 18 以降のグローバル fetch で取得し、Web のストリームである res.body を Readable.fromWeb で Node.js のストリームに変えてから、pipeline でファイルへ流し込む。
fetch は 404 や 500 でも reject しないので、res.ok を確かめずに保存するとエラーページの HTML がそのままファイルになる。
pipeline はどちらかのストリームでエラーが起きたときに両方を閉じるため、pipe を手でつなぐより後始末が確実である。

うまくいかない時: Node.js で「fetch is not defined」が解決できない(古い Node)Node.js で fetch が「self-signed certificate in certificate chain」で接続できない

TypeScript 実行確認済み

import { createWriteStream } from "node:fs";
import { rename } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream } from "node:stream/web";
 
const url = "https://github.com/python/cpython/raw/main/LICENSE";
const dest = "LICENSE.txt";
const tmp = `${dest}.part`;
 
const res = await fetch(url);
// res.body の型は ReadableStream | null なので、null も一緒に弾く
if (!res.ok || res.body === null) {
  throw new Error(`download failed: ${res.status}`);
}
 
// lib に DOM が入っていても通るよう、node:stream/web の型として渡す
await pipeline(Readable.fromWeb(res.body as ReadableStream<Uint8Array>), createWriteStream(tmp));
await rename(tmp, dest);
console.log(res.status, res.redirected); // -> 200 true

処理は JavaScript と同じだが、res.body の型は ReadableStream | null なので、null を弾かないまま Readable.fromWeb に渡すと TS2345 になる。
tsconfig の lib に DOM が入っていると res.body は DOM 側の ReadableStream 型になり、node:stream/web の ReadableStream とは別の型として扱われて同じく TS2345 になるため、node:stream/web の型へアサーションしてから渡している。

うまくいかない時: TypeScript で「Argument of type ... is not assignable」が解消できないNode.js で「fetch is not defined」が解決できない(古い Node)

Go 実行確認済み

package main
 
import (
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)
 
func download(url, dest string) error {
	// Timeout は本文を読み終えるまでを含む上限なので、ファイルの大きさに見合う値にする
	client := &http.Client{Timeout: 10 * time.Minute}
	res, err := client.Get(url)
	if err != nil {
		return err
	}
	defer res.Body.Close()
 
	// 404 や 500 でも err は nil なので、ステータスを自分で確かめる
	if res.StatusCode != http.StatusOK {
		return fmt.Errorf("download failed: %s", res.Status)
	}
 
	tmp := dest + ".part"
	f, err := os.Create(tmp)
	if err != nil {
		return err
	}
	// io.Copy は少しずつ読んで書くので、大きなファイルでもメモリに全部載せない
	if _, err := io.Copy(f, res.Body); err != nil {
		f.Close()
		os.Remove(tmp)
		return err
	}
	// 書き込みの失敗は Close で初めて返ることがあるので、エラーを捨てない
	if err := f.Close(); err != nil {
		return err
	}
	return os.Rename(tmp, dest)
}
 
func main() {
	err := download("https://github.com/python/cpython/raw/main/LICENSE", "LICENSE.txt")
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println("saved") // -> saved
}

http.Client の Get はリダイレクトを自動で追う一方、404 や 500 でも err を返さないので、StatusCode を確かめてから io.Copy でファイルへ写す。
Client の Timeout は本文を読み終えるまでを含む上限なので、ダウンロードで短くしすぎると大きなファイルが途中で切れる。
Close もエラーを返しうるため、戻り値を確かめてから os.Rename で置き換える。

Rust 実行確認済み

// Cargo.toml: reqwest = { version = "0.13", features = ["blocking"] }
use std::fs::{self, File};
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = "https://github.com/python/cpython/raw/main/LICENSE";
    let dest = "LICENSE.txt";
    let tmp = format!("{dest}.part");
 
    // error_for_status() で 4xx / 5xx を Err に変え、エラーページを保存しない
    let mut res = reqwest::blocking::get(url)?.error_for_status()?;
    println!("{} {}", res.status().as_u16(), res.url()); // -> 200 https://raw.githubusercontent.com/python/cpython/main/LICENSE
 
    let mut file = File::create(&tmp)?;
    // copy_to は本文を少しずつファイルへ書き出す
    res.copy_to(&mut file)?;
    drop(file);
    fs::rename(&tmp, dest)?;
    Ok(())
}

標準ライブラリに HTTP クライアントが無いため、reqwest の blocking 機能を使う。
error_for_status() で 4xx / 5xx を Err に変えてから copy_to で本文を少しずつファイルへ書き出すので、エラーページを保存することも、本文全体をメモリに載せることもない。
リダイレクトは既定で追う。

Java 実行確認済み

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
 
public class Main {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .followRedirects(HttpClient.Redirect.NORMAL) // 既定の NEVER のままだと 302 で止まる
                .connectTimeout(Duration.ofSeconds(10))
                .build();
        HttpRequest req = HttpRequest.newBuilder(
                URI.create("https://github.com/python/cpython/raw/main/LICENSE")).build();
 
        Path dest = Path.of("LICENSE.txt");
        Path tmp = Path.of("LICENSE.txt.part");
        // ofFile はステータスに関係なく本文を書き出すので、一時ファイルで受けてから確かめる
        HttpResponse<Path> res = client.send(req, HttpResponse.BodyHandlers.ofFile(tmp));
        if (res.statusCode() != 200) {
            Files.deleteIfExists(tmp);
            throw new RuntimeException("download failed: " + res.statusCode());
        }
        Files.move(tmp, dest, StandardCopyOption.REPLACE_EXISTING);
        System.out.println(res.statusCode() + " " + res.uri()); // -> 200 https://raw.githubusercontent.com/python/cpython/main/LICENSE
    }
}

HttpClient は既定でリダイレクトを追わない(Redirect.NEVER)ので、followRedirects(HttpClient.Redirect.NORMAL) を指定しないと、GitHub の raw リンクのような 302 で止まって空のファイルができるだけになる。
BodyHandlers.ofFile はステータスコードに関係なく本文を書き出すため、一時ファイルで受けてから 200 かどうかを確かめ、Files.move で本来の名前に置き換える。

C# 実行確認済み

using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
 
class Program
{
    static readonly HttpClient client = new HttpClient();
 
    static async Task Main()
    {
        const string url = "https://github.com/python/cpython/raw/main/LICENSE";
        const string dest = "LICENSE.txt";
        string tmp = dest + ".part";
 
        // ResponseHeadersRead を渡すと、本文を読み込む前に制御が返り、ストリームで受け取れる
        using var res = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
        res.EnsureSuccessStatusCode(); // 4xx / 5xx は例外にして保存しない
 
        await using (var body = await res.Content.ReadAsStreamAsync())
        await using (var file = File.Create(tmp))
        {
            await body.CopyToAsync(file);
        }
        File.Move(tmp, dest, overwrite: true);
        Console.WriteLine($"{(int)res.StatusCode} {res.RequestMessage?.RequestUri}"); // -> 200 https://raw.githubusercontent.com/python/cpython/main/LICENSE
    }
}

GetAsync は既定で本文をすべて読み込んでから返るので、HttpCompletionOption.ResponseHeadersRead を渡してヘッダーを受け取った時点で制御を戻させ、ReadAsStreamAsync のストリームを CopyToAsync でファイルへ写す。
GetAsync は 404 でも例外を投げないため、EnsureSuccessStatusCode で失敗を例外に変えてから保存する。
HttpClient は static フィールドで 1 つを使い回す。

つまずき

ダウンロードでいちばん気づきにくい失敗は、404 や 500 の応答本文をそのまま保存してしまうことである。
GitHub で存在しないファイルの raw リンクを開くと、404 と一緒に約 26 万バイトの HTML が返ってくる。
JavaScript の fetch、Go の http.Client、C# の GetAsync はこのとき例外もエラーも返さないので、ステータスを確かめずに書き出すと処理は正常に終わり、名前だけそれらしい HTML ファイルが残る。
Java の BodyHandlers.ofFile も 404 の本文をそのまま書き出す。
これに対して Python の urlopen は HTTPError を投げ、Rust の reqwest は error_for_status() を呼べば Err になる。
保存したファイルが開けないときは、まず先頭をテキストとして表示して HTML になっていないかを確かめたい。

リダイレクトを追うかどうかは言語で違う

配布ファイルの URL はリダイレクトを挟むことが多く、例に使った CPython の LICENSE の raw リンク(github.com 側)も 302 で raw.githubusercontent.com へ転送される。
Python の urlopen、fetch、Go の http.Client、Rust の reqwest、C# の HttpClient は既定で転送先まで追うが、Java の HttpClient は既定が Redirect.NEVER で、302 の応答をそのまま返す。
このとき ofFile は 0 バイトのファイルを作って終わるので、例外が出ないまま空のファイルが残る。
コマンドラインの curl も既定では追わず、-L を付ける必要がある。

本文をまとめてメモリに読み込まない

Python の res.read()、fetch の res.arrayBuffer()、C# の ReadAsByteArrayAsync のように本文をまとめて受け取る書き方は、ファイルの大きさと同じだけのメモリを使う。
数 GB のファイルでは、それだけでプロセスが落ちかねない。
サンプルではどの言語も、少しずつ読んで書くストリームの API(shutil.copyfileobj、pipeline、io.Copy、copy_to、CopyToAsync)を使っている。
C# の GetAsync は既定で本文を読み終えてから返るため、ストリームで受けるつもりでも HttpCompletionOption.ResponseHeadersRead を渡さないと、先に全体がメモリへ読み込まれる。

タイムアウトがどこまでを含むかを確かめる

同じ「タイムアウト」でも、どこまでを含むかは実装によって違う。
1 秒ごとに 1 バイトずつ、5 秒かけて本文を返すサーバで試すと、Go の http.Client に Timeout を 2 秒で設定した場合は本文の読み取り中に Client.Timeout のエラーになった。
fetch に AbortSignal.timeout(2000) を渡した場合も、ヘッダーはすぐ届いたのに 2 秒で TimeoutError になった。
一方 Python の urlopen の timeout は接続や 1 回の読み取りを待つ時間の上限なので、同じ 2 秒でも最後まで受け取れた。
大きなファイルを落とすときは、全体の上限を短くしすぎないようにするか、Go の http.Transport の ResponseHeaderTimeout のように、ヘッダーが届くまでだけを制限する設定を使う。

書き終わるまで本来の名前を使わない

書き込みの途中で接続が切れると、中途半端なファイルが残る。
最初から本来の名前で書いていると、次に処理するプログラムが壊れたファイルを完全なものとして読んでしまう。
サンプルでは .part を付けた一時ファイルに書き、書き終わってから os.replace、rename、os.Rename、fs::rename、Files.move、File.Move で置き換えている。
置き換えるまで本来の名前のファイルには触れないので、失敗しても前回の正常なファイルが残るか、ファイルが無いかのどちらかになる。
Python の os.rename は Windows で置き換え先が既にあると失敗するため、上書きしたいときは os.replace を使う。

この記事は役立ちましたか?