- 投稿日:2021-02-20T23:26:49+09:00
AtCoder Beginner Contest 192のメモ
前置き
Atcoderをやってみたので、自分用のメモです。
あとから加筆・修正する予定です。問題
https://atcoder.jp/contests/abc192
A
Q_A.gopackage main import ( "fmt" ) func main() { var X int fmt.Scanf("%d", &X) amari := X % 100 ans := 100 - amari fmt.Printf("%d\n", ans) }B
Q_B.gopackage main import ( "fmt" "strings" "unicode" ) func IsFirstUpper(v string) bool { if v == "" { return false; } r := rune(v[0]) return unicode.IsUpper(r) } func main() { var S string fmt.Scanf("%s", &S) s := strings.Split(S, "") var ans bool = true for i:=0; i<len(s); i++{ check := IsFirstUpper(s[i]) if i % 2 == 0{ if check{ ans = false } } else { if !check{ ans = false } } } if ans { fmt.Printf("Yes\n") } else { fmt.Printf("No\n") } }C
Q_C.gopackage main import ( "fmt" "strconv" "strings" "sort" ) func calc (a int) int{ a_s := strconv.Itoa(a) a_s_1 := strings.Split(a_s, "") a_s_2 := strings.Split(a_s, "") sort.Strings(a_s_1) sort.Slice(a_s_2, func(i, j int) bool { return a_s_2[i] > a_s_2[j] }) a_1 := strings.Join(a_s_1, "") a_2 := strings.Join(a_s_2, "") a__1, _ := strconv.Atoi(a_1) a__2, _ := strconv.Atoi(a_2) ans := a__2 - a__1 return ans } func main() { var N, K int fmt.Scanf("%d %d", &N, &K) for i:=0; i<K ; i++{ N = calc(N) } fmt.Printf("%d\n", N) }D
覚えてたら後で書きます。
E
覚えてたら後で書きます。
F
覚えてたら後で書きます。
- 投稿日:2021-02-20T22:46:04+09:00
GitHub Actionsでスクレイピングを定期実行して結果をSlackに通知する
はじめに
GitHub Actionsで処理を自動化する話です。
Go言語でスクレイピングして結果をSlackに通知します。参考にさせていただいた記事
- Go で Slack Bot を作る (2020年3月版)
- Goとgoqueryでスクレイピング
準備
GitHubとSlackのアカウントが必要です。
Slackはフリープランでスペースを作成し、Botから通知を送るためのチャンネルを作成しておきます。GitHubにはプライベートのリポジトリを作成しておきます(DDoS攻撃などに悪用されないため非公開にする)流れ
以下の順番で作成を進めます。
- 対象ページから必要な情報を取得する処理の作成
- Slackアプリの作成とトークンの取得
- SlackにPOSTする処理の作成
- GitHub Actionsで処理を定時実行できるようにする
対象ページから必要な情報を取得する処理の作成
Go言語でスクレイピングする際の定番パッケージである(ように思われる)
PuerkitoBio/goquery
を使用します。スクレイピングするからには有用でなおかつ毎日更新される情報が欲しいので、Yahoo!ショッピングの日替わりクーポンを取得してみます。https://topics.shopping.yahoo.co.jp/campaign/cate_coupon/index.html このURLがスクレイピングの対象です。サンプル https://github.com/PuerkitoBio/goquery#examples を参考にしつつtitleタグの中身だけ取得する処理を作成します。
main.gopackage main import ( "fmt" "log" "net/http" "github.com/PuerkitoBio/goquery" ) func main() { // Request the HTML page. res, err := http.Get("https://topics.shopping.yahoo.co.jp/campaign/cate_coupon/index.html") if err != nil { log.Fatal(err) } defer res.Body.Close() if res.StatusCode != 200 { log.Fatalf("status code error: %d %s", res.StatusCode, res.Status) } // Load the HTML document doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { log.Fatal(err) } title := doc.Find("title").Text() fmt.Println(title) }ビルドして実行するとタイトルの取得結果を確認することができます。
$ go mod init $ go build $ ./goquery_test 【今日のクーポン】メガネ、くもり止めカテゴリで使える30%OFFクーポンSlackアプリの作成とトークンの取得
SlackにメッセージをPOSTする処理を作成する前にSlackアプリ(Bot)を作成し、トークンを取得しておく必要があります。
- https://api.slack.com/apps にアクセスして
Create New App
ボタンを押下
Bot Token Scopes
にchat:write
を追加(Add an OAuth Scopeをクリックしてリストから選択)
左カラムのSettings配下にある
Install App
をクリック7.ワークスペースにアプリのインストールが完了するとトークン(OAuth Access Token)が表示されるのでメモしておく。
8.メッセージを受信したいチャンネルに作成したアプリを追加しておく。ショートカットを開いて
invite
と入力、表示されたリストからこのチャンネルにアプリを追加する
を選択する。
SlackにPOSTする処理の作成
続いてSlackにPOSTする処理を作成します。Qiitaの多くの記事にも書かれている
slack-go/slack
パッケージを使います。examples以下を探したところ単純にメッセージを送信するサンプルを見つけることができました。https://github.com/slack-go/slack/tree/master/examples/messages こちらをベースに作成します。2箇所書き換えが必要です。
YOUR_TOKEN_HERE
: メモしておいたOAuth Access Token
に書き換え
CHANNEL_ID
: メッセージを受信したいチャンネル名かチャンネルIDを指定main.gopackage main import ( "fmt" "github.com/slack-go/slack" ) func main() { // YOUR_TOKEN_HEREをメモしておいたトークンに置き換える api := slack.New("YOUR_TOKEN_HERE") attachment := slack.Attachment{ Pretext: "some pretext", Text: "some text", // Uncomment the following part to send a field too /* Fields: []slack.AttachmentField{ slack.AttachmentField{ Title: "a", Value: "no", }, }, */ } channelID, timestamp, err := api.PostMessage( "CHANNEL_ID", // メッセージを送信したいチャンネルを指定する slack.MsgOptionText("Some text", false), slack.MsgOptionAttachments(attachment), slack.MsgOptionAsUser(true), // Add this if you want that the bot would post message as a user, otherwise it will send response using the default slackbot ) if err != nil { fmt.Printf("%s\n", err) return } fmt.Printf("Message successfully sent to channel %s at %s\n", channelID, timestamp) }ビルドして実行するとSlackにメッセージが送信されます。
$ go mod init $ go build $ ./slack_bot Message successfully sent to channel XXXXXXXXXXXXX at 1613796836.000500
not_in_channel
とエラーが出力された場合にはチャンネルにアプリを追加して下さい。GitHub Actionsで処理を定時実行できるようにする
まずは動作確認用のプライベートリポジトリを作成しておく。
Hello, World
を出力するだけの処理を作りgo.mod
も含めてリポジトリに追加しておく。main.gopackage main import "fmt" func main() { fmt.Println("Hello, World!") }Actionsタブをクリックするとそのリポジトリに適したワークフローテンプレートがサジェストされます。サジェストされたGo言語用のテンプレートをベースにしつつワークフローを作成します。cronによる定期実行だけではなく手動で実行するため
workflow_dispatch
を登録しておくと確認が楽になります。schedule
イベントはcron形式で記述できますがUTC時間なので注意が必要です。
詳しくはコチラ https://docs.github.com/ja/actions/reference/events-that-trigger-workflows作成したワークフローファイルは、リポジトリの
.github/workflows
ディレクトリに登録します。ワークフローを手動実行するか設定した時刻になれば結果を確認できるはずです。ワークフローの手動実行方法 https://docs.github.com/ja/actions/managing-workflow-runs/manually-running-a-workflowcron.ymlname: Go on: workflow_dispatch: schedule: - cron: '5 15 * * *' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.15 - name: Build run: | mkdir /home/runner/work/dist go build -v -o /home/runner/work/dist/myapp ./... - name: RunMyApp run: /home/runner/work/dist/myappまとめ+α
仕上げとしてスクレイピング+Slack送信の処理を作成します。さらにトークンはシークレットから取得するようにしておきます。シークレットはリポジトリの
Settings
タブのsecrets
から登録する。下に掲載したサンプル(main.go)ではトークンをSLACK_TOKEN
にしています。main.gopackage main import ( "fmt" "log" "net/http" "os" "github.com/PuerkitoBio/goquery" "github.com/slack-go/slack" ) func mustGetenv(k string) string { v := os.Getenv(k) if v == "" { log.Panic("env not set.") } return v } var token string func init() { token = mustGetenv("SLACK_TOKEN") } func main() { // Request the HTML page. res, err := http.Get("https://topics.shopping.yahoo.co.jp/campaign/cate_coupon/index.html") if err != nil { log.Fatal(err) } defer res.Body.Close() if res.StatusCode != 200 { log.Fatalf("status code error: %d %s", res.StatusCode, res.Status) } // Load the HTML document doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { log.Fatal(err) } title := doc.Find("title").Text() api := slack.New(token) attachment := slack.Attachment{ Text: "https://topics.shopping.yahoo.co.jp/campaign/cate_coupon/", } channelID, timestamp, err := api.PostMessage( "CHANNEL_ID", slack.MsgOptionText(title, false), slack.MsgOptionAttachments(attachment), slack.MsgOptionAsUser(true), ) if err != nil { log.Fatal(err) } fmt.Printf("Message successfully sent to channel %s at %s\n", channelID, timestamp) }ワークフローにもシークレットを取得してセットする処理を追加しておきます。(追加した前後だけ抜粋)
cron.ymlname: Go env: SLACK_TOKEN: ${{secrets.SLACK_TOKEN}} on: workflow_dispatch: schedule: - cron: '5 15 * * *'処理や設定に問題がなければ以下のように毎日通知が届くようになります。
最後に
GitHub Actionsで何か自動化してみたかったのでスクレイピングしてSlackに通知をしてみましたが、スクレイピングする場合にはルールとマナーを守りましょう。
- 投稿日:2021-02-20T18:02:55+09:00
Goのencoding/json.Decoderが構造体フィールドにjson:"-"タグがついていたら無視する処理を探してみた
コンテキスト
- Goの構造体のフィールドには"tag_name:tag_value"というようにタグをつけることができる。
- jsonのエンコードやデコードで使われるjsonタグというものがある。
- json.Marshalで構造体->jsonのエンコードする場合に、構造体のフィールドのjsonタグに紐づいた文字列がjsonにおける文字列となる。
- jsonタグに"-"という値を紐付けると、jsonには出力されないフィールドとなる。
type Struct struct { Name string `json:"name"` Age int Credential string `json:"-"` }上の構造体は以下のようなjsonに変換されてエンコードされる。
{"name": "Tomori Yu", "Age": 21}疑問
- エンコードに関する挙動は以上に見た通りだが、デコードの挙動はどうなっているのだろう。
試してみる
package main import ( "bytes" "encoding/json" "fmt" ) type person struct { Name string `json:"name"` Age int Credential string `json:"-"` } func main() { p1 := person{"yu", 21, "AKIi9854u4t8394j8gf"} bs1, _ := json.Marshal(p1) fmt.Printf("marshaled person: %v\n", string(bs1)) // -> marshaled person: {"name":"yu","Age":21} var p2 person _ = json.Unmarshal(bs, &p2) fmt.Printf("unmarshaled person: %v\n", p2) // -> unmarshaled person: {yu 21 } bs2 := []byte(`{"name": "yu", "Age": 21, "Credential": "AKIi9854u4t8394j8gf"}`) decoder := json.NewDecoder(bytes.NewBuffer(bs2)) var p3 person _ = decoder.Decode(&p3) fmt.Printf("decoded person: %v\n", p3) // -> decoded person: {yu 21 } }
- json.MarshalではAgeがアウトプットに残るが、json.Unmarshal&json.DecodeではAgeが残らない。
- 構造体->jsonの場合と同じように、json->構造体のデコードにおいても構造体のフィールドにjson:"-"というタグが付いていれば無視される。
json.Decoderに関して構造体のフィールドにjson:"-"というタグが付いていたら無視する処理を探してみた
encoding/json.Decoder
decoder := json.NewDecoder(buffer)json.NewDecoderはDecoder構造体を返す。
type Decoder struct { r io.Reader buf []byte d decodeState scanp int // start of unread data in buf scanned int64 // amount of data already scanned scan scanner err error tokenState int tokenStack []int }Decoder構造体のポインタ型に対するDecodeメソッドは引数として渡した変数にデコードした結果を返す。
Decoder.Decodefunc (dec *Decoder) Decode(v interface{}) error { if dec.err != nil { return dec.err } if err := dec.tokenPrepareForDecode(); err != nil { return err } if !dec.tokenValueAllowed() { return &SyntaxError{msg: "not at beginning of value", Offset: dec.InputOffset()} } // Read whole value into buffer. n, err := dec.readValue() if err != nil { return err } dec.d.init(dec.buf[dec.scanp : dec.scanp+n]) dec.scanp += n // Don't save err from unmarshal into dec.err: // the connection is still usable since we read a complete JSON // object from it before the error happened. err = dec.d.unmarshal(v) // fixup token streaming state dec.tokenValueEnd() return err }dec.dは、Decoder構造体のフィールドであり、decodeState構造体のインスタンスである。
dec.d.initでdecodeState.dataにまだ読まれてない[]byteを初期化している。
そして、err = dec.d.unmarshal(v)に、デコードの処理がありそうだ。
decodeStatetype decodeState struct { data []byte off int // next read offset in data opcode int // last read result scan scanner errorContext struct { // provides context for type errors Struct reflect.Type FieldStack []string } savedError error useNumber bool disallowUnknownFields bool }func (d *decodeState) unmarshal(v interface{}) error { rv := reflect.ValueOf(v) if rv.Kind() != reflect.Ptr || rv.IsNil() { return &InvalidUnmarshalError{reflect.TypeOf(v)} } d.scan.reset() d.scanWhile(scanSkipSpace) // We decode rv not rv.Elem because the Unmarshaler interface // test must be applied at the top level of the value. err := d.value(rv) if err != nil { return d.addErrorContext(err) } return d.savedError }d.scanは、decodeState構造体のscanフィールドであり、scanner構造体のフィールドである。
scannerはjsonをパースする処理におけるステートマシンとして利用される。
d.scan.reset()でdecodeStateのフィールドであるscannerインスタンスが初期化される。
d.value(rv)でデコード処理が行われていそうだ。
scanner// A scanner is a JSON scanning state machine. // Callers call scan.reset and then pass bytes in one at a time // by calling scan.step(&scan, c) for each byte. // The return value, referred to as an opcode, tells the // caller about significant parsing events like beginning // and ending literals, objects, and arrays, so that the // caller can follow along if it wishes. // The return value scanEnd indicates that a single top-level // JSON value has been completed, *before* the byte that // just got passed in. (The indication must be delayed in order // to recognize the end of numbers: is 123 a whole value or // the beginning of 12345e+6?). type scanner struct { // The step is a func to be called to execute the next transition. // Also tried using an integer constant and a single func // with a switch, but using the func directly was 10% faster // on a 64-bit Mac Mini, and it's nicer to read. step func(*scanner, byte) int // Reached end of top-level value. endTop bool // Stack of what we're in the middle of - array values, object keys, object values. parseState []int // Error that happened, if any. err error // total bytes consumed, updated by decoder.Decode (and deliberately // not set to zero by scan.reset) bytes int64 }// value consumes a JSON value from d.data[d.off-1:], decoding into v, and // reads the following byte ahead. If v is invalid, the value is discarded. // The first byte of the value has been read already. func (d *decodeState) value(v reflect.Value) error { switch d.opcode { default: panic(phasePanicMsg) case scanBeginArray: if v.IsValid() { if err := d.array(v); err != nil { return err } } else { d.skip() } d.scanNext() case scanBeginObject: if v.IsValid() { if err := d.object(v); err != nil { return err } } else { d.skip() } d.scanNext() case scanBeginLiteral: // All bytes inside literal return scanContinue op code. start := d.readIndex() d.rescanLiteral() if v.IsValid() { if err := d.literalStore(d.data[start:d.readIndex()], v, false); err != nil { return err } } } return nil }v.IsValid()はvがゼロ値でなければtrueを返します。
構造体を引数に渡した場合は、d.object(v)が呼ばれるっぽいです。decodeState.object()を読むと、switch文で引数のunderlyingな型がStructな時の処理が書かれてあります。
case reflect.Struct: fields = cachedTypeFields(t) // okfieldsはstructFields構造体のインスタンスです。
structFieldstype structFields struct { list []field nameIndex map[string]int }cachedTypeFields関数は、typeFields関数にキャッシュ機能を足した薄いラッパーとなっています。
typeFieldsは以下のようになっています。
https://github.com/golang/go/blob/master/src/encoding/json/encode.go#L1209-L1391// typeFields returns a list of fields that JSON should recognize for the given type. // The algorithm is breadth-first search over the set of structs to include - the top struct // and then any reachable anonymous structs. func typeFields(t reflect.Type) structFields { // Anonymous fields to explore at the current level and the next. current := []field{} next := []field{{typ: t}} [省略] tag := sf.Tag.Get("json") if tag == "-" { continue } [省略]ありました。sfは構造体のフィールドです。
forの中でcontinueされなかったフィールドはforブロックの後の処理でfields = append(fields, field)という感じでフィールドとして登録されます。
構造体のフィールドのうちjsonタグに"-"が付いているものはcontinueされているので、無視することになっているみたいです。先ほど見たdecodeState.object()ではfields = cachedTypeFields(t)という感じで構造体の有効なフィールドだけが処理されることになります。
その後にfields変数に関して、以下の処理が書かれています。
https://github.com/golang/go/blob/master/src/encoding/json/decode.go#L696-L709if i, ok := fields.nameIndex[string(key)]; ok { // Found an exact name match. f = &fields.list[i] } else { // Fall back to the expensive case-insensitive // linear search. for i := range fields.list { ff := &fields.list[i] if ff.equalFold(ff.nameBytes, key) { f = ff break } } }jsonのkeyにマッチする有効なStructのフィールドが見つかればfという変数に入れられて後の処理に続くようです。
まとめ
- json.Decodeもjson:"-"タグのついた構造体のフィールドは無視する。
- 投稿日:2021-02-20T17:05:48+09:00
Go言語とClean ArchitectureでAPIサーバを構築する
Clean Architectureとは
Clean Architecture(クリーンアーキテクチャ)とは,レイヤに内側と外側の関係性を持たせるアーキテクチャである.
「外側のレイヤは内側のレイヤだけに依存する」というルールを守ることによって,アプリケーションから技術を分離することが目的である.アプリケーションから技術を分離すると何が嬉しいのか
ここでの「技術」とは,HTTPやcsv,MySQLなどのことを意味している.
アプリケーションから技術を分離すると,技術を容易に変更できたり,テストコードを書くときに容易にモックできたりする.
例えば,出力をHTTPからcsvに変更したくなったときなどに容易に変更が可能である.各レイヤの責務
Clean Architectureで提案されているレイヤ構造は以下の画像のようなものである.
内側から,Entitiesレイヤ,Use Casesレイヤ,Interface Adaptersレイヤ,Frameworkd & Driversレイヤの4つのレイヤから構成される.
「外側のレイヤは内側のレイヤだけに依存する」というルールが存在し,例えば,Use CasesレイヤがExternal Interfacesレイヤに依存するようなことがあってはならない.
また,技術に依存しているコードを置いていいのはInterface Adaptersレイヤ,Frameworkd & Driversレイヤの外側2層だけで,Entitiesレイヤ,Use Casesレイヤには技術に依存したコードをおいてはならない.各レイヤの責務を大まかに説明すると次のようなものである.
Entitiesレイヤ
ドメインロジックを実装する責務を持つ.
DB操作などの技術的な実装を持ってはならない.
また,他のどのレイヤにも依存してはならない.Use Casesレイヤ
Entitiesレイヤのオブジェクトを操作してビジネスロジックを実行する責務を持つ.
さらに,このレイヤにはポートを定義する.ここで,ポートとは,アダプターで実装を差し替えることができる対象のことである.
Go言語の場合,ポートはInterfaceにあたる.
InputPort・OutputPortはそれぞれ,入力・出力に関するポートである.Interface Adaptersレイヤ
Use Casesレイヤで定義したポートに対する実装を提供する.すなわち,InterfaceのMethodを定義する(実態を作ると考えるとよい).
それゆえ,このレイヤでDB操作やHTTP入出力などの技術的な実装を定義する.
Controllersは入力に関するアダプター,Presentersは出力に関するアダプター,Gatewaysは永続化に関するアダプターである.Frameworks & Driversレイヤ
DBのconnection生成やroutingなどの技術的な実装をおく.
Golangを用いてAPIサーバを構築する.
ここで作成するAPIはPathParameterからuserIDを受け取り,そのuserIDをもつuserの名前をDBから取得し,出力するものである.
GET /user/:id input: userID string output: userName string以下で出てくるコードは全て サンプルコード においてある.
package構成
大まかなpackage構成が以下である.
. ├── adapter │ ├── controller │ │ └── user.go │ ├── gateway │ │ └── user.go │ └── presenter │ └── user.go ├── driver │ └── user.go ├── entity │ └── user.go └── usecase ├── interactor │ └── user.go └── port └── user.go
package同士の関係
package同士の関係は以下の画像のとおりである.ただし,重要な部分だけを抜き出している.
実線は依存(使用していると読み替えても良い),点線は実装(Interfaceを満たすようにMethodを定義)を表している.
大まかな流れとしては,以下のようなものである.
driver
からadapter/controller
を呼び出すadapter/controller
は,ポートを全て組み立てて,usecase/port/inputPort
を実行するusecase/port/inputPort
はusecase/interactor
が実装しているので,usecase/interactor
のMethodが呼ばれるusecase/interactor
では,entity
のドメインロジックを実行する.usecase/interactor
では,usecase/port/userRepository
を呼び出し,DBの永続化処理を行う(usecase/port/userRepository
はadapter/gateway
が実装しているので,adapter/gatewayのMethod
が呼ばれる)usecase/interactor
では,usecase/port/outputPort
を呼び出し,出力を行う(usecase/port/outputPort
はadapter/presenter
が実装しているので,adapter/presenter
のMethodが呼ばれる)通常のMVCなどでは,
controller
が入力を受け取り,model
を呼び出しドメインロジックを実行し,controller
が出力を行うが,Clean Architectureでは,入力はinputPort
,出力はoutputPort
が担当していることに注意する.それゆえ,adapter/controller
では,単にusecase/port/inputPort
を実行するだけで,返り値を受け取ったり出力を行ったりする必要はない.サンプルコード
次にサンプルコードを読みながらClean Architectureの流れを理解する.
1.
driver
からadapter/controller
を呼び出すdriver/user.gopackage driver import ( "database/sql" "fmt" "log" "net/http" "os" "github.com/ari1021/clean-architecture/adapter/controller" "github.com/ari1021/clean-architecture/adapter/gateway" "github.com/ari1021/clean-architecture/adapter/presenter" "github.com/ari1021/clean-architecture/usecase/interactor" // blank import for MySQL driver _ "github.com/go-sql-driver/mysql" ) // Serve はserverを起動させます. func Serve(addr string) { dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DATABASE")) conn, err := sql.Open("mysql", dsn) if err != nil { log.Println(err) return } user := controller.User{ OutputFactory: presenter.NewUserOutputPort, InputFactory: interactor.NewUserInputPort, RepoFactory: gateway.NewUserRepository, Conn: conn, } http.HandleFunc("/user/", user.GetUserByID) err = http.ListenAndServe(addr, nil) if err != nil { log.Fatalf("Listen and serve failed. %+v", err) } }
driver/user.go
ではDBのconnectionを生成し,routingの設定を行なっている.
adapter/controller/user.go
で定義されているcontroller.User
を作成し,http.HandleFunc()
にcontroller.User.GetUserByID
を渡している
2.adapter/controller
は,ポートを全て組み立てて,usecase/port/inputPort
を実行するadapter/controller/user.gopackage controller import ( "database/sql" "net/http" "strings" "github.com/ari1021/clean-architecture/usecase/port" ) type User struct { OutputFactory func(w http.ResponseWriter) port.UserOutputPort // -> presenter.NewUserOutputPort InputFactory func(o port.UserOutputPort, u port.UserRepository) port.UserInputPort // -> interactor.NewUserInputPort RepoFactory func(c *sql.DB) port.UserRepository // -> gateway.NewUserRepository Conn *sql.DB } // GetUserByID は,httpを受け取り,portを組み立てて,inputPort.GetUserByIDを呼び出します. func (u *User) GetUserByID(w http.ResponseWriter, r *http.Request) { userID := strings.TrimPrefix(r.URL.Path, "/user/") outputPort := u.OutputFactory(w) repository := u.RepoFactory(u.Conn) inputPort := u.InputFactory(outputPort, repository) inputPort.GetUserByID(userID) }1で呼ばれた
controller.User.GetUserByID
内では,入力を受け取りPathParmeterを取得した後,全てのポート(UserInputPort
,UserOutputPort
,UserRepository
)を組み立てて,inputPort.GetUserByID
を呼び出す.
3.usecase/port/inputPort
はusecase/interactor
が実装しているので,usecase/interactor
のMethodが呼ばれるusecase/port/user.gopackage port import "github.com/ari1021/clean-architecture/entity" type UserInputPort interface { GetUserByID(userID string) } type UserOutputPort interface { Render(*entity.User) RenderError(error) } // userのCRUDに対するDB用のポート type UserRepository interface { GetUserByID(userID string) (*entity.User, error) }
usecase/port/user.go
に定義されているUserInputPort
はInterfaceなので,このInterfaceを実装しているコードが呼ばれることになる.
UserInputPort
は,usecase/interactor/user.go
で実装されている.usecase/interactor/user.gopackage interactor import ( "github.com/ari1021/clean-architecture/usecase/port" ) type User struct { OutputPort port.UserOutputPort UserRepo port.UserRepository } // NewUserInputPort はUserInputPortを取得します. func NewUserInputPort(outputPort port.UserOutputPort, userRepository port.UserRepository) port.UserInputPort { return &User{ OutputPort: outputPort, UserRepo: userRepository, } } // usecase.UserInputPortを実装している // GetUserByID は,UserRepo.GetUserByIDを呼び出し,その結果をOutputPort.Render or OutputPort.RenderErrorに渡します. func (u *User) GetUserByID(userID string) { user, err := u.UserRepo.GetUserByID(userID) if err != nil { u.OutputPort.RenderError(err) return } u.OutputPort.Render(user) }
usecase/interactor/user.go
に定義されているInputPort
の実装(GetUserByID
)内では,まずport.UserRepository.GetUserByID
が呼ばれている(5).
その後,err
の有無により,port.UserOutputPort.Render
かport.UserOutputPort.RenderError
が呼ばれている(6).
4.usecase/interactor
では,entity
のドメインロジックを実行する.
今回は,単純にDBから取得したデータを出力しているのでこの部分の実装はない.
5.usecase/interactor
では,usecase/port/userRepository
を呼び出し,DBの永続化処理を行う(usecase/port/userRepository
はadapter/gateway
が実装しているので,adapter/gatewayのMethod
が呼ばれる)adapter/gateway/user.gopackage gateway import ( "database/sql" "errors" "fmt" "log" "github.com/ari1021/clean-architecture/entity" "github.com/ari1021/clean-architecture/usecase/port" ) type UserRepository struct { conn *sql.DB } // NewUserRepository はUserRepositoryを返します. func NewUserRepository(conn *sql.DB) port.UserRepository { return &UserRepository{ conn: conn, } } // GetUserByID はDBからデータを取得します. func (u *UserRepository) GetUserByID(userID string) (*entity.User, error) { conn := u.GetDBConn() row := conn.QueryRow("SELECT * FROM `user` WHERE id=?", userID) user := entity.User{} err := row.Scan(&user.ID, &user.Name) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("User Not Found. UserID = %s", userID) } log.Println(err) return nil, errors.New("Internal Server Error. adapter/gateway/GetUserByID") } return &user, nil } // GetDBConn はconnectionを取得します. func (u *UserRepository) GetDBConn() *sql.DB { return u.conn }3の
usecase/interactor/user.go
で呼び出されたUserRepository.GetUserByID
はadapter/gateway/user.go
で実装されている.
adapter/gateway/user.go
では,DB操作を実装している.
6.usecase/interactor
では,usecase/port/outputPort
を呼び出し,出力を行う(usecase/port/outputPort
はadapter/presenter
が実装しているので,adapter/presenter
のMethodが呼ばれる)adapter/presenter/user.gopackage presenter import ( "fmt" "net/http" "github.com/ari1021/clean-architecture/entity" "github.com/ari1021/clean-architecture/usecase/port" ) type User struct { w http.ResponseWriter } // NewUserOutputPort はUserOutputPortを取得します. func NewUserOutputPort(w http.ResponseWriter) port.UserOutputPort { return &User{ w: w, } } // usecase.UserOutputPortを実装している // Render はNameを出力します. func (u *User) Render(user *entity.User) { u.w.WriteHeader(http.StatusOK) // httpでentity.User.Nameを出力 fmt.Fprint(u.w, user.Name) } // RenderError はErrorを出力します. func (u *User) RenderError(err error) { u.w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(u.w, err) }3の
usecase/interactor/user.go
で呼び出されたUserOutputPort.Render
(RenderError
)はadapter/presenter/user.go
で実装されている.
adapter/presenter/user.go
では,Headerを付与して出力を行っている.アダプターの差し替え
上のサンプルコードのように,技術的な実装は全てInterface AdaptersレイヤとFrameworkd & Driversレイヤで行っている.それよりも内側の層は,技術が何を使われているかを知ることがない.
したがって,HTTP出力ではなく,ファイル出力に変えたければ,adapter/presenter/user.go
のRender
とRenderError
を変えるだけで良い.まとめ
Clean Architectureでは,アプリケーションから技術を分離することが重要である.
その観点では,Hexagonal Architecture(ヘキサゴナルアーキテクチャ)でもClean Architectureと同様に,アプリケーションから技術を分離することができる.
それらの違いはレイヤ構造の細分化の程度であり,Clean Architectureの方がHexagonal Architectureよりも細分化されている.
ただし,Hexagonal Architectureを実際に用いることを考えると,レイヤをさらに細かく分割すると思われるので,結局,Clean Architectureに類似していくと考えられる.また,ここで紹介したパッケージ構成はあくまでも一例である.
例えば,今回はUserRepository
をusecase/port
内に配置したが,UserRepository
をentity
に置くという選択肢などもある.
(Clean Architecture で実装するときに知っておきたかったこと)参考文献
この記事は以下の情報を参考にして執筆しました.
・pospomeのサーバサイドアーキテクチャ(PDF版)
・Clean Architecture で実装するときに知っておきたかったこと
・The Clean Architecture
- 投稿日:2021-02-20T17:05:48+09:00
Go言語でのClean Architectureを理解する
Clean Architectureとは
Clean Architecture(クリーンアーキテクチャ)とは,レイヤに内側と外側の関係性を持たせるアーキテクチャである.
「外側のレイヤは内側のレイヤだけに依存する」というルールを守ることによって,アプリケーションから技術を分離することが目的である.アプリケーションから技術を分離すると何が嬉しいのか
ここでの「技術」とは,HTTPやcsv,MySQLなどのことを意味している.
アプリケーションから技術を分離すると,技術を容易に変更できたり,テストコードを書くときに容易にモックできたりする.
例えば,出力をHTTPからcsvに変更したくなったときなどに容易に変更が可能である.各レイヤの責務
Clean Architectureで提案されているレイヤ構造は以下の画像のようなものである.
内側から,Entitiesレイヤ,Use Casesレイヤ,Interface Adaptersレイヤ,Frameworkd & Driversレイヤの4つのレイヤから構成される.
「外側のレイヤは内側のレイヤだけに依存する」というルールが存在し,例えば,Use CasesレイヤがExternal Interfacesレイヤに依存するようなことがあってはならない.
また,技術に依存しているコードを置いていいのはInterface Adaptersレイヤ,Frameworkd & Driversレイヤの外側2層だけで,Entitiesレイヤ,Use Casesレイヤには技術に依存したコードをおいてはならない.各レイヤの責務を大まかに説明すると次のようなものである.
Entitiesレイヤ
ドメインロジックを実装する責務を持つ.
DB操作などの技術的な実装を持ってはならない.
また,他のどのレイヤにも依存してはならない.Use Casesレイヤ
Entitiesレイヤのオブジェクトを操作してビジネスロジックを実行する責務を持つ.
さらに,このレイヤにはポートを定義する.ここで,ポートとは,アダプターで実装を差し替えることができる対象のことである.
Golangの場合,ポートはInterfaceにあたる.
InputPort・OutputPortはそれぞれ,入力・出力に関するポートである.Interface Adaptersレイヤ
Use Casesレイヤで定義したポートに対する実装を提供する.すなわち,InterfaceのMethodを定義する(実態を作ると考えるとよい).
それゆえ,このレイヤでDB操作やHTTP入出力などの技術的な実装を定義する.
Controllersは入力に関するアダプター,Presentersは出力に関するアダプター,Gatewaysは永続化に関するアダプターである.Frameworks & Driversレイヤ
DBのconnection生成やroutingなどの技術的な実装をおく.
Golangを用いてAPIサーバを構築する.
ここで作成するAPIはPathParameterからuserIDを受け取り,そのuserIDをもつuserの名前をDBから取得し,出力するものである.
GET /user/:id input: userID string output: userName string以下で出てくるコードは全て サンプルコード においてある.
package構成
大まかなpackage構成が以下である.
. ├── adapter │ ├── controller │ │ └── user.go │ ├── gateway │ │ └── user.go │ └── presenter │ └── user.go ├── driver │ └── user.go ├── entity │ └── user.go └── usecase ├── interactor │ └── user.go └── port └── user.go
package同士の関係
package同士の関係は以下の画像のとおりである.ただし,重要な部分だけを抜き出している.
実線は依存(使用していると読み替えても良い),点線は実装(Interfaceを満たすようにMethodを定義)を表している.
大まかな流れとしては,以下のようなものである.
driver
からadapter/controller
を呼び出すadapter/controller
は,ポートを全て組み立てて,usecase/port/inputPort
を実行するusecase/port/inputPort
はusecase/interactor
が実装しているので,usecase/interactor
のMethodが呼ばれるusecase/interactor
では,entity
のドメインロジックを実行する.usecase/interactor
では,usecase/port/userRepository
を呼び出し,DBの永続化処理を行う(usecase/port/userRepository
はadapter/gateway
が実装しているので,adapter/gatewayのMethod
が呼ばれる)usecase/interactor
では,usecase/port/outputPort
を呼び出し,出力を行う(usecase/port/outputPort
はadapter/presenter
が実装しているので,adapter/presenter
のMethodが呼ばれる)通常のMVCなどでは,
controller
が入力を受け取り,model
を呼び出しドメインロジックを実行し,controller
が出力を行うが,Clean Architectureでは,入力はinputPort
,出力はoutputPort
が担当していることに注意する.それゆえ,adapter/controller
では,単にusecase/port/inputPort
を実行するだけで,返り値を受け取ったり出力を行ったりする必要はない.サンプルコード
次にサンプルコードを読みながらClean Architectureの流れを理解する.
1.
driver
からadapter/controller
を呼び出すdriver/user.gopackage driver import ( "database/sql" "fmt" "log" "net/http" "os" "github.com/ari1021/clean-architecture/adapter/controller" "github.com/ari1021/clean-architecture/adapter/gateway" "github.com/ari1021/clean-architecture/adapter/presenter" "github.com/ari1021/clean-architecture/usecase/interactor" // blank import for MySQL driver _ "github.com/go-sql-driver/mysql" ) // Serve はserverを起動させます. func Serve(addr string) { dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DATABASE")) conn, err := sql.Open("mysql", dsn) if err != nil { log.Println(err) return } user := controller.User{ OutputFactory: presenter.NewUserOutputPort, InputFactory: interactor.NewUserInputPort, RepoFactory: gateway.NewUserRepository, Conn: conn, } http.HandleFunc("/user/", user.GetUserByID) err = http.ListenAndServe(addr, nil) if err != nil { log.Fatalf("Listen and serve failed. %+v", err) } }
driver/user.go
ではDBのconnectionを生成し,routingの設定を行なっている.
adapter/controller/user.go
で定義されているcontroller.User
を作成し,http.HandleFunc()
にcontroller.User.GetUserByID
を渡している
2.adapter/controller
は,ポートを全て組み立てて,usecase/port/inputPort
を実行するadapter/controller/user.gopackage controller import ( "database/sql" "net/http" "strings" "github.com/ari1021/clean-architecture/usecase/port" ) type User struct { OutputFactory func(w http.ResponseWriter) port.UserOutputPort // -> presenter.NewUserOutputPort InputFactory func(o port.UserOutputPort, u port.UserRepository) port.UserInputPort // -> interactor.NewUserInputPort RepoFactory func(c *sql.DB) port.UserRepository // -> gateway.NewUserRepository Conn *sql.DB } // GetUserByID は,httpを受け取り,portを組み立てて,inputPort.GetUserByIDを呼び出します. func (u *User) GetUserByID(w http.ResponseWriter, r *http.Request) { userID := strings.TrimPrefix(r.URL.Path, "/user/") outputPort := u.OutputFactory(w) repository := u.RepoFactory(u.Conn) inputPort := u.InputFactory(outputPort, repository) inputPort.GetUserByID(userID) }1で呼ばれた
controller.User.GetUserByID
内では,入力を受け取りPathParmeterを取得した後,全てのポート(UserInputPort
,UserOutputPort
,UserRepository
)を組み立てて,inputPort.GetUserByID
を呼び出す.
3.usecase/port/inputPort
はusecase/interactor
が実装しているので,usecase/interactor
のMethodが呼ばれるusecase/port/user.gopackage port import "github.com/ari1021/clean-architecture/entity" type UserInputPort interface { GetUserByID(userID string) } type UserOutputPort interface { Render(*entity.User) RenderError(error) } // userのCRUDに対するDB用のポート type UserRepository interface { GetUserByID(userID string) (*entity.User, error) }
usecase/port/user.go
に定義されているUserInputPort
はInterfaceなので,このInterfaceを実装しているコードが呼ばれることになる.
UserInputPort
は,usecase/interactor/user.go
で実装されている.usecase/interactor/user.gopackage interactor import ( "github.com/ari1021/clean-architecture/usecase/port" ) type User struct { OutputPort port.UserOutputPort UserRepo port.UserRepository } // NewUserInputPort はUserInputPortを取得します. func NewUserInputPort(outputPort port.UserOutputPort, userRepository port.UserRepository) port.UserInputPort { return &User{ OutputPort: outputPort, UserRepo: userRepository, } } // usecase.UserInputPortを実装している // GetUserByID は,UserRepo.GetUserByIDを呼び出し,その結果をOutputPort.Render or OutputPort.RenderErrorに渡します. func (u *User) GetUserByID(userID string) { user, err := u.UserRepo.GetUserByID(userID) if err != nil { u.OutputPort.RenderError(err) return } u.OutputPort.Render(user) }
usecase/interactor/user.go
に定義されているInputPort
の実装(GetUserByID
)内では,まずport.UserRepository.GetUserByID
が呼ばれている(5).
その後,err
の有無により,port.UserOutputPort.Render
かport.UserOutputPort.RenderError
が呼ばれている(6).
4.usecase/interactor
では,entity
のドメインロジックを実行する.
今回は,単純にDBから取得したデータを出力しているのでこの部分の実装はない.
5.usecase/interactor
では,usecase/port/userRepository
を呼び出し,DBの永続化処理を行う(usecase/port/userRepository
はadapter/gateway
が実装しているので,adapter/gatewayのMethod
が呼ばれる)adapter/gateway/user.gopackage gateway import ( "database/sql" "errors" "fmt" "log" "github.com/ari1021/clean-architecture/entity" "github.com/ari1021/clean-architecture/usecase/port" ) type UserRepository struct { conn *sql.DB } // NewUserRepository はUserRepositoryを返します. func NewUserRepository(conn *sql.DB) port.UserRepository { return &UserRepository{ conn: conn, } } // GetUserByID はDBからデータを取得します. func (u *UserRepository) GetUserByID(userID string) (*entity.User, error) { conn := u.GetDBConn() row := conn.QueryRow("SELECT * FROM `user` WHERE id=?", userID) user := entity.User{} err := row.Scan(&user.ID, &user.Name) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("User Not Found. UserID = %s", userID) } log.Println(err) return nil, errors.New("Internal Server Error. adapter/gateway/GetUserByID") } return &user, nil } // GetDBConn はconnectionを取得します. func (u *UserRepository) GetDBConn() *sql.DB { return u.conn }3の
usecase/interactor/user.go
で呼び出されたUserRepository.GetUserByID
はadapter/gateway/user.go
で実装されている.
adapter/gateway/user.go
では,DB操作を実装している.
6.usecase/interactor
では,usecase/port/outputPort
を呼び出し,出力を行う(usecase/port/outputPort
はadapter/presenter
が実装しているので,adapter/presenter
のMethodが呼ばれる)adapter/presenter/user.gopackage presenter import ( "fmt" "net/http" "github.com/ari1021/clean-architecture/entity" "github.com/ari1021/clean-architecture/usecase/port" ) type User struct { w http.ResponseWriter } // NewUserOutputPort はUserOutputPortを取得します. func NewUserOutputPort(w http.ResponseWriter) port.UserOutputPort { return &User{ w: w, } } // usecase.UserOutputPortを実装している // Render はNameを出力します. func (u *User) Render(user *entity.User) { u.w.WriteHeader(http.StatusOK) // httpでentity.User.Nameを出力 fmt.Fprint(u.w, user.Name) } // RenderError はErrorを出力します. func (u *User) RenderError(err error) { u.w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(u.w, err) }3の
usecase/interactor/user.go
で呼び出されたUserOutputPort.Render
(RenderError
)はadapter/presenter/user.go
で実装されている.
adapter/presenter/user.go
では,Headerを付与して出力を行っている.アダプターの差し替え
上のサンプルコードのように,技術的な実装は全てInterface AdaptersレイヤとFrameworkd & Driversレイヤで行っている.それよりも内側の層は,技術が何を使われているかを知ることがない.
したがって,HTTP出力ではなく,ファイル出力に変えたければ,adapter/presenter/user.go
のRender
とRenderError
を変えるだけで良い.まとめ
Clean Architectureでは,アプリケーションから技術を分離することが重要である.
その観点では,Hexagonal Architecture(ヘキサゴナルアーキテクチャ)でもClean Architectureと同様に,アプリケーションから技術を分離することができる.
それらの違いはレイヤ構造の細分化の程度であり,Clean Architectureの方がHexagonal Architectureよりも細分化されている.
ただし,Hexagonal Architectureを実際に用いることを考えると,レイヤをさらに細かく分割すると思われるので,結局,Clean Architectureに類似していくと考えられる.また,ここで紹介したパッケージ構成はあくまでも一例である.
例えば,今回はUserRepository
をusecase/port
内に配置したが,UserRepository
をentity
に置くという選択肢などもある.
(Clean Architecture で実装するときに知っておきたかったこと)参考文献
この記事は以下の情報を参考にして執筆しました.
・pospomeのサーバサイドアーキテクチャ(PDF版)
・Clean Architecture で実装するときに知っておきたかったこと
・The Clean Architecture
- 投稿日:2021-02-20T12:13:44+09:00
[Golang] 文字列を固定幅(固定長)で改行する。全角文字列の WordWrap
UTF-8 マルチバイト文字列を全角 n 文字(半角 n*2 文字)の固定長で改行させたい。
対象文字列あいうえおかきくけこさしすせそたちつてと 123456789012345678901234567890↓
n=6で改行あいうえおか きくけこさし すせそたちつ てと 123456789012 345678901234 567890等幅フォントでない限り、
全角 1 文字 == 半角 2 文字
の幅にならないのは当然として、それでも CLI アプリやコマンドのヘルプ表示など、なるべく等幅(1 行が固定長)になるように折り返したいのです。しかし「"golang" 文字列 固定幅 改行」でググっても、なかなかヒットしなかったり、ルーンをゴニョゴニョしろとは言うもの具体的なものがなかったので、自分のググラビリティとして。
TL; DR (今北産業)
- @mattn さんの
github.com/mattn/go-runewidth
パッケージ(runewidth
)を使う。 [ awesome-go パッケージ ]runewidth.Wrap(s string, w int) string
メソッドで文字列s
をw
文字幅で改行した文字列が取得できる。- 全角 3 文字の場合は
result := runewidth.Wrap(myString, 3 * 2)
と 2 倍にする。TS; DR
sample.gopackage main import ( "fmt" "strings" "github.com/mattn/go-runewidth" ) // WrapZenkakuString は文字列を固定幅で折り返します。幅の指定(width)は全角文字数を指定します。 // 半角文字は、その倍の文字数で折り返されます。主にヘルプ表示などに使われ、フォントによっては必ずし // も固定幅になるとは限りません。 func WrapZenkakuString(input string, width int) string { var strTmp string strTmp = input strTmp = strings.Replace(strTmp, "\n", "", -1) strTmp = strings.Replace(strTmp, "\r", "", -1) // Windows strTmp = strings.Replace(strTmp, "。", "。\n", -1) return runewidth.Wrap(strTmp, width*2) } func main() { var msgHelp string = ` これはサンプルのヘルプです。 アイウエオかきくけこサシスセソたちつ てとナニヌネコ。` fmt.Println(WrapZenkakuString(msgHelp, 6)) }実行結果$ go run . これはサンプ ルのヘルプで す。 アイウエオか きくけこサシ スセソたちつ てとナニヌネ コ。
- オンラインで動作をみる @ Go Playground
- オンラインでテストをみる @ Go Playground
参考文献
- Text Processing | Awesome-go(golangのライブラリリスト)のスター数まとめ-全部編(2018/10/9版) @ Qiita
- awesome-go @ GitHub
- Goでマルチバイトが混在した文字列を文字幅でtruncateする @ Qiita
- コメント | Golang で指定した幅で文字列を丸める方法 @ Qiita
関連文献
github.com/mattn/go-runewidth
- リポジトリ: https://github.com/mattn/go-runewidth @ GitHub
- API ドキュメント: https://pkg.go.dev/github.com/mattn/go-runewidth @ pkg.go.dev
- ライセンス: MIT
- 投稿日:2021-02-20T12:13:44+09:00
【Golang】文字列を固定幅(固定長)で改行する。全角文字列の WordWrap
UTF-8 マルチバイト文字列を全角 n 文字(半角 n*2 文字)の固定長で改行させたい。
対象文字列あいうえおかきくけこさしすせそたちつてと 123456789012345678901234567890↓
n=6で改行あいうえおか きくけこさし すせそたちつ てと 123456789012 345678901234 567890等幅フォントでない限り、
全角 1 文字 == 半角 2 文字
の文字幅にならないのは当然として、それでも CLI アプリやコマンドのヘルプ表示など、なるべく等幅(1 行が固定長)になるように折り返したいのです。しかし「"golang" 文字列 固定幅 改行」でググっても、なかなかヒットしなかったり、ルーンをゴニョゴニョしろとは言うもの具体的なものがなかったので、自分のググラビリティとして。
TL; DR (今北産業)
github.com/mattn/go-runewidth
パッケージのrunewidth
を使う。go-runewidth
は @mattn さんによる awesome-go なパッケージです。runewidth.Wrap(s string, w int) string
メソッドで文字列s
をw
文字幅で改行した文字列が取得できる。- 全角 3 文字の場合は
result := runewidth.Wrap(myString, 3 * 2)
と 2 倍にする。TS; DR
sample.gopackage main import ( "fmt" "strings" "github.com/mattn/go-runewidth" ) // WrapZenkakuString は文字列を固定幅で折り返します。幅の指定(width)は全角文字数を指定します。 // 半角文字は、その倍の文字数で折り返されます。主にヘルプ表示などに使われ、フォントによっては必ずし // も固定幅になるとは限りません。 func WrapZenkakuString(input string, width int) string { var strTmp string strTmp = input strTmp = strings.Replace(strTmp, "\n", "", -1) strTmp = strings.Replace(strTmp, "\r", "", -1) // Windows strTmp = strings.Replace(strTmp, "。", "。\n", -1) return runewidth.Wrap(strTmp, width*2) } func main() { var msgHelp string = ` これはサンプルのヘルプです。 アイウエオかきくけこサシスセソたちつ てとナニヌネコ。` fmt.Println(WrapZenkakuString(msgHelp, 6)) }実行結果$ go run . これはサンプ ルのヘルプで す。 アイウエオか きくけこサシ スセソたちつ てとナニヌネ コ。
- オンラインで動作をみる @ Go Playground
- オンラインでテストをみる @ Go Playground
参考文献
- Text Processing | Awesome-go(golangのライブラリリスト)のスター数まとめ-全部編(2018/10/9版) @ Qiita
- awesome-go @ GitHub
- Goでマルチバイトが混在した文字列を文字幅でtruncateする @ Qiita
- コメント | Golang で指定した幅で文字列を丸める方法 @ Qiita
関連文献
github.com/mattn/go-runewidth
- リポジトリ: https://github.com/mattn/go-runewidth @ GitHub
- API ドキュメント: https://pkg.go.dev/github.com/mattn/go-runewidth @ pkg.go.dev
- ライセンス: MIT