Rustは、C/C++と同等の速度で実行できて、システムプログラミングなど低レベルな処理も得意です。加えて、C/C++よりも安全で生産性が高いことで、日々、その存在感が増しています。今回は、Rustでビットシフトやバイナリファイル生成といった処理を実装し、日本最小の郵便番号検索ツールを目指して作ってみましょう。

  • 日本最小を目指した郵便番号検索ツールを作ってみよう

    日本最小を目指した郵便番号検索ツールを作ってみよう

日本最小の郵便番号バイナリファイルを作ってみよう

RustはC/C++の代替ツールとして重宝されていますが、バイナリファイル操作などの低レベルな処理も得意です。今回は、郵便局のサイトで配付されている郵便番号のCSVファイルを読み込み、最小のバイナリデータに変換してみましょう。

なお、最小のバイナリデータを利用した検索ツールは、ストレージやメモリが限られた環境でも使えるものを想定します。そして、郵便番号を入力すると住所を表示するという最低限のツールを作ってみましょう。

まず、元となる郵便番号データを入手しましょう。こちらからダウンロードできます。今回は、『住所の郵便番号(1レコード1行、UTF-8形式)(CSV形式)』のものを選んでダウンロードしましょう。

  • 郵便番号データをダウンロードしよう

    郵便番号データをダウンロードしよう

ZIP形式なので展開しておきましょう。すると「utf_ken_all.csv」という18.3MBのCSVファイルになります。こちらにCSVファイルの各フィールドの説明が書かれています。このCSVファイルには、全国地方公共団体コードや5桁の旧郵便番号、フリガナなど、今回目的とするデータ以外のフィールドも含まれています。そこで、次の方針で最小の郵便番号データを作ろうと思います。

(1)郵便番号から住所を取り出すという目的以外の付加データを省略
(2)都道府県や市区町村など重複して登場する部分をID番号に置き換える

例えば、都道府県は47個しかないため、6ビット(2の6乗=64以下)で表現できます。市区町村の数は変動しているものの、本コラム執筆時点では、1890個でした。そうであれば、11ビット(2の11乗=2048以下)で表現できます。そして、それ以下の町域名の部分も同名のものがあります。この部分を数えてみると、87,151個だったので、17ビット(2の17乗=131,072以下)で表現できます。

ここから、1つの郵便番号データは、郵便番号(7桁の数字=24ビット)+都道府県(6ビット)+市区町村(11ビット)+町域名(17ビット)、つまり、58ビット≒8バイトで表現できることが分かります。そして、CSVファイルの総行数を数えると124,434行のデータがあります。文字列データが全くない状態でも省いたとしても1メガバイト程度は必要であることが分かります。

  • 郵便番号データを58ビット≒8バイトで表現する

    郵便番号データを58ビット≒8バイトで表現する

なお、上記のデータはID情報のみを記録したものなので、実際の文字列データを別途保存する必要があります。そこで、都道府県の文字列を「ken.txt」に、市区町村の文字列を「shi.txt」に、町域を「cho.txt」というファイルに保存します。これは、文字列を改行で区切っただけのデータです。

CSVからバイナリデータを作成するプログラム

それでは、RustでCSVを読んでバイナリデータを作成するプログラムを作ってみましょう。ターミナル(WindowsならPowerShell、macOSならターミナル.app)を起動して、下記のコマンドを実行して、プロジェクトを作成しましょう。

# プロジェクトの作成
mkdir csv2bin
cd csv2bin
cargo init
# CSVの読み込みクレートを追加
cargo add csv

そして、csv2bin/src/main.rsを開いて次のプログラムを記述しましょう。なお、こちらでも確認できます。

use std::collections::HashMap;
// ファイル名の宣言
const FILE_UTF_KEN_ALL: &str = "utf_ken_all.csv"; // 入力ファイル
const FILE_ZIPCODE_BIN: &str = "zipcode.bin"; // 出力ファイル(データのみ)
const KEN_TXT: &str = "ken.txt"; // 出力ファイル(都道府県)
const SHI_TXT: &str = "shi.txt"; // 出力ファイル(市区町村)
const CHO_TXT: &str = "cho.txt"; // 出力ファイル(町域)

fn main() {
    // CSVファイルを読み込む --- (*1)
    let mut rdr = csv::Reader::from_path(FILE_UTF_KEN_ALL).unwrap();
    // 都道府県、市区町村、町域のコードを格納するハッシュマップを初期化 --- (*2)
    let mut ken_map: HashMap<String, u32> = HashMap::new();
    let mut shi_map: HashMap<String, u32> = HashMap::new();
    let mut cho_map: HashMap<String, u32> = HashMap::new();
    // 文字列データを格納するバッファを初期化 --- (*3)
    let mut ken_b: Vec<u8> = vec![];
    let mut shi_b: Vec<u8> = vec![];
    let mut cho_b: Vec<u8> = vec![];
    // 文字列をidに変換する関数 --- (*4)
    let str_to_id = |map: &mut HashMap<String, u32>, b: &mut Vec<u8>, s: &str| -> u32 {
        let s = s.trim();
        if map.contains_key(s) {
            *map.get(s).unwrap()
        } else {
            let id = map.len() as u32; // IDを発行
            map.insert(s.to_string(), id);
            b.extend(s.as_bytes()); // 文字データ
            b.push(10); // 改行コード
            id
        }
    };
    // CSVファイルの各行を読み込み、都道府県、市区町村、町域のIDを64ビットにパック
    let mut output: Vec<u64> = vec![];
    for result in rdr.records() {
        // CSVのフィールドを読む --- (*5)
        let record = result.unwrap();
        let code = record.get(2).unwrap();
        let ken = record.get(6).unwrap();
        let shi = record.get(7).unwrap();
        let cho = record.get(8).unwrap();
        // println!("{}:{} {} {}", code, ken, shi, cho);
        // 各コードを数値に変換 --- (*6)
        let code = code.parse::<u32>().unwrap(); // 24ビット
        let ken_id = str_to_id(&mut ken_map, &mut ken_b, ken) as u8; // 6ビット
        let shi_id = str_to_id(&mut shi_map, &mut shi_b, shi) as u16; // 11ビット
        let cho_id = str_to_id(&mut cho_map, &mut cho_b, cho) as u32; // 17ビット
        let unused = 0u8; // 6ビット
        // データを64ビットにパックする --- (*7)
        let packed: u64 = (code as u64) << 40
                        | (ken_id as u64) << 34
                        | (shi_id as u64) << 23
                        | (cho_id as u64) << 6
                        | (unused as u64) << 0;
        output.push(packed);
    }
    // ファイルにバイナリデータを書き込む --- (*8)
    let bytes: Vec<u8> = output.iter().flat_map(|&x| x.to_be_bytes().to_vec()).collect();
    std::fs::write(FILE_ZIPCODE_BIN, bytes).unwrap();
    // 文字列データを書き込む --- (*9)
    std::fs::write(KEN_TXT, ken_b).unwrap();
    std::fs::write(SHI_TXT, shi_b).unwrap();
    std::fs::write(CHO_TXT, cho_b).unwrap();
    println!("ok: {} {} {}", ken_map.len(), shi_map.len(), cho_map.len());
}

プログラムを確認してみましょう。(*1)ではCSVファイルを読み込みます。csvクレートを使っているので手軽にCSVファイルを読み込めます。(*2)では文字列を数値(ID)に変換するためにHashMapを利用するため初期化処理を行います。都道府県(ken_map)、市区町村(shi_map)、町域(cho_map)と異なる別のフィールドごとにHashMapを用意します。(*3)では文字列データをテキストファイルに書き込むために利用します。

(*4)ではHashMapを利用して実際に文字列をIDに変換する処理を次述します。クロージャ(無名関数)を使って定義しました。

(*5)ではCSVの必要なフィールドを取り出し、(*6)で文字列を数値に変換します。(*7)では変換した数値を符号なし64ビット整数(u64)にパックします。そのためにビットシフト演算(<<)と、ビットOR(|)を利用します。ここが今回のプログラムの一番のポイントとなります。

(*8)では郵便番号と文字列IDをパックしたデータをバイナリファイル「zipcode.bin」に保存します。そして、(*9)ではテキストファイルに文字列データを保存します。

CSVを変換するプログラムを実行しよう

そして、プログラムを実行するには下記のコマンドをターミナルで実行しましょう。すると郵便番号データの「zipcode.bin」、と3つのテキストファイルが生成されます。

cargo run

バイナリデータを読んで郵便番号データを検索するプログラム

続いて、バイナリデータを読み込んで郵便番号データを検索するプログラムを作ってみましょう。ターミナルから次のコマンドを実行してプロジェクトを作成しましょう。

プログラムを実行するには下記のコマンドをターミナルで実行しましょう。

mkdir zipfind
cd zipfind
cargo init

そして、郵便番号から住所を検索するプログラムを作りましょう。「zipfind/src/main.rs」を開いて、以下のプログラムを記述しましょう。こちらからも確認できます。

use std::io::Read;
use std::fs::File;
use std::io::{self, BufRead};
use std::path::Path;
// データファイルの宣言 --- (*1)
const FILE_CODE: &str = "zipcode.bin"; // 郵便番号情報のデータ
const KEN_TXT: &str = "ken.txt"; // 都道府県
const SHI_TXT: &str = "shi.txt"; // 市区町村
const CHO_TXT: &str = "cho.txt"; // 町域
fn main() {
    // コマンドライン引数を得る --- (*2)
    let args: Vec<String> = std::env::args().collect();
    // 引数が1つでない場合はエラーを表示して終了
    if args.len() != 2 {
        eprintln!("[使い方] {} (郵便番号)", args[0]);
        std::process::exit(1);
    }
    let zipcode = &args[1].replace("-", "");
    let zipcode: u32 = zipcode.parse().unwrap_or_else(|_| {
        eprintln!("[エラー] 郵便番号は7桁の数字で指定してください");
        std::process::exit(1);
    });
    find_address(zipcode);
}
// 郵便番号を検索して都道府県、市区町村、町域を表示する --- (*3)
fn find_address(zipcode: u32) {
    // 郵便番号ファイルを開く
    let file = std::fs::File::open(FILE_CODE).unwrap();
    // 64ビットずつファイルを読んで、対象となる郵便番号を探す --- (*4)
    let mut reader = std::io::BufReader::new(file);
    let mut buf = [0u8; 8];
    let mut found = false;
    while let Ok(b) = reader.read(&mut buf) {
        if b == 0 { break; }
        // バッファの中に郵便番号があるか調べる
        let packed = u64::from_be_bytes(buf);
        // 各フィールドをビットシフトして取り出す --- (*5)
        let code = ((packed >> 40) & 0xFFFFFF) as u32; // 24ビット
        let ken_id = ((packed >> 34) & 0x3F) as u8;  // 6ビット
        let shi_id = ((packed >> 23) & 0x7FF) as u16; // 11ビット
        let cho_id = ((packed >> 6) & 0x1FFFF) as u32; // 17ビット
        let _unused = (packed & 0x3F) as u8;          // 6ビット
        if zipcode == code {
            // 郵便番号が見つかったので都道府県、市区町村、町域を表示
            let s = show_address(ken_id, shi_id, cho_id);
            println!("〒{} → {}", zipcode, s);
            found = true;
        }
    }
    if !found { eprintln!("[エラー] 郵便番号が見つかりません"); }
}

// 都道府県、市区町村、町域のIDから文字列を表示する --- (*6)
fn show_address(ken_id: u8, shi_id: u16, cho_id: u32) -> String {
    // ファイル名を指定して指定した行の文字列を得る
    let get_data = |filename: &str, target_id: u32| -> String {
        // ファイルを開く --- (*7)
        let path = Path::new(filename);
        let file = File::open(&path).expect("ファイルを開けませんでした");
        // 行をバッファで読み取る --- (*8)
        let reader = io::BufReader::new(file);
        for (index, line) in reader.lines().enumerate() {
            let line = line.expect("行を読み取れませんでした");
            if index as u32 == target_id { // 指定行ならその行を返す --- (*9)
                return line;
            }
        }
        String::from("---") // 指定したtarget_idが見つからなかった時
    };
    // 実際の文字列を得る
    let ken = get_data(KEN_TXT, ken_id as u32);
    let shi = get_data(SHI_TXT, shi_id as u32);
    let cho = get_data(CHO_TXT, cho_id as u32);
    format!("{} {} {}", ken, shi, cho)
}

プログラムを確認しましょう。(*1)ではプログラム内で利用する各種ファイル名を指定します。(*2)では、コマンドライン引数を取得して、ユーザーが指定した郵便番号を取得します。

(*3)では郵便番号データを検索する関数find_addressを定義します。(*4)ではバイナリファイルを開いて64ビットずつ読み込んで、対象となる郵便番号を検索します。(*5)では64ビットのデータから、各種情報を取り出します。前のプログラムでビットシフト演算子を使ってパックしたのと逆の操作を行います。

(*6)では指定したIDから文字列を取り出す操作を行います。ここではテキストファイルを一行ずつ読み込み、ID行目を取り出すだけです。(*7)でテキストファイルを開き(*8)で一行ずつ読みます。(*9)では指定行目かどうかを判定します。

郵便番号検索を実行しよう

そして、zipfindディレクトリに、先ほどのプログラムで作成した4つのファイル(「zipcode.bin」と「ken.txt」「shi.txt」「cho.txt」)をコピーしましょう。

そして、次のようにして、プログラムを実行します。以下は郵便番号「105-0011」を調べる場合の実行例です。

cargo run 105-0011

すると「〒1050011 → 東京都 港区 芝公園」のように結果が表示されます。

この記事は
Members+会員の方のみ御覧いただけます

ログイン/無料会員登録

会員サービスの詳細はこちら