EMoshU Blog
2021/08/27
2021-08-30
Masaki

Go言語でサクッとCUIツールを作ってみる

Go言語の勉強も兼ねて、最小限のコードで並列処理やAPIの実行、JSONの取り扱い等を実際に実装して、簡単なCUIツールを作ってみました。

はじめに

こんにちは。

エンジニア・マネージャーのまさきです。

 

弊社では、自社開発でGo言語を使用して(いこうとして)います。

 

社内にはまだ、Go言語に精通したメンバーがいないということもあり、キャッチアップするところから始めています。

そもそもGo言語とは?何ができるのか?というところから調べ始めたのですが、色々と情報収集してみたところ、実に幅広い用途で利用できそうな言語であることが分かって、とても興味が湧いている今日この頃です。

 

そんなこんなで、Go言語のキャッチアップを進めているのですが、何か新しいことを学習したりする際は、ただ漠然と学習を進めてもあまり理解を深められないような気がするので、目的(ゴール)を決めて、その目的を達成するために必要な知識や方法を身に着けていく、というやり方の方がきちんと理解を深められるのかな、と思っています。

 

個人的には、操作の自動化や運用負担軽減のためのツール作成が好きだったりするので、ちょっとしたCUIツールを作れたらいいな、と思いました。

では、どんなツールを作ってみようか、と考えていたら、1つ思い浮かびました。

 

弊社は、ドキュメント管理にAtlassian社のConfluenceを利用しています。

社内ルールからランチのお店候補のメモまで、ありとあらゆる情報をConfluenceにまとめています。

その中でも今回は、朝会の資料に着目してみました。

 

弊社では、毎日朝会を実施しており、朝会ではその日予定しているタスクや相談事項の共有などを、アジェンダに沿って実施しています。

この朝会アジェンダの作成が、手動で行う運用になっています。

 

具体的には、毎日以下のような流れでアジェンダ作成を行っています。

  1. 毎朝朝会後に、アジェンダをコピーして翌日分のアジェンダを作成する
  2. 翌日の朝会までに、各々がアジェンダを更新する

上記のうち、1の操作を自動化できたら楽になるのでは、と思い、それを実現するツールを試しにGo言語で作ってみようと思い、実際にやってみました。

 

どうやって実現するか

実現方法は、Atlassian社が提供しているConfluence APIというREST APIがあるようなので、このAPIをGo側から叩いてごにょごにょして実現、という方針です。

 

Confluence APIのドキュメントを見てみたかぎりでは、ページをコピーするAPIは存在しないようなので、以下のような流れで実装を行います。

  1. APIトークンの設定を行う
  2. コピー対象のページ情報を取得し、本文などの必要な情報を抽出する
  3. 新規作成するページの情報を設定して、新規ページを作成する

 

では、順番に見ていきましょう。

 

 

1. APIトークンの設定を行う

まずは、APIを実行できるようにするために、Confluence側の設定で、APIトークンの払い出しを行います。

この作業は、公式ドキュメントの内容に沿って機械的に行う感じなので、詳細は割愛します。

 

 

2. コピー対象のページ情報を取得し、本文などの必要な情報を抽出する

ここからが本題です。

 

最初のステップとして、コピー元となるページの情報を取得します。

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実行をgoroutine化して別スレッドで実行する
  • 並列処理はするが、それぞれの処理が完了するまで待機する(WaitGroupを使用)
  • 各スレッドでの実行結果(API実行結果)は、チャネルを使用して取得する

 

コードとしては、以下のようになります。

  • main関数から抜粋
// ユーザー名と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()

 

  • API実行処理
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,
   }
}

 

 

3. 新規作成するページの情報を設定して、新規ページを作成する

新たに作成するページのタイトルや本文などの情報を設定したら、改めてConfluence APIを実行して、新規ページを作成します。

この時、親ページを指定すると、指定した親ページの子ページとして作成されます。

コードとしては、以下のようになります。

  • main関数から抜粋
// 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()

 

  • API実行処理
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で定期実行するようにしてあげれば、アジェンダの自動コピーを実現できます。

実行後に作成されるページのイメージは以下のとおりです。

img.png

image2.png

 

とりあえず動作する状態ではありますが、実際には曜日判定などのロジックを追加したり、微調整が必要な気がするので、本格運用に持っていくにはもう少しブラッシュアップする必要があります。

 

 

まとめ

必要最小限&ざっくりとした実装ですが、実際にやってみると、少ないコード量でサクッと実装できました。

(所要時間:調べる時間込みで4〜5時間程度)

もちろん、本格的に運用していくためには、色々と細かな調整が必要になってきますが、こんなに簡単にできるとは、というのが正直な感想です。

Go言語の文法等にまだ慣れない部分もあるので、これからどんどん使っていって慣れてくると、生産性が飛躍的に向上しそうな気がします。

 

以下、自身の備忘も兼ねた所感メモです。

  • Confluence APIの実行まわりの、Authorization部分で小1時間ほどはまった(Basic指定&base64エンコード対応が必要だった点)
  • エラーハンドリングまわりはまだ改良する余地がありそう
  • goroutineは便利なので、もっと理解を深めてより使いこなせるようになりたい

 

 

参考URL

 

 

さいごに

EMoshUは、一緒に自社サービスを開発し、会社と共に成長していける仲間を募集しています。

 

会社を自分の力で大きくしていきたいという、そんな志を持って一緒に戦ってくれる方、さらなるレベルアップを図りたいという方がいらっしゃいましたら、ぜひ募集要項をご確認の上、ご連絡いただければと思います。

 

 

 

 

募集要項

 

 

 

まずは話を聞いてみたいという方へ