nao1215/fileprep(前処理ライブラリ) を開発した理由

理由は、データが汚れているからです。

私は、過去に nao1215/csv ライブラリ(パブリックアーカイブ済み) を開発していました。nao1215/csv は、struct tag でバリデーションルールを指定すると、CSV ファイルの読込み後に「どの行のどのカラムが不正値なのか」を教えてくれます。

nao1215/csv を開発した理由は、「非エンジニアが渡してくる CSV データに誤りが多すぎるので、読み込み時にバリデーションする必要がある」と思い立ったのが、キッカケです。nao1215/csv を作成してから1年以上が経ち、当時と状況が変わってきました。

  • 複数の表形式ファイルを扱う nao1215/filesql を開発
  • 機械学習を学び、前処理(データ整形)の重要性に気づく

さらに、nao1215/filesql のバリデーション機能の弱さのせいで、会社で別プロジェクトメンバが戸惑っている姿を見て、「nao1215/filesql と統合でき、かつ前処理とバリデーションができるライブラリを作ろう」と思い立ちました。

nao1215/filesql に機能追加しなかった理由は、「データ整形とバリデーションだけしたい需要もあると考えたから」および「nao1215/filesql が巨大なライブラリになることを避けたから」です。


file + preprocess = fileprep とは

nao1215/fileprep は、struct tag を指定した構造体を用いて、ファイル読み込みを行うライブラリです。ファイル読み込み時に、前処理とバリデーションを行います。

主に、以下の特徴を持ちます。

  • データ整形用の prep タグ
  • データバリデーション用の validate タグ
  • カラム名を指定する name タグ(通常、カラム名は自動推定)
  • CSV、TSV、LTSV、Parquet、Excel および gzip, bzip2, xz, zstd をサポート
  • 前処理/バリデーション時の入力は io.Reader、出力は前処理後の io.Reader、構造体スライス、前処理エラー/バリデーションエラー

io.Reader として出力することで、nao1215/filesql にそのまま受け渡せるメリットがあります。過去に作成した nao1215/csv は、読み込み結果を構造体スライスとして返していたので、nao1215/filesql にそのまま渡せませんでした。とは言え、nao1215/filesql を使わないユーザーもいる筈なので、構造体スライスを返す仕様も踏襲しました。


fileprep サンプルコード

実際にコードを見た方が理解しやすいと思われるので、以下に示します。

このサンプルコードは、前処理として、Name は前後にある空白を除去し、Email は空白除去と小文字化をしています。構造体フィールド名をスネークケースに変換したものが、カラム名となります。カラム名を明示したい場合はname タグを利用しますが、今回のサンプルでは登場しません。

package main

import (
    "fmt"
    "os"
    "strings"

    "github.com/nao1215/fileprep"
)

type User struct {
    Name  string `prep:"trim" validate:"required"`
    Email string `prep:"trim,lowercase"`
    Age   string
}

func main() {
    csvData := `name,email,age
  John Doe  ,JOHN@EXAMPLE.COM,30
Jane Smith,jane@example.com,25
`

    processor := fileprep.NewProcessor(fileprep.FileTypeCSV)
    var users []User

    // reader(返り値): 前処理後の io.Reader
    // result(返り値): 前処理とバリデーションの結果
    // users(引数): processor.Process() 後は、前処理後のデータが追加されている。
    reader, result, err := processor.Process(strings.NewReader(csvData), &users)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Printf("Processed %d rows, %d valid\n", result.RowCount, result.ValidRowCount)

    for _, user := range users {
        fmt.Printf("Name: %q, Email: %q\n", user.Name, user.Email)
    }
}

Output:

Processed 2 rows, 2 valid
Name: "John Doe", Email: "john@example.com"
Name: "Jane Smith", Email: "jane@example.com"

タグ一覧

go-playground/validatorを参考に、validate タグを設計しています。なお、バリデーションロジックは一致していません。

タグのカスタマイズ性は、意図的に持たせていません。つまり、ユーザー指定のタグ名に、ユーザー指定の関数を定義するような機能はありません。そこまで複雑な前処理とバリデーションが必要な場合、nao1215/fileprep が適していない気がします。

前処理タグ (prep)

基本的な前処理

タグ説明
trim前後の空白を削除prep:"trim"
ltrim先頭の空白を削除prep:"ltrim"
rtrim末尾の空白を削除prep:"rtrim"
lowercase小文字に変換prep:"lowercase"
uppercase大文字に変換prep:"uppercase"
default=value空の場合にデフォルト値を設定prep:"default=N/A"

文字列変換

タグ説明
replace=old:newすべての出現を置換prep:"replace=;:,"
prefix=value文字列を先頭に追加prep:"prefix=ID_"
suffix=value文字列を末尾に追加prep:"suffix=_END"
truncate=NN文字に制限prep:"truncate=100"
strip_htmlHTMLタグを削除prep:"strip_html"
strip_newline改行を削除 (LF, CRLF, CR)prep:"strip_newline"
collapse_space複数のスペースを1つにprep:"collapse_space"

文字フィルタリング

タグ説明
remove_digitsすべての数字を削除prep:"remove_digits"
remove_alphaすべてのアルファベットを削除prep:"remove_alpha"
keep_digits数字のみを保持prep:"keep_digits"
keep_alphaアルファベットのみを保持prep:"keep_alpha"
trim_set=chars指定文字を両端から削除prep:"trim_set=@#$"

パディング

タグ説明
pad_left=N:charN文字まで左にパディングprep:"pad_left=5:0"
pad_right=N:charN文字まで右にパディングprep:"pad_right=10: "

高度な前処理

タグ説明
normalize_unicodeUnicodeをNFC形式に正規化prep:"normalize_unicode"
nullify=value特定の文字列を空として扱うprep:"nullify=NULL"
coerce=type型変換 (int, float, bool)prep:"coerce=int"
fix_scheme=schemeURLスキームを追加/修正prep:"fix_scheme=https"
regex_replace=pattern:replacement正規表現による置換prep:"regex_replace=\\d+:X"

バリデーションタグ (validate)

基本的なバリデータ

タグ説明
requiredフィールドは空であってはならないvalidate:"required"
booleantrue, false, 0, または 1 である必要があるvalidate:"boolean"

文字種バリデータ

タグ説明
alphaASCIIアルファベットのみvalidate:"alpha"
alphaunicodeUnicode文字のみvalidate:"alphaunicode"
alphaspaceアルファベットまたはスペースvalidate:"alphaspace"
alphanumericASCII英数字のみvalidate:"alphanumeric"
alphanumunicodeUnicode文字または数字validate:"alphanumunicode"
numeric有効な整数validate:"numeric"
number有効な数値(整数または小数)validate:"number"
asciiASCII文字のみvalidate:"ascii"
printascii印刷可能なASCII文字(0x20-0x7E)validate:"printascii"
multibyteマルチバイト文字を含むvalidate:"multibyte"

数値比較バリデータ

タグ説明
eq=N値がNと等しいvalidate:"eq=100"
ne=N値がNと等しくないvalidate:"ne=0"
gt=N値がNより大きいvalidate:"gt=0"
gte=N値がN以上validate:"gte=1"
lt=N値がNより小さいvalidate:"lt=100"
lte=N値がN以下validate:"lte=99"
min=N値が最小Nvalidate:"min=0"
max=N値が最大Nvalidate:"max=100"
len=N正確にN文字validate:"len=10"

文字列バリデータ

タグ説明
oneof=a b c許可された値のいずれかvalidate:"oneof=active inactive"
lowercaseすべて小文字であるvalidate:"lowercase"
uppercaseすべて大文字であるvalidate:"uppercase"
eq_ignore_case=value大文字小文字を無視して等しいvalidate:"eq_ignore_case=yes"
ne_ignore_case=value大文字小文字を無視して等しくないvalidate:"ne_ignore_case=no"

文字列内容バリデータ

タグ説明
startswith=prefix指定プレフィックスで始まるvalidate:"startswith=http"
startsnotwith=prefix指定プレフィックスで始まらないvalidate:"startsnotwith=_"
endswith=suffix指定サフィックスで終わるvalidate:"endswith=.com"
endsnotwith=suffix指定サフィックスで終わらないvalidate:"endsnotwith=.tmp"
contains=substr部分文字列を含むvalidate:"contains=@"
containsany=charsいずれかの文字を含むvalidate:"containsany=abc"
containsrune=r指定ルーンを含むvalidate:"containsrune=@"
excludes=substr部分文字列を含まないvalidate:"excludes=admin"
excludesall=charsいずれの文字も含まないvalidate:"excludesall=<>"
excludesrune=r指定ルーンを含まないvalidate:"excludesrune=$"

フォーマットバリデータ

タグ説明
email有効なメールアドレスvalidate:"email"
uri有効なURIvalidate:"uri"
url有効なURLvalidate:"url"
http_url有効なHTTPまたはHTTPS URLvalidate:"http_url"
https_url有効なHTTPS URLvalidate:"https_url"
url_encodedURLエンコード文字列validate:"url_encoded"
datauri有効なデータURIvalidate:"datauri"
uuid有効なUUIDvalidate:"uuid"

ネットワークバリデータ

タグ説明
ip_addr有効なIPアドレス(v4またはv6)validate:"ip_addr"
ip4_addr有効なIPv4アドレスvalidate:"ip4_addr"
ip6_addr有効なIPv6アドレスvalidate:"ip6_addr"
cidr有効なCIDR表記validate:"cidr"
cidrv4有効なIPv4 CIDRvalidate:"cidrv4"
cidrv6有効なIPv6 CIDRvalidate:"cidrv6"
fqdn有効な完全修飾ドメイン名validate:"fqdn"
hostname有効なホスト名(RFC 952)validate:"hostname"
hostname_rfc1123有効なホスト名(RFC 1123)validate:"hostname_rfc1123"
hostname_port有効なホスト名:ポートvalidate:"hostname_port"

クロスフィールドバリデータ

タグ説明
eqfield=Field値が別のフィールドと等しいvalidate:"eqfield=Password"
nefield=Field値が別のフィールドと等しくないvalidate:"nefield=OldPassword"
gtfield=Field値が別のフィールドより大きいvalidate:"gtfield=MinPrice"
gtefield=Field値が別のフィールド以上validate:"gtefield=StartDate"
ltfield=Field値が別のフィールドより小さいvalidate:"ltfield=MaxPrice"
ltefield=Field値が別のフィールド以下validate:"ltefield=EndDate"
fieldcontains=Field別のフィールドの値を含むvalidate:"fieldcontains=Keyword"
fieldexcludes=Field別のフィールドの値を含まないvalidate:"fieldexcludes=Forbidden"

最後に:鉄は熱いうちに打つ

nao1215/fileprep の発想に辿り着いたのは、23時でした。

ワンオペが続くので、開発タイミングに迷いました。しかし、平日にゆるゆる開発すると、次第にモチベーションが失せていくので、休日での開発を決意しました。朝方までカタカタ作業していました。昼間、吐き気と疲労感に悩まされました。でも、リリースまでこじつけられて良かったです。

filesql、fileprep のような file + α 系のライブラリ、圧倒的に名前が決めやすいので、これからも増やしていく可能性があります。最近は、「ライブラリ API は可能な限りシンプルに」「標準パッケージ、標準機能に寄せる」と考えながら、設計していますが……あまりフィードバックが得られていません。今回は、struct tag という概念は分かりやすかったと思いますが、各タグの使い方は理解しづらいだろうなと推測しています。