テキストエリアで入力補完 (Redmine View Customize Plugin)

先日2.1.0をリリースしてHTMLをそのまま埋め込めるようになったので、それを利用したサンプルということで、テキストエリアで入力補完的なことを行うサンプルを書いてみました。

やっていることは、下記のGreasemonkeyで行っていたものと基本的には同じです。

今回は、textile記法の補完だけでなく、テンプレート的なものも埋め込めるようにしています。

  • <preと入力したら、<pre><code class="java">のようなシンタックスハイライト
  • {{と入力したら、{{thumbnail(image.png)}}のようなマクロ
  • templateと入力したら、テンプレート文面

JavaScriptだけでも書けなくはないですが、、外部JSを読み込むのと、CSSを書かなければならないので、HTMLで書けたほうが楽だと思います。

設定内容

  • Path pattern: .*
  • Insertion position: Head of all pages
  • Type: HTML

注:Edgeだとうまく動作しないことがわかったため、外部ライブラリ読み込み時のasyncを消しました。(2019-07-29)

<script src="https://cdnjs.cloudflare.com/ajax/libs/at.js/1.5.2/js/jquery.atwho.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Caret.js/0.3.1/jquery.caret.min.js" defer></script>
<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>
<script>
$(function() {
  $('textarea.wiki-edit').atwho({
    at: '<pre',
    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}}'},
      {name: 'thumbnail', content: '{{thumbnail(image.png)}}'}],
    insertTpl: '${content}',
    suffix: ''
  }).atwho({
    at: 'template',
    data: [
      {name: 'バグ', content: 'h2. 発生バージョン\n\nh2. 再現手順\n\nh2. ログ\n\n'},
      {name: '問い合わせ', content: 'h2. 問い合わせ内容\n\nh2. 回答期限\n\n'}],
    insertTpl: '${content}',
    suffix: ''
  });
});
</script>

動作

下記のような感じで、特定の文字をトリガーにして候補が表示され、候補を選ぶと文字列が埋め込まれます。

f:id:onozaty:20190322000212g:plain

Redmine: View customize plugin の v2.1.0 をリリースしました

View customize plugin の v2.1.0 をリリースしました。

cat-in-136 さんからいただいたプルリクエストを取り込み、種別にHTMLが追加されました。

種別でHTMLを選ぶと、そのままHTMLとして埋め込まれます。これが使えて便利だと思うシチュエーションは、外部JavaScriptを読み込む際です。

今まで外部JavaScriptの読み込みはJavaScriptのコードとして書かなければなりませんでした。

$(document.createElement("script")).attr({
  type: "application/javascript",
  src: "https://some.cdn.example.com/path/to/javascript.js",
  defer: "defer",
  async: "async"
}).appendTo("head");

それが種別HTMLで行うと、scriptタグでそのまま書けることになります。

<script type="application/javascript" src="https://some.cdn.example.com/path/to/javascript.js" defer async></script>

あとはCSSとJavaScriptをまとめて書くといったことも、今回からできることになります。

今までのJavaScript、CSSといった種別できていたことは、全てHTMLに置き換えられますが、今のところ種別をHTMLにまとめる予定はありません。HTMLにするまでも無いことは、今までどおりJavaScript、CSSを使っていただいたほうが、いちいちタグを書なくて済むので。

社内勉強会で「View customize plugin for Redmineの紹介(2019年版)」というタイトルで発表しました

社内勉強会でView customizeについて発表(LT)しました。

www.slideshare.net

例がメインで、しかも最後のほうはアニメーションGIFです。Slideshareにアップロードするとアニメーションじゃなくなってしまうのでわかりずらいですね、、

クリップボードから画像を貼り付ける(Redmine View Customize Plugin)

ということで View customizeでやってみました。

設定内容

  • Path pattern: .*
  • Insertion position: Head of all pages
$(function() {

  $('form div.box').has('input:file.filedrop').on('paste', copyImageFromClipboard);

  function copyImageFromClipboard(e) {
    if (!$(e.target).hasClass('wiki-edit')) { return; }
    var clipboardData = e.clipboardData || e.originalEvent.clipboardData
    if (!clipboardData) { return; }
    var items = clipboardData.items
    for (var i = 0 ; i < items.length ; i++) {
      var item = items[i];
      if (item.type.indexOf("image") != -1) {
        var blob = item.getAsFile();
        var date = new Date();
        var filename = 'clipboard-'
          + date.getFullYear()
          + ('0'+(date.getMonth()+1)).slice(-2)
          + ('0'+date.getDate()).slice(-2)
          + ('0'+date.getHours()).slice(-2)
          + ('0'+date.getMinutes()).slice(-2)
          + '-' + randomKey(5).toLocaleLowerCase()
          + '.' + blob.name.split('.').pop();
        var file = new File([blob], filename, {type: blob.type});
        var inputEl = $('input:file.filedrop').first()
        handleFileDropEvent.target = e.target;
        addFile(inputEl, file, true);
      }
    }
  }
})

動作

ファイルアップロードができるようなテキストエリア(チケットの説明やWikiとか)でペーストすると画像のアップロード+Wiki記法で画像表示が埋め込まれます。Redmine 4.0 で確認しています。

f:id:onozaty:20190314000117g:plain

Spring BootでgRPCとRESTを比べてみる

前回gRPCを試したので、RESTとgRPCで同じAPIのパフォーマンスを比較してみます。

受け取ったメッセージをそのまま返す単純なAPIです。

REST

package com.example.server;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.Value;
import lombok.extern.slf4j.Slf4j;

@RestController
@RequestMapping("api/echo")
@Slf4j
public class EchoRestController {

    @PostMapping
    public EchoResponse echo(@RequestBody EchoRequest request) {

        log.info("REST: " + request.getMessage());

        return new EchoResponse(request.getMessage());
    }

    @Value
    public static class EchoRequest {
        private final String message;
    }

    @Value
    public static class EchoResponse {
        private final String message;
    }
}

gRPC

syntax = "proto3";

option java_package = "com.example.server";

service Echo {
  rpc echo (EchoRequest) returns (EchoReply) {}
}

message EchoRequest {
  string message = 1;
}

message EchoReply {
  string message = 1;
}
package com.example.server;

import org.lognet.springboot.grpc.GRpcService;

import com.example.server.EchoGrpc.EchoImplBase;
import com.example.server.EchoOuterClass.EchoReply;
import com.example.server.EchoOuterClass.EchoRequest;

import io.grpc.stub.StreamObserver;
import lombok.extern.slf4j.Slf4j;

@GRpcService
@Slf4j
public class EchoGrpcService extends EchoImplBase {

    @Override
    public void echo(EchoRequest request, StreamObserver<EchoReply> responseObserver) {

        log.info("gRPC: " + request.getMessage());

        EchoReply reply = EchoReply.newBuilder()
                .setMessage(request.getMessage())
                .build();
        responseObserver.onNext(reply);
        responseObserver.onCompleted();
    }
}

パフォーマンス比較

10文字のメッセージを10万回投げて比較してみましたが、手元のPCでgRPCが71秒、RESTが86秒といった形で、思ったほど差がでませんでした。

それぞれのコードは下記の通りです。

REST

package com.example.client;

import java.util.stream.IntStream;

import org.springframework.web.client.RestTemplate;

import com.example.server.EchoRestController.EchoRequest;
import com.example.server.EchoRestController.EchoResponse;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class EchoRestClient {

    private final RestTemplate restTemplate = new RestTemplate();

    private final String url;

    public static void main(String[] args) {

        long startTime = System.currentTimeMillis();

        String message = "1234567890";
        EchoRestClient client = new EchoRestClient("http://localhost:8080/api/echo");

        IntStream.range(0, 100000)
                .forEach(x -> client.echo(message));

        long totalTime = System.currentTimeMillis() - startTime;

        System.out.println(
                String.format(
                        "Finish. Total time: %,.3f seconds",
                        totalTime / 1000d));
    }

    public String echo(String message) {

        EchoResponse response = restTemplate.postForObject(
                url,
                new EchoRequest(message),
                EchoResponse.class);

        return response.getMessage();
    }
}

gRPC

package com.example.client;

import java.io.Closeable;
import java.util.stream.IntStream;

import com.example.server.EchoGrpc;
import com.example.server.EchoGrpc.EchoBlockingStub;
import com.example.server.EchoOuterClass.EchoReply;
import com.example.server.EchoOuterClass.EchoRequest;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;

public class EchoGrpcClient implements Closeable {

    private final ManagedChannel channel;
    private final EchoBlockingStub stub;

    public static void main(String[] args) {

        long startTime = System.currentTimeMillis();

        String message = "1234567890";
        try (EchoGrpcClient client = new EchoGrpcClient("localhost:6565")) {

            IntStream.range(0, 100000)
                    .forEach(x -> client.echo(message));
        }

        long totalTime = System.currentTimeMillis() - startTime;

        System.out.println(
                String.format(
                        "Finish. Total time: %,.3f seconds",
                        totalTime / 1000d));
    }

    public EchoGrpcClient(String target) {

        channel = ManagedChannelBuilder.forTarget(target)
                .usePlaintext()
                .build();

        stub = EchoGrpc.newBlockingStub(channel);
    }

    public String echo(String message) {

        EchoRequest request = EchoRequest.newBuilder()
                .setMessage(message)
                .build();

        EchoReply reply = stub.echo(request);

        return reply.getMessage();
    }

    @Override
    public void close() {

        channel.shutdown();
    }
}

終わりに

全体のコードは下記になります。

Spring Boot使うとRESTもgRPCも簡単ですね!

ちなみにREST側はSwagger使うことによってOpenAPIの定義作れて、クライアントの生成も楽なのですが、gRPCはprotoファイル作るのが結構面倒な気がします。OpenAPIの情報から作れるとREST→gRPCへの移行が楽なので、そのあたりも探ってみたいと思います。

あとはパフォーマンスの差が思ったほど出なかったのが、シンプルなAPIのせいかもしれないので、もう少し複雑なメッセージのやり取りを行うようなものでも試してみたいと思います。

Spring BootでgRPCを試してみる

gRPCとRESTの比較をしてみたかったので、まずはgRPCを触ってみました。

Srping Bootだと、grpc-spring-boot-starterを使うと簡単にgRPCのサーバが実装できます。すばらしい。

build.gradle

build.gradle は下記のような感じです。Spring BootのStarterで作成したものに、gRPCに必要なものを付け加えています。 参考にしたのは、grpc-spring-boot-starter のサンプルです。

buildscript {
    ext { springBootVersion = '2.1.2.RELEASE' }
    repositories { mavenCentral() }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath('com.google.protobuf:protobuf-gradle-plugin:0.8.8')
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'com.google.protobuf'

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'io.github.lognet:grpc-spring-boot-starter:3.1.0'
    compileOnly 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.assertj:assertj-core:3.11.1'
}

protobuf {
    protoc { artifact = 'com.google.protobuf:protoc:3.5.1' }
    plugins {
        grpc { artifact = "io.grpc:protoc-gen-grpc-java:1.18.0" }
    }

    generateProtoTasks {
        ofSourceSet('main').each { task ->
            task.builtins {
                java{ outputSubDir = 'protogen' }
            }
            task.plugins {
                grpc { outputSubDir = 'protogen' }
            }
        }
    }
    generatedFilesBaseDir = "$projectDir/src/"
}

sourceSets {
    main {
        java { srcDir 'src/main/protogen' }
    }
}

task cleanProtoGen{
    doFirst{
           delete("$projectDir/src/main/protogen")
    }
}

clean.dependsOn cleanProtoGen

proto

proto の定義は、とりあえずgRPCの公式サイトにあるサンプルと同じで。

syntax = "proto3";

option java_package = "com.example.grpc";

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

Gradle の generateProto タスクを実行すると、上記定義を元にJavaのコードが生成されます。

@GRpcService の実装

あとは@GRpcServiceを付与したサービスクラスを実装するだけです。

package com.example.grpc;

import org.lognet.springboot.grpc.GRpcService;

import com.example.grpc.GreeterOuterClass.HelloReply;
import com.example.grpc.GreeterOuterClass.HelloRequest;

import io.grpc.stub.StreamObserver;

@GRpcService
public class GreeterService extends GreeterGrpc.GreeterImplBase {

    @Override
    public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {

        HelloReply reply = HelloReply.newBuilder()
                .setMessage("Hello " + request.getName())
                .build();
        responseObserver.onNext(reply);
        responseObserver.onCompleted();
    }
}

クライアントのコード

クライアントのコードは下記のような感じで。(Spring Bootのテストコードとして書いてます)

package com.example.grpc;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.lognet.springboot.grpc.context.LocalRunningGrpcPort;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import com.example.grpc.GreeterOuterClass.HelloReply;
import com.example.grpc.GreeterOuterClass.HelloRequest;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;

@RunWith(SpringRunner.class)
@SpringBootTest
public class GreeterServiceTest {

    @LocalRunningGrpcPort
    private int runningPort;

    @Test
    public void sayHello() {

        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", runningPort)
                .usePlaintext()
                .build();

        GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);

        String name = "Taro";

        HelloRequest request = HelloRequest.newBuilder()
                .setName(name)
                .build();

        HelloReply reply = stub.sayHello(request);

        assertThat(reply.getMessage()).isEqualTo("Hello " + name);
    }
}

おわりに

ちょっと試してみる分には、すごく簡単に実行できました。

コード全体は、下記のプロジェクトになります。

Channelは使いまわしできるのかとか、並列で呼び出す場合にはどのように使うのか、、など、まだまだわからないところがあるので、今後もう少し調べてみようと思います。

これ読むといいよ!!とかありましたら、ぜひ教えてください。

Redmine: View customize plugin の v2.0.1 をリリースしました

View customizeのバージョン2.0.1を先ほどリリースしました。

軽微なFixのみになります。