🐭

Docker + Golang(Gin)のMisskeyサードパーティWebアプリをAzureにデプロイするまでの流れ

2024/08/20に公開

はじめに

Misskeyのタイムライン上で,リノートされた画像付きノートかつ,未フォローのみを表示して,新たな絵師さんを見つけやすくする機能が欲しかったのですが,デフォルトでは備わっていなかったのでMisskeyサードパーティWebアプリを作成しました.今回はそのアプリを作る過程として,Docker + Golang(Gin)で作成したWebアプリをAzureにデプロイするまでの流れをまとめます.ソースコードは下記のリポジトリに置いてあります.

https://github.com/Kizitora3000/art-explorer

要旨

  • Misskeyのタイムラインから,リノートされた画像付きノートかつ未フォローのみを表示するWebアプリを開発
  • 開発にはDockerとGolang (Gin) を使用して,最後にAzureへデプロイ
  • GinでのWebサーバ構築,Dockerfileの作成,MisskeyのMiAuth認証によるログイン機能の実装,リノートされた画像付きノートのフェッチ機能の実装,そしてAzureへのデプロイを解説

対象読者

  • Docker,Goに関する基礎知識,コマンドが分かる方を対象とします.基礎的な部分の説明はスキップしつつ,ポイントとなる部分を解説していきます.
  • Windowsユーザの方(Macでもできるとは思いますが,一部操作が異なる可能性があります)
  • Docker Desktopをインストール済み

実行環境

  • Windows 11 Home
  • Docker version 27.0.3, build 7d4bcd8
  • go version go1.22.5 linux/amd64 (on WSL2)

MisskeyサードパーティWebアプリ

作成するWebアプリの概要を述べていきます

Webアプリのイメージ

アプリ自体は非常にシンプルで,一つの枠にアカウントへ飛ぶリンクとイラストが表示されます.また,ここに表示されるイラストは全て「リノートされたノート」かつ「未フォロー」のアカウントのノートとなっており,どんどんと下へ続いていきます.アプリ上から直接フォローできるようにするのが理想ですが,妥協したのでリンクを張るだけになっています.やる気がでたらフォロー機能も実装します.

アーキテクチャ

今回のアプリのアーキテクチャについて説明します.大まかにローカル上とAzure上に分かれます.はじめにGolangのDockerfileを作成して開発を進めます.つぎに,DockerfileをビルドしてDockerイメージを作成した後,それをAzure Container Registryにプッシュします.最後に,Azure App ServiceからプッシュしたDockerイメージを選択してデプロイすることでブラウザ上から利用できるようにします.

実装

実装は下記の順番で進めていきます.

  1. Webサーバ:Ginを利用します
  2. インフラ構築:Dockerfileを作成します
  3. ログイン機能:MisskeyのMiAuth認証を利用してアクセストークンを取得します
  4. フェッチ機能:リノートされた画像付きノートかつ未フォローのみを抽出します
  5. デプロイ:Azure container registryおよびAzure app serviceを利用してブラウザから閲覧できるようにします

Webサーバ

WebサーバはGinと呼ばれる,Golangで書かれたWebアプリケーションフレームワークを使用します.ひとまず,Docker上で動作させるための簡単なWebサーバを立てていきます.任意の場所にディレクトリを作成して,main.goを作成してください.

main.go
package main

import "github.com/gin-gonic/gin"

// JSONレスポンスを返す関数
// gin.Context: HTTPリクエスト/レスポンス を管理する構造体
func helloWorldHandler(c *gin.Context) {
	c.JSON(200, gin.H{
		"message": "Hello World",
	})
}

func main() {
	// ginのコアとなるEngineインスタンスを作成
	r := gin.Default()

	// ルートエンドポイント"/"にGETリクエストを処理するハンドラーを登録
	r.GET("/", helloWorldHandler)

	// http://localhost:8080 でサーバを立てる
	r.Run()
}

コードの詳細はコメントの通りです.これ以降,行ごとの解説はコード内のコメントに書き,関数やGinの機能などの大まかな説明はこちらに記載していきますのでよろしくお願いします.

go mod init art-explorer
go get github.com/gin-gonic/gin
go run main.go

あとは上記のコマンドを実行して http://localhost:8080 にアクセスすると下記のように「{"message":"Hello World"}」と表示されます.なお,「go mod init」の後に続くパッケージ名は任意ですが,今回はart-explorerとします.

インフラ構築

Golangのイメージに基づくDockerfileを書いていきます.Dockerfileはgo.mod, go.sum, main.goと同じディレクトリに配置します.コード内容は GolangのDockerhub の 「How to use this image」を参考に記述しました.

Dockerfile
FROM golang:1.22

# コンテナ内において以降に続くコマンドの実行場所を指定する
WORKDIR /usr/src/app
# go.mod, go.sumを/usr/src/appにコピー
COPY go.mod go.sum ./
# go.modに記載されたモジュールを全てダウンロード
RUN go mod download
# go.sumのハッシュ値とダウンロードしたモジュールのハッシュ値を検証 
RUN go mod verify
# ソースコード全体をコンテナ内にコピー
COPY . .
# 指定したパスにmain.goの実行ファイルappを生成
RUN go build -v -o /usr/local/bin/app ./main.go
# コンテナ起動時にappを実行
CMD ["/usr/local/bin/app"] 

あとは下記のコマンドを実行して http://localhost:8080 にアクセスすると先ほどと同様に「{"message":"Hello World"}」と表示されます.

docker build -t art-explorer .
docker run -d -p 8080:8080 art-explorer

ログイン機能

ログイン機能は公式ドキュメントを参考に進めていきます.以下のコードは「login」というディレクトリを作成して,「login/login.go」ファイルに書き込んでいきます.ここからコード量が増えていくため,全てトグルで畳み込んでいきます.

./login/login.go
./login/login.go
package login

import (
	"art-explorer/utils"
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"

	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
)

func LoginHandler(c *gin.Context) {
	// セッションに関する話は後述
	session := sessions.Default(c)

	// ----- MiAuth step1 -----

	// 認証先のサーバを選択する
	// デフォルトなら misskey.io サーバでアカウントを作成しているはず
	host := "misskey.io"

	// ランダムなUUIDを生成
	sessionID, err := uuid.NewRandom()
	if err != nil {
		panic(err)
	}

	// ----- MiAuth step2 -----

	// リダイレクト先のURLを設定
	// ローカル上の場合: http://localhost:8080/redirect
	// Azure上の場合: https://<azure site>/redirect
	// GetRootPath関数はのちほど実装
	redirectUri := fmt.Sprintf("%s/redirect", utils.GetRootPath(c))

	// アクセストークンが持つ権限:タイムラインの取得とフォロー状態の確認だけなので,アカウント情報を見る「read:account」のみ与える
	permission := "read:account"

	authorizationURL := fmt.Sprintf("https://%s/miauth/%s?callback=%s&permission=%s", host, sessionID, redirectUri, permission)

	// redirect先で使用するためhostを保存
	session.Set("host", host)
	session.Save()

	// HTMLテンプレートに渡す (この機能が何なのかはのちほどmain.goを加筆する際に説明します)
	c.HTML(http.StatusOK, "login.tmpl", gin.H{
		"authorization_url": authorizationURL,
	})
}

// 認証後のリダイレクト先
func RedirectHandler(c *gin.Context) {
	// ----- MiAuth step3 -----

	// LoginHandlerで作成したUUIDを取得
	// UUIDはURLのクエリパラメータで付いてくる
	redirectedSessionID := c.Query("session")

	// セッションから host を取得
	session := sessions.Default(c)
	redirectedHost := session.Get("host")

	getAccessTokenURL := fmt.Sprintf("https://%s/api/miauth/%s/check", redirectedHost, redirectedSessionID)

	// POSTリクエストを作成
	req, err := http.NewRequest("POST", getAccessTokenURL, bytes.NewBuffer([]byte("")))
	if err != nil {
		c.String(http.StatusInternalServerError, "Error creating request: %s", err)
		return
	}

	// クライアントでリクエストを実行
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		c.String(http.StatusInternalServerError, "Error making request: %s", err)
		return
	}
	defer resp.Body.Close()

	// レスポンスボディを読み取る
	responseBody, err := io.ReadAll(resp.Body)
	if err != nil {
		c.String(http.StatusInternalServerError, "Error reading response: %s", err)
		return
	}

	// レスポンスをJSONとしてパース
	var responseJson map[string]interface{}
	if err := json.Unmarshal(responseBody, &responseJson); err != nil {
		c.String(http.StatusInternalServerError, "Error unmarshaling response: %s", err)
		return
	}

	// "token" キーの値をセッションに保存
	token, ok := responseJson["token"].(string)
	if ok {
		session.Set("token", token)
		session.Save()
	}

	// インデックスページにリダイレクト
	c.Redirect(http.StatusFound, "/")
}

はじめにLoginHandler関数でUUIDの生成と認証フォームのURL + オプションを設定していきます.セッションID(UUID)はリダイレクト時も使用するためGinのセッション機能で値を保持しておきます.その後,RedirectHandler関数で先ほど使ったセッションIDを使用してPOSTすることでアクセストークンが返ってきます.POSTする際は何もデータを送らないので空データで送ります.

ルートパスを取得する関数として,新たに「utils」ディレクトリを作成してutils.goも作成します.こちらはGinの機能を利用してホスト名を取得してURLを作成するだけの関数です.

./utils/utils.go
./utils/utils.go
package utils

import (
	"fmt"

	"github.com/gin-gonic/gin"
)

func GetRootPath(c *gin.Context) string {
	scheme := "http"

	host := c.Request.Host
	return fmt.Sprintf("%s://%s", scheme, host)
}

これでログイン機能は完成です.

コラム:UUIDとは?

Universally Unique Identifier (UUID) とは,あるリソースを,同じ種類の他のすべてのリソースの中から一意に識別するために使用されるラベルです[1].Universally (普遍的に) Unique (一意な) Identifier (ID) とあるように,論理的に一意であることが約束されたわけではないけど,衝突率が非常に小さくなるよう設計されたIDです.

UUIDは用途別や脆弱性の問題などでさまざまなバージョンが存在しています[2].詳細な説明はほかのサイトに任せるとして,golangのuuidパッケージにも同様に様々なバージョンのUUIDを生成することができます.特にNewUUIDとNewRandomはそれぞれバージョン1とバージョン4のUUIDを生成します.似た名前ですが,今回はユーザの識別として使用したいため,乱数に基づいて衝突率の低いIDを生成するバージョン4を使用します.バージョン1はタイムスタンプを使用して生成するためシーケンシャルなIDを作成可能ですが,今回はWebアプリのユーザを識別することが目的なので,シーケンシャル性は不要です.


ログイン機能を実装した後は,main.goに加筆していきます.先ほど作成したLoginHandler関数とRedirectHandler関数をエンドポイントに登録して,セッション管理およびログインページへの遷移に関する処理を実装します.

main.go
main.go
package main

import (
	"art-explorer/login"
	"fmt"
	"net/http"

	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"
	"github.com/gin-gonic/gin"
)

// メインページ
// gin.Context: HTTPリクエスト/レスポンス を管理する構造体
func indexHandler(c *gin.Context) {
	session := sessions.Default(c)

	// ---------- check access token ----------
	// セッションからtokenを取得して表示
	token := session.Get("token")

	if token == nil {
		// アクセストークンが存在しない場合はログインページに遷移
		fmt.Println("No token found in session")
		c.Redirect(http.StatusFound, "/login")
		return
	}

	// HTMLテンプレートに渡す
	c.HTML(http.StatusOK, "index.tmpl", gin.H{
		"token": token, // for debug
	})
}

func main() {
	// ginのコアとなるEngineインスタンスを作成
	r := gin.Default()

	// レンダリングするHTMLのディレクトリを指定
	r.LoadHTMLGlob("templates/*")

	// セッションミドルウェアを追加
	// 同じユーザーが同じセッションでアクセスした際の値を管理することができる
	// login.indexHandlerで保存した値をlogin.redirectHandlerで取得できるのはこの機能のおかげ
	store := cookie.NewStore([]byte("secret"))
	r.Use(sessions.Sessions("mysession", store))

	// ルートエンドポイント"/"にGETリクエストを処理するハンドラーを登録
	r.GET("/", indexHandler)

	// アクセストークンが存在しない場合に遷移するログインページを/loginエンドポイントを登録
	r.GET("/login", login.LoginHandler)

	// 認証後のリダイレクト先である/redirectエンドポイントを登録
	r.GET("/redirect", login.RedirectHandler)

	// http://localhost:8080 でサーバを立てる
	r.Run()
}

加筆した内容はセッションミドルウェア,indexHandler関数,redirect先の登録,HTMLレンダリング機能の4点です.

セッションミドルウェア

ここからページの遷移が入るため,サーバへの接続から切断までのセッションの間に遷移先でも値を保持できるようにする必要があります.そのため,Ginの機能の一つであるセッションミドルウェアを使用して値を管理します.

コンピューターネットワークにおけるセッションとは,通信の開始から終了までを指しており,クライアントとサーバーで通信を行う場合であれば,クライアントからサーバーへ接続した時点でセッションが始まり,サーバーから切断するとセッションが終了します[3].そして,セッションを識別するセッションIDはCookieとしてブラウザに保存されます.これにより,Webアプリ側はユーザを識別して値を管理することができます[4]

cookie.NewStore([]byte("secret"))

はじめにセッション情報を格納するためのストレージを作成します.公式ドキュメント[5]より,引数には認証鍵と暗号鍵(省略可能)のペアを使用することが明記されています.そのため,今回は認証鍵として"secret",暗号鍵は省略しています.公式ドキュメントより認証鍵は32バイトまたは64バイトのキーを使用することが推奨されています.また,暗号鍵を設定する場合は,AES暗号に使用するため16,24,32バイトのいずれかを使用する必要があります.そのため,実際にアプリケーションを運用する場合は注意が必要なポイントです.

r.Use(sessions.Sessions("mysession", store))

ストレージを作成した後はルータ(ginのコアエンジン)にミドルウェアを登録するUse関数を使用してセッションミドルウェアを登録します[6].Sessions関数では,第一引数にセッション名を,第二引数に対応するストレージを指定します[7]

sessionを利用する際はDefault関数でセッションを取得します.次にSet関数で値の格納,Save関数で値の保存,Get関数で値の取得という流れです.

indexHandler関数

これは本Webアプリのメインページを扱う関数です.初めにアクセストークンがセッション情報の中に存在するかどうかを確認して,ログインページに遷移するかどうかを判断します.すでにアクセストークンが存在する場合はMisskey APIを叩いてノートを取得する処理が入りますが,ここでは一度index.tmplを表示するようにします.

redirect先の登録

MiAuthを利用して認証が終わった後の帰ってくる先を登録しています.リダイレクト先では認証後の情報に基づいてアクセストークンを取得する処理を実行します.

HTMLレンダリング機能

ここからGinのHTMLレンダリング機能を利用して,Golang部分とHTML部分を分離させます.この機能を利用することで,データの処理とデータの表示を分けて書けるので保守性が向上します.

main.go (一部抜粋)
// main関数内
r.LoadHTMLGlob("templates/*")

// indexHandler関数内
c.HTML(http.StatusOK, "index.tmpl", gin.H{
    "token": token, // for debug
})

上記コードはさきほどのmain.goの一部抜粋です.はじめに「LoadHTMLGlob」で読み込むHTMLファイルのディレクトリを指定しています.今回はmain.goと同じ階層の「templates」ディレクトリ内にあるファイルをすべて読み込むようにしています.つぎにHTML関数でレンダリングしたいHTMLファイル(index.tmpl)を指定しています.Go言語ではHTMLファイルの拡張子を.htmlではなく.tmplとします.さらに,gin.H{ "token": token })ではHTMLファイルで表示したいデータを指定することができます.HTML側でどのようにコーディングするのかはこの後述べます.


さいごにレンダリング先のHTMLファイルを作成します.これは「templates」というディレクトリを作成して記述していきます.

./templates
./templates/index.tmpl
<html>
	<h1>
		This is a index page
	</h1>
	<div>
		<p>token: {{.token}}</p>
	</div>
</html>
./templates/login.tmpl
<html>
	<h1>
		This is a login page
	</h1>
	<div>
		<a href={{.authorization_url}}>Authorization</a>
	</div>
</html>

login.goやmain.goでHTML関数を実行する際に渡したtokenやauthorization_urlは,HTML側で「{{ }}」(二重中括弧)で囲むことで表示することができます.

go get github.com/gin-contrib/sessions
go get github.com/google/uuid
go get github.com/gin-contrib/sessions/cookie
docker build -t art-explorer .
docker run -d -p 8080:8080 art-explorer

あとは上記のコマンドを実行して http://localhost:8080 にアクセスすると「This is a login page」というページが開きます.

次に「Authorization」をクリックすると,Misskeyのログインページが開きます.ここからは今作っているWebアプリではなく,Misskeyが用意したページに飛ばされます.

事前に作成したMisskeyのユーザ名とパスワードを入力してログインすると,権限を許可するページに遷移します.

許可ボタンをおすとMisskeyのページから再び今作っているWebアプリに戻ってきて,「This is a index page」というページに遷移します.そして,下側にアクセストークンが表示されます.

フェッチ機能

ログイン機能を実装するとアクセストークンをセッション内に保存できます.このアクセストークンを使ってWebアプリのメインとなるリノートされた画像付きノートかつ,未フォローのみを表示する機能を実装していきます.

./fetch/fetch.go
./fetch/fetch.go
package fetch

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

const (
	LIMIT = 100
)

// フォロー済みか否かのAPIリクエストの結果を格納する構造体
type RelationResponse struct {
	Following bool `json:"isFollowing"`
}

// タイムラインのノートの情報を格納する構造体
type Note struct {
	RenoteID string `json:"renoteId"`
	Renote   struct {
		User struct {
			UserId   string `json:"id"`
			Username string `json:"username"`
		} `json:"user"`
		Files []struct {
			URL string `json:"url"`
		} `json:"files"`
	} `json:"renote"`
}

// index.htmlで表示する情報を格納する構造体
type NoteDisplay struct {
	UserURL string
	Files   []struct {
		URL string `json:"url"`
	}
}

// sendPostRequest は共通のHTTP POSTリクエストを送信する関数
// requestBody interface{}, responseStruct interface{} と定義することで,異なる構造のリクエストボディやデータ構造を受け入れられる
func sendPostRequest(apiURL string, requestBody interface{}, responseStruct interface{}) error {
	jsonBody, err := json.Marshal(requestBody)
	if err != nil {
		return fmt.Errorf("JSON変換エラー: %v", err)
	}

	req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBody))
	if err != nil {
		return fmt.Errorf("リクエスト作成エラー: %v", err)
	}

	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("リクエスト送信エラー: %v", err)
	}
	defer resp.Body.Close()

	responseBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("レスポンス読み取りエラー: %v", err)
	}

	err = json.Unmarshal(responseBody, responseStruct)
	if err != nil {
		return fmt.Errorf("JSONデコードエラー: %v, %s", err, string(responseBody))
	}

	return nil
}

// checkFollowStatus はユーザーをフォローしているかどうかを確認する関数
func checkFollowStatus(token interface{}, userId string) (bool, error) {
	apiURL := "https://misskey.io/api/users/relation"

	requestBody := map[string]string{
		"i":      token.(string),
		"userId": userId,
	}

	var relations []RelationResponse // APIレスポンスが配列なので配列で宣言
	err := sendPostRequest(apiURL, requestBody, &relations)
	if err != nil {
		return false, err
	}

	if len(relations) > 0 {
		return relations[0].Following, nil
	}

	return false, fmt.Errorf("no relation data found")

}

// fetchNotes はMisskeyからノートを取得し,未フォローのユーザーのノートのみを返す関数
func FetchNotes(token interface{}) ([]NoteDisplay, error) {
	if token == nil {
		log.Fatal("ACCESS_TOKEN is not set in the environment")
	}

	apiURL := "https://misskey.io/api/notes/timeline"

	requestBody := map[string]interface{}{
		"i":     token,
		"limit": LIMIT,
	}

	var notes []Note
	err := sendPostRequest(apiURL, requestBody, &notes)
	if err != nil {
		return nil, err
	}

        // HTML上に表示するデータをNoteDisplayとしてまとめておく
	var notesToDisplay []NoteDisplay
	processedUsernames := make(map[string]bool) // 複数人が同一のノートをリノートする場合があるため処理済みユーザーネームを追跡
	for i := 0; i < LIMIT && i < len(notes); i++ {
		// リノートされたノート出ない場合はスキップ
		if notes[i].RenoteID == "" {
			continue
		}

		// ユーザーネームが既に処理済みの場合はスキップ
		if processedUsernames[notes[i].Renote.User.Username] {
			continue
		}

		isFollowing, err := checkFollowStatus(token, notes[i].Renote.User.UserId)
		if err != nil {
			return nil, fmt.Errorf("フォロー状態確認エラー: %v", err)
		}

		// リノートされたノートのユーザがすでにフォロー済みならスキップ
		if isFollowing {
			continue
		}

		// リノートされたノートが画像付きでない場合はスキップ
		if len(notes[i].Renote.Files) == 0 {
			continue
		}

		user_url := "https://misskey.io/@" + notes[i].Renote.User.Username
		notesToDisplay = append(notesToDisplay, NoteDisplay{
			UserURL: user_url,
			Files:   notes[i].Renote.Files,
		})
		processedUsernames[notes[i].Renote.User.Username] = true // ユーザーネームを処理済みとしてマーク
	}

	return notesToDisplay, nil
}

構造体はGolangのタグ機能を利用して,JSONデータのうち取得したいキーを記述しています.これにより,sendPostRequest関数内でjson.Unmarshal関数を実行するときに,構造体のタグとレスポンスJSONデータのキーが一致する部分の値を格納することができます.

これに合わせてmain.goのindexHandler関数に加筆していきます.

main.go
main.go
package main

import (
	"art-explorer/fetch"
	"art-explorer/login"
	"fmt"
	"html/template"
	"net/http"
	"path/filepath"

	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"
	"github.com/gin-gonic/gin"
)

// メインページ
// gin.Context: HTTPリクエスト/レスポンス を管理する構造体
func indexHandler(c *gin.Context) {
	session := sessions.Default(c)

	// ---------- check access token ----------
	// セッションからtokenを取得して表示
	token := session.Get("token")

	if token != nil {
		// アクセストークンが存在する場合はそのまま
		fmt.Println("Token found in session:", token)
	} else {
		// アクセストークンが存在しない場合はログインページに遷移
		fmt.Println("No token found in session")
		c.Redirect(http.StatusFound, "/login")
		return
	}

	notes, err := fetch.FetchNotes(token)
	if err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}

        // HTMLテンプレートのパスを設定
	tmplPath := filepath.Join("templates", "index.tmpl")

	t, err := template.ParseFiles(tmplPath)
	if err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}

	// テンプレート(html)にnotes(データ)をバインドすることで最終的なHTMLを生成する
	err = t.Execute(c.Writer, notes)
	if err != nil {
		c.String(http.StatusInternalServerError, err.Error())
		return
	}
}

func main() {
	// ginのコアとなるEngineインスタンスを作成
	r := gin.Default()

	// レンダリングするHTMLのディレクトリを指定
	r.LoadHTMLGlob("templates/*")

	// セッションミドルウェアを追加
	// 同じユーザーが同じセッションでアクセスした際の値を管理することができる
	// indexHandlerで保存した値をredirectHandlerで取得できるのはこの機能のおかげ
	store := cookie.NewStore([]byte("secret"))
	r.Use(sessions.Sessions("mysession", store))

	// ルートエンドポイント"/"にGETリクエストを処理するハンドラーを登録
	r.GET("/", indexHandler)

	// アクセストークンが存在しない場合に遷移するログインページを/loginエンドポイントを登録
	r.GET("/login", login.LoginHandler)

	// 認証後のリダイレクト先である/redirectエンドポイントを登録
	r.GET("/redirect", login.RedirectHandler)

	// http://localhost:8080 でサーバを立てる
	r.Run()
}

indexHandler関数を加筆して,GinのHTMLレンダリング機能によりfetch.goで取得したNoteDisplay構造体の配列をHTML上に表示します.具体的には,ParseFiles関数でHTMLテンプレートをパースしたあと,Execute関数でHTMLテンプレートにデータを組み込んでいます.

つぎに,NoteDisplay構造体の配列をHTML上に表示するためindex.tmplを加筆します.

./templates/index.tmpl
./templates/index.tmpl
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Art Explorer</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f0f0f0;
        }
        h1 {
            color: #333;
            text-align: center;
        }
        .note {
            background-color: white;
            border: 1px solid #ddd;
            padding: 15px;
            margin-bottom: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .user-link {
            display: block;
            margin-bottom: 10px;
            color: #0066cc;
            text-decoration: none;
            font-weight: bold;
        }
        .user-link:hover {
            text-decoration: underline;
        }
        .images-container {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            justify-content: start;
        }
        .note-image {
            max-width: 400px;
            max-height: 400px;
            width: auto;
            height: auto;
            object-fit: cover;
            border-radius: 4px;
        }
        @media (max-width: 600px) {
            .images-container {
                justify-content: center;
            }
            .note-image {
                max-width: 100%;
            }
        }
    </style>
</head>
<body>
    <h1>未フォローのユーザーのノート</h1>
    {{range .}}
    <div class="note">
        <a href="{{.UserURL}}" class="user-link">ユーザーページ</a>
        <div class="images-container">
            {{range .Files}}
            <img src="{{.URL}}" alt="Attached image" class="note-image" loading="lazy">
            {{end}}
        </div>
    </div>
    {{end}}
</body>
</html>

構造体NoteDisplayの各変数(UserURL, Files)が「{{.UserURL}}」や「{{.URL}}」と指定するだけで書けるのは,main.go関数のExecute関数で事前に構造体の中身を処理してくれているおかげです.また,NoteDisplayやFilesは配列のため,「{{range .}}」や「{{range .Files}}」によりループ処理を行っています.

これにより,NoteDisplay構造体の配列の情報を使ってブラウザ上に表示できるようになりました.あとは下記のコマンドを実行して http://localhost:8080 にアクセスすると「This is a login page」というページが開きます.

docker build -t art-explorer .
docker run -d -p 8080:8080 art-explorer

デプロイ

デプロイの流れは下記の記事を参考に書かせていただきます.実装時にも非常に参考になりました.ありがとうございます.

https://zenn.dev/akuru_jp/articles/8574be4f03d03f

今回のデプロイ作業ではAzure PortalAzure CLIの2種類のツールを使用して進めていきます.1つ目のAzure Portalはすべての Azure リソースを作成および管理できる Web ベースの統合コンソールです[8].まずはこれを使ってデプロイに必要なAzureリソースを作成していきます.2つ目のAzure CLIはAzureに接続してAzureリソースに対して管理コマンドを実行するコマンドラインツールです[9].Azure Portalで作成でリソースを作成した後,Azure CLIでコマンドを実行していきます.

Azure CLIは下記のリンクからインストールする必要があります.

https://learn.microsoft.com/ja-jp/cli/azure/install-azure-cli

インストールしている間にAzure Portalで必要なリソースを作成します.作成するリソースはアーキテクチャの右側に示したように,Azure Container RegistryとAzure App Serviceの2種類を作成します.まずAzure Container Registryで,ローカルからプッシュするDockerイメージの置き場所を作った後,Azure App Serviceでプッシュしたイメージをデプロイできるようにします.

注意点として,Azure Container Registry を利用する場合,日額で料金がかかります.最新の情報は下記の公式ドキュメントに任せるとして,2024/09/21 現在で最安プランでも$0.0167 かかります.そのため,実際に動かせることを確認したら削除するのがおすすめです.Azure App Service では無料プランを使用するので料金は発生しません.

https://azure.microsoft.com/ja-jp/pricing/details/container-registry/

それではまずAzure Container Registryを作成します.Azure Portalにログインしたら,上の検索窓に「コンテナーレジストリ」と入力して,表示された項目をクリックします.その後,左上の「作成」ボタンを押してページが遷移したら下記の一覧に従って必要な情報を入れていきます.

項目 内容
サブスクリプション 任意
リソースグループ 新規作成→art-explorer
レジストリ名 artexplorer
Pricing plan Basic

入力したらそのまま作成します.その後,作成したリソースに移動して,左側のタブにある「設定」→「アクセス キー」をクリックして「管理者」にチェックを付けます.これはAzure App Serviceからこのレジストリにアクセスできるようにするためです.

az login
az acr login --name artexplorer
docker build -t artexplorer.azurecr.io/art-explorer:v1.0.0 .
docker push artexplorer.azurecr.io/art-explorer:v1.0.0

つぎにローカル上のDockerイメージをAzure Container Registryにプッシュしていきます.コマンドラインを開いて上記のコードを実行してください.コピペして一気に実行すると処理がおかしくなる可能性があるので順番に一つずつ実行してください.このコマンドでは,はじめに1行目でAzureにログインした後,2行目でさきほど作成したレジストリにログインします.つぎに3行目でレジストリにプッシュするためのDockerイメージを作成しています.このときイメージ名「artexplorer.azurecr.io/art-explorer」に「:v1.0.0」を付随させることで,イメージのタグを指定できます.これによりデプロイ時にAzure上でタグを指定することができます.最後に4行目でローカル上のイメージをAzure上のレジストリにプッシュしています.

プッシュしたら今度はAzure App Serviceを作成します.再び上の検索窓に「app service」と入力して,表示された項目をクリックします.その後,左上の「作成」→「Webアプリ」ボタンを押してページが遷移したら下記の一覧に従って必要な情報を入れていきます.

項目 内容
サブスクリプション 任意
リソースグループ art-explorer
名前 artexplorer
公開 コンテナー
オペレーティングシステム Linux
地域 Japan East
価格プラン Free F1

つぎに上部の「コンテナー」タブをクリックして追加の情報を入れていきます.

項目 内容
イメージソース Azure Container Registry
レジストリ artexplorer

これらが入力出来たらあとはそのままボタンをクリックして作成します.

作成したApp Serviceに対して,ブラウザから閲覧できるよう環境変数を設定する必要があります.リソースに移動して左側のタブの「設定」→「環境変数」から,追加ボタンをクリックして,名前を「WEBSITES_PORT」,値を「8080」とします.その後下側の適用ボタンをクリックして,さらに一覧画面の下側にある適用ボタンもクリックします.その後,左側のタブの「概要」をクリックして右側に表示された「既定のドメイン」の示すリンクにアクセスすることでページが表示されます.完成!

コラム:一意の既定のホスト名とは?

Azure App ServiceのWebアプリでWebアプリ名を設定する際,下部に「一意の既定のホスト名 (プレビュー) がオンになっています」と表示されています.これはサブドメイン・テイクオーバー(サブドメインの乗っ取り)を防ぐ手法です.まず,サブドメインとは,「example.com」という本体ドメインに対して「hoge.example.com」のように本体ドメインの先頭に文字列を挿入しているドメインのことを指します.そして,このhoge.example.comの利用者がリソースを削除する際に,DNS設定を削除し忘れてしまうと,第三者がこれを利用して悪意のあるリソースを作成することができてしまいます.そこで,ホスト名にランダムな文字列と地域情報(Japan East,East USなど)を付けることで第三者が同じホスト名をとれないようにします.

https://techcommunity.microsoft.com/t5/apps-on-azure-blog/public-preview-creating-web-app-with-a-unique-default-hostname/ba-p/4156353

コラム:静的Webアプリとは?

App Serviceの作成時に「Webアプリ」だけでなく「静的Webアプリ」が表示されます.これは,Angular,React,Svelte,Vue,Blazorなどのサーバー側のレンダリングが不要なライブラリと Web フレームワークを使用して構築されます.静的Webアプリは従来のWebサーバーと異なり世界各地の地理的に分散したポイントから提供されるので,ユーザーのアクセス速度が大幅に高速になるというメリットがあります.特に公式サイトや簡単なブログなど軽量でフロントエンドのみで成り立つサイトでは有効です.Golangのようなサーバーサイド言語を利用する場合,静的Webアプリとして活用するには別途APIサーバーを構築し,静的WebアプリからAPIを呼び出す形で連携する必要があります.

https://learn.microsoft.com/ja-jp/azure/static-web-apps/overview

まとめ

以上でDocker + Golang(Gin)のMisskeyサードパーティWebアプリをAzureにデプロイするまでの一連の流れは終わりです.わからない点は気軽にご質問いただけると幸いです.ここまで読んでいただきありがとうございました.

脚注
  1. https://developer.mozilla.org/ja/docs/Glossary/UUID ↩︎

  2. https://ja.wikipedia.org/wiki/UUID#バージョン ↩︎

  3. https://www.ntt.com/bizon/glossary/j-s/session.html ↩︎

  4. https://ipeinc.jp/media/cookie-session/ ↩︎

  5. https://pkg.go.dev/github.com/gin-contrib/sessions/cookie#NewStore ↩︎

  6. https://pkg.go.dev/github.com/gin-gonic/gin#Engine.Use ↩︎

  7. https://pkg.go.dev/github.com/gin-gonic/contrib/sessions#Sessions ↩︎

  8. https://learn.microsoft.com/ja-jp/azure/azure-portal/azure-portal-overview ↩︎

  9. https://learn.microsoft.com/ja-jp/cli/azure/what-is-azure-cli ↩︎

Discussion