Spring Boot で WebSocket (STOMPではなく、TextWebSocketHandler)を試してみる

こないだSTOMP over WebSocketを試してみましたが、今度はSTOMPを使わずに、TextWebSocketHandlerを使ったWebSocketを試してみます。題材も同じくチャットです。

ルームの情報をどのように渡そうか悩みました。コードを書き始めるまでは、WebSocketで接続する先にヘッダとかで渡せばいいかなと思っていましたが、調べてみたら渡せなかったので、やもえずURLのクエリパラメータとして渡すようにしました。

コード

テキストでのやり取りを行うので、TextWebSocketHandler を利用します。TextWebSocketHandlerの各メソッドをoverrideして必要な処理を実装するだけです。

ルームの情報は、URLのクエリとしてクライアントから送っているので、接続が確立したタイミング(afterConnectionEstablished)にて、ルーム毎にWebSocketSessionを保持するようにします。 メッセージを受け取ったら、自分のルームと同じWebSocketSessionに対して、メッセージを送るだけです。

package com.example;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Component
public class ChatHandler extends TextWebSocketHandler {

    private ConcurrentHashMap<String, Set<WebSocketSession>> roomSessionPool = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

        String roomName = session.getUri().getQuery();

        roomSessionPool.compute(roomName, (key, sessions) -> {

            if (sessions == null) {
                sessions = new CopyOnWriteArraySet<>();
            }
            sessions.add(session);

            return sessions;
        });
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

        String roomName = session.getUri().getQuery();

        for (WebSocketSession roomSession : roomSessionPool.get(roomName)) {
            roomSession.sendMessage(message);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {

        String roomName = session.getUri().getQuery();

        roomSessionPool.compute(roomName, (key, sessions) -> {

            sessions.remove(session);
            if (sessions.isEmpty()) {
                // 1件もない場合はMapからクリア
                sessions = null;
            }

            return sessions;
        });
    }
}

WebSocketの設定として、URLとHandlerを紐付けます。

package com.example;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

import lombok.AllArgsConstructor;

@Configuration
@EnableWebSocket
@AllArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatHandler chatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatHandler, "/endpoint");
    }

}

クライアント側では、下記のようなコードになりました。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>チャット</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css" />
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css" />
</head>
<body>
  <div class="container">
    <h2>チャット</h2>
    <div class="form-horizontal">
      <div class="form-group">
        <label for="roomName" class="col-sm-2 control-label">ルーム</label>
        <div class="col-sm-2">
          <input id="roomName" type="text" class="form-control" value="example" />
        </div>
        <div class="col-sm-3">
          <button id="connectButton" type="button" class="btn btn-default">接続</button>
          <button id="disconnectButton" class="btn btn-default">切断</button>
        </div>
      </div>
      <div class="form-group">
        <label for="message" class="col-sm-2 control-label">メッセージ</label>
        <div class="col-sm-4">
          <input id="message" type="text" class="form-control" />
        </div>
        <div class="col-sm-2">
          <button id="sendButton" type="button" class="btn btn-default">送信</button>
        </div>
      </div>
      <div class="row">
        <div class="col-sm-4 col-sm-offset-2">
          <ul id="messageList" class="list-unstyled">
          </ul>
        </div>
      </div>
    </div>
  </div>
  <script src="/webjars/jquery/1.12.4/jquery.min.js"></script>
  <script src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
  <script>
    $(function() {
      var endpoint = 'ws://' + location.host + '/endpoint';
      var webSocket = null;

      $('#connectButton').click(function() {

        $("#messageList").empty();

        webSocket = new WebSocket(endpoint + '?' + encodeURIComponent($('#roomName').val()));
        webSocket.onopen = function() {
          $('#roomName').prop('disabled', true);
          $('#connectButton').prop('disabled', true);
          $('#disconnectButton').prop('disabled', false);
        };
        webSocket.onclose = function() {
        };
        webSocket.onmessage = function(message) {
          $('#messageList').prepend($('<li>').text(message.data));
        };
        webSocket.onerror = function() {
          alert('エラーが発生しました。');
        };
      });

      $('#disconnectButton').click(function() {

        webSocket.close();
        webSocket = null;

        $('#roomName').prop('disabled', false);
        $('#connectButton').prop('disabled', false);
        $('#disconnectButton').prop('disabled', true);
      });

      $('#sendButton').click(function() {
        if (!webSocket) {
          alert('未接続です。');
          return;
        }

        webSocket.send($('#message').val());
      });
    });
  </script>
</body>
</html>

STOMPの時のほうが、いろいろシンプルに書けるので、ブラウザがクライアントならば、STOMPを使わない理由はないかなと思っています。

Spring Boot で STOMP Over WebSocket を試してみる

Spring BootSTOMP Over WebSocketを使って、試しにチャットっぽいものを作ってみました。

f:id:onozaty:20170503222047p:plain

同じルームに接続しているクライアントに、同じメッセージを配信するだけならば、Controllerの定義はいりません。

WebSocketConfigとして、エンドポイントとSimpleBrokerでハンドリングする宛先を定義するだけです。

package com.example;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@EnableWebSocketMessageBroker
@Configuration
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/endpoint");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");
    }
}

画面側では、配信と購読で同じ宛先を指定しておきます。自分で送ったものも受け取ることになります。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>チャット</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css" />
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css" />
</head>
<body>
  <div class="container">
    <h2>チャット</h2>
    <div class="form-horizontal">
      <div class="form-group">
        <label for="roomName" class="col-sm-2 control-label">ルーム</label>
        <div class="col-sm-2">
          <input id="roomName" type="text" class="form-control" value="example" />
        </div>
        <div class="col-sm-3">
          <button id="connectButton" type="button" class="btn btn-default">接続</button>
          <button id="disconnectButton" class="btn btn-default">切断</button>
        </div>
      </div>
      <div class="form-group">
        <label for="message" class="col-sm-2 control-label">メッセージ</label>
        <div class="col-sm-4">
          <input id="message" type="text" class="form-control" />
        </div>
        <div class="col-sm-2">
          <button id="sendButton" type="button" class="btn btn-default">送信</button>
        </div>
      </div>
      <div class="row">
        <div class="col-sm-4 col-sm-offset-2">
          <ul id="messageList" class="list-unstyled">
          </ul>
        </div>
      </div>
    </div>
  </div>
  <script src="/webjars/jquery/1.12.4/jquery.min.js"></script>
  <script src="/webjars/stomp-websocket/2.3.3-1/stomp.js"></script>
  <script src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
  <script>
    $(function() {
      var endpoint = 'ws://' + location.host + '/endpoint';
      var subscribePrefix = '/topic/';
      var stompClient = null;

      $('#connectButton').click(function() {

        $("#messageList").empty();

        stompClient = Stomp.over(new WebSocket(endpoint));
        stompClient.connect({}, function() {
          stompClient.subscribe(
              subscribePrefix + $('#roomName').val(),
              function(message) {
                $('#messageList').prepend($('<li>').text(message.body));
              });

          $('#roomName').prop('disabled', true);
          $('#connectButton').prop('disabled', true);
          $('#disconnectButton').prop('disabled', false);
        });
      });

      $('#disconnectButton').click(function() {

        stompClient.disconnect();
        stompClient = null;

        $('#roomName').prop('disabled', false);
        $('#connectButton').prop('disabled', false);
        $('#disconnectButton').prop('disabled', true);
      });

      $('#sendButton').click(function() {
        if (!stompClient) {
          alert('未接続です。');
          return;
        }

        stompClient.send(subscribePrefix + $('#roomName').val(), {}, $('#message').val());
      });
    });
  </script>
</body>
</html>

クライアント間でメッセージをやり取りするといったことが、少しのコードで簡単に実現できますね。

POPSPRING 2017 (2017年3月25日@幕張メッセ)

POPSPRING 2017 幕張公演に、嫁さんと2人で参加してきました。フェスは初参加です。

GOLDチケットだったので、とても余裕もって見れました。最初は前の方にいたのですが、逆に人が密集していてみずらかったので、途中からちょっと後ろ目で見ていました。それでもこんな感じで十分近くに見えました。

11時から21時過ぎまでの長丁場だったので、疲れたらちょっと下がって座って見れるのも良かったです。

POPSPRINGでは、アーティストの写真撮影NGとのことだったので、今回は写真がほとんど撮れませんでした。 海外アーティストだと珍しいなぁと思いましたが、いろんなアーティストが出る都合上、NGにせざるを得ないのかもしれませんね。(周りで結構撮っている人がいたけど、特に注意が入ることもなく、、、)

タイムテーブル

Opeging

会場前から並んで参加しました。GOLDだと入場も優先だったので、会場ぎりぎりでもスムーズに入れました。

DJ SHOTA(@djshotamusic)のOpening DJで、かなりテンションあがりました。早く来たかいがありました。

続くOpening Actは FlowBack、RIRI、FAKY と日本人アーティストが続きましたが、知らなかったアーティストと出会えるのはいいですね。とくにRIRI(@riri_tone)が良かったです。

まだ17歳なのにびっくりなのと、歌のときの大人の雰囲気と、MCのときの可愛さのギャップに、すぐにファンというか、応援したくなりました。可愛くて歌はパワフルなところが、アリアナ・グランデに似てるねと、嫁さんと盛り上がりました。

Jordan Fisher

機器のトラブルでかなり遅れて、Jordan Fisherとなりました。POPSPRINGに出演するということで、初めて知ったアーティストでしたが、All About UsをYouTubeで見て、すぐに好きになりました。

Jordan Fisher - All About Us (Official Video)

ちなみにダンスみて、なぜかUsherを思い出しました。似てるかな、、

パフォーマンスを見て、これからさらに売れるんだろうなぁと確信しました。また見たい!

Da-iCE

ちょうどお昼休憩とっていて、ほとんど見逃してしまいました、、ごめんなさい。

Austin Mahone

昨年に続いてのAustin Mahone。ダンサーさんが色っぽい。

Dirty Work でブルゾンちえみが出てこないかなぁと思いましたが、出てきませんでした(笑)。

最後に発表となったアーティストで、まぁ、Austin最後に持ってこられたら、全てOKになりますよね!(日本人アーティストが多いことで、ちょっと話題になっていましたが、最後のAustinの発表で全て吹き飛んだ気が)

Sabrina Carpenter

Sabrina Carpenter もPOPSPRINGに出演するということで初めて知ったアーティストでしたが、ほんと今となってはすごい好きなアーティストになりました。とくに1stアルバムの曲に好きなのが多いです。

Can’t Blame a Girl for Trying や Eyes Wide Open とか。

Sabrina Carpenter - Eyes Wide Open (Official Video)

ステージのSabrinaはすごい輝いてました。

w-inds.

We Don’t Need To Talk Anymore を初めて聞きましたが、これはいい曲だと思いました。

We Don’t Need To Talk Anymore(MUSIC VIDEO Full ver.+15s SPOT) / w-inds.

DNCE

一番盛り上がったと思います。楽しくて、一番疲れました(笑)。ほんと弾けすぎです。

Shown Mendes

いや、ほんとすごいというか、感動しました。

曲は好きで良く聞いてましたが、ギター一本であんな聞かせるパフォーマンスするなんて。すいません、全然Shownのことわかって無かったです。

ずっとハッピ姿のままいくとも思ってませんでした。ちょっとパフォーマンスとアンマッチな気が。

Fifth Harmony

Fifth Harmonyが一番のお目当てだったので、待ちに待った… といった感じでした。

いろいろな形でのパフォーマンスを見せてくれて、疲れがふっとびました。4人みんな特徴があって、いいグループですよね!(Allyのキュートな声が、一番好きです)

終わりに

自分が知らなかったアーティストに出会えたこともあって、ほんと値段以上の価値を感じたフェスでした。 また来年も行きたいです!!(サマソニにも興味がわいてきています…)

参考リンク

Lombokの@Builder(toBuilder = true)で、オブジェクトの複製を作成する

前回の記事の続きで。

@Builder(toBuilder = true)とすると、オブジェクトからBuilderを生成できます。

どういうときに使えるかというと、オブジェクトの複製を作成して、一部のプロパティのみ変えたいといった場合です。

package com.example.lombok;

import lombok.Builder;
import lombok.ToString;

public class BuilderExample {

    public static void main(String[] args) {

        Customer customer1 = new Customer("Taro", "Urashima", "ura@exmple.com", "111-111-1111");

        System.out.println(customer1);

        Customer customer2 = customer1.toBuilder()
                .lastName("Yamada")
                .build();

        System.out.println(customer2);
    }
}

@Builder(toBuilder = true)
@ToString
class Customer {

    private String firstName;

    private String lastName;

    private String mailAddress;

    private String telephone;
}

実行結果は下記の通りです。lastNameだけが異なるオブジェクトが生成されます。

Customer(firstName=Taro, lastName=Urashima, mailAddress=ura@exmple.com, telephone=111-111-1111)
Customer(firstName=Taro, lastName=Yamada, mailAddress=ura@exmple.com, telephone=111-111-1111)

Lombokで生成されるのは、下記のようなコードになります。(delombokで確認)

class Customer {
    private String firstName;
    private String lastName;
    private String mailAddress;
    private String telephone;

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    Customer(final String firstName, final String lastName, final String mailAddress, final String telephone) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.mailAddress = mailAddress;
        this.telephone = telephone;
    }


    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public static class CustomerBuilder {
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String firstName;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String lastName;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String mailAddress;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String telephone;

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        CustomerBuilder() {
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder firstName(final String firstName) {
            this.firstName = firstName;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder lastName(final String lastName) {
            this.lastName = lastName;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder mailAddress(final String mailAddress) {
            this.mailAddress = mailAddress;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder telephone(final String telephone) {
            this.telephone = telephone;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public Customer build() {
            return new Customer(firstName, lastName, mailAddress, telephone);
        }

        @java.lang.Override
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public java.lang.String toString() {
            return "Customer.CustomerBuilder(firstName=" + this.firstName + ", lastName=" + this.lastName + ", mailAddress=" + this.mailAddress + ", telephone=" + this.telephone + ")";
        }
    }

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public static CustomerBuilder builder() {
        return new CustomerBuilder();
    }

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public CustomerBuilder toBuilder() {
        return new CustomerBuilder().firstName(this.firstName).lastName(this.lastName).mailAddress(this.mailAddress).telephone(this.telephone);
    }

    @java.lang.Override
    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public java.lang.String toString() {
        return "Customer(firstName=" + this.firstName + ", lastName=" + this.lastName + ", mailAddress=" + this.mailAddress + ", telephone=" + this.telephone + ")";
    }
}

Lombokの@BuilderでBuilderを簡単に作成する

多数のプロパティを持つクラスを生成するときに、Builderが有用だと思っているのですが、普通に書くとそれなりにコード書く必要があります。そこでLombokの@Builderです。

package com.example.lombok;

import lombok.Builder;
import lombok.ToString;

public class BuilderExample {

    public static void main(String[] args) {

        // 引数が多くなると、どのプロパティに対するものかわかりずらい
        Customer customer1 = new Customer("Taro", "Urashima", "ura@exmple.com", "111-111-1111");

        System.out.println(customer1);

        // Builder使うことによって、どのプロパティに対しての値なのかわかりやすくなる
        Customer customer2 = Customer.builder()
                .firstName("Taro")
                .lastName("Urashima")
                .mailAddress("ura@exmple.com")
                .telephone("111-111-1111")
                .build();

        System.out.println(customer2);
    }
}

@Builder
@ToString
class Customer {

    private String firstName;

    private String lastName;

    private String mailAddress;

    private String telephone;
}

上記によってLombokで生成されるのは、下記のようなコードになります。(delombokで確認)

class Customer {
    private String firstName;
    private String lastName;
    private String mailAddress;
    private String telephone;

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    Customer(final String firstName, final String lastName, final String mailAddress, final String telephone) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.mailAddress = mailAddress;
        this.telephone = telephone;
    }


    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public static class CustomerBuilder {
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String firstName;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String lastName;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String mailAddress;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String telephone;

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        CustomerBuilder() {
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder firstName(final String firstName) {
            this.firstName = firstName;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder lastName(final String lastName) {
            this.lastName = lastName;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder mailAddress(final String mailAddress) {
            this.mailAddress = mailAddress;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder telephone(final String telephone) {
            this.telephone = telephone;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public Customer build() {
            return new Customer(firstName, lastName, mailAddress, telephone);
        }

        @java.lang.Override
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public java.lang.String toString() {
            return "Customer.CustomerBuilder(firstName=" + this.firstName + ", lastName=" + this.lastName + ", mailAddress=" + this.mailAddress + ", telephone=" + this.telephone + ")";
        }
    }

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public static CustomerBuilder builder() {
        return new CustomerBuilder();
    }

    @java.lang.Override
    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public java.lang.String toString() {
        return "Customer(firstName=" + this.firstName + ", lastName=" + this.lastName + ", mailAddress=" + this.mailAddress + ", telephone=" + this.telephone + ")";
    }
}

Lombokで生成されたメソッドに対してアノテーションを設定する

毎回調べているような気がするのでメモ。

Lombok+Jackson(Spring MVCやJerseyなどで使ってたり)を使っている場合に、@JsonIgnoreで一部のフィールドを対象外にしたい場合に、privateなフィールドに設定してもうまく動きません。

Lombokで生成したsetterまたはgetterに設定してあげる必要があり、下記のような書き方をします。(java - @JsonIgnore with @Getter Annotation - Stack Overflowより引用)

@Getter
@Setter
public class User {

    private userName;

    @Getter(onMethod = @__(@JsonIgnore))
    @Setter
    private password;
}

ちょっと特殊な書き方なので、いつも忘れます…

なお、Lombokで生成されたメソッドに対してアノテーションを指定する方法は、下記の3パターンになります。

  1. 生成されるメソッドに対して: @Setter(onMethod = @__(@ExampleAnnotation))
  2. 生成されるメソッドの引数に対して: @Setter(onParam = @__(@ExampleAnnotation))
  3. 生成されるコンストラクタに対して: @AllArgsConstructor(onConstructor = @__(@ExampleAnnotation))

Lombokのドキュメントだと、下記に記載があります。

Greasemonkeyでテキストエリアに入力補完を追加する

Redmineのtextile記法で、コードハイライトは<pre><code class="java"></code></pre>のような書き方をするのですが、これを入力するのが面倒になってきたので、Greasemonkeyを使ってテキストエリアで入力補完を行ってみました。

なお、Redmine 3.3 からは、ツールバーにコードハイライト用のボタンが追加されていますので、それを使うことによっても手間は軽減できるかと思います。

実装方法

テキストエリアでの入力補完は、カーソル位置を取るのが面倒なため、他のライブラリを利用します。

いくつかよさそうなものがありましたが、手軽そうなものということで、At.jsを今回は利用しました。

Greasemonkeyの@requireで外部JavaScriptを読み込めるので、cdnjsにあるAt.jsと、At.jsが依存しているCaret.jsというライブラリを読み込むようにしました。At.jsはjQueryに依存していますが、Redmineの各画面でも読み込んでいるので、指定しなくて大丈夫です。

At.js用のスタイルは、直接styleタグとしてヘッダに追加しました。

後は対象のテキストエリアを指定して、入力補完の設定をしていくだけです。 At.jsでは、atwhoという関数に対して、必要な情報を指定することよって、補完が行われるようになります。 とてもシンプルなので、迷うことはありませんでした。

$('.atwho-inputor').atwho({
  at: "@",
  data: ["one", "two", "three"],
}).atwho({
  at: ":",
  data: ["+1", "-1", "smile"]
});

動作イメージ

f:id:onozaty:20170228025858g:plain

スクリプト

ということで出来上がったスクリプトは、下記のようになります。

// ==UserScript==
// @name        Redmine wiki textcomplete
// @namespace   com.enjoyxstudy.redmine.wiki.textcomplete
// @version     1
// @grant       none
// @require     https://cdnjs.cloudflare.com/ajax/libs/at.js/1.5.2/js/jquery.atwho.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/Caret.js/0.3.1/jquery.caret.min.js
// ==/UserScript==
(function($) {
  $('textarea.wiki-edit').atwho({
    at: '<',
    data: [
      {name: 'java', content: '<pre><code class="java">\n</code></pre>'},
      {name: 'sql', content: '<pre><code class="sql">\n</code></pre>'}],
    insertTpl: '${content}',
    suffix: ''
  }).atwho({
    at: '{',
    data: [
      {name: 'collapse', content: '{{collapse(詳細を表示...)\n}}'}],
    insertTpl: '${content}',
    suffix: ''
  }).atwho({
    at: 'a',
    data: [
      {name: 'attachment', content: 'attachment:'}],
    insertTpl: '${content}',
    suffix: ''
  }).atwho({
    at: 'c',
    data: [
      {name: 'commit', content: 'commit:'}],
    insertTpl: '${content}',
    suffix: ''
  });

  $('head').append(
    `<style>
.atwho-view {
    position:absolute;
    top: 0;
    left: 0;
    display: none;
    margin-top: 18px;
    background: white;
    color: black;
    border: 1px solid #DDD;
    border-radius: 3px;
    box-shadow: 0 0 5px rgba(0,0,0,0.1);
    min-width: 120px;
    z-index: 11110 !important;
}

.atwho-view .atwho-header {
    padding: 5px;
    margin: 5px;
    cursor: pointer;
    border-bottom: solid 1px #eaeff1;
    color: #6f8092;
    font-size: 11px;
    font-weight: bold;
}

.atwho-view .atwho-header .small {
    color: #6f8092;
    float: right;
    padding-top: 2px;
    margin-right: -5px;
    font-size: 12px;
    font-weight: normal;
}

.atwho-view .atwho-header:hover {
    cursor: default;
}

.atwho-view .cur {
    background: #3366FF;
    color: white;
}
.atwho-view .cur small {
    color: white;
}
.atwho-view strong {
    color: #3366FF;
}
.atwho-view .cur strong {
    color: white;
    font:bold;
}
.atwho-view ul {
    /* width: 100px; */
    list-style:none;
    padding:0;
    margin:auto;
    max-height: 200px;
    overflow-y: auto;
}
.atwho-view ul li {
    display: block;
    padding: 5px 10px;
    border-bottom: 1px solid #DDD;
    cursor: pointer;
    /* border-top: 1px solid #C8C8C8; */
}
.atwho-view small {
    font-size: smaller;
    color: #777;
    font-weight: normal;
}
    </style>`
  );
})(jQuery);

おわりに

いろいろ候補を追加して、自分好みのものに変えていこうと思っています。