Goのinterface

Goで単体試験などをしたくなった場合、テスト対象となる機能が呼び出す関数を外から渡したくなる。 そんなときはinterfaceを使うことで実現できる。

こんな感じ

package main

import "fmt"

// インタフェースとして、どんな関数を具備している必要があるかを定義。
type Speaker interface {
    Greeting()
}

// そのインタフェースで定義された関数を使う関数を定義
// 単体テストのことを想定しているならば、これがテスト対象
func Run(i Speaker) {
    i.Greeting()
}

// インタフェースを持たせる構造体を定義
type Japanese struct {
}

// インタフェースに合うように、インタフェースで定義されている
// 関数を構造体に対して実装
func (p *Japanese)Greeting() {
    fmt.Println("こんにちは")
}

// もうひとつ、同じインタフェースを持たせる別の構造体を定義しておいて、
type American struct {
}

// 先ほどと同じく、インタフェースに合うように、インタフェースで定義されている
// 関数を構造体に対して実装
func (a *American)Greeting() {
    fmt.Println("Hi!")
}


// 使ってみる
func main() {

    // Japanese構造体を渡してもちゃんと実行できるし、
    japanesePeople := &Japanese{}
    Run(japanesePeople)

    // American構造体を渡してもちゃんと実行できる
    americanPeople := &American{}
    Run(americanPeople)
}

Redmineを試したくなった

Dockerを使うのが簡単だ。

docker -p 3000:3000 redmine

管理者の初期アカウントはadmin、パスワードもadmin。

hub.docker.com

管理者権限のあるアカウントでログインして、以下の作業を実施していく。 ページ最上部の左寄りの場所に「管理」というリンクがあるのでそこをクリックして管理画面に遷移。各項目の設定画面へのリンクがあるのでそちらで作業する。

  • ユーザを作る
    • ユーザはAPIでも作ることができるので、何かのシステムと同じユーザを作るなどといったことも簡単にできる。
  • ロールを作る
    • プロジェクトにユーザを登録する際に必要
    • ロールはAPIでは作れない。GUIを操作して作るしかない。
  • ステータスを作る
    • トラッカーを作るのに必要
  • トラッカーを作る
  • プロジェクトを作る
    • APIで作ることも可能、だけど、そんなにたくさん作るものでもないのであまり意味ないか?
  • プロジェクトにユーザを追加する、同時にそのプロジェクトにおけるそのユーザのロールも設定する。
    • チェックボックスになっているので、同じロールの人であれば複数のユーザを一気に登録可能。
  • 優先度を作る
    • チケットを作るときに必要
    • 設定の中の値のリストの中にあるので注意

この辺までいじって、自分がやりたいことができないことがわかったので、終了。 (複数のグループをissueに割り当てたかった)

ginを使って作成したWebアプリをsystemdを使ってデーモンとして動かす

ginを使って作成したWebアプリをデーモンとして動かす方法について、少し前の記事でsupervisordを用いる方法を延々書いてしまったが、ここではsystemdを使う方法を書いてみる。

1. ログをsyslogに吐くようにする。

デーモン化したらログは画面ではなくファイルに出したいところ。 Linuxで動かす(よね?)なら、syslogを使ってしまうのがお手軽だ。

   logger, err := syslog.New(
        syslog.LOG_NOTICE|syslog.LOG_USER,
        "SampleWebApp")
    if err != nil {
        panic(err)
    }
    gin.SetMode(gin.ReleaseMode)
    gin.DisableConsoleColor()
    gin.DefaultWriter = logger

2. systemd用のユニットファイルを作成する。

たぶん、これくらいが一番シンプルなんじゃないかな。。

[Unit]
Description=Sample Web Application

[Service]
ExecStart=/usr/local/bin/SampleWebApp
KillMode=process
Restart=always

[Install]
WantedBy=multi-user.target

3. sytemctlコマンドで起動する。

systemctl restart サービス名

4. おまけ firewalldでポートを開放する

とりあえず今の状態を確認して、

firewall-cmd --list-all

たとえばこんなコマンドを打って、

firewall-cmd --add-port=3000/tcp --zone=public --permanent

そのあとリロードしてから、

firewall-cmd --reload

再度状態を確認すると、

firewall-cmd --list-all

先ほど追加したポートが開放されていることがわかるはず。

Goで特定の環境向けのビルドのときだけ取り込むソースを指定する

goはビルド時の環境変数の設定で異なる環境向けのバイナリを生成できるのだが、開発環境ではWindowsLinuxを使っていて、実行環境はLinuxということがあり、Linux向けのバイナリだけsyslogを使いたくなった。 そんなときはbuild constraintという機能を使うと解決できる。

ファイルの冒頭(package宣言より前)に

//go:build linux

とかくとそのファイルはLinux向けのビルドのときにしか取り込まれなくなる。 windows向けだったらwindowsmac向けだったらdarwinなどと書く。 アーキテクチャの指定も可能。

ginのログ出力先をsyslogにする

こんなかんじ

package main

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

func main() {
        logger, err := syslog.New(
                syslog.LOG_NOTICE|syslog.LOG_USER,
                "gin-test")
        if err != nil {
                panic(err)
        }
        gin.SetMode(gin.ReleaseMode)
        gin.DisableConsoleColor()
        gin.DefaultWriter = logger
        app := gin.Default()
        grp := app.Group("/api")
        grp.GET("/", func(ctx *gin.Context) {
                ctx.JSON(200, gin.H{"message": "ok"})
        })
        app.Run("localhost:3000")
}

まぁでもsupervisorとか使うだろうからログも標準出力にだして、あとはsupervisorに任せればよいでしょう。(※と、当初書いたけれど、systemdでやればいいんじゃないのって言われたが、とりあえず記事はそのまま残しておく。)

RHEL8系の場合、

sudo dnf install epel-release

ってやって(すでにやってあれば当然不要)

sudo yum -y install supervisor

とかやるとインストールされ、設定ファイルは /etc/supervisord.conf になっているので、そのなかに設定を追記するなり、設定ファイルをよく見るとわかるように /etc/supervisord.d/*.iniが読み込まれるようになっているのでそれにマッチする個別の設定ファイルを置くなりして自分のプログラムがsupervisordに起動されるようにすればよい。

例えば自分のプログラムがapiという名前で識別されるようにしたかったら、/etc/supervisord.d/api.ini などといった名前のファイルを作って、その中に

[program:api]
command=/usr/local/bin/api
autostart=true
autorestart=true
stderr_logfile=/var/log/api.err.log
stderr_logfile=/var/log/api.out.log

などと書いてやればよいだろう。

ってsupervisorの話のほうが長くなった。

ginでAPIサーバを作る

ディレクトリをほって初期化する。 モジュール名のところはリポジトリ名にするんだよ、とする情報が多くて、正しいのだけれど、公開しないものであれば別にそのフォルダ名とかでも特に支障はない(たぶん)。

mkdir XXXX
cd XXXX
go mod init モジュール名

DBを立ち上げるdocker-compose.ymlをかく

services:
  db:
    image: postgres:latest
    container_name: postgresql
    ports:
      - 5432:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - ./pgdata:/var/lib/postgresql/data
      - ./pginit:/docker-entrypoint-initdb.d

初期化して初期データをつっこむSQLを書く

DROP TABLE IF EXISTS Users;

CREATE TABLE Users (
    user_id SERIAL,
    username VARCHAR(128),
    account VARCHAR(64),
    password VARCHAR(64)
);

INSERT INTO Users(username, account, password) VALUES('', 'user01', 'user01');
INSERT INTO Users(username, account, password) VALUES('', 'user02', 'user02');
INSERT INTO Users(username, account, password) VALUES('', 'user03', 'user03');
INSERT INTO Users(username, account, password) VALUES('', 'user04', 'user04');
INSERT INTO Users(username, account, password) VALUES('', 'user05', 'user05');

勢いで書く。

package main

import (
    "database/sql"
    "fmt"
    "log"
    "strconv"
    "strings"
    "time"

    _ "github.com/lib/pq"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt"
)

const SECRETKEY = "secretkey"


func main() {
    // ログにファイル名と行番号を出す設定
    log.SetFlags(log.Lshortfile)
    app := gin.Default()
    // グループの作成
    apiRouter := app.Group("/api/v0")
    // CORS設定、どこからでもアクセスOKにしてみた
    apiRouter.Use(cors.New(cors.Config{
        AllowOrigins: []string{"*"},
        AllowMethods: []string{"POST", "GET", "OPTIONS"},
        AllowHeaders: []string{"Authorization", "Content-Type"}}))
    // このグループ共通のミドル、ログインしていたらユーザ情報を取得する
    apiRouter.Use(middleware)
    // ログイン
    apiRouter.POST("/login", Login)
    // ログインしているユーザの情報の取得
    apiRouter.GET("/me", Me)
    app.Run()
}

//
// Authorizationヘッダに正しいトークンが乗っていたらコンテキストにそのユーザの情報を乗せる
//
func middleware(c *gin.Context) {
    // Authorizationヘッダからトークンを取り出す
    authorizationHeader := c.Request.Header.Get("Authorization")
    if authorizationHeader != "" {
        ary := strings.Split(authorizationHeader, " ")
        if len(ary) == 2 {
            if ary[0] == "Bearer" {
                // ここまででトークンと思われるものを取り出せたので、解析する
                // ここのロジックは公式サイト参照
                // https://pkg.go.dev/github.com/golang-jwt/jwt#example-Parse-Hmac
                token, err := jwt.Parse(ary[1], func(token *jwt.Token) (interface{}, error) {
                    if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                        return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
                    }
                    return []byte(SECRETKEY), nil
                })
                if err == nil {
                    // 解析に成功したら、ユーザIDを取り出す
                    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
                        userIDStr := claims["sub"].(string)
                        userID, err := strconv.Atoi(userIDStr)
                        if err == nil {
                            // ユーザIDを使って新しいトークンを取り出す(有効期限切れ対策)
                            newToken, err := generateToken(userID)
                            if err == nil {
                                // ユーザの情報を取り出す
                                conn, err := connectDB()
                                if err == nil {
                                    defer conn.Close()
                                    var username string
                                    err := conn.QueryRow(`
                                      SELECT username FROM Users WHERE user_id = $1`,
                                        userID).Scan(&username)
                                    if err == nil {
                                        // 新しいトークンとユーザの情報をコンテキストに保存
                                        c.Set("token", newToken)
                                        c.Set("username", username)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    c.Next()
}

//
// 投入されたログイン情報を格納するための構造体
//
type LoginInfo struct {
    Account  string `json:"account" binding:"required"`
    Password string `json:"password" binding:"required"`
}

//
// ログイン処理
//   正しいアカウントとパスワードが渡されたらトークンを生成して返す
//
func Login(c *gin.Context) {
    // 投げつけられた情報を取り出す
    var loginInfo LoginInfo
    err := c.ShouldBindJSON(&loginInfo)
    if err != nil {
        log.Println(err)
        c.JSON(400, gin.H{"message": "bad request"})
        return
    }
    // DBに接続
    conn, err := connectDB()
    if err != nil {
        log.Println(err)
        c.JSON(500, gin.H{"message": "server internal error"})
        return
    }
    defer conn.Close()
    // 渡された情報に該当するユーザを探す。
    // 本当だったらDBに生パスワードは保存しないだろうからこの前にハッシュ化などが必要
    var userID int
    rows, err := conn.Query(`
      SELECT user_id FROM Users WHERE account = $1 AND password = $2`,
        loginInfo.Account, loginInfo.Password)
    for rows.Next() {
        err := rows.Scan(&userID)
        if err != nil {
            log.Println(err)
            c.JSON(500, gin.H{"message": "server internal error"})
            return
        }
    }
    // トークン生成
    token, err := generateToken(userID)
    if err != nil {
        c.JSON(500, gin.H{"message": "server error"})
        return
    }
    c.JSON(200, gin.H{"token": token})
}

//
// ログインしているユーザの自分自身の情報取得
//
func Me(c *gin.Context) {
    // 自作ミドルウェアがコンテキストに保存した情報を取り出す
    username, exist := c.Get("username")
    if !exist {
        c.JSON(403, gin.H{"message": "need to login"})
        return
    }
    token, exist := c.Get("token")
    if !exist {
        c.JSON(403, gin.H{"message": "need to login"})
        return
    }
    c.JSON(200, gin.H{"username": username, "token": token})
}

//
// DBへの接続
//
func connectDB() (*sql.DB, error) {
    db, err := sql.Open("postgres", `
      host=localhost
      port=5432
      dbname=postgres
      user=postgres
      password=postgres
      sslmode=disable`)
    if err != nil {
        log.Println(err)
        return nil, err
    }
    return db, nil

}

//
// トークン生成
//
func generateToken(userID int) (string, error) {
    now := time.Now()
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "sub": fmt.Sprintf("%d", userID),
        "iat": now.Unix(),
        "exp": now.Add(60 * time.Minute).Unix(), // 一時間で無効になる
    })
    return token.SignedString([]byte(SECRETKEY))
}