JMXでログイン情報を管理してみた

「踊ってみた」みたいなタイトルですが、真面目な記事です。

ちょっと前に自分が参加しているプロジェクトでJMXについて調査してみたんですが、これ使えば簡単な設定なら設定画面作らなくてもいいじゃないかと思ったんです。 
お客さんには勧めづらいと思うけど、社内の自社製品とかなら設定画面作る工数を削減できるんじゃないかと思って簡単なログイン情報管理できるアプリを作って使用感などを見てみました。

## 環境

今回は簡易機能なのでDBは使用してないです。
ログインするだけの簡単なWebアプリケーションをSpringを使用して作成しました。
使用したライブラリなんかは以下の通りです。

- OpenJDK 13.0.2
- Sprinig Boot 2.2.5
- Thymeleaf 3.0.11
- Apache Commons CLI 1.4
- Tomcat 9.0.31

## ログイン管理の仕様

ログイン管理の仕様について簡単に記載してみます。

- ログインユーザ一覧が閲覧できる。
- 最大ログイン人数が設定できる。
- IDロックが実施できる。

ログイン管理機能についてはこのぐらいでいいでしょう。
あまり多くの機能を実装するのはめんどくさいので。

## サーバ側の実装について

ログインするだけの簡単なWebアプリケーションを作成しました。
実装はSpringMVCを使ったごくごく一般的なWebアプリケーションです。
ログインした情報をJMXで閲覧できるように実装しています。

### MBeanインタフェースの作成

ログイン情報を管理するためのMBeanを作成しました。
まずはインタフェースを作成します。

```
public interface LoginMonitorMBean {

    public static final String NAME = "examples.jmx:type=LoginMonitoring";

    public static final String LUI_ITEM_ID = "id";

    public static final String LUI_ITEM_NAME = "name";

    public int getLoginCount();

    public CompositeData[] getLoginInfos();

    void addLoginInfo(CompositeData loginUserInfo);

    public void removeLoginInfo(int id);

    public void resetLoginInfo();

    public int getMaxLoginCount();

    public void setMaxLoginCount(int count);

    public int[] getLoginLockIds();

    public void addLoginLockId(int id);

    public void removeLoginLockId(int id);

    public void resetLoginLockId();

    public static ObjectName createObjectName() {
        try {
            return new ObjectName(LoginMonitorMBean.NAME);
        } catch (MalformedObjectNameException e) {
            throw new IllegalArgumentException(e);
        }
    }

}
```

インタフェース名の末尾には必ずMBeanとつける必要があるみたいです。
定数とかstaticメソッドは無視してください。
それ以外のメソッドがjconsoleから操作できるメソッド群です。
それぞれのメソッドについての説明を以下に記載します。

| メソッド名 | 説明 |
| --- | --- |
| getLoginCount | ログイン数が取得できる。 |
| getLoginInfos | ログインしているユーザのIDと名称が取得できる。 |
| addLoginInfo | ログイン情報を追加することができる。 |
| removeLoginInfo | 指定されたIDのログイン情報を削除する。 |
| resetLoginInfo | ログイン情報をリセットする。 |
| getMaxLoginCount | 最大ログイン数を取得できる。 |
| setMaxLoginCount | 最大ログイン数を設定できる。 |
| getLoginLockIds | ロックしているIDの配列を取得できる。 |
| addLoginLockId | ロックするIDを追加することができる。 |
| removeLoginLockId | ロックしているIDを削除する。 |
| resetLoginLockId | ロックしているIDをリセットする。 |

### 管理クラスの作成

先程作成したインタフェースを実装したクラスを作成します。

```
public class LoginMonitor implements LoginMonitorMBean {
}
```

管理クラスはインタフェース名からMBeanを除いたクラス名にしないとエラーとなってしまいます。
実装内容は省略しますが、基本的にはクラスフィールドにログイン情報やロックIDなどを保持、取得するだけの簡単な実装となっています。

### MBeanの登録

ログイン情報を保持しているMBeanを登録します。
登録はWebアプリケーション起動時に行います。

```
public class StartupBean {

    private static Logger log = LoggerFactory.getLogger(StartupBean.class);

    @PostConstruct
    public void initAfterStartup() {
        try {
            log.info("MBean登録処理");
            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
            mbs.registerMBean(new LoginMonitor(), LoginMonitorMBean.createObjectName());
        } catch (InstanceAlreadyExistsException | MBeanRegistrationException | NotCompliantMBeanException e) {
            throw new IllegalStateException(e);
        }
    }

}
```

### ログイン情報を保持するクラスについて

一般的なsetter/getterが実装されているデータクラス（DTO的なクラス）ではjconsoleからデータの内容を閲覧することはできません。
jconsoleなどから閲覧できるようにするためには`javax.management.openmbean.CompositeData`を使用します。

ログイン情報を保持する際の例を見てます。

```
public class LoginUserInfo {

    private static final String ID = LoginMonitorMBean.LUI_ITEM_ID;

    private static final String NAME = LoginMonitorMBean.LUI_ITEM_NAME;

    private final int id;

    private final String name;

    public LoginUserInfo(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void addMBean(LoginMonitorMBean mbean) throws OpenDataException {
        CompositeType compositeType = new CompositeType(
                "LoginUserInfo",
                "ログインユーザ情報を保持するデータ型",
                new String[] { ID, NAME },
                new String[] { "ログインユーザID", "ログインユーザ名" },
                new OpenType[] { SimpleType.INTEGER, SimpleType.STRING });
        Map<String, Object> dataMap = new HashMap<>();
        dataMap.put(ID, id);
        dataMap.put(NAME, name);
        mbean.addLoginInfo(new CompositeDataSupport(compositeType, dataMap));
    }

    public static int getMBeanId(CompositeData data) {
        return (Integer) data.get(ID);
    }

}
```

`addMBean`メソッドが`javax.management.openmbean.CompositeData`のデータを登録している例となります。
この`LoginUserInfo`はよくあるログインIDとログインユーザ名を保持するためだけの簡単なデータクラスです。
ログイン時に使用したIDとデータベースなどから取得したユーザ名（今回は簡易アプリのため固定値）を保持します。
このクラスの情報を`javax.management.openmbean.CompositeData`インタフェースの実装クラスである`javax.management.openmbean.CompositeDataSupport`に変換しています。
このようにしておくと、jconsoleから以下のように閲覧することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440605/15dee3f5-dd2b-d0c5-68cb-4112b6472b63.png)

### ログイン機能について

ログイン機能は簡単なものとなっています。
ログイン後にJMXで管理しているMBeanを取り出し、ログイン上限やロックIDのチェックを行い、エラーならなければログイン情報をMBeanに登録しています。
実装した`LoginController`は以下の通りです。

```
@Controller
public class LoginController {

    @PostMapping("/login")
    public String login(@Validated @ModelAttribute LoginForm form, BindingResult result, Model model) {
        // 入力エラーチェック
        if (result.hasErrors()) {
            model.addAttribute("validationError", "入力エラー");
            return "login";
        }

        // ログインチェック
        if (100 > form.getLoginId() && 300 < form.getLoginId()) {
            model.addAttribute("validationError", "ログインエラー");
            return "login";
        }
        if (!"testtest".equals(form.getLoginPasswd())) {
            model.addAttribute("validationError", "ログインエラー");
            return "login";
        }

        MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
        LoginMonitorMBean mbean = JMX.newMBeanProxy(mbs, LoginMonitorMBean.createObjectName(), LoginMonitorMBean.class);

        // ログイン人数チェック
        if (mbean.getMaxLoginCount() > 0 && mbean.getLoginCount() >= mbean.getMaxLoginCount()) {
            model.addAttribute("validationError", "ログイン人数制限");
            return "login";
        }

        // IDロックチェック
        if (Arrays.stream(mbean.getLoginLockIds()).filter(lockId -> lockId == form.getLoginId()).findFirst()
                .isPresent()) {
            model.addAttribute("validationError", "IDロック中");
            return "login";
        }

        // ログイン情報登録
        try {
            LoginUserInfo info = new LoginUserInfo(form.getLoginId(),
                    String.format("テストユーザ（%d）", form.getLoginId()));
            info.addMBean(mbean);
        } catch (OpenDataException e) {
            e.printStackTrace();
            model.addAttribute("validationError", "内部システムエラー");
            return "login";
        }

        model.addAttribute("loginCount", mbean.getLoginCount());
        return "home";
    }

}
```

### 起動パラメータ

今回のWebアプリケーションはSpring Bootなので以下のような感じで起動できます。

```
java -jar jmx-examples-1.0.0.war
```

登録したMBeanをリモートで確認するためには以下ように起動パラメータを設定する必要があります。

```
java -Dcom.sun.management.jmxremote.port=5000 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -jar jmx-examples-1.0.0.war
```

| 起動パラメータ | 説明 |
| --- | --- |
| com.sun.management.jmxremote.port | MBeanサーバーのポート番号を指定することができます。 |
| com.sun.management.jmxremote.authenticate | 今回は簡易機能のため、MBeanサーバーにアクセスする際の認証機能を無効にしています。 |
| com.sun.management.jmxremote.ssl | 今回は簡易機能のため、MBeanサーバーにアクセスする際のSSLを無効にしています。 |

上記のように起動しておくとjconsoleなどからリモートプロセスでアクセスすることができます。

## クライアント側の実装について

jconsoleからローカルプロセスからもリモートプロセスからもアクセスできますが、同じようにプロセスIDとリモートでアクセスするクライアントアプリケーションを作成してみました。

JMXの接続方法については以下のページを参考にしています。
[2 Monitoring and Management Using JMX Technology](https://docs.oracle.com/en/java/javase/13/management/monitoring-and-management-using-jmx-technology.html#GUID-805517EC-2D33-4D61-81D8-4D0FA770D1B8)

### リモートアクセス

MBeanサーバーにリモートアクセスするためには、`com.sun.management.jmxremote.port`の起動パラメータを指定する必要があります。
上記起動パラメータで指定したポート番号を使って以下のようなアドレスを生成します。

```
service:jmx:rmi:///jndi/rmi://localhost:5000/jmxrmi
```

あとは、上記のアドレスを使用してMBeanサーバーに接続します。

```
String connectAddress = "service:jmx:rmi:///jndi/rmi://localhost:5000/jmxrmi";
try (JMXConnector jmxc = JMXConnectorFactory.connect(new JMXServiceURL(connectAddress))) {
    MBeanServerConnection mbsc = jmxc.getMBeanServerConnection();
    LoginMonitorMBean lmbean = JMX.newMBeanProxy(mbsc, LoginMonitorMBean.createObjectName(),
            LoginMonitorMBean.class);
} catch (MalformedURLException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}
```

### ローカルアクセス

ローカルアクセスするためにはプロセスIDが必要です。
プロセスIDが分かれば接続できるため、先程の起動パラメータは指定する必要はありません。

```
String CONNECTOR_ADDRESS = "com.sun.management.jmxremote.localConnectorAddress";
VirtualMachine vm = VirtualMachine.attach(pid);
String connectorAddress;
try {
    connectorAddress = vm.getAgentProperties().getProperty(CONNECTOR_ADDRESS);
    if (connectorAddress == null) {
        vm.startLocalManagementAgent();
        connectorAddress = vm.getAgentProperties().getProperty(CONNECTOR_ADDRESS);
    }
} finally {
    vm.detach();
}
```

上記のロジックで接続用の文字列が取得できるので、その文字列を使用してリモート接続の方でも記載した以下の記述でローカルプロセスに接続することができます。

```
JMXConnector jmxc = JMXConnectorFactory.connect(new JMXServiceURL(connectAddress))
```

### JVMの情報

MBeanサーバーに接続すると、登録したMBeanの他にJVMの情報も取得する事できます。
仮想マシン名やプロセスIDを取得するためのMBeanやヒープ情報を取得するMBeanは以下のような記述で取得することができます。

```
try (JMXConnector jmxc = JMXConnectorFactory.connect(new JMXServiceURL(connectAddress))) {
    MBeanServerConnection mbsc = jmxc.getMBeanServerConnection();

    // Java仮想マシンの実行時情報を表示する
    RuntimeMXBean rmxbean = ManagementFactory.getPlatformMXBean(mbsc, RuntimeMXBean.class);
    // rmxbeanからプロセスIDなどを取得する

    // メモリ情報を表示する
    MemoryMXBean mmxbean = ManagementFactory.getPlatformMXBean(mbsc, MemoryMXBean.class);
    MemoryUsage memoryUsage = mmxbean.getHeapMemoryUsage();
    // memoryUsageからヒープ情報を取得する

    // OSの情報を表示する
    OperatingSystemMXBean omxbean = ManagementFactory.getPlatformMXBean(mbsc, OperatingSystemMXBean.class);
    // omxbeanからOSの情報を取得する
} catch (MalformedURLException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}
```

## まとめ

JMXを使ってみて、凄く簡単だし便利なので、テキストボックス一つしかない設定画面とか作る必要ないなと思いました。
もちろん性能の問題や、設定情報をDBに持っている場合、DB接続できるのか、負荷はどのぐらいなどの考慮することはあるとはいえ、jconsoleで設定を簡単に変えられるので画面を作る工数を削減できるのではないかと感じました。
今回作成したアプリケーションは以下のリポジトリに格納していますのでよければご覧になってください。

[jmx-examples](https://github.com/specialweek1128/jmx-examples)