Go言語の勉強も兼ねて、最小限のコードで並列処理やAPIの実行、JSONの取り扱い等を実際に実装して、簡単なCUIツールを作ってみました。
Go言語の勉強も兼ねて、最小限のコードで並列処理やAPIの実行、JSONの取り扱い等を実際に実装して、簡単なCUIツールを作ってみました。
こんにちは。
エンジニア・マネージャーのまさきです。
弊社では、自社開発でGo言語を使用して(いこうとして)います。
社内にはまだ、Go言語に精通したメンバーがいないということもあり、キャッチアップするところから始めています。
そもそもGo言語とは?何ができるのか?というところから調べ始めたのですが、色々と情報収集してみたところ、実に幅広い用途で利用できそうな言語であることが分かって、とても興味が湧いている今日この頃です。
そんなこんなで、Go言語のキャッチアップを進めているのですが、何か新しいことを学習したりする際は、ただ漠然と学習を進めてもあまり理解を深められないような気がするので、目的(ゴール)を決めて、その目的を達成するために必要な知識や方法を身に着けていく、というやり方の方がきちんと理解を深められるのかな、と思っています。
個人的には、操作の自動化や運用負担軽減のためのツール作成が好きだったりするので、ちょっとしたCUIツールを作れたらいいな、と思いました。
では、どんなツールを作ってみようか、と考えていたら、1つ思い浮かびました。
弊社は、ドキュメント管理にAtlassian社のConfluenceを利用しています。
社内ルールからランチのお店候補のメモまで、ありとあらゆる情報をConfluenceにまとめています。
その中でも今回は、朝会の資料に着目してみました。
弊社では、毎日朝会を実施しており、朝会ではその日予定しているタスクや相談事項の共有などを、アジェンダに沿って実施しています。
この朝会アジェンダの作成が、手動で行う運用になっています。
具体的には、毎日以下のような流れでアジェンダ作成を行っています。
上記のうち、1の操作を自動化できたら楽になるのでは、と思い、それを実現するツールを試しにGo言語で作ってみようと思い、実際にやってみました。
実現方法は、Atlassian社が提供しているConfluence APIというREST APIがあるようなので、このAPIをGo側から叩いてごにょごにょして実現、という方針です。
Confluence APIのドキュメントを見てみたかぎりでは、ページをコピーするAPIは存在しないようなので、以下のような流れで実装を行います。
では、順番に見ていきましょう。
まずは、APIを実行できるようにするために、Confluence側の設定で、APIトークンの払い出しを行います。
この作業は、公式ドキュメントの内容に沿って機械的に行う感じなので、詳細は割愛します。
ここからが本題です。
最初のステップとして、コピー元となるページの情報を取得します。
Confluence APIのURLやConfluenceのユーザー名、APIトークン等の情報を定義して、APIを実行します。
APIの実行結果(レスポンス)はJSON形式で返ってくるので、JSON形式に沿った構造体を定義して、レスポンスを構造体に変換します。
Go言語でJSONを扱う方法を調べていて、構造体の定義を書くのが面倒だなと思ったのですが、json-to-goというサービスを使用すると、JSONデータから構造体の定義を一瞬で自動的に生成できるようなので、便利ですね。
今回は、以下のような構造体を使用します。
定義名称 | 用途 |
---|---|
ApiResult |
APIの実行結果を格納(チャネルを使用) |
ConfluencePage |
コピー元ページ(サマリー)情報のJSONマッピング用 |
ConfluencePageDetail |
コピー元ページ(本文含む詳細)情報のJSONマッピング用 |
ConfluenceNewPage |
新規作成ページ情報のJSONマッピング用 |
具体的な定義情報としては、以下のようになります。
※ApiResult以外は、json-to-goを使用して自動生成しています。
type ApiResult struct {
body []byte
err error
}
type ConfluencePage struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Title string `json:"title"`
Space struct {
ID int `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Expandable struct {
Settings string `json:"settings"`
Metadata string `json:"metadata"`
Operations string `json:"operations"`
LookAndFeel string `json:"lookAndFeel"`
Identifiers string `json:"identifiers"`
Permissions string `json:"permissions"`
Icon string `json:"icon"`
Description string `json:"description"`
Theme string `json:"theme"`
History string `json:"history"`
Homepage string `json:"homepage"`
} `json:"_expandable"`
Links struct {
Webui string `json:"webui"`
Self string `json:"self"`
} `json:"_links"`
} `json:"space"`
History struct {
Latest bool `json:"latest"`
CreatedBy struct {
Type string `json:"type"`
AccountID string `json:"accountId"`
AccountType string `json:"accountType"`
Email string `json:"email"`
PublicName string `json:"publicName"`
ProfilePicture struct {
Path string `json:"path"`
Width int `json:"width"`
Height int `json:"height"`
IsDefault bool `json:"isDefault"`
} `json:"profilePicture"`
DisplayName string `json:"displayName"`
IsExternalCollaborator bool `json:"isExternalCollaborator"`
Expandable struct {
Operations string `json:"operations"`
PersonalSpace string `json:"personalSpace"`
} `json:"_expandable"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
} `json:"createdBy"`
CreatedDate time.Time `json:"createdDate"`
Expandable struct {
LastUpdated string `json:"lastUpdated"`
PreviousVersion string `json:"previousVersion"`
Contributors string `json:"contributors"`
NextVersion string `json:"nextVersion"`
} `json:"_expandable"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
} `json:"history"`
Version struct {
By struct {
Type string `json:"type"`
AccountID string `json:"accountId"`
AccountType string `json:"accountType"`
Email string `json:"email"`
PublicName string `json:"publicName"`
ProfilePicture struct {
Path string `json:"path"`
Width int `json:"width"`
Height int `json:"height"`
IsDefault bool `json:"isDefault"`
} `json:"profilePicture"`
DisplayName string `json:"displayName"`
IsExternalCollaborator bool `json:"isExternalCollaborator"`
Expandable struct {
Operations string `json:"operations"`
PersonalSpace string `json:"personalSpace"`
} `json:"_expandable"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
} `json:"by"`
When time.Time `json:"when"`
FriendlyWhen string `json:"friendlyWhen"`
Message string `json:"message"`
Number int `json:"number"`
MinorEdit bool `json:"minorEdit"`
SyncRev string `json:"syncRev"`
SyncRevSource string `json:"syncRevSource"`
ConfRev string `json:"confRev"`
ContentTypeModified bool `json:"contentTypeModified"`
Expandable struct {
Collaborators string `json:"collaborators"`
Content string `json:"content"`
} `json:"_expandable"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
} `json:"version"`
MacroRenderedOutput struct {
} `json:"macroRenderedOutput"`
Extensions struct {
Position int `json:"position"`
} `json:"extensions"`
Expandable struct {
ChildTypes string `json:"childTypes"`
Container string `json:"container"`
Metadata string `json:"metadata"`
Operations string `json:"operations"`
SchedulePublishDate string `json:"schedulePublishDate"`
Children string `json:"children"`
Restrictions string `json:"restrictions"`
Ancestors string `json:"ancestors"`
Body string `json:"body"`
Descendants string `json:"descendants"`
} `json:"_expandable"`
Links struct {
Editui string `json:"editui"`
Webui string `json:"webui"`
Context string `json:"context"`
Self string `json:"self"`
Tinyui string `json:"tinyui"`
Collection string `json:"collection"`
Base string `json:"base"`
} `json:"_links"`
}
type ConfluencePageDetail struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Title string `json:"title"`
MacroRenderedOutput struct {
} `json:"macroRenderedOutput"`
Body struct {
Storage struct {
Value string `json:"value"`
Representation string `json:"representation"`
EmbeddedContent []interface{} `json:"embeddedContent"`
Expandable struct {
Content string `json:"content"`
} `json:"_expandable"`
} `json:"storage"`
Expandable struct {
Editor string `json:"editor"`
AtlasDocFormat string `json:"atlas_doc_format"`
View string `json:"view"`
ExportView string `json:"export_view"`
StyledView string `json:"styled_view"`
Dynamic string `json:"dynamic"`
Editor2 string `json:"editor2"`
AnonymousExportView string `json:"anonymous_export_view"`
} `json:"_expandable"`
} `json:"body"`
Extensions struct {
Position int `json:"position"`
} `json:"extensions"`
Expandable struct {
ChildTypes string `json:"childTypes"`
Container string `json:"container"`
Metadata string `json:"metadata"`
Operations string `json:"operations"`
SchedulePublishDate string `json:"schedulePublishDate"`
Children string `json:"children"`
Restrictions string `json:"restrictions"`
History string `json:"history"`
Ancestors string `json:"ancestors"`
Version string `json:"version"`
Descendants string `json:"descendants"`
Space string `json:"space"`
} `json:"_expandable"`
Links struct {
Editui string `json:"editui"`
Webui string `json:"webui"`
Context string `json:"context"`
Self string `json:"self"`
Tinyui string `json:"tinyui"`
Collection string `json:"collection"`
Base string `json:"base"`
} `json:"_links"`
}
type ConfluenceNewPage struct {
Title string `json:"title"`
Type string `json:"type"`
Space struct {
Key string `json:"key"`
} `json:"space"`
Status string `json:"status"`
Ancestors []struct {
ID string `json:"id"`
} `json:"ancestors"`
Body struct {
Storage struct {
Value string `json:"value"`
Representation string `json:"representation"`
} `json:"storage"`
} `json:"body"`
}
ページ情報が取得できたら、取得したページ情報から、本文などの必要な情報を抽出します。
ちなみに、Confluence APIを使ってページ情報を取得する場合、最小限のパラメータで実行すると、ページ本文の情報がレスポンスに含まれない仕様のようです。
本文を取得するには、?expand=body.storage
というパラメータを追加して実行することで、本文の情報がレスポンスに含まれて返ってきます。
ただ、本文ありの方だと、新規ページを作成する際に必要な情報が一部足りないので、APIを2回実行して、ページ情報のサマリーと詳細をそれぞれ取得するような実装にします。
APIを2回実行する部分は、順次処理で1つずつ実行すればいいのですが、せっかくGo言語を使うので、APIを実行する部分をgoroutine化して、並列処理で実行するようにしてみます。
処理のポイントは、以下のとおりです。
コードとしては、以下のようになります。
// ユーザー名とAPIトークンを定義
userName := "username@example.com"
apiToken := "xxxxxxxxxxxxxxxx"
// baser64エンコード
encodedToken := base64.StdEncoding.EncodeToString([]byte(userName + ":" + apiToken))
// 接続先のConfluence情報を定義
pageId := "12345678"
baseUrl := "https://example.atlassian.net/wiki/rest/api/content"
url := baseUrl + "/" + pageId
authHeaderName := "Authorization"
authHeaderValue := "Basic " + encodedToken
var (
apiResult ApiResult
err error
ch = make(chan ApiResult)
wg sync.WaitGroup
)
wg.Add(2)
// Confluence APIからページ情報(サマリ)を取得する
confluencePage := new(ConfluencePage)
go getConfluencePageInfo(url, authHeaderName, authHeaderValue, ch, &wg)
apiResult = <-ch
if apiResult.err != nil {
fmt.Println(apiResult.err)
return
}
err = json.Unmarshal(apiResult.body, &confluencePage)
if err != nil {
fmt.Println(err)
return
}
// Confluence API からページ情報(詳細)を取得する
params := "?expand=body.storage"
detailUrl := url + params
confluencePageDetail := new(ConfluencePageDetail)
go getConfluencePageInfo(detailUrl, authHeaderName, authHeaderValue, ch, &wg)
apiResult = <-ch
if apiResult.err != nil {
fmt.Println(apiResult.err)
return
}
err = json.Unmarshal(apiResult.body, &confluencePageDetail)
if err != nil {
fmt.Println(err)
}
wg.Wait()
func getConfluencePageInfo(url string, authHeaderName string, authHeaderValue string, ch chan ApiResult, wg *sync.WaitGroup) {
defer wg.Done()
// リクエスト生成
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Add(authHeaderName, authHeaderValue)
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
client := new(http.Client)
res, err := client.Do(req)
if err != nil {
ch <- ApiResult{
body: nil,
err: err,
}
return
}
defer res.Body.Close()
// 取得したConfluenceページのJSON情報を解析
body, err := ioutil.ReadAll(res.Body)
if err != nil {
ch <- ApiResult{
body: body,
err: err,
}
return
}
if res.StatusCode != 200 {
ch <- ApiResult{
body: nil,
err: errors.New(res.Status),
}
return
}
ch <- ApiResult{
body: body,
err: nil,
}
}
新たに作成するページのタイトルや本文などの情報を設定したら、改めてConfluence APIを実行して、新規ページを作成します。
この時、親ページを指定すると、指定した親ページの子ページとして作成されます。
コードとしては、以下のようになります。
// Confluenceの新規ページ作成
parentPageId := "87654321"
ancestors := struct {
ID string `json:"id"`
}{
ID: parentPageId,
}
url = baseUrl
time := time.Now()
format := "20060102"
confluenceNewPage := new(ConfluenceNewPage)
confluenceNewPage.Title = "朝会_" + time.Format(format)
confluenceNewPage.Type = "page"
confluenceNewPage.Space.Key = confluencePage.Space.Key
confluenceNewPage.Status = "current"
confluenceNewPage.Ancestors = append(confluenceNewPage.Ancestors, ancestors)
confluenceNewPage.Body.Storage.Value = confluencePageDetail.Body.Storage.Value
confluenceNewPage.Body.Storage.Representation = "storage"
wg.Add(1)
go createNewConfluencePage(url, authHeaderName, authHeaderValue, confluenceNewPage, ch, &wg)
apiResult = <-ch
if apiResult.err != nil {
fmt.Println(apiResult.err)
return
}
err = json.Unmarshal(apiResult.body, &confluencePageDetail)
if err != nil {
log.Fatal("Error: ", err)
}
wg.Wait()
func createNewConfluencePage(url string, authHeaderName string, authHeaderValue string, confluenceNewPage *ConfluenceNewPage, ch chan ApiResult, wg *sync.WaitGroup) {
defer wg.Done()
jsonString, err := json.Marshal(confluenceNewPage)
if err != nil {
ch <- ApiResult{
body: nil,
err: err,
}
return
}
// リクエスト生成
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonString))
req.Header.Add(authHeaderName, authHeaderValue)
req.Header.Add("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
client := new(http.Client)
res, err := client.Do(req)
if err != nil {
ch <- ApiResult{
body: nil,
err: err,
}
return
}
if res.StatusCode != 200 {
ch <- ApiResult{
body: nil,
err: errors.New(res.Status),
}
return
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
ch <- ApiResult{
body: body,
err: err,
}
return
}
ch <- ApiResult{
body: body,
err: nil,
}
}
一通り処理の実装ができたら、go run等で実行して動作を確認します。
動作に特に問題ないことが確認できたら、go buildして実行ファイルを作成します。
実行ファイルをサーバーにアップして実行権限を付与して、cronで定期実行するようにしてあげれば、アジェンダの自動コピーを実現できます。
実行後に作成されるページのイメージは以下のとおりです。
とりあえず動作する状態ではありますが、実際には曜日判定などのロジックを追加したり、微調整が必要な気がするので、本格運用に持っていくにはもう少しブラッシュアップする必要があります。
必要最小限&ざっくりとした実装ですが、実際にやってみると、少ないコード量でサクッと実装できました。
(所要時間:調べる時間込みで4〜5時間程度)
もちろん、本格的に運用していくためには、色々と細かな調整が必要になってきますが、こんなに簡単にできるとは、というのが正直な感想です。
Go言語の文法等にまだ慣れない部分もあるので、これからどんどん使っていって慣れてくると、生産性が飛躍的に向上しそうな気がします。
以下、自身の備忘も兼ねた所感メモです。
EMoshUは、一緒に自社サービスを開発し、会社と共に成長していける仲間を募集しています。
会社を自分の力で大きくしていきたいという、そんな志を持って一緒に戦ってくれる方、さらなるレベルアップを図りたいという方がいらっしゃいましたら、ぜひ募集要項をご確認の上、ご連絡いただければと思います。