## 経緯
[[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,
```