2026年6月26日金曜日

Go + gin で dispatcher パターン入門

Go + gin で dispatcher パターン入門

概要

簡単なサンプルを紹介します
公式サンプルが基本一番わかりやすいです

環境

  • macOS 26.5.1
  • golang 1.26.4
    • gin 1.12.0

準備

とりあえず動かす

ルータを作成しルーティング情報を追加します
ルーティングが行う処理は関数として渡します

gin にはレスポンスを返すためのメソッド (application/json なら JSON や text/plain なら String などなど) があります

ルーティングを登録したら Run で起動できます

  • vim main.go
package main

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

func main() {
        // ルーター作成(デフォルト設定)
        r := gin.Default()

        // GET / にアクセスしたときの処理
        r.GET("/", func(c *gin.Context) {
                c.JSON(200, gin.H{
                        "message": "Hello, Gin!",
                })
        })

        // GET /ping
        r.GET("/ping", func(c *gin.Context) {
                c.String(200, "pong")
        })

        // サーバー起動(デフォルト: :8080)
        r.Run()
}
  • go run main.go
curl localhost:8080     
{"message":"Hello, Gin!"}

curl localhost:8080/ping
pong

こんな感じで動作します

POST + JSON でボディを受け取る

これも王道な方法です
fastapi に近い感じですが gin の場合は struct を準備しボディ情報を自動で変換してくれます
変換できない場合はエラーを返してくれます

  • vim main.go
package main

import (
        "net/http"

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

// リクエスト用構造体
type User struct {
        Name  string `json:"name" binding:"required"`
        Email string `json:"email" binding:"required,email"`
        Age   int    `json:"age"`
}

func main() {
        r := gin.Default()

        // GET /
        r.GET("/", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{
                        "message": "Hello, Gin!",
                })
        })

        // POST /user
        r.POST("/user", func(c *gin.Context) {
                var user User

                // JSONを構造体にバインド
                if err := c.ShouldBindJSON(&user); err != nil {
                        c.JSON(http.StatusBadRequest, gin.H{
                                "error": err.Error(),
                        })
                        return
                }

                // 成功レスポンス
                c.JSON(http.StatusOK, gin.H{
                        "message": "user created",
                        "user":    user,
                })
        })

        r.Run()
}

正常系と異常系は以下の通りです

curl -X POST http://localhost:8080/user \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Taro",
    "email": "taro@example.com",
    "age": 20
  }'
{"message":"user created","user":{"name":"Taro","email":"taro@example.com","age":20}}
curl -X POST http://localhost:8080/user \
  -H "Content-Type: application/json" \
  -d '{
    "email": "taro@example.com",
    "age": 20
  }'
{"error":"Key: 'User.Name' Error:Field validation for 'Name' failed on the 'required' tag"}

POST + JSON でボディの生情報を扱う

c.Request.Body で取得できます gin ではボディ情報は IO ストリームです
ストリームなので Read するのですが Read した場合次の Read ではデータがないのでエラーになります
つまり一度しか Read できないので注意しましょう

package main

import (
        "encoding/json"

        "io"
        "net/http"

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

func main() {
        r := gin.Default()

        r.POST("/raw", func(c *gin.Context) {
                body, err := io.ReadAll(c.Request.Body)
                if err != nil {
                        c.String(http.StatusBadRequest, "read error")
                        return
                }

                // byte → string
                raw := string(body)

                c.JSON(http.StatusOK, gin.H{
                        "raw": raw,
                })
        })

        r.POST("/raw-to-map", func(c *gin.Context) {
                body, _ := c.GetRawData()

                var data map[string]interface{}

                if err := json.Unmarshal(body, &data); err != nil {
                        c.JSON(http.StatusBadRequest, gin.H{
                                "error": "invalid json",
                        })
                        return
                }

                c.JSON(http.StatusOK, gin.H{
                        "map": data,
                })
        })

        r.Run()
}

動作確認は以下です

curl -X POST http://localhost:8080/raw \
  -H "Content-Type: application/json" \
  -d '{"name":"taro"}'
{"raw":"{\"name\":\"taro\"}"}
curl -X POST http://localhost:8080/raw-to-map \
  -H "Content-Type: application/json" \
  -d '{"name":"taro"}'
{"map":{"name":"taro"}}

ルーティングの処理用の関数(ハンドラ)を切り出す

実際のプロダクションではもっと大規模になるはずなのでハンドラを切り出す必要があります

ポイントとしては切り出した関数は *gin.Context を持つ必要があります

  • vim handler/user.go
package handler

import (
        "net/http"

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

// 依存を持つための構造体
type UserHandler struct {
        AppName string
}

// コンストラクタ
func NewUserHandler(appName string) *UserHandler {
        return &UserHandler{
                AppName: appName,
        }
}

// GET /
func (h *UserHandler) Hello(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
                "message": "Hello from " + h.AppName,
        })
}

// POST /user
func (h *UserHandler) CreateUser(c *gin.Context) {
        var body map[string]interface{}

        if err := c.ShouldBindJSON(&body); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

        c.JSON(http.StatusOK, gin.H{
                "app":  h.AppName,
                "user": body,
        })
}
  • vim main.go
package main

import (
        "gin-sample/handler"

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

func main() {
        r := gin.Default()

        // ハンドラ生成(依存注入)
        userHandler := handler.NewUserHandler("MyApp")

        // ルーティング
        r.GET("/", userHandler.Hello)
        r.POST("/user", userHandler.CreateUser)

        r.Run()
}

動作確認は以下です

curl -X POST http://localhost:8080/user \      
  -H "Content-Type: application/json" \
  -d '{               
    "email": "taro@example.com",
    "age": 20
  }'
{"app":"MyApp","user":{"age":20,"email":"taro@example.com"}}
curl -X GET http://localhost:8080/
{"message":"Hello from MyApp"}

dispatcher パターンを使う

特定のクエリストリングで処理を分けたい場合などに使えます
REST などでは基本使いませんが / しか受けないような API でパラメータに応じて処理を分けたい場合などに使えるパターンです

特定のアクションに応じた関数を登録しておくことでアクションごとに指定の関数を実行できます

  • vim handler/dispatcher.go
package handler

import (
	"errors"
	"net/http"
)

// Actionの関数型
type ActionFunc func(params map[string]string, method string, r *http.Request) (any, error)

type Dispatcher struct {
	actions map[string]ActionFunc
}

func NewDispatcher() *Dispatcher {
	return &Dispatcher{
		actions: make(map[string]ActionFunc),
	}
}

// Action登録
func (d *Dispatcher) Register(name string, fn ActionFunc) {
	d.actions[name] = fn
}

// 実行
func (d *Dispatcher) Execute(name string, params map[string]string, method string, r *http.Request) (any, error) {
	if fn, ok := d.actions[name]; ok {
		return fn(params, method, r)
	}
	return nil, errors.New("unknown action: " + name)
}

ハンドラのメインとなる処理です
dispathcher に登録されたアクションごとの関数を実行する前やあとにする処理を記載することができます

  • handler/action_handler.go
package handler

import (
	"fmt"
	"net/http"

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

func HandleAction(dispatcher *Dispatcher) gin.HandlerFunc {
	return func(c *gin.Context) {
		requestID := c.GetHeader("X-Request-Id")
		if requestID == "" {
			requestID = "dummy-request-id"
		}

		// パラメータ統合
		if err := c.Request.ParseForm(); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}

		params := make(map[string]string)
		for key, values := range c.Request.Form {
			if len(values) > 0 {
				params[key] = values[0]
			}
		}

		actionName := params["Action"]

		result, err := dispatcher.Execute(actionName, params, c.Request.Method, c.Request)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{
				"error": err.Error(),
			})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"requestId": requestID,
			"action":    actionName,
			"result":    result,
		})
	}
}

メインではアクションとそれに紐づく関数の登録を行います
紐づく関数は今回 main.go に記載していますが別の箇所に記載しても OK です

  • vim main.go
package main

import (
        "fmt"
        "net/http"
        "strconv"

        "gin-sample/handler"

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

func main() {
        // Dispatcher作成
        dispatcher := handler.NewDispatcher()

        // Action登録
        dispatcher.Register("Hello", helloAction)
        dispatcher.Register("Sum", sumAction)

        // Gin
        r := gin.Default()

        // ★ここがポイント(引数付きハンドラ)
        r.GET("/", handler.HandleAction(dispatcher))
        r.POST("/", handler.HandleAction(dispatcher))

        r.Run()
}

// --- Action実装 ---

func helloAction(params map[string]string, method string, r *http.Request) (any, error) {
        name := params["name"]
        if name == "" {
                name = "guest"
        }
        return map[string]string{
                "message": "hello " + name,
        }, nil
}

func sumAction(params map[string]string, method string, r *http.Request) (any, error) {
        aStr := params["a"]
        bStr := params["b"]

        // 文字列 → 数値変換
        a, err := strconv.Atoi(aStr)
        if err != nil {
                return nil, fmt.Errorf("invalid parameter 'a'")
        }

        b, err := strconv.Atoi(bStr)
        if err != nil {
                return nil, fmt.Errorf("invalid parameter 'b'")
        }

        sum := a + b

        return map[string]interface{}{
                "a":   a,
                "b":   b,
                "sum": sum,
        }, nil
}

動作確認は以下です

curl "http://localhost:8080/?Action=Hello&name=Taro"
{"action":"Hello","requestId":"dummy-request-id","result":{"message":"hello Taro"}}

curl -X POST http://localhost:8080 \
  -d "Action=Sum&a=10&b=20"
{"action":"Sum","requestId":"dummy-request-id","result":{"a":10,"b":20,"sum":30}}

最後に

gin で dispatcher パターンを試してみました
エラーハンドリングやレスポンスモデルの作成などまだありますがリクエストのハンドリングの部分はだいたい試せたかなと思います

参考サイト

2026年6月12日金曜日

VSCode の Ruby LSP をセットアップする方法

VSCode の Ruby LSP をセットアップする方法

概要

https://marketplace.visualstudio.com/items?itemName=Shopify.ruby-lsp これをセットアップする方法を紹介します

環境

  • macOS 26.5.1
  • VSCode 1.123.1
    • Ruby LSP 0.10.4

watchman のインストール

  • brew install watchman

watchman はグローバルにあるものを使うので homebrew でインストールします

sorbet のインストール

  • vim Gemfile
group :development do
  gem 'sorbet'
  gem 'sorbet-runtime'
  gem 'tapioca'
end

sorbet を使うのでインストールします
これは開いているプロジェクト内で bundle 経由で実行するので Gemfile からインストールします

tapioca の初期化

必要な rbi ファイルを作成します

  • bundle exec tapioca init
  • bundle exec tapioca gems

これで bin/tapiocasorbet/ が生成されます

rubocop を使っている場合は

.rubocop.yaml に以下を追記しておきましょう

Style/StringLiterals:
  Exclude:
    - 'bin/tapioca'

動作確認

VSCode を開くと自動的に Ruby LSP が起動します
状態が Idle になれば OK です

Restarting を繰り返している場合は Output を確認し Sorbet や watchman が正しく動作しているか確認しましょう
起動後に自作のクラスなどで警告が出る場合は tapioca で rbi ファイルなどが生成されている確認しましょう

最後に

bundle exec tapioca gems は定期的に実行する必要があります

2026年6月11日木曜日

VSCode + copilot + Playwright MCP サーバでブラウザ操作をする

VSCode + copilot + Playwright MCP サーバでブラウザ操作をする

概要

過去に puppetter を使いましたが vscode + copilot がいい感じらしいのでそっちも試してみます

環境

事前作業

nodejs が必要なのでインストールしましょう

https://nodejs.org/ja/download

インストール

拡張機能の一覧から playwright-mcp をインストールできるのでそこからインストールしましょう

@mcp playwright で検索しインストールしましょう

検索できない場合は settings.json で以下を追加します

{
	"chat.mcp.gallery.enabled": true
}

なおこの設定は C:\Users\username\AppData\Roaming\Code\User\mcp.json にあります

{
	"servers": {
		"microsoft/playwright-mcp": {
			"type": "stdio",
			"command": "npx",
			"args": [
				"@playwright/mcp@latest"
			],
			"gallery": "https://api.mcp.github.com",
			"version": "0.0.1-seed"
		}
	},
	"inputs": []
}

起動する

インストールした MCP の設定ページから起動します
「Start Server」で起動できます

起動ログが下部のターミナル画面に表示されるので問題なく表示されることを確認しましょう

自動起動したい場合は settings.json に以下を追加しましょう

{
	"chat.mcp.autostart": "newAndOutdated"
}

動作確認

copilot のプロンプトに以下のように入力しましょう

https://hawksnowlog.blogspot.com/ にアクセスしホームページのスクリーンショットを送ってください

これで別途ブラウザが起動しスクリーンショットを保存してくれます
スクリーンショットは png 形式で開いているプロジェクトのルートに保存されます
また .playwright-mcp というディレクトリが作成され処理中のファイルが保存されるので不要であれば .gitignore に追加しておきましょう

プロンプトが雑だと「Sorry, the response matched public code so it was blocked. Please rephrase your prompt.」になるので playwright-mcp が反応してくれそうなワード (例えばスクリーンショットを取得してやここをクリックしてなど) をプロンプトに追加するようにしましょう

最後に

VSCode + copilot + playwright mcp でブラウザを自動操作してみました
設定自体はかなり簡単です

問題はブラウザのプロファイルで完全に新規のプロファイルになるのでセッション情報などがありません
つまりログイン済みのページや認証が必要なページなどはアクセスできません

このあたりも解決方法があれば紹介したいと思います

参考サイト

2026年6月10日水曜日

SpringBoot で actuator を有効にアプリのプロファイリングをする

SpringBoot で actuator を有効にアプリのプロファイリングをする

概要

spring-boot-starter-actuator を使います

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
    • jobrunr 8.6.1
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

インストール

dependencies に以下を追記しましょう

  • vim build.gradle
dependencies {
       implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

有効化

プロパティファイルを編集するだけです
既存の application.properties に以下を追記しましょう

  • vim src/main/resources/application.properties
# Spring Boot Actuator
management.endpoints.web.exposure.include=health,info,metrics,env,beans,mappings,loggers,threaddump
management.endpoint.health.show-details=always

動作確認

  • ./gradlew clean && ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'

以下のエンドポイントが使えるようになります

# ヘルスチェック(DB/Redis含む接続状態)
curl http://localhost:8080/actuator/health

# メトリクス一覧
curl http://localhost:8080/actuator/metrics
# 個別メトリクス例: JVMヒープ使用量
curl http://localhost:8080/actuator/metrics/jvm.memory.used
# 個別メトリクス例: HTTPリクエスト統計
curl "http://localhost:8080/actuator/metrics/http.server.requests"

# 環境変数・設定値一覧(パスワード等はマスクされる)
curl http://localhost:8080/actuator/env

# 登録済みSpring Bean一覧
curl http://localhost:8080/actuator/beans

# 登録済みHTTPルート一覧
curl http://localhost:8080/actuator/mappings

# ロガーレベル一覧 / 動的変更
curl http://localhost:8080/actuator/loggers
curl -X POST http://localhost:8080/actuator/loggers/com.example.demo \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel":"DEBUG"}'

# スレッドダンプ
curl http://localhost:8080/actuator/threaddump

最後に

SpringBoot の actuator を有効にし様々なメトリイックスを取得する方法を紹介しました
Spring コンテナないの Bean 情報なども取得できます

Prometheus 専用のメトリックスはデフォルトではないので https://mvnrepository.com/artifact/io.micrometer/micrometer-registry-prometheus を追加する必要があります

2026年6月9日火曜日

SpringBoot のエラーハンドリングで500エラー時にトレースバックを表示する方法

SpringBoot のエラーハンドリングで500エラー時にトレースバックを表示する方法

概要

これまでカスタムエラーを作成しそれをハンドリングし返却できるようにしました
今回はそれにトレースバックを表示する方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
    • jobrunr 8.6.1
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

エラーハンドラの修正

各種エラーをハンドリングした際に Exception 情報は受け取れるのでそこからスタックトレース情報を取得します
今回は dev の場合だけレスポンスにも trace 情報を含めるようにしていますがこの機能は不要であれば削除して OK です

@RestControllerAdvice で定義したエラーハンドラのクラスでは引数で Environment が受け取れるのでこれを元にデプロイ環境を判断しています

  • vim src/main/java/com/example/demo/exception/GlobalExceptionHandler.java
package com.example.demo.exception;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.LocalDateTime;
import java.util.Arrays;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.example.demo.exception.dto.ErrorResponse;

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;

@RestControllerAdvice
public class GlobalExceptionHandler {

        private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
        private final Environment environment;

        public GlobalExceptionHandler(Environment environment) {
                this.environment = environment;
        }

        @ExceptionHandler(BusinessException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public ErrorResponse handleBusinessException(BusinessException ex) {
                return new ErrorResponse(ex.getCode(), ex.getMessage(), LocalDateTime.now());
        }

        @ExceptionHandler(Exception.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ErrorResponse handleException(Exception ex) {
                log.error("Unexpected internal server error", ex);
                String trace = isDevProfile() ? getStackTrace(ex) : null;
                return new ErrorResponse("INTERNAL_SERVER_ERROR", "予期しないエラーが発生しました", LocalDateTime.now(), trace);
        }

        private boolean isDevProfile() {
                return Arrays.stream(environment.getActiveProfiles()).anyMatch("dev"::equalsIgnoreCase);
        }

        private String getStackTrace(Exception ex) {
                StringWriter sw = new StringWriter();
                PrintWriter pw = new PrintWriter(sw);
                ex.printStackTrace(pw);
                pw.flush();
                return sw.toString();
        }
}

エラーレスポンス用の DTO の修正

レスポンスに trace 情報を含めたい場合は追加してください
不要な場合はこちらの修正は不要です

  • vim src/main/java/com/example/demo/exception/dto/ErrorResponse.java
package com.example.demo.exception.dto;

import java.time.LocalDateTime;

public class ErrorResponse {

        private String code;
        private String message;
        private LocalDateTime timestamp;
        private String trace;

        public ErrorResponse(String code, String message, LocalDateTime timestamp) {
                this(code, message, timestamp, null);
        }

        public ErrorResponse(String code, String message, LocalDateTime timestamp, String trace) {
                this.code = code;
                this.message = message;
                this.timestamp = timestamp;
                this.trace = trace;
        }

        public String getCode() {
                return code;
        }

        public String getMessage() {
                return message;
        }

        public LocalDateTime getTimestamp() {
                return timestamp;
        }

        public String getTrace() {
                return trace;
        }
}

動作確認

  • ./gradlew clean && ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'

で500エラーを発生させトレースバックが表示されることを確認しましょう

最後に

ハンドリングできている40x系のエラーはトレース不要なケースが多いですが500系のエラーは基本ハンドリングできていない予期せぬエラーの場合が多いのでその場合はトレースバックをちゃんと出力させましょう

2026年6月8日月曜日

SpringBoot のダッシュボードと本体の Web アプリを分離する方法

SpringBoot のダッシュボードと本体の Web アプリを分離する方法

概要

前回 Web アプリとワーカーを分離しました
更にダッシュボードを追加しましたが Web アプリと同じプロセスで起動するのでセキュリティ的に微妙でした
今回はダッシュボードアプリと Web アプリのプロセスを分離する方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
    • jobrunr 8.6.1
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

ダッシュボード用アプリの作成

JobRunr の設定が必要なので @Import で取り込みます
JobRunrConfig はこちらです

  • vim src/main/java/com/example/demo/DashboardApplication.java
package com.example.demo;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

import com.example.demo.config.JobRunrConfig;
import com.example.demo.config.JobRunrDashboardProperties;
import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties;

@EnableEncryptableProperties
@Configuration
@EnableAutoConfiguration
@ConfigurationPropertiesScan(basePackageClasses = JobRunrDashboardProperties.class)
@Import(JobRunrConfig.class)
public class DashboardApplication {

        public static void main(String[] args) {
                new SpringApplicationBuilder(DashboardApplication.class).profiles("dashboard").run(args);
        }
}

ダッシュボード用設定の追加

Web アプリとしての設定とワーカーとしてのはオフにしダッシュボードだけ有効にします

  • vim src/main/resources/application-dashboard.properties
spring.main.web-application-type=none

app.jobrunr.background-job-server.enabled=false
app.jobrunr.dashboard.enabled=true
app.jobrunr.dashboard.port=8000

Web アプリケーションとワーカーの設定は以下の通りです

  • vim src/main/resources/application.properties
spring.application.name=demo
# update はアプリケーション起動時に、Entityに対応するテーブルがなければ作成します
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:127.0.0.1}:3306/mydatabase
spring.datasource.username=ENC(bFMupckSRHJ/9QmXd1EVTw==)
spring.datasource.password=ENC(QFCt95vHHcJpgm8oKhghkJoEknQv3tF0)

spring.data.redis.host=localhost
spring.data.redis.port=6379

# Web起動時はJobRunrのBackgroundJobServerを無効化(worker jarで有効化)
app.jobrunr.background-job-server.enabled=false

# JobRunr Dashboard (管理画面)
app.jobrunr.dashboard.enabled=false
app.jobrunr.dashboard.port=8000

# Jasypt 暗号化キーの設定(環境変数から取得)
jasypt.encryptor.algorithm=PBEWithMD5AndDES
jasypt.encryptor.iv-generator-classname=org.jasypt.iv.NoIvGenerator
jasypt.encryptor.password=${JASYPT_PASSWORD:default_password}
  • vim src/main/resources/application-worker.properties
spring.main.web-application-type=none

app.jobrunr.background-job-server.enabled=true
app.jobrunr.dashboard.enabled=false

build.gradle の修正

ダッシュボードアプリを起動するタスクとダッシュボード用の jar を作成するタスクを追加します

  • vim build.gradle
tasks.register('dashboardBootRun', org.springframework.boot.gradle.tasks.run.BootRun) {
       group = 'application'
       description = 'Runs the JobRunr dashboard process using DashboardApplication.'
       mainClass = 'com.example.demo.DashboardApplication'
       classpath = sourceSets.main.runtimeClasspath
}

tasks.register('dashboardBootJar', org.springframework.boot.gradle.tasks.bundling.BootJar) {
       group = 'build'
       description = 'Builds the executable dashboard jar.'
       archiveClassifier = 'dashboard'
       mainClass = 'com.example.demo.DashboardApplication'
       classpath = sourceSets.main.runtimeClasspath
       targetJavaVersion = webBootJar.get().targetJavaVersion
       dependsOn tasks.named('classes')
}

tasks.named('assemble') {
       dependsOn tasks.named('workerBootJar')
       dependsOn tasks.named('workerBootJar'), tasks.named('dashboardBootJar')
}

動作確認

  • ./gradlew clean && ./gradlew dashboardBootRun -Dspring-boot.run.mainClass=com.example.demo.DashboardApplication --args='--jasypt.encryptor.password=asdfghjkl'

で localhost:8000/dashboard にアクセスすると JobRunr のダッシュボードにアクセスできます

jar は JASYPT_PASSWORD="xxx" ./gradlew clean build で作成します

  • JASYPT_PASSWORD=asdfghjkl java -jar build/libs/demo-0.0.1-SNAPSHOT-dashboard.jar

で実行します

最後に

SpringBoot のダッシュボード機能を Web アプリケーション本体と分離する方法を紹介しました
MySQL の設定を変更するだけで他のホストでも変更できるので基本はアプリのサーバとは別のサーバで動作させるのがいいでしょう

2026年6月6日土曜日

SpringBoot + JobRunr で JobRunr の管理画面を追加する方法

SpringBoot + JobRunr で JobRunr の管理画面を追加する方法

概要

JobRunr にはデフォルトでキューやジョブ情報を確認するための管理画面機能があるのでそれを追加する方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
    • jobrunr 8.6.1
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

管理画面の設定

まずはプロパティファイルに管理画面の設定情報を記載します
今回追加したのは app.jobrunr.dashboard.enabled=trueapp.jobrunr.dashboard.port=8000 です

  • vim src/main/resources/application.properties
spring.application.name=demo
# update はアプリケーション起動時に、Entityに対応するテーブルがなければ作成します
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:127.0.0.1}:3306/mydatabase
spring.datasource.username=ENC(bFMupckSRHJ/9QmXd1EVTw==)
spring.datasource.password=ENC(QFCt95vHHcJpgm8oKhghkJoEknQv3tF0)

spring.data.redis.host=localhost
spring.data.redis.port=6379

# Web起動時はJobRunrのBackgroundJobServerを無効化(worker jarで有効化)
app.jobrunr.background-job-server.enabled=false

# JobRunr Dashboard (管理画面)
app.jobrunr.dashboard.enabled=true
app.jobrunr.dashboard.port=8000

# Jasypt 暗号化キーの設定(環境変数から取得)
jasypt.encryptor.algorithm=PBEWithMD5AndDES
jasypt.encryptor.iv-generator-classname=org.jasypt.iv.NoIvGenerator
jasypt.encryptor.password=${JASYPT_PASSWORD:default_password}

またワーカー側の設定ファイルではそれらを無効にする設定を記載します

  • vim src/main/resources/application-worker.properties
spring.main.web-application-type=none

app.jobrunr.background-job-server.enabled=true
app.jobrunr.dashboard.enabled=false

管理画面の設定値を Bean に追加

先ほどプロパティファイルに追加した値を Bean に追加するためにプロパティファイルの値を管理するクラスを作成します

前回も説明しましたが Bean ではプロパティファイルの値を自動で読み込んでくれないためクラスを準備する必要があります

  • vim src/main/java/com/example/demo/config/JobRunrDashboardProperties.java
package com.example.demo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.jobrunr.dashboard")
public class JobRunrDashboardProperties {

        private boolean enabled = true;
        private int port = 8000;

        public boolean isEnabled() {
                return enabled;
        }

        public void setEnabled(boolean enabled) {
                this.enabled = enabled;
        }

        public int getPort() {
                return port;
        }

        public void setPort(int port) {
                this.port = port;
        }
}

管理画面の有効化

先ほど追加したプロパティの値を取得するクラスを使ってダッシュボードを有効化します

JobRunr の設定は Bean として登録してあるので先程のクラスを使う必要があります

有効化する実体は jobRunrConfiguration.useDashboard でここにプロパティの値を渡します

  • vim src/main/java/com/example/demo/config/JobRunrConfig.java
package com.example.demo.config;

import javax.sql.DataSource;

import org.jobrunr.configuration.JobRunr;
import org.jobrunr.scheduling.JobScheduler;
import org.jobrunr.server.JobActivator;
import org.jobrunr.storage.StorageProvider;
import org.jobrunr.storage.StorageProviderUtils.DatabaseOptions;
import org.jobrunr.storage.sql.mysql.MySqlStorageProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JobRunrConfig {

        @Bean
        StorageProvider storageProvider(DataSource dataSource) {
                return new MySqlStorageProvider(dataSource, DatabaseOptions.CREATE);
        }

        @Bean
        JobActivator jobActivator(ApplicationContext applicationContext) {
                return applicationContext::getBean;
        }

        @Bean
        JobScheduler jobScheduler(StorageProvider storageProvider, JobActivator jobActivator,
                        JobRunrBackgroundJobServerProperties jobRunrBackgroundJobServerProperties,
                        JobRunrDashboardProperties jobRunrDashboardProperties) {
                var jobRunrConfiguration = JobRunr.configure().useStorageProvider(storageProvider).useJobActivator(jobActivator)
                                .useBackgroundJobServerIf(jobRunrBackgroundJobServerProperties.isEnabled());

                if (jobRunrDashboardProperties.isEnabled()) {
                        jobRunrConfiguration.useDashboard(jobRunrDashboardProperties.getPort());
                }

                return jobRunrConfiguration.initialize().getJobScheduler();
        }
}

動作確認

localhost:8000/dashboard にアクセスすると管理画面にアクセスできます

最後に

JobRunr の管理画面を追加する方法を紹介しました
このままだと Web アプリケーションの本体に管理画面が付属してしまい予期せぬアクセスが発生する場合があるので本来はダッシュボードのアプリだけを切り出して別プロセスで起動するのが定石です

次回はダッシュボードを分離する方法を紹介します

参考サイト