## 経緯 [[Go]]のキャッチアップをするため以下のようなツールを作ってみる。 - [[CSV]]を入力として[[URL]]を指定する - [[URL]]にアクセスしてサイトの存在有無を返却する - 404なら存在しない - 404以外でもホストによってある程度制御する - 結果を[[CSV]]で出力する ## [[Go]]の最新化 ```console $ scoop update go $ go version go version go1.21.0 windows/amd64 ``` ## リポジトリの作成 ```console mkdir urei cd urei go mod init github.com/tadashi-aikawa/urei ``` ## コードの作成 [[IntelliJ IDEA]]の2023.02を使う。[[Go (IntelliJ)|Go]]のバージョンは`232.8660.142`。 ```console idea . ``` [[IntelliJ IDEAでGo開発環境整備]]を参考に各種設定をする。 ## Hello world `main.go` ```go package main func main() { println("Hello World!") } ``` ターミナルから実行。 ```console $ go run . Hello World! ``` [[IntelliJ IDEA]]の`func main()`左側からも実行できることを確認。 ![[Pasted image 20230811215413.png]] ## 依存関係の追加 [[Resty]]を使ったコードを書いてみる。 ```console package main import ( "fmt" "github.com/go-resty/resty/v2" ) func main() { client := resty.New() resp, _ := client.R().Get("https://github.com/go-resty/resty") fmt.Printf("Status is %d", resp.StatusCode()) } ``` そもそも[[Resty]]をダウンロードしていないので警告が出るが、`Sync dependencies of ...`を選ぶと依存関係へと追加される。 ![[Pasted image 20230811215731.png]] [[go.mod]]もこんな感じになる。(警告が出たのでnetのバージョンは変更した) ``` module github.com/tadashi-aikawa/urei go 1.21.0 require github.com/go-resty/resty/v2 v2.7.0 require golang.org/x/net v0.14.0 // indirect ``` 実行して結果を確認する。 ```console $ go run . Status is 200 ``` ## [[CSV]]を受け付ける 2つ目は404な[[URL]]。 ```csv name,url resty,https://github.com/go-resty/resty hoge,https://mimizou.mamansoft.net/hogehoge ``` [[Go言語でCSVファイルを読み込み]]ために[[csvutil]]を使う。 ```go package main import ( "fmt" "github.com/jszwec/csvutil" "log" "os" ) type Record struct { Name string `csv:"name"` Url string `csv:"url"` } func main() { bytes, err := os.ReadFile("./urls.csv") if err != nil { log.Fatal(err) } var records []Record if err := csvutil.Unmarshal(bytes, &records); err != nil { log.Fatal(err) } fmt.Printf("%#v", records) } ``` ## [[CSV]]の内容で[[URL]]の確認をする 上記の処理をマージしてリファクタリングする。 `main.go` ```go package main import ( "fmt" "github.com/go-resty/resty/v2" "github.com/jszwec/csvutil" "log" "os" ) type Record struct { Name string `csv:"name"` Url string `csv:"url"` } func loadRecords(path string) ([]Record, error) { bytes, err := os.ReadFile(path) if err != nil { return nil, err } var records []Record if err := csvutil.Unmarshal(bytes, &records); err != nil { return nil, err } return records, nil } type InspectionResult struct { No int Status int Origin Record } func inspect(records []Record) (results []InspectionResult) { client := resty.New() for i, record := range records { r, err := client.R().Get(record.Url) if err != nil { log.Print(err) continue } results = append(results, InspectionResult{No: i, Status: r.StatusCode(), Origin: record}) } return results } func main() { records, err := loadRecords("./urls.csv") if err != nil { log.Fatal(err) } fmt.Printf("%+v\n", records) results := inspect(records) fmt.Printf("%+v\n", results) } ``` 実行結果。 ```console $ go run . [{Name:resty Url:https://github.com/go-resty/resty} {Name:hoge Url:https://mimizou.mamansoft.net/hogehoge}] [{No:0 Status:200 Origin:{Name:resty Url:https://github.com/go-resty/resty}} {No:1 Status:404 Origin:{Name:hoge Url:https://mimizou.mamansoft.net/hogehoge}}] ``` ## 結果を[[CSV]]で出力する [[csvutil]]で書き込み処理を書く。書き込みと読み込み処理をそれぞれ抽象化。 ```go package main import ( "fmt" "github.com/go-resty/resty/v2" "github.com/jszwec/csvutil" "log" "os" ) type Record struct { Name string `csv:"name"` Url string `csv:"url"` } func loadCsv[T any](path string) ([]T, error) { bytes, err := os.ReadFile(path) if err != nil { return nil, err } var records []T if err := csvutil.Unmarshal(bytes, &records); err != nil { return nil, err } return records, nil } func saveCsv[T any](path string, records []T) error { bytes, err := csvutil.Marshal(records) if err != nil { return err } if err := os.WriteFile(path, bytes, 644); err != nil { return err } return nil } type InspectionResult struct { // 中略 } func inspect(records []Record) (results []InspectionResult) { // 中略 } func main() { records, err := loadCsv[Record]("./urls.csv") if err != nil { log.Fatal(err) } fmt.Printf("%+v\n", records) results := inspect(records) fmt.Printf("%+v\n", results) if err := saveCsv("./result.csv", results); err != nil { log.Fatal(err) } fmt.Println("Success!") } ``` ## [[CLI]]パラメータを受け取って処理する 今回作成するパラメータは以下2つ。 - 入力CSVのpath - 出力CSVのpath 今後追加するとしても更に2つくらい。 - 入力CSVのエンコーディング - 出力CSVのエンコーディング というわけで公式の[[flagパッケージ]]を使う。 ```go package main import ( "flag" "fmt" "github.com/go-resty/resty/v2" "github.com/jszwec/csvutil" "log" "os" ) // 中略 type Args struct { source string dst string } func parseArgs() Args { source := flag.String("source", "./input.csv", "入力CSVのパス") dst := flag.String("dst", "./output.csv", "出力CSVのパス") flag.Parse() return Args{source: *source, dst: *dst} } func main() { args := parseArgs() records, err := loadCsv[Record](args.source) if err != nil { log.Fatal(err) } fmt.Printf("%+v\n", records) results := inspect(records) fmt.Printf("%+v\n", results) if err := saveCsv(args.dst, results); err != nil { log.Fatal(err) } fmt.Println("Success!") } ``` これで[[コマンドライン引数]]が指定できる。 ```console go run . --source ./urls.csv --dst ./results.csv ``` デフォルトを`input.csv`と`output.csv`にしたので、それをそのまま使ってもよい。 ## 一旦push <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://github.githubassets.com/favicons/favicon.svg" /> <span class="link-card-v2-site-name">GitHub</span> </div> <div class="link-card-v2-title"> GitHub - tadashi-aikawa/urei at e969b29cc2440018c662aa702075d44a730e93ae </div> <div class="link-card-v2-content"> Contribute to tadashi-aikawa/urei development by creating an account on GitHub. </div> <img class="link-card-v2-image" src="https://opengraph.githubassets.com/ea4eda7e28a786a5b3e72187f28a15d9aef0c0e84867e3b91ac681524d181b7c/tadashi-aikawa/urei" /> <a href="https://github.com/tadashi-aikawa/urei/tree/e969b29cc2440018c662aa702075d44a730e93ae"></a> </div> ## [[ゴルーチン]]で[[並行]]に処理する 並行実行数を[[コマンドライン引数]]に指定する。 ```go type Args struct { source string dst string concurrency int } func parseArgs() Args { source := flag.String("source", "./input.csv", "入力CSVのパス") dst := flag.String("dst", "./output.csv", "出力CSVのパス") concurrency := flag.Int("concurrency", 1, "同時実行数") flag.Parse() return Args{source: *source, dst: *dst, concurrency: *concurrency} } ``` この値を使って並行実行できる実装に置き換える。 ```go type AsyncResult struct { Ok *InspectionResult Err error } func inspect(seq int, record Record) (*InspectionResult, error) { client := resty.New() log.Printf("request %d", seq) r, err := client.R().Get(record.Url) log.Printf("response %d", seq) if err != nil { return nil, err } return &InspectionResult{No: seq, Status: r.StatusCode(), Origin: record}, nil } func inspectRecords(records []Record, concurrency int) (results []InspectionResult) { semChan := make(chan struct{}, concurrency) asyncResultsChan := make(chan AsyncResult, len(records)) for i, record := range records { i := i record := record go func() { semChan <- struct{}{} r, err := inspect(i+1, record) asyncResultsChan <- AsyncResult{Ok: r, Err: err} <-semChan }() } for _ = range records { result := <-asyncResultsChan if result.Err != nil { log.Fatal(result.Err) } else { results = append(results, *result.Ok) } } slices.SortFunc( results, func(a, b InspectionResult) int { return cmp.Compare(a.No, b.No) }, ) return results } ``` 3並行だと以下のような感じ。 ```console $ go run . --concurrency 3 [{Name:resty Url:https://github.com/go-resty/resty} {Name:hoge Url:https://mimizou.mamansoft.net/hogehoge} {Name:minerva Url:https://minerva.mamansoft.net/Home} {Name:東京都 Url:https://www.metro.tokyo.lg.jp/}] 2023/08/19 18:00:22 request 1 2023/08/19 18:00:22 request 2 2023/08/19 18:00:22 request 4 2023/08/19 18:00:22 response 4 2023/08/19 18:00:22 request 3 2023/08/19 18:00:22 response 2 2023/08/19 18:00:22 response 1 2023/08/19 18:00:23 response 3 [{No:1 Status:200 Origin:{Name:resty Url:https://github.com/go-resty/resty}} {No:2 Status:404 Origin:{Name:hoge Url:https://mimizou.mamansoft.net/hogehoge}} {No:3 Status:200 Origin:{Name:minerva Url:https://minerva.mamansoft.net/Home}} {No:4 Status:200 Origin:{Name:東京都 Url:https://www.metro.tokyo.lg.jp/}}] Success! ``` [[📝Goでfor文を使ってゴルーチンやクロージャを呼び出すと実行時にイテレートされた変数の値が不適切になる]] 問題に少しハマったが、それ以外は順調だった。[[ゴルーチン]]周りの思い出しに少し時間がかかってしまったのは仕方ない。 ## [[パッケージ (Go)|パッケージ]]に分離する 以下のパッケージにそれぞれ分離する。 | 処理 | パッケージ | | ---------------------------- | ---------- | | [[コマンドライン引数]]の解析 | main | | CSV読み込み | file | | 検査 | inspection | | 結果の表示 | main | ```console  . ├──  app │ └──  inspection │ └──  inspection.go ├──  go.mod ├──  go.sum ├──  main.go └──  pkg └──  file └──  file.go ``` `app/inspection/inspection.go` ```go package inspection import ( "cmp" "github.com/go-resty/resty/v2" "log" "slices" ) type Seed struct { Name string `csv:"name"` Url string `csv:"url"` } type Result struct { No int `csv:"id"` Status int `csv:"status"` Origin Seed `csv:"-"` } type AsyncResult struct { Value *Result Err error } func inspect(seq int, record Seed) (*Result, error) { client := resty.New() log.Printf("request %d", seq) r, err := client.R().Get(record.Url) log.Printf("response %d", seq) if err != nil { return nil, err } return &Result{No: seq, Status: r.StatusCode(), Origin: record}, nil } func InspectRecords(records []Seed, concurrency int) (results []Result) { semChan := make(chan struct{}, concurrency) asyncResultsChan := make(chan AsyncResult, len(records)) for i, record := range records { i := i record := record go func() { semChan <- struct{}{} r, err := inspect(i+1, record) asyncResultsChan <- AsyncResult{Value: r, Err: err} <-semChan }() } for _ = range records { result := <-asyncResultsChan if result.Err != nil { log.Fatal(result.Err) } else { results = append(results, *result.Value) } } slices.SortFunc( results, func(a, b Result) int { return cmp.Compare(a.No, b.No) }, ) return results } ``` `pkg/file/file.go` ```go package file import ( "github.com/jszwec/csvutil" "os" ) func LoadCsv[T any](path string) ([]T, error) { bytes, err := os.ReadFile(path) if err != nil { return nil, err } var records []T if err := csvutil.Unmarshal(bytes, &records); err != nil { return nil, err } return records, nil } func SaveCsv[T any](path string, records []T) error { bytes, err := csvutil.Marshal(records) if err != nil { return err } if err := os.WriteFile(path, bytes, 644); err != nil { return err } return nil } ``` `main.go` ```go package main import ( "flag" "github.com/tadashi-aikawa/urei/app/inspection" "github.com/tadashi-aikawa/urei/pkg/file" "log" ) type Args struct { source string dst string concurrency int } func parseArgs() Args { source := flag.String("source", "./input.csv", "入力CSVのパス") dst := flag.String("dst", "./output.csv", "出力CSVのパス") concurrency := flag.Int("concurrency", 1, "同時実行数") flag.Parse() return Args{source: *source, dst: *dst, concurrency: *concurrency} } func main() { args := parseArgs() records, err := file.LoadCsv[inspection.Seed](args.source) if err != nil { log.Fatal(err) } results := inspection.InspectRecords(records, args.concurrency) if err := file.SaveCsv(args.dst, results); err != nil { log.Fatal(err) } } ``` 見通しがかなり良くなった。 ## ロガーを導入する 最近だと[[slog]]というロガーを使うのがモダンみたいなので入れてみる。 取り急ぎここから。 ```go func inspect(seq int, record Seed) (*Result, error) { client := resty.New() log.Printf("request %d", seq) r, err := client.R().Get(record.Url) log.Printf("response %d", seq) if err != nil { return nil, err } return &Result{No: seq, Status: r.StatusCode(), Origin: record}, nil } ``` [[slog]]は構造的な造りになっているので以下のような感じに。 ```go func inspect(seq int, record Seed) (*Result, error) { client := resty.New() slog.Info("request", "seq", seq) r, err := client.R().Get(record.Url) slog.Info("response", "seq", seq) if err != nil { return nil, err } return &Result{No: seq, Status: r.StatusCode(), Origin: record}, nil } ``` 出力は以下のようになる。ハンドラを変えれば[[JSON]]なども可能そう。 ```console 2023/08/19 18:36:01 INFO request seq=4 2023/08/19 18:36:01 INFO request seq=3 2023/08/19 18:36:01 INFO request seq=2 2023/08/19 18:36:01 INFO response seq=4 2023/08/19 18:36:01 INFO request seq=1 2023/08/19 18:36:01 INFO response seq=2 2023/08/19 18:36:02 INFO response seq=3 2023/08/19 18:36:02 INFO response seq=1 ``` ## 一旦push <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://github.githubassets.com/favicons/favicon.svg" /> <span class="link-card-v2-site-name">GitHub</span> </div> <div class="link-card-v2-title"> GitHub - tadashi-aikawa/urei at 24409fb36a826731a947865106e831f2133573c0 </div> <div class="link-card-v2-content"> Contribute to tadashi-aikawa/urei development by creating an account on GitHub. </div> <img class="link-card-v2-image" src="https://opengraph.githubassets.com/ea4eda7e28a786a5b3e72187f28a15d9aef0c0e84867e3b91ac681524d181b7c/tadashi-aikawa/urei" /> <a href="https://github.com/tadashi-aikawa/urei/tree/24409fb36a826731a947865106e831f2133573c0"></a> </div> ## 進捗状況を可視化する [[pb]]を使って進捗状況を表示する。 `slog.Info`だと進捗バーが都度表示されてしまうので`slog.Debug`に切り替える。デバッグ時は表示が乱れても問題ないため。 `inspection.go` ```go package inspection import ( "cmp" "github.com/cheggaaa/pb/v3" "github.com/go-resty/resty/v2" "log/slog" "slices" ) type Seed struct { Name string `csv:"name"` Url string `csv:"url"` } type Result struct { No int `csv:"id"` Status int `csv:"status"` Origin Seed `csv:"-"` } type AsyncResult struct { Value *Result Err error } func inspect(seq int, record Seed) (*Result, error) { client := resty.New() slog.Debug("request", "seq", seq) r, err := client.R().Get(record.Url) slog.Debug("response", "seq", seq) if err != nil { return nil, err } return &Result{No: seq, Status: r.StatusCode(), Origin: record}, nil } func InspectRecords(records []Seed, concurrency int) (results []Result) { semChan := make(chan struct{}, concurrency) asyncResultsChan := make(chan AsyncResult, len(records)) progress := pb.StartNew(len(records)) for i, record := range records { i := i record := record go func() { semChan <- struct{}{} progress.Increment() r, err := inspect(i+1, record) asyncResultsChan <- AsyncResult{Value: r, Err: err} <-semChan }() } for _ = range records { result := <-asyncResultsChan if result.Err != nil { slog.Error(result.Err.Error()) } else { results = append(results, *result.Value) } } slices.SortFunc( results, func(a, b Result) int { return cmp.Compare(a.No, b.No) }, ) return results } ``` ## テストとエラーハンドリングについて 今は一旦後回し。まずはビジネスロジックを固めることが優先。あとで必要が出てきたら追加する。 ## リリースする 一旦ローカルで[[Windows]]用のバイナリが作成できればよい。リリースというよりはビルド。 [[Taskfile.yml]]を作成する。 ```yaml version: "3" tasks: default: - task: help help: silent: true cmds: - task -l build: desc: Build cmds: - go mod tidy - go mod verify - go build ``` [[go mod tidy]]で必要な[[モジュール (Go)|モジュール]]のみをインストールし、[[go mod verify]]でそれらの正当性を確認してからビルドする。 あとは実行すればOK. [[Windows]]環境でビルドし、[[Windows]]環境で実行する前提。 ```console task build ``` > [!note] > 今後もし必要になったら、[[macOS]]版を追加したり、[[GitHub Actions]]でリリースできるようにする。 ## Push ここまでの成果。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://github.githubassets.com/favicons/favicon.svg" /> <span class="link-card-v2-site-name">GitHub</span> </div> <div class="link-card-v2-title"> GitHub - tadashi-aikawa/urei at 8308c4bea9784772d9979e91bec541fbbbe64ba3 </div> <div class="link-card-v2-content"> Contribute to tadashi-aikawa/urei development by creating an account on GitHub. </div> <img class="link-card-v2-image" src="https://opengraph.githubassets.com/ea4eda7e28a786a5b3e72187f28a15d9aef0c0e84867e3b91ac681524d181b7c/tadashi-aikawa/urei" /> <a href="https://github.com/tadashi-aikawa/urei/tree/8308c4bea9784772d9979e91bec541fbbbe64ba3"></a> </div> ## input.csv `input.csv`のコピー ```csv id,name,url,status,lastModified 1,resty,https://github.com/go-resty/resty,200, 2,hoge,https://mimizou.mamansoft.net/hogehoge,404, 3,minerva,https://minerva.mamansoft.net/Home,200, 4,東京都,https://www.metro.tokyo.lg.jp/,200,"Fri, 25 Aug 2023 05:52:02 GMT" 5,存在しないドメイン,https://tatsuwo-mimizou-seikichi.com,error, 6,サンマルクカフェ(人形町),https://www.saint-marc-hd.com/saintmarccafe/shop/684/,200, 7,カフェ・ド・クリエ 日本橋三丁目スクエア店,https://tabelog.com/tokyo/A1302/A130202/13267843/,200, 8,MAMANのITブログ,https://blog.mamansoft.net/,200, 9,MAMANのITブログ(404),https://blog.mamansoft.net/hogehoge,404, 10,存在しないドメイン,https://tatsuwo-mimizou-seikichi.com,error, ```