Go logLevel設計 logrus echo

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

今回はGo言語でログ出力を実装します。 Go言語には標準でlog パッケージがありますが、今回はよりリッチな logrus を使ったログ設計を行なっていきます。

レベル設計

ログをレベル単位で分けることでバグの検出を容易にするだけでなく、特定のレベルが出力されたらslackに通知するといった事もできるようになります。

今回は以下のように設計しました。

レベル 説明 slack通知
debug 開発時に使う出力 なし
info 障害があったときに参考になる情報 なし
warn 警告 緊急性の低いエラー 出力する
error 対処が必要なエラー @here 付きで出力
fatal システムが継続不能なエラー @here 付きで出力

エラーコードのマッピング

上記で設計したログレベルに対して独自に定義した以下のエラーをマッピングします。

今回はデモなので DB系のエラーを2種類のみ用意しました。

  • NotFound: dbからデータが見つからない
  • Database: 接続エラーなど
package errorcode

type ErrorCode string

const (
    NotFound         ErrorCode = "notfound_error"
    Database         ErrorCode = "database_error"
)

マッピングは以下とします。

errorcode logLevel
NotFound info
Database warn

以上までが設計となります。 次から実装を行なっていきます。

実装

今回はechoでサーバーを構築しています。 main関数で必要なmiddlewareを注入します。

   middlewares := []echo.MiddlewareFunc{
                 // 省略
        middleware.NewInjectRequestID(),
        middleware.NewInjectLogger(),
    }

log出力の為に requestIDの付与とLogger自体の生成を行うmiddlewareです。

middleware requestID

各リクエストに対してrequestIDを付与します。 requestIDはechoから提供されているパッケージもありましたが、今回は不要なので直接uuidを付与しています。

package middleware

import (
    "context"

    "github.com/repo/backend/src/infra/logger"
    "github.com/google/uuid"
    "github.com/labstack/echo"
)

type key string

const (
    REQUEST_ID_KEY key = "requestID"
)

func NewInjectRequestID() echo.MiddlewareFunc {
    return func(h echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            requestID := c.Request().Header.Get("X-Request-ID")
            if requestID == "" {
                requestID = uuid.NewString()
            }

            newCtx := context.WithValue(c.Request().Context(), REQUEST_ID_KEY, requestID)
            c.SetRequest(c.Request().WithContext(newCtx))
            return h(c)
        }
    }
}

func GetRequestID(ctx context.Context) string {
    requestID := ctx.Value(REQUEST_ID_KEY)

    if target, ok := requestID.(string); ok {
        return target
    } else {
        logger.Fatal("cannot get requestID from Context")
        panic("")
    }
}

middleware logger

上記で生成したrequestIDを取得し、loggerを生成しcontextにセットしています。 *loggerの実装は後述します

package middleware

import (
    "github.com/repo/backend/src/infra/logger"
    "github.com/repo/backend/src/util/appcontext"
    "github.com/labstack/echo"
)

func NewInjectLogger() echo.MiddlewareFunc {
    return func(h echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            requestID := GetRequestID(c.Request().Context())
            logger := logger.New(requestID)
            newCtx := appcontext.SetLogger(c.Request().Context(), logger)
            c.SetRequest(c.Request().WithContext(newCtx))
            return h(c)
        }
    }
}

middlewareの実装は以上です。

logger パッケージ

次にlogrusをラップする独自のloggerパッケージを実装します。 infra層に実装しておけばlogrusから変更する時も容易です。

package logger

import (
    "bytes"
    "fmt"
    "os"
    "time"

    "github.com/sirupsen/logrus"
)

func init() {
    logrus.SetFormatter(&logrus.TextFormatter{
        DisableTimestamp: true,
        ForceColors:      true,
        PadLevelText:     true,
    })
    logrus.SetLevel(logrus.DebugLevel)
    logrus.SetOutput(&MyOutput{})
    logrus.SetFormatter(&MyFormatter{})
}

func New(requestID string) *logrus.Entry {
    return logrus.StandardLogger().WithFields(logrus.Fields{"requestId": requestID})
}

func Info(v ...interface{}) {
    logrus.Info(v...)
}

func Infof(format string, v ...interface{}) {
    logrus.Infof(format, v...)
}

func Debug(v ...interface{}) {
    logrus.Debug(v...)
}

func Debugf(format string, v ...interface{}) {
    logrus.Debugf(format, v...)
}

func Warn(v ...interface{}) {
    logrus.Warn(v...)
}

func Warnf(format string, v ...interface{}) {
    logrus.Warnf(format, v...)
}

func Error(v ...interface{}) {
    logrus.Error(v...)
}

func Errorf(format string, v ...interface{}) {
    logrus.Errorf(format, v...)
}

func Fatal(v ...interface{}) {
    logrus.Fatal(v...)
}

func Fatalf(format string, v ...interface{}) {
    logrus.Fatalf(format, v...)
}

type MyFormatter struct{}

func (f *MyFormatter) Format(entry *logrus.Entry) ([]byte, error) {
    var b *bytes.Buffer

    if entry.Buffer != nil {
        b = entry.Buffer
    } else {
        b = &bytes.Buffer{}
    }

    b.WriteString(entry.Time.Format(time.RFC3339))
    b.WriteString(" ")
    b.WriteString(fmt.Sprintf("[%s]", entry.Level))
    b.WriteString(" ")
    if entry.Data["context"] != nil {
        b.WriteString(fmt.Sprintf("(%v)", entry.Data["context"]))
        b.WriteString(" ")
    }
    if entry.Data["requestId"] != nil {
        b.WriteString(fmt.Sprintf("(%v)", entry.Data["requestId"]))
        b.WriteString(" ")
    }
    b.WriteString(fmt.Sprintf("%v", entry.Message))

    b.WriteByte('\n')
    return b.Bytes(), nil
}

type MyOutput struct{}

func (splitter *MyOutput) Write(p []byte) (n int, err error) {
    if bytes.Contains(p, []byte("[debug]")) || bytes.Contains(p, []byte("[info]")) {
        return os.Stdout.Write(p)
    }
    return os.Stderr.Write(p)
}

init()関数でlogrusの設定を行います。 SetOutput SetFormatter ではlogrusが提供しているinterfaceを満たす事でカスタマイズしています。

ログ出力の関数 Info()Warn()はlogrusのメソッドをそのままラッピングしているだけです。

logを出力したい箇所でこれらの関数を呼び出します。

HandleError

loggerパッケージを実装できたのでエラーハンドリングを行なっている関数で呼び出しを行なっていきます。


func HandleError(ctx context.Context, err apperror.AppError) {
    logger := appcontext.GetLogger(ctx)

    var msg string

    msg += fmt.Sprintf("%+v", err)

    switch err.Code() {
    case errorcode.NotFound:
        graphqlerr.AddErr(ctx, getInfoMessage(err), graphqlerr.NOT_FOUND_ERR)
        logger.Info(msg)
    case errorcode.Database:
        graphqlerr.AddErr(ctx, getInfoMessage(err), graphqlerr.NOT_FOUND_ERR)
        logger.Warn(msg)
    }
}

contextからloggerを取り出し、各エラーコードに対してlogの呼び出しを行なっています。 ※GraphQLで実装しているのでそのエラーハンドリングもしています。

呼び出し元のResolver(コントローラー)は以下のように実装されています。

func (r *queryResolver) Task(ctx context.Context, id string) (*gmodel.Task, error) {
    var task gmodel.Task

    if err := r.db.First(&task, id).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            gql.HandleError(
                ctx,
                apperror.New("new error").Info("this is Info1").SetCode(errorcode.NotFound),
            )
        } else {

            gql.HandleError(
                ctx,
                apperror.Wrap(err, fmt.Sprintf("failed to get task id = %s", id)).SetCode(errorcode.Database),
            )
        }
        return nil, nil
    }

    return &task, nil
}

Taskを検索する単純なAPIです。DBに存在しない場合は NotFound それ以外のエラーは Databaseエラーを返しています。

それでは試しにAPIを叩いて出力してみます。

[10:21:32][APP] :    ____    __
[10:21:32][APP] :   / __/___/ /  ___
[10:21:32][APP] :  / _// __/ _ \/ _ \
[10:21:32][APP] : /___/\__/_//_/\___/ v3.3.10-dev
[10:21:32][APP] : High performance, minimalist Go web framework
[10:21:32][APP] : https://echo.labstack.com
[10:21:32][APP] : ____________________________________O/_______
[10:21:32][APP] :                                     O\
[10:21:32][APP] : ⇨ http server started on [::]:8080
[10:21:32][APP] : 
[10:21:32][APP] : 
[10:21:32][APP] :  // Databaseエラー
[10:21:37][APP] : 2022-01-03T10:21:37Z [warning] (a1d2b095-7a3e-434d-a593-d832020f006f) new error:
[10:21:37][APP] :     github.com/DaisukeMatsumoto0925/backend/src/graphql/resolver.(*queryResolver).Task
[10:21:37][APP] :         /app/src/graphql/resolver/task.resolver.go:251
[10:21:32][APP] : 
[10:21:32][APP] : 
[10:21:32][APP] :   // NotFoundエラー
[10:21:37][APP] : 2022-01-03T10:21:37Z [info] (a1d2b095-7a3e-434d-a593-d832020f006f) new error:
[10:21:37][APP] :     github.com/DaisukeMatsumoto0925/backend/src/graphql/resolver.(*queryResolver).Task
[10:21:37][APP] :         /app/src/graphql/resolver/task.resolver.go:256

無事に出力することができました。 今回は以上となります。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

Go カスタムエラー ~xerros.Frame, errors~

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

今回はGo言語のエラー処理におけるカスタムエラーの実装を行なっていきます。 GraphQLのサーバーから構築している為、実装をGraphQL仕様に寄せています。

それでは独自のエラー構造体を定義してカスタムエラーを作成していきます。


Go 言語でカスタムエラー

まずは interfaceとstructをそれぞれ用意します。

type AppError interface {
    error
    Code() errorcode.ErrorCode
    SetCode(code errorcode.ErrorCode) AppError
    Info(infoMessage string) AppError
    InfoMessage() string
}

type appError struct {
    err         error
    message     string
    frame       xerrors.Frame
    errCode     errorcode.ErrorCode
    infoMessage string
}

AppError interfaceはアプリケーションでカスタムエラーを呼び出す為のinterfaceです。

appError structがGoのerror interfaceを実装するカスタムエラー構造体です。 Go 組み込みの error は以下のようなinterfaceとなっています。

type error interface {
    Error() string
}

Error() メソッドを実装すれば error として扱われます。

さらにAppErrorのメソッドを実装することで AppErrorとして使い回すことができます。

Wrap

Wrap()関数を定義します。 errorを返すコードは全てこのWrap関数でラップしてカスタムエラーのinterfaceであるAppErrorを返すようにします。

外部パッケージのカスタムエラー(gormなど)もこのWrap関数を使えば自前のカスタムエラーとして扱うことができます。

func Wrap(err error, msg ...string) AppError {
    var m string
    if len(msg) != 0 {
        m = msg[0]
    }
    e := create(m)
    e.err = err
    return e
}

New

errors.New() のようにエラーを生成したいときは独自のNew関数を作成します。 New関数の中では create関数をを呼び出し *appErrorを返します。

このときframe に スタックトレースを付与しています。


func create(msg string) *appError {
    var e appError
    e.message = msg
    e.frame = xerrors.Caller(2)
    return &e
}

func New(msg string) AppError {
    return create(msg)
}

HandleError

エラーハンドリングをまとめた関数を定義します。

func HandleError(ctx context.Context, err apperror.AppError) {

    var msg string

    msg += fmt.Sprintf("%+v", err)

    switch err.Code() {
    case errorcode.Validation:
        var validationErr *validationutil.ValidationErr
        if errors.As(err, &validationErr) {
            graphqlerr.AddValidationErr(ctx, validationErr)
        }
        log.Println(msg)
    case errorcode.NotFound:
        graphqlerr.AddErr(ctx, getInfoMessage(err), graphqlerr.NOT_FOUND_ERR)
        log.Println(msg)
    }
}


func getInfoMessage(apperr apperror.AppError) string {
    if apperr.InfoMessage() != "" {
        return apperr.InfoMessage()
    }

    if msg, ok := ErrMessageMap[apperr.Code()]; ok {
        return msg
    }

    return "internal server error"
}

今回はお試しでValidation, NotFoundのパターンを実装しました。 graphqlerrパッケージではgraphqlの仕様に沿ってエラーを吐き出しています。 ここはGraphQLの仕様に基づく実装なので割愛します。

getInfoMessage()では カスタムエラーにInfoMessageを付与していた場合はその文字列を返し、何も入っていない場合はSetCode()で付与したエラーメッセージを返します。

エラーを吐き出してみる

ここまででカスタムエラーの実装が完了したので試しにエラーを吐き出してみましょう。

func (r *queryResolver) Task(ctx context.Context, id string) (*gmodel.Task, error) {
    var task gmodel.Task

    if err := r.db.First(&task, id).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {

            gql.HandleError(
                ctx,
                apperror.Wrap(err, fmt.Sprintf("failed to get task id = %s", id)).SetCode(errorcode.NotFound),
            )
        } else {

            gql.HandleError(
                ctx,
                apperror.Wrap(err, fmt.Sprintf("failed to get task id = %s", id)).SetCode(errorcode.Database),
            )
        }
        return nil, nil
    }

    return &task, nil
}

上記はGraphQLのResolver内でDBから検索し、 見つからなければ NotFoundのカスタムエラーを返すようにしています。

出力結果

[10:58:27][APP] : 2021/12/26 10:58:27 failed to get task id = 13: record not found:
[10:58:27][APP] :     github.com/repo/backend/src/graphql/resolver.(*queryResolver).Task
[10:58:27][APP] :         /app/src/graphql/resolver/task.resolver.go:253
[10:58:27][APP] :   - record not found

無事にスタックトレース, エラーメッセージを出力することができました。

ここではWrapで gormパッケージのカスタムエラーをラップしています。

新しくエラーを生成したいときは次のようにします。

           gql.HandleError(
                ctx,
                apperror.New("new error").Info("this is Info").SetCode(errorcode.NotFound),
            )

Info()メソッドはカスタムエラーのにinfoMessageを付与するメソッドです。 infoMessageがある場合APIのエラーメッセージに表示させるような設計になっています。

以下 Info(), SetCode()

func (err *appError) Info(infoMessage string) AppError {
    err.infoMessage = infoMessage
    return err
}

func (err *appError) SetCode(code errorcode.ErrorCode) AppError {
    err.errCode = code
    return err
}

以上です。 実はこの記事を書こうと思った発端はロギングについて調べようと思ったときに前提としてカスタムエラーを理解しておく必要があると思ったからです。

という事で次回はロギングについてまとめたいと思っています。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

Go CSV download API

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

今回はGo製のAPICSVをダウンロードする方法を見ていきたいと思います。

普段はGraphQLについて発信していますが、今回はRestで実装し、"encoding/csv"パッケージなどメイン機能部分のみ解説していきます。

ルーター

Restなのでまずはルーティングを作ります。 echoのFWを使っています。

func csvRouter(g *echo.Group, ctrl *controller.Controller) {
    constroller := controller.NewAnalyticsController(ctrl)
    g.GET("/analytics", func(e echo.Context) error {
        return constroller.Analytics(e)
    })
}

controller

次はコントローラーです。 dbを持たせた構造体とコンストラクタを定義します。

package controller

import (
    "github.com/jinzhu/gorm"
)

type Controller struct {
    db          *gorm.DB
}

func NewController(db *gorm.DB) *Controller {
    return &Controller{
        db:          db,
    }
}

AnalyticsController

いよいよCSV出力の実装です。 全体のコードを見たあとに一つ一つ解説していきます。

package controller

import (
    "bytes"
    "encoding/csv"
    "fmt"
    "net/http"
    "strconv"

    "github.com/hoge/backend/src/domain"
    "github.com/labstack/echo"
)

type AnalyticsController struct {
    controller *Controller
}

func NewAnalyticsController(controller *Controller) *AnalyticsController {
    return &AnalyticsController{
        controller: controller,
    }
}

func (a *AnalyticsController) Analytics(c echo.Context) error {
    var tasks []domain.Task

    if err := a.controller.db.Find(&tasks).Error; err != nil {
        fmt.Println("error")
        return err
    }

    fmt.Println(tasks)

    csvBytes, err := convertCSV(tasks)
    if err != nil {
        fmt.Println("convertError:", err)
        return err
    }
    return newCSVResponse(c, http.StatusOK, csvBytes)
}

func newCSVResponse(c echo.Context, status int, data []byte) error {
    c.Response().Writer.Header().Set("Content-Disposition", "attachment; filename=tasks.csv")
    c.Response().Writer.Header().Set("Content-Type", "text/csv")
    return c.Blob(status, "text/csv", data)
}

func convertCSV(tasks []domain.Task) ([]byte, error) {
    b := new(bytes.Buffer)
    w := csv.NewWriter(b)

    var header = []string{
        "id",
        "userID",
        "title",
        "note",
        "completed",
        "createdAt",
        "updatedAt",
    }
    w.Write(header)

    for _, task := range tasks {

        var col = []string{
            strconv.Itoa(task.ID),
            strconv.Itoa(task.UserID),
            task.Title,
            task.Note,
            strconv.Itoa(task.Completed),
            task.CreatedAt.String(),
            task.UpdatedAt.String(),
        }
        w.Write(col)
    }
    w.Flush()

    if err := w.Error(); err != nil {
        return nil, err
    }
    return b.Bytes(), nil
}

以上がAPIの実装部分となります。 エラー時の処理が適当ですがご了承ください。

Analytics()

まずはAnalyticsメソッドです。 事前に定義していたドメインエンティティのTaskを全て取得します。 ORMはgormです。

ちなみにTaskは以下のような構造体になっています。

type Task struct {
    ID        int
    UserID    int
    Title     string
    Note      string
    Completed int
    CreatedAt time.Time
    UpdatedAt time.Time
}

convertCSV()

convertCSV()メソッドはCSVで出力したい形にtasksを変換するメソッドです。 今回はシンプルに以下のような形で出力します。

f:id:shikatech:20211205081001p:plain

コードの中身を確認していきます。 "encoding/csv"パッケージにはcsv用の Writer構造体が用意されているので、bytes.Bufferから生成します。

b := new(bytes.Buffer)
w := csv.NewWriter(b)

次にcsvのヘッダーに当たる配列を作成します。 csvはstring配列の組み合わです。

   var header = []string{
        "id",
        "userID",
        "title",
        "note",
        "completed",
        "createdAt",
        "updatedAt",
    }
    w.Write(header)

次は表の中身となる部分を作ります。 ヘッダーと同じくstring配列なので各フィールドをstringに変換し一件ずつバッファリングします。

   for _, task := range tasks {

        var col = []string{
            strconv.Itoa(task.ID),
            strconv.Itoa(task.UserID),
            task.Title,
            task.Note,
            strconv.Itoa(task.Completed),
            task.CreatedAt.String(),
            task.UpdatedAt.String(),
        }
        w.Write(col)
    }

最後にバッファリングされたデータを書き込み、エラーハンドリングをして バイト配列でreturnします。

    w.Flush()

    if err := w.Error(); err != nil {
        return nil, err
    }
    return b.Bytes(), nil

バイト列を生成できたのであとはHttpレスポンスを返すだけです。

newCSVResponse()

今回の実装はechoに依存しています。

ヘッダーにはダウロードファイルである事を示す "Content-Disposition", "attachment; filename=tasks.csv"

Content-Disposition - HTTP | MDN

csvファイルである事を示す "Content-Type", "text/csv" を付与しています。

最後にechoのBlob()メソッドでhttpレスポンスを送信します。

Response | Echo - High performance, minimalist Go web framework

func newCSVResponse(c echo.Context, status int, data []byte) error {
    c.Response().Writer.Header().Set("Content-Disposition", "attachment; filename=tasks.csv")
    c.Response().Writer.Header().Set("Content-Type", "text/csv")
    return c.Blob(status, "text/csv", data)
}

これで実装が完了しました。

最後にリクエストを送ってみます。

curl head http://localhost:8080/api/v1/analytics --output tasks.csv

tasks.csvがダウンロードできれば完了です。

CSV以外にもGoの io パッケージ、bytes.Bufferではさまざまな形式の入出力が可能です。

こちらの記事に大変詳しく書かれていました。

Goメモ-153 (Goでのファイル I/O のやり方まとめ) - いろいろ備忘録日記

今回は以上です。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

SkyWay API使ってみた webRTC 入門

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

業務でも使用しているSkyWayのJavaScript SDK チュートリアルをやってみました。 SkyWayはwebRTC 技術のSDK API を提供しているサービスです。

無料、有料プランがありチュートリアルレベルなら無料で十分試す事ができます。

f:id:shikatech:20211127163009p:plain

webrtc.ecl.ntt.com

コード

コードです。 チュートリアルをなぞっただけなので説明は省きます。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://cdn.webrtc.ecl.ntt.com/skyway-4.4.3.js"></script>

</head>

<body>

    <video id="my-video" width="400px" autoplay muted playsinline></video>
    <p id="my-id">test</p>
    <input id="their-id"></input>
    <button id="make-call">発信</button>
    <video id="their-video" width="400px" autoplay muted playsinline></video>
    <script>

  const peer = new Peer({
    key: 'xxxxxxxxxx',
    debug: 3
  });

  peer.on('open', () => {
    document.getElementById('my-id').textContent = peer.id;
  });

        let localStream;
      navigator.mediaDevices.getUserMedia({ video: true, audio: true })
        .then(stream => {
          const videoElm = document.getElementById('my-video');
          videoElm.srcObject = stream;
          videoElm.play();
          localStream = stream;
        }).catch(error => {
          console.error('mediaDevice.getUserMedia() error:', error);
          return;
        });

  document.getElementById('make-call').onclick = () => {
    const theirID = document.getElementById('their-id').value;
    const mediaConnection = peer.call(theirID, localStream);
    setEventListener(mediaConnection);
  }

  const setEventListener = mediaConnection => {
    mediaConnection.on('stream', stream => {
      const videoElm = document.getElementById('their-video')
      videoElm.srcObject = stream;
      videoElm.play();
    });
  }

  peer.on('call', mediaConnection => {
    mediaConnection.answer(localStream);
    setEventListener(mediaConnection);
  });
    </script>
</body>

</html>

注意点としてvscodeの Live Server機能を使用したのですが、 ドメイン127.0.0.1を登録する必要がありました。 localhostでは上手く動きませんでした。

f:id:shikatech:20211128125624p:plain

実行後、無事に映像を取得することができました。

f:id:shikatech:20211128125744p:plain

PeerID

チュートリアルではPeerIDは自動生成されました。 PeerIDは電話番号の役割なので毎回自動生成されるよりも、できればサーバーやDBと連携する為に固定のものを使用したいです。

new Peer('peerID',{}) とすることで任意の値をpeerIDに指定することができます。

実際のアプリケーションでは、通話をするクライアント同士のpeerIDをサーバーを経由して共有することで発信相手を特定します。

その他のAPI

SkyWay以外にもwebRTC技術を提供しているサービスがありました。

WebRTCの商用サービスまとめ|Twilio, SkyWay, Agora.io など | Agora 開発者ブログ | ブイキューブ

今回は以上です。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

Go gorilla/sessions

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

今回はGoでcookieを扱うパッケージ gorilla/sessionsを触ってみます。https://github.com/gorilla/sessions

最初は公式のサンプルコードを見て、次に用意された構造体やメソッドの中身を見ていきたいと思います。

公式のサンプルコード

以下サンプルコード

var store = sessions.NewCookieStore([]byte("SESSION_KEY"))

func MyHandler(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session-name")
    session.Values["foo"] = "bar"
    session.Values[42] = 43
    err := session.Save(r, w)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

上記のhanderを適当なルーティングに割り当てる

func main() {
    http.HandleFunc("/gorilla", MyHandler)
    if err := http.ListenAndServe(":8081", nil); err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

レスポンスを確認

$ curl --head http://localhost:8081/gorilla 


HTTP/1.1 200 OK
Set-Cookie: session-name=MTYzNzQ4MzIxM3xEdi1CQkFFQ180SUFBUkFCRUFBQU1QLUNBQUlEYVc1MEJBSUFWQU5wYm5RRUFnQldCbk4wY21sdVp3d0ZBQU5tYjI4R2MzUnlhVzVuREFVQUEySmhjZz09fJzVsQrt4j5jQV79BAUCBahsc4ZmHKcdrEAIHn56icPZ; Path=/; Expires=Tue, 21 Dec 2021 08:26:53 GMT; Max-Age=2592000
Date: Sun, 21 Nov 2021 08:26:53 GMT

"session-name" keyに対してエンコードされたvalueが保持されている。

Get() メソッドではkeyからSession構造体の取得を行う。該当するkeyがなければ新規に登録される。

Save() メソッドで加工されたSessionを保存している。

Session

Session構造体の中身

// Session stores the values and optional configuration for a session.
type Session struct {
    // The ID of the session, generated by stores. It should not be used for
    // user data.
    ID string
    // Values contains the user-data for the session.
    Values  map[interface{}]interface{}
    Options *Options
    IsNew   bool
    store   Store
    name    string
}

cookieのオプションを設定したい時は Optionsフィールドに値を追加する。

type Options struct {
    Path   string
    Domain string
    // MaxAge=0 means no Max-Age attribute specified and the cookie will be
    // deleted after the browser session ends.
    // MaxAge<0 means delete cookie immediately.
    // MaxAge>0 means Max-Age attribute present and given in seconds.
    MaxAge   int
    Secure   bool
    HttpOnly bool
    // Defaults to http.SameSiteDefaultMode
    SameSite http.SameSite
}

HttpOnly, MaxAge, Secureを設定する。

func MyHandler(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session-name")
    session.Values["foo"] = "bar"
    session.Values[42] = 43

    session.Options.HttpOnly = true
    session.Options.MaxAge = 10000
    session.Options.Secure = true

    err := session.Save(r, w)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}
$ curl --head http://localhost:8081/gorilla
HTTP/1.1 200 OK
Set-Cookie: session-name=MTYzNzQ4NDM2MXxEdi1CQkFFQ180SUFBUkFCRUFBQU1QLUNBQUlHYzNSeWFXNW5EQVVBQTJadmJ3WnpkSEpwYm1jTUJRQURZbUZ5QTJsdWRBUUNBRlFEYVc1MEJBSUFWZz09fAznLgZpNT7cqWO_KlXh6eVzOo7NfSdwcCvG_Ulp-CCF; Path=/; Expires=Sun, 21 Nov 2021 11:32:41 GMT; Max-Age=10000; HttpOnly; Secure
Date: Sun, 21 Nov 2021 08:46:01 GMT

設定したオプションが無事に付与されている。

簡単ですが以上 gorilla/sessionを触ってみました。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

Go time.Timeの扱いで学んだこと

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

今回は本業で開発しているGoのAPIで時刻の概念をドメインとして扱ったのでその時に学んだことを復習したいと思います。

TL;DR

要件はDateTimeのような日付ではなく、時刻のみを扱い比較した結果に応じてビジネスロジックを振り分けるようなイメージです。

以下の順でユースケースごとに振り返ります。

  • MySQLのTIME型を使いたい
  • stringをtime.Timeに変換したい
  • tzをどの環境でも同じにしたい
  • Before, After で時間を比較したい

MySQLのTIME型を使いたい

開発中のプロダクトでは gormとMySQLを使用しています。

時刻のみを扱いたかったのでMySQLのTIME型 (12:00:00 )にしました。

parseTime=True オプションを付ければDATETIME型をスキャンできるみたいなのですが、どうやらTIME型はスキャンできないみたいです。

そこでDBの型を変えるかAPIの型を変えるかの二択だと思うのでですが、DBに余計な情報を入れたくなかったので後者を選びGoの型をstringに変更しました。

Go(string) ✖︎ MySQL(TIME)では上手いことスキャンしてくれました。

stringをtime.Timeに変換したい

スキャンする時はstringにしているものの、時刻である以上time.Time型にしたほうが色々と便利メソッドを使えそうです。

DB TIME型 → API String → API time.Time

こちらもDateTimeのように日付と共に扱えばtimeパッケージでいい感じに変換してくれるのですが時刻のみが正しければよかったので以下のような関数を作りました。

func StrToTimeJST(clock string) time.Time {
    t := time.Now()
    splitted := strings.Split(clock, ":")
    hour, _ := strconv.Atoi(splitted[0])
    min, _ := strconv.Atoi(splitted[1])
    parsed := time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0, tz)
    return parsed
}

DBには必ず 12:00:00の形で保存されるので 日付は常にtime.Now()を取得するようにします。

この関数で作った値を比較するときに時刻以外はすべて同じになるということです。

tzをどの環境でも同じにしたい

ここまでの実装だと問題がありました。 Docker環境とPCのローカル環境でTimeZoneが違いテストに落ちていました。

TimeZoneを追加して対応しました。

func StrToTimeJST(clock string) time.Time {
    tz, _ := time.LoadLocation("Asia/Tokyo")
    t := time.Now().In(tz)
    splitted := strings.Split(clock, ":")
    hour, _ := strconv.Atoi(splitted[0])
    min, _ := strconv.Atoi(splitted[1])
    parsed := time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0, tz)
    return parsed
}

timeパッケージのtime.LoadLocation()メソッドでtime.Location型を作るのですがこの扱いで迷ったポイントがありました。

time.Time型には In()メソッドでTimeZoneを付与できます。

time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0,  time.Local).In(tz)

ただ、上記だと Date()メソッドのtime.Localが優先されるみたいで

time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0, tz)

この書き方が正しかったみたいです。

Before, After で時間を比較したい

ここではtime.Time型に変換した旨味が得られます。

timeパッケージのAfter(), Before()メソッドが使えました。

func InTimeSpan(start, end, check time.Time) bool {
    return !start.After(check) && check.Before(end)
}

上記は start <= now < endと同意です。

AfterやBeforeは比較対象が全く同じtime.Timeだとfalseを返すので 以上, 以下 の表現はエクスクラメーションを使います。

!start.After(check)

これで同時刻でもtrueを返せます。

今回は以上となります。

時刻の概念を扱う時は言語やDBによって癖があるので毎回ベストプラクティスがを探している気がします。

個人的には言語で1番人気のライブラリに型を合わせて扱いたいです。Goだとtimeパッケージ、JavaScriptだとMomentを使ったことがあります。(確かMomentは開発中止されたらしい) Goで CreateAtのようなよくある日付の概念は扱った事があったのですが、時刻を中心としたビジネス要件を初めて設計したのでハマりどころがたくさんありました。もっとtimeパッケージを使い倒したい。。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

追記

この記事を書いた後にもっとtimeパッケージを知りたいと思って色々調べていたら

string型からtime.Time型に変換するにはこのほうが綺麗だなと思いました。

func StrToTimeJST(clock string) time.Time {
    const format = "15:04:00"
    jst, _ := time.LoadLocation("Asia/Tokyo")
    parsed, _ := time.Parse(format, clock)
    return parsed.In(jst)
}

このほうがGoっぽい。

しかしこれだとJSTではなくLMTになる問題があるみたいです。

www.m3tech.blog

結局時刻はUTCに合わせるのが1番扱いやすそう。

DDDことはじめ ~アプリケーションサービス~

こんにちは、マットです。
都内ITベンチャーのエンジニアです。
Go/Next.js/GraphQLを使っています。

前回の続きです。 今回はDDDのサービスについて振り返りたいと思います。

shikatech.hatenablog.com

アプリケーションサービス

前回に続きこちらの記事からコードを引用します。

How to Implement Domain-Driven Design (DDD) in Golang | by Percy Bolmér | Sep, 2021 | Towards Data Science

まずDDDにはドメインサービスとアプリケーションサービスという二つのサービスが登場します。

ドメインサービスはドメインの不自然さを解決するオブジェクトです。 ドメイン名sercive のように命名するのが一般的です。

対してアプリケーションサービスはドメインリポジトリを一つにまとめあげアプリケーションのユースケースを表現するオブジェクトです。

今回はアプリケーションサービスについて説明します。

まずはサービスのオブジェクトを定義します。

type OrderService struct {
    customers customer.CustomerRepository
    products  product.ProductRepository
}

customers, productsはエンティティの集約ルートオブジェクトです。 二つの集約オブジェクトを持たせOrderServiceを定義しています。

次にサービスの振る舞いをメソッドとして作成します。

func (o *OrderService) CreateOrder(customerID uuid.UUID, productIDs []uuid.UUID) (float64, error) {
    c, err := o.customers.Get(customerID)
    if err != nil {
        return 0, err
    }

    var products []product.Product
    var price float64
    for _, id := range productIDs {
        p, err := o.products.GetByID(id)
        if err != nil {
            return 0, err
        }
        products = append(products, p)
        price += p.GetPrice()
    }

    log.Printf("Customer: %s has ordered %d products", c.GetID(), len(products))

    return price, nil
}

名前の通り注文を作成するメソッドです。 Get(), GetByID()はインフラ層にあたるのでinterfaceを使用しています。

custoemr, product単体のドメインでは表現できなかった振る舞いがサービスとしてまとめられた事で見通しが良くなりました。

サービスの生成

サービスはmain.goで生成される事が多いと思いますが、テクニカルなコードが紹介されていたので振り返りたいと思います。

orderServiceと同じファイルで実装します。

type OrderConfiguration func(os *OrderService) error

func NewOrderService(cfgs ...OrderConfiguration) (*OrderService, error) {
    os := &OrderService{}

    for _, cfg := range cfgs {
        err := cfg(os)
        if err != nil {
            return nil, err
        }
    }
    return os, nil
}

func WithCustomerRepository(cr customer.CustomerRepository) OrderConfiguration {
    return func(os *OrderService) error {
        os.customers = cr
        return nil
    }
}

func WithMongoCustomerRepository(connectionString string) OrderConfiguration {
    return func(os *OrderService) error {
        cr, err := mongo.New(context.Background(), connectionString)
        if err != nil {
            return err
        }
        os.customers = cr
        return nil
    }
}

参考記事ではMongoDB, インメモリ二つのデータストアが提案されています。

NewOrderService()で可変量のOrderConfiguration()を受け取り簡単に永続先を入れ替える事を可能にしています。

order.NewOrderService(      
  order.WithMongoCustomerRepository("connection"),
  // or
  order.WithCustomerRepository(memoRepo),
    )

以上になります。 今回はアプリケーションサービスの振り返りをしました。

リポジトリパターンについては多くのアーキテクチャでも紹介されているので割愛させていただきます。

まとめと感想

DDDの学習は綺麗なコードを書く為にかなり即効性がある項目だと感じました。一方で個人で取り入れるには軽量DDD(いくつかのパターンだけを使用する方法)になってしまいがちです。

現職のチームでは軽量DDDに近い体制なのですが、もう少し踏み込んで取り入れていくと良いインパクトになりそうなので提案していきたいと思います。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。