Ed Sheeran - DIVIDE WORLD TOUR 2019 (2019年4月10日@東京ドーム)

Ed Sheeran(エド・シーラン)のライブに嫁さんと一緒に参戦してきました。

15時過ぎに東京ドームについて、まずは物販からということで、Tシャツとタオルをを購入。物販は10分程度並んだだけで、そんな待たずに買えました。

席は1階スタンドです。ステージから真正面ですが遠いです。S席の範囲がとても広く、どこになるかは運任せなのでしょうがないですね。

17時45分からオープニングアクトでONE OK ROCKです。チケット購入した後に発表されたオープニングアクトですが、ワンオクも聞けるなんて、、すごい得した気分です。エド・シーランと交流があるのは有名な話だったので、もしかしたらなーって思っていたけど、まさか本当にそうなるなんて!!

狭いステージを動き回るTakaかっこいい。

オープニングアクトは45分くらいで終わり、19時からエド・シーランのライブ開始です。

ギターとループステーションだけでライブやりきってしまうというのは知っていましたが、いや、ほんと凄すぎました。ループステーションを使ってどんどん音を重ねていって、歌いながらプラスしてさらにギター、、エド・シーラン一人によってすごい複雑な音楽が作り出されます。

特に最後のYou Need Me, I Don’t Need Youは圧巻で、鳥肌が立ちました。ほんと最高のライブでした。

セットリスト (ONE OK ROCK)

  1. Push Back
  2. Deeper Deeper
  3. Clock Strikes
  4. Head High
  5. Stand Out Fit In
  6. The Beginning
  7. Mighty Long Fall
  8. Wasted Nights

セットリスト (Ed Sheeran)

  1. Castle on the Hill
  2. Eraser
  3. The A Team
  4. Don’t / New Man
  5. Dive
  6. Bloodstream
  7. Love Yourself (Justin Bieber)
  8. Tenerife Sea
  9. Lego House / Kiss Me / Give Me Love
  10. Galway Girl
  11. Feeling Good / I See Fire
  12. Thinking Out Loud
  13. One / Photograph
  14. Perfect
  15. Nancy Mulligan
  16. Sing
  17. Shape of You (アンコール)
  18. You Need Me, I Don’t Need You (アンコール)

REST APIを利用して複数の関連チケットをまとめて作成する(Redmine View Customize Plugin)

チケットを作成して、関連付けを行いたいという問い合わせがあったので書いてみました。

チケットの関連付けには下記のAPIを使います。

設定内容

  • Path pattern: .*
  • Insertion position: Bottom of issue detail
$(function() {

  var projectId = $('#issue_project_id').val();
  var trackerId = $('#issue_tracker_id').val();
  var subject = $('#issue_subject').val();
  var priorityId = $('#issue_priority_id').val();
  var currentIssueId =  ViewCustomize.context.issue.id;

  // 関連チケットとして作成する情報
  var issueChildren = [
    {
      'issue': {
        'project_id': projectId,
        'tracker_id': trackerId,
        'subject': subject + ' - 関連チケット1',
        'priority_id': priorityId
      }
    },
    {
      'issue': {
        'project_id': projectId,
        'tracker_id': trackerId,
        'subject': subject + ' - 関連チケット2',
        'priority_id': priorityId
      }
    },
    {
      'issue': {
        'project_id': projectId,
        'tracker_id': trackerId,
        'subject': subject + ' - 関連チケット3',
        'priority_id': priorityId
      }
    }
  ];

  var link = $('<a title="関連チケットの一括作成" class="icon icon-add" href="#">関連チケットの一括作成</a>');
  $('#issue_tree').before($('<p>').append(link));

  link.on('click', function() {

    if (!confirm('関連チケットをまとめて作成します。よろしいですか。')) {
      return;
    }

    // チケット作成処理(非同期)を順次実行し、最後にリロード
    var defer = $.Deferred();
    var promise = defer.promise();

    for (var i = 0; i < issueChildren.length; i++) {
      promise = promise.then(createIssue(issueChildren[i]));
    }

    promise
      .done(function() {
        // 成功したらリロード
        location.reload();
      })
      .fail(function() {
        alert('失敗しました');
      });

    defer.resolve();
  });

  function createIssue(issue) {

    return function() {

      return $.ajax({
        type: 'POST',
        url: '/issues.json',
        headers: {
          'X-Redmine-API-Key': ViewCustomize.context.user.apiKey
        },
        dataType: 'json',
        contentType: 'application/json',
        data: JSON.stringify(issue)
      })
      .then(function(response) {
        var createdIssueId = response.issue.id;
        return $.ajax({
          type: 'POST',
          url: '/issues/' + currentIssueId + '/relations.json',
          headers: {
            'X-Redmine-API-Key': ViewCustomize.context.user.apiKey
          },
          dataType: 'json',
          contentType: 'application/json',
          data: JSON.stringify({
            'relation' : {
              'issue_to_id' : createdIssueId,
              'relation_type' : 'relates'
            }
          })
        })
      })
    };
  }
})

画面イメージ

f:id:onozaty:20190324003243g:plain

テキストエリアで入力補完 (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のせいかもしれないので、もう少し複雑なメッセージのやり取りを行うようなものでも試してみたいと思います。