find-pull-requestプラグインをメジャーアップデートした

本編

久しぶり!

最近、本職で満足してたし、家族のイベントがいっぱいあるし、OSSから離れてたんだけど、自分が使いにくいということでfind-pull-requestプラグインの更新した。メジャーアップデート。

https://raw.githubusercontent.com/shiraji/find-pull-request/main/website/images/find-pr.gif

https://raw.githubusercontent.com/shiraji/find-pull-request/main/website/images/copy-pr.gif

メニューの位置を変えました。元々は右クリックのメニューのトップに2つともいた。

ブラウザ開く機能: 右クリック | Open In | Pull Request
URLコピー機能: 右クリック | Copy / Paste Special | Copy Link to Pull Request

になった。

cmd+shift+aで利用してる人はメニュー名が変わったので注意してね。なんか探すの難しいから他に方法ないか今模索してる。 The action "Pull Request" is not easy to find from cmd+shift+a · Issue #157 · shiraji/find-pull-request · GitHub

2020年の時にこの機能を出して放置してたんだが、Copy / Paste SpecialやOpen Inってメニューができて、ずっと汚いなーって思ってたけど、いい加減直すことにした。

頑張った。

ちなみにTwitterに報告しようかとも思ったんだけど、あんま関わりたくないので、ここで報告だけして終わりにします。

次の記事でUI以外で何やったか?の詳細を書きます。一旦メジャーアップデートの報告だけ。

以上。

Ubie(が関わる医療データ)の話を聞いてくれ!!!

先日、初めて、Meetyさん経由でカジュアル面談をさせて頂きました。

meety.net

ちなみにカジュアル面談とは名ばかりの会でUbieの紹介とか一切せず、リンク先にある内容の通り、ひたすら医療情報に関して話しました。 資料もさくっと作って、それをベースに話していたのですが、それを社内にちら見せしたらなかなか好評で、じゃあこの話をブログにも書いてみようということで、今ブログを書いております。

f:id:shiraji:20210831175700p:plain

医療のレガシーデータから会社(Ubie)を守りたい というタイトルのスライドです。全体としてはタイトル入れて16Pあります。スライドは直接お話しする時のお楽しみと言うことで今日はそのうちの何枚かを使い内容を紹介します。

二つのトピックに関して話しています。

  1. 医療データとは何か?
  2. Ubieではどう守っているのか?

医療データとは何か?

まず医療データですが、ざっくりというと2種類あります。

  1. Ubie社内で生成しているデータ
  2. 外部に委託して生成しているデータ

それぞれ説明していきます。

Ubie社内で作っているデータ

  • 社内の医師が作っている・監修しているデータ
  • 医師がスクラムに入り、開発に必要なデータを必要なタイミングで作っていきます
  • 海外向けの方はシンガポールにいるグローバル担当医師(日本語話せない)が担当

データの管理方法や同期方法も説明しますが、詳しくはこちらで!

meety.net

外部に委託して生成しているデータ

薬、ICD10(国際疾病分類)、医療機関情報、これらは外部に委託しています。

電カルベンダーさんや薬局でこういったデータが使われています。私たちもそのデータを使わせてもらおうと思ったのですが、いくつか問題があります。

  • IDは変わるもの
  • CSVで最新版のみもらえる
  • はい、IDは変わるものです
  • 数百万レコード月1更新 -> 普通にdumpしたら、サービス止まる(実績あり)

これらは代表的なものですが、他にもいくつか問題があります。 委託のデータが悪いわけではなく、それらの本来の目的とUbieのようなWebサービスのデータに対する狙いが乖離しているために発生している問題です。業界ではUbieの方がまだまだ異端児だったりします。詳細は

meety.net

Calendlyも用意してあるよ!!!

calendly.com

あと、今回話さなかったので、ここで初出しですが、実は外部委託のデータから内製に切り替えたデータも存在しています。 これは外部委託のデータがあまりに多く、そのまま利用するとデータが多すぎて逆にユーザを混乱させてしまうのがわかったため、社内医師により、内製に切り替えました。このあたりの話も当然・・・

各種サービスでのデータの扱い方

  • そのまま使う
  • 組み合わせて使う
  • ユーザが上書きして使う可能性ある

ほとんどがUbieの内製データそのまま利用することが多いですが、内製データと委託しているデータを組み合わせて使っているものもあります。 さらに最近は、社内外のユーザがそのデータを上書きすることもあります。これはいろいろここで書くと長くなりますので、ぜひ

カジュアル面談でお話ししましょう! meety.net

Ubieではどう守っているのか?

次にUbieでは実際どうUbieのシステムを外部のデータから守っているかの説明です。

f:id:shiraji:20210831175724p:plain

ざっくりとしたアーキテクチャ図を見ながら実際どうやっているのかを説明します。このアーキテクチャでどうデータを入れているのか?システムを止めないか?他のサービスとの関わり方などを・・・もう話した方が早いんで、応募してもらえませんか?

meety.net

calendly.com

Twitter DM公開してます。Meetyさんのチャット自分気づけないので、出来たらこっちの方が連絡取りやすいです!

twitter.com

最後に

最後に現在対応している問題について説明します。

f:id:shiraji:20210831175740p:plain

上書きできるデータに対して、データの優先度を考えたり、月1の更新をしたり、以下略!ということで、絶賛お話相手になってくれる方募集しております。

meety.net

最後2

Ubieではどのポジションも絶賛採用中です。特にこのブログにアクセスするであろうエンジニアは常に人員が足りません。一度ぜひこちら見てみてください。

recruit.ubie.life

あと、実はですね、Ubieは生活者向けのサービスも作っています。体調が悪くなった人がスマホで受診相談が出来ます。ちょっと体調悪いなー病院行った方が良いかもなー?不安だなーって人はぜひこれを使ってみてください。

ubie.app

最後にUbieの普通のカジュアル面談して欲しいという方はこちらを見てください。

https://meety.net/matches?q=Ubie

以上です。それではカジュアル面談応募楽しみに待ってます!

graphql-javaで@deprecatedがついてるフィールドにアクセスするクエリを見つける

graphql-javaを使っていて、@deprecatedのdirectiveをつけているのに、ずっとアクセスがあるので、どのクエリがアクセスしてくんの?ってなりました。そこで、このクエリを見つけようと思います。

tl;dr

以下をコピペすればOK

@Component
class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {

    private val log = LoggerFactory.getLogger(LogDeprecatedQueryInstrumentation::class.java)

    private val deprecatedFieldMap = mutableMapOf<String, MutableSet<String>>()

    override fun beginExecution(parameters: InstrumentationExecutionParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginExecution(parameters)
        val id = parameters.executionInput.executionId.toString()
        val query = parameters.query.takeIf { it.isNotBlank() } ?: return super.beginExecution(parameters)
        return whenCompleted { _, _ ->
            if (deprecatedFieldMap[id]?.isNotEmpty() == true) {
                log.warn(
                    """
                        |Accessing deprecated field: ${deprecatedFieldMap[id]?.joinToString()}
                        |Query: 
                        |$query
                    """.trimMargin()
                )
            }
            deprecatedFieldMap.remove(id)
        }
    }

    override fun beginField(parameters: InstrumentationFieldParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginField(parameters)
        val id = parameters.executionContext.executionId.toString()
        if (parameters.field.isDeprecated) {
            if (deprecatedFieldMap.containsKey(id)) {
                deprecatedFieldMap[id]?.add(parameters.field.name)
            } else {
                deprecatedFieldMap[id] = mutableSetOf(parameters.field.name)
            }
        }
        return super.beginField(parameters)
    }
}

想定読者

  • graphql-javaでの開発をしたい、している人
  • graphql-spring-boot-starterを使ったことがある人

@deprecated

まず最初に@deprecated、これはgraphql-java自体がデフォルトでバンドルしているdirectiveの一つです。directiveに関しては様々なところでその存在含め議論されていますが、一旦ここでは忘れることにします。公式ドキュメントを見てもらえれば、わかりますが、@deprecatedはSDLのフィールドとEnumにつけることが可能で、これをつけることにより、そのフィールドやEnumは非推奨であるということをSDL上で宣言することが可能です。

type Drug {
    # 医薬品名
    name: String!
    # 効能効果
    description: String @deprecated(reason: "No longer supported")
}

type Query {
    # 薬名から薬情報を取得する
    drugsByName(name: String!) : [Drug!]
}

ちなみに余談ですが、この設定をして、GraphiQLを起動すると、deprecatedのワーニングが出ます。

f:id:shiraji:20210218200817p:plain
deprecatedのワーニングが出る

新規クエリに関してはこれを見てもらえれば明確であるため、わかるのですが、問題はすでに動いているクエリです。@deprecatedを後でつけたとしても、利用側はそれに気づくことはできません。

クエリをログに吐く

どんなクエリがリクエストされているかはいくつかの方法でログにすることが可能です。今回はInstrumentationを使ってログを出力してみます。

Instrumentation

[Instrumentation] allows you to inject code that can observe the execution of a query and also change the runtime behaviour.

www.graphql-java.com

クエリ実行中に何が起こっているのか確認したり、実行時の挙動を変えたりするための仕組みです。

実装はInstrumentationやSimpleInstrumentationなどのクラスを継承するだけです。(instrumentationの登録方法はgraphql-java-kickstartを使っていると、@Componentなどにすれば、特に何もせずに登録されるため、ここでは一旦省略します。)

@Component
class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {
}

Instrumentationには様々なタイミングでコードをInjectする方法が提供されています。

/**
 * This is called right at the start of query execution and its the first step in the instrumentation chain.
 *
 * @param parameters the parameters to this step
 *
 * @return a non null {@link InstrumentationContext} object that will be called back when the step ends
 */
InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters);

beginExecutionはクエリの実行が始まる直前に呼ばれるメソッドです。引数は InstrumentationExecutionParameters でこのオブジェクトには実行前のクエリ情報やスキーマ情報が格納されています。

@Component
class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {

    private val log = LoggerFactory.getLogger(LogDeprecatedQueryInstrumentation::class.java)

    override fun beginExecution(parameters: InstrumentationExecutionParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginExecution(parameters)
        log.info(parameters.query)
        return super.beginExecution(parameters)
    }
}

これでクエリを実行してみると以下のようにクエリがログに出力されます。

INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - {
  drugsByName(name: "ハチミツ") {
    name
    description
  }
}

deprecatedフィールドをログに吐く

次にフィールドが @deprecated を判定したいです。各フィールドにアクセスする度に実行されるメソッドは beginField です。

    /**
     * This is called just before a field is resolved into a value.
     *
     * @param parameters the parameters to this step
     *
     * @return a non null {@link InstrumentationContext} object that will be called back when the step ends
     */
    InstrumentationContext<ExecutionResult> beginField(InstrumentationFieldParameters parameters);

引数は InstrumentationFieldParameters その取得しようとしているフィールドの情報が格納されています。そのフィールドに @deprecatedがついているかどうか?は parameters.field.isDeprecated で確認できます。

    public boolean isDeprecated() {
        return deprecationReason != null;
    }

isDeprecatedの実装が若干本当にそれで判断するの!?って初見で驚きましたが、見なかったことにしましょう。

実際にそのフィールドが@deprecatedだった場合、フィールド名とそのパスをログ出力するようにしてみます。

@Component
class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {

    private val log = LoggerFactory.getLogger(LogDeprecatedQueryInstrumentation::class.java)

    override fun beginField(parameters: InstrumentationFieldParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginField(parameters)
        if (parameters.field.isDeprecated) {
            log.info(parameters.field.name)
        }
        return super.beginField(parameters)
    }
}

そうするとこんな感じのログが出ました。

INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - description
INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - description
INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - description
INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - description
INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - description
INFO  a.u.m.p.i.LogDeprecatedQueryInstrumentation - description

6回出力されているのは、"はちみつ"という名前のついている薬が6個あるためです。(はちみつは薬です。)

[余談] N+1問題で障害発生

6回出力されている時点で明確なのですが、beginFieldメソッドは出力するフィールド全てで出力されます。

要するにこのメソッドはN+1問題が確実に発生するメソッドであるので、注意が必要です。

当初、自分はinfoログではなく、errorログを出力するように実装していました。

    override fun beginField(parameters: InstrumentationFieldParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginField(parameters)
        if (parameters.field.isDeprecated) {
            log.error(parameters.field.name)
        }
        return super.beginField(parameters)
    }

この処理を本番に出した結果、本番で障害を出しました (テヘペロ

理由として、これは完全に見落としだったのですが、errorログを出力するとSentryへのワーニングを出力するように設定されていました。 QA環境やステージング環境でもSentryへのワーニングが発生していたのですが、本番と違いそこまで負荷がかからず、通知も数件という感じで問題ない判定になっていました。 しかしそこは本番環境、アクセスが多く、Deprecatedフィールドへのアクセスもあり、Sentryへの通知をめちゃめちゃしてしまいました。 この通知処理があまりに多く、サーバがその負荷に耐えられずにダウンしてしまっていました。 infoログ(通知なし)に変えたら、問題なく動き出したときは、人生で初めてログ出力変えることによって動くようになるプログラム書いてしまったか・・・という衝撃を受けました。

deprecatedフィールドを一回だけ出力する(方針編)

話を戻して、deprecatedフィールドへのアクセスを検知したら、クエリ毎に一回だけ出力されれば十分であるため、一回のみ出力されるように修正します。

方針として以下となります。

  1. beginExecutionでクエリを取得
  2. beginFieldでdeprecatedフィールドの検知をする
  3. 全ての処理が終わった時に、deprecatedフィールドがある場合、クエリをログ出力する

こんな感じの方針で行こうと思います。

whenCompletedメソッド

InstrumentationやSimpleInstrumentationクラスは全て begin しか提供されていません。終わった時の処理はwhenCompletedメソッドを利用します。

    /**
     * Allows for the more fluent away to return an instrumentation context that runs the specified
     * code on instrumentation step completion.
     *
     * @param codeToRun the code to run on completion
     * @param <U>       the generic type
     *
     * @return an instrumentation context
     */
    public static <U> SimpleInstrumentationContext<U> whenCompleted(BiConsumer<U, Throwable> codeToRun) {
        return new SimpleInstrumentationContext<>(null, codeToRun);
    }

使い方は各種beginメソッド内で利用可能です。

class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {

    private val log = LoggerFactory.getLogger(LogDeprecatedQueryInstrumentation::class.java)

    override fun beginExecution(parameters: InstrumentationExecutionParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginExecution(parameters)
        log.info("beginExecution[${parameters.executionInput.executionId}]")
        return whenCompleted { _, _ ->
            log.info("beginExecution[${parameters.executionInput.executionId}] - whenCompleted")
        }
    }

    override fun beginField(parameters: InstrumentationFieldParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginField(parameters)
        log.info("beginField[${parameters.field.name}]")
        return whenCompleted { _, _ ->
            log.info("beginField[${parameters.field.name}] - whenCompleted")
        }
    }
}

実際に使ってみるとこんな感じのログになります。

beginExecution[034a160a-4c3a-498f-a305-43f954445fb9] // -- (1)
beginField[drugsByName] // -- (2)
beginField[name] // -- (3)
beginField[name] - whenCompleted // -- (4)
beginField[description] // -- (5)
beginField[description] - whenCompleted
beginField[name]
beginField[name] - whenCompleted
beginField[description]
beginField[description] - whenCompleted
beginField[name]
beginField[name] - whenCompleted
beginField[description]
beginField[description] - whenCompleted
beginField[name]
beginField[name] - whenCompleted
beginField[description]
beginField[description] - whenCompleted
beginField[name]
beginField[name] - whenCompleted
beginField[description]
beginField[description] - whenCompleted
beginField[name]
beginField[name] - whenCompleted
beginField[description]
beginField[description] - whenCompleted
beginField[drugsByName] - whenCompleted // -- (6)
beginExecution[034a160a-4c3a-498f-a305-43f954445fb9] - whenCompleted // -- (7)
  • (1)でbeginExecutionの最初のログが出力されます。
  • (2)でbeginFieldのdrugsByNameのログが出力されます。GraphQLではメソッド定義?もフィールド扱いです。
  • (3)でnameフィールドのログ、(4)でそのフィールドへのアクセスが終わった時のログ
  • (5)で次のdescriptionフィールドのログ
  • (6)で(2)でアクセスしていたdrugsByNameへのアクセスが終わった時のログ
  • (7)でクエリ実行全体が終わった時のログ

executionId

whenCompletedメソッドの実装時にしれっと使いましたが、executionIdというものがexecution毎に振られます。このIDはexecution毎にユニークで、どのexecutionの処理をしているのかを確認することが可能になります。

class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {

    private val log = LoggerFactory.getLogger(LogDeprecatedQueryInstrumentation::class.java)

    override fun beginExecution(parameters: InstrumentationExecutionParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginExecution(parameters)
        log.info("beginExecution[${parameters.executionInput.executionId}]")
        return whenCompleted { _, _ ->
            log.info("beginExecution[${parameters.executionInput.executionId}] - whenCompleted")
        }
    }

    override fun beginField(parameters: InstrumentationFieldParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginField(parameters)

        log.info("beginField[${parameters.executionContext.executionId}]")
        return whenCompleted { _, _ ->
            log.info("beginField[${parameters.executionContext.executionId}] - whenCompleted")
        }
    }
}

例えばこんな感じの実装で確認してみます。ログ出力してみると

beginExecution[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7]
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginField[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted
beginExecution[a83e77aa-8f51-4499-95af-75c4240ab0b7] - whenCompleted

一つのクエリ実行が完了するまで同じIDになります。ちなみにこんなクエリを実行してみます。

{
  one: drugsByName(name: "ハチミツ") {
    name
    description
  }
  
  two: drugsByName(name: "ロキソプロフェン") {
    name
    description
  }
}

複数のフィールドへのアクセスですが、クエリの実行としては同一であるため、同じIDで全て実行されています。

beginExecution[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
// 長いので省略
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45]
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginField[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted
beginExecution[60010edd-dd6c-4490-a3d9-fa92e167ce45] - whenCompleted

Exceptionが出た時もwhenCompletedは実行されてます。以下がRuntimeExceptionが出た時のログです。

beginExecution[9e729dab-5fbe-4611-b6db-22c0a1f78406]
beginField[9e729dab-5fbe-4611-b6db-22c0a1f78406]
Exception while fetching data (/drugsByName) : エラーだよ
java.lang.RuntimeException: エラーだよ
// stacktrace
beginField[9e729dab-5fbe-4611-b6db-22c0a1f78406] - whenCompleted
beginExecution[9e729dab-5fbe-4611-b6db-22c0a1f78406] - whenCompleted

deprecatedフィールドを一回だけ出力する(実装編)

方針をもう一度確認します。

  1. beginExecutionでクエリを取得
  2. beginFieldでdeprecatedフィールドの検知をする
  3. 全ての処理が終わった時に、deprecatedフィールドがある場合、クエリをログ出力する

こちらですが、whenCompletedメソッドを使うことにより、beginFieldでアクセスできるInstrumentationExecutionParametersにクエリ実行後にもアクセスできるため、クエリの取得とログ出力は同時に可能です。新しい方針としてはこんな感じに

  1. beginFieldでdeprecatedフィールドの検知をする
  2. beginExecutionのwhenCompletedでdeprecatedフィールドがある場合、クエリをログ出力する

deprecatedフィールドを検知した場合、先ほどのexecutionIdを何処かに格納して、beginExecutionのwhenCompletedでログ出力、不必要になったexecutionIdは削除という感じで実装していきます。 で、今回は簡単にフィールド変数に格納しましょう。

@Component
class LogDeprecatedQueryInstrumentation : SimpleInstrumentation() {

    private val log = LoggerFactory.getLogger(LogDeprecatedQueryInstrumentation::class.java)

    private val deprecatedFieldMap = mutableMapOf<String, MutableSet<String>>()

    override fun beginExecution(parameters: InstrumentationExecutionParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginExecution(parameters)
        val id = parameters.executionInput.executionId.toString()
        val query = parameters.query.takeIf { it.isNotBlank() } ?: return super.beginExecution(parameters)
        return whenCompleted { _, _ ->
            if (deprecatedFieldMap[id]?.isNotEmpty() == true) {
                log.warn(
                    """
                        |Accessing deprecated field: ${deprecatedFieldMap[id]?.joinToString()}
                        |Query: 
                        |$query
                    """.trimMargin()
                )
            }
            deprecatedFieldMap.remove(id)
        }
    }

    override fun beginField(parameters: InstrumentationFieldParameters?): InstrumentationContext<ExecutionResult> {
        parameters ?: return super.beginField(parameters)
        val id = parameters.executionContext.executionId.toString()
        if (parameters.field.isDeprecated) {
            if (deprecatedFieldMap.containsKey(id)) {
                deprecatedFieldMap[id]?.add(parameters.field.name)
            } else {
                deprecatedFieldMap[id] = mutableSetOf(parameters.field.name)
            }
        }
        return super.beginField(parameters)
    }
}

で、これを実行してみると

Accessing deprecated field: description
Query: 
{
  drugsByName(name: "ハチミツ") {
    name
    description
  }
}

これで障害起こさず、悪いクエリを見つけることが出来ました。

まとめる

まとめると

  • InstrumentationやSimpleInstrumentationなどのクラスで出力されるフィールドベースでアクセスすることが可能
  • beginメソッドを使いのなしてね
  • beginFieldはN+1になるので扱い注意!
  • beginFieldはN+1になるので扱い注意!
  • whenCompletedメソッドはそのアクセスが終わった後に呼ばれるよ

終わり。

Ubieのリモートワーク(テレワーク, WFH)を活用して、子育てが充実した話

みなさん、リモートワーク、テレワーク、WFH生活どうでしょうか?

この生活がどれだけ続くか分かりませんが、案外会社が回ることがわかったことですし、新型コロナウイルス問題収束したあかつきにはリモートでの仕事をすることができるような日々が来るかもしれませんね。

そんなご家庭にこんな働き方もありまっせ!ということで、決まった曜日にリモートをすることにより、共働きの家庭の未就学児に習い事をさせ、子育てを充実させてみたので、それについて書いてみます。

前提

Ubieの定義

Ubieには2020年3月現在、UbieとUbie AI Consulting(UAC)の2つの組織が存在しています。

f:id:shiraji:20200314195252p:plain

このエントリーでは私が所属している開発を主に担当しているDevチームの話になります。UbieのDevチームはコアタイムなしのフルフレックス制度を採用しています。

UACの詳細はきっと UAC代表 がどこかでしてくれると思います。楽しみにしていてください。

平日の子供の習い事

共働きで保育園に子供を通わせてるご家庭ならわかってくれるはず!と思うのですが、平日子供に習い事をさせるのはほぼほぼ無理です。平日の習い事は15時には帰宅する幼稚園に通っている子供向けに時間が設定されており、15:30~16:00くらいからスタートし、18:00までには終わる時間になっています。6時間勤務の時短を使っているご家庭は16:00~17:00くらいに退社することが多いはずです。その時間に会社から子供を迎えに行き、習い事のところまで届けるとしてもかなり厳しいです。私たち夫婦はフルタイムの共働きで、私がUbieに入社するまでは平日の習い事なんて考える余地がないくらい無縁なものでした。

リモート利用状況

Ubieには新型コロナウイルス問題以前から週2日のリモートワーク制度が存在しています。新型コロナウイルス問題以前の私のだいたいなリモート利用状況はこんな感じです。

  • 月曜日 基本出社。木、金に出勤すべき予定が入っていれば、リモート
  • 火曜日 リモート
  • 水曜日 会社の出社推奨日であるため、出社
  • 木曜日 予定が無ければリモート
  • 金曜日 木曜日がリモートできなければ、リモート

1年間このルールに則って出社したりリモートしたりしていました。少なくとも週1は必ずリモートでした。

それと半年経過して書いたエントリーにもありますが、出社時間は10:00-17:30にして、子供の送り迎えをし、タスク消費量が足らないので、その後は家で仕事をしています。

shiraji.hatenablog.com

水曜日

水曜日だけ特殊で、出社推奨日となっています。基本この日はリモートせずに対面でみんな集まろう!と決まっています。その水曜日はスプリントのレビュー、全社でOKRの進捗確認、全員でやる採用定例などなどを実施しています。

月、木、金

月曜日をリモートしてしまうと水、木、金と三日連続で満員電車に乗らないといけない状況に陥るので、基本的に木、金のどちらかをリモートするようにしていました。あと月曜に出社すると週が始まった感があってシャキーン!🤩としてその週のパフォーマンスが若干よくなってる印象があります。。。(多分気のせい)

火曜日

さて本題の火曜日のお話です。

習い事に通わせる経緯

そもそもの発端は4歳児の運動会の時でした。私の息子は他の子たちと比べ、明らかに運動が出来ない子という感じで各競技をこなしていました。別に苦手なら苦手で良いのですが、息子は運動することがどちらかと言うとかなり好きな方であり、息子自身も何か歯痒いそうな感じが見て取れました。納得が出来ず妻と2人で振り返りをしました。そこで気づいたのが

  • 私と妻は共に保育園や学生時代は早く家に帰ってくることができる子供で、帰ってきたら外で遊ぶ生活をしていた
  • そのため、子供とは親が何もせずとも普通に運動ができるものだと言う認識だった
  • 息子は家に帰ってくるのが19:30過ぎ。平日運動する機会が他の子供たちと比べ少なく、運動する機会を提供できていない

完全に私たち親の問題だと気づきました。そこで運動ができる習い事をさせてあげようと探し始めました。ただ4歳児ともなると習い事をする子供が多く、特に保育園児が通う事ができる土日の習い事は半年待ち(ただし半年で入れるとは言ってない)状態で、空いていたのが、火曜日の15:30~17:30までやっている運動教室でした。

当時まだUbieに入社して数ヶ月でリモートもあまり積極的に活用していませんでした。しかし、この習い事に通わせるため、数ヶ月週2リモート+フレックス生活を試し、自身のパフォーマンスを落とさずに仕事ができることを確認してから、息子をその習い事に通わせることにしました。

初日にその習い事のお試し体験をさせてもらいました。あの時の息子の笑顔や楽しそうな大きな声は1年経った今でも鮮明に覚えています。本当に心から運動することを楽しんでいる息子を見て、ものすごく嬉しかったし、同時になんでもっと早く気づいてやれなかったのか?と後悔もしました。何がなんでもこの生活を回していくぞと決めた日でもありました。

スケジュール

ざっくりとした火曜日のスケジュールはこんな感じです。

f:id:shiraji:20200314030058p:plain
火曜日のタイムテーブル

夜は21時くらいまでは仕事してますが、子供の習い事のときの自分のパフォーマンスが悪かったら、もう少し遅くまで仕事してますし、そのスプリントで進捗良ければ、20時頃には切り上げたりしています。(冷静に考えて奥さん仕事し過ぎでは?🤔)

火曜日午後の会議

順調にリモートしつつ、習い事に行かせる生活を送っていたのですが、7月頃に大きな問題が発生しました。その時まで実は私と大半がリモートの業務委託の人たちだけのチームに所属していたため、同期的なコミュニケーションを取らずにリモートで仕事をすることができていました。しかし、7月頃にオフィスでスクラムをバリバリこなしているチームへ移籍となり、同期的なコミュニケーションが必要になりました。スクラムの各種セレモニーにも参加する必要があります。特にこのチームに移籍することの問題だったのが火曜日の午後に実施していたリファインメントでした。リファインメントは重要なスクラムセレモニーの一つで一回出ることが出来ないと次以降のスプリントのキャッチアップすることが厳しくなります。このタイミングで子供の習い事を辞めさせることを検討すべきかもしれませんが、あの笑顔を見た後にこの習い事をやめさせることは私の中に選択肢としてありませんでした。そこで、チームにリファインメントを午前中に変更してもらえないか?と話したところ、こんなプライベートな理由だったのですが、快くリファインメントの時間を変更してくれました。

Ubieの本当に素晴らしいところは、チームメンバーのパフォーマンスを最大限引き出せる環境をみんなが真剣に考えて作ってくれる事です。この件に関して本当に感謝する事しか出来ません。ありがとうございます。この判断をしてくれたことにより、パフォーマンスを落とさず、習い事に通わせつつ、スクラムの一員になることが出来ました。

その後また別のチームに参加することになったのですが、火曜日の午後は基本会議を入れないように配慮してもらっています。時間がどうしても合わず、今までに2回この時間に会議が入ったのですが、教室からリモートで参加をさせてもらいました。子供や周りの声が入り、聞こえづらい時があったのでノイズキャンセリングのヘッドフォンが必要だなーという感じでした。私が頻繁に発言するような会議がもしその時間に組まれた場合は子供たちの声を拾ってしまうことと、周りのママさんたちに迷惑になりそうなので、教室の外に行った方が良いかもしれないなーと考えています。

結果

Ubieやチームのサポートのおかげで、保育園の送迎など子育てを中心にした仕事のスケジュールを組むことができるようになっています。

4歳児の時はとにかく何事もなく終わらせることしか頭になかったようですが、5歳児の運動会のダンスでは一番難しい技を出すグループに自ら手を上げて参加し、本番で成功させていました。走り方もすごく綺麗なフォームでしっかり走れていました。祖父母も明らかによくなったと喜んでいました。本人もやれることが増えて自信が顔に出ていました。保育園の先生からいろんなことを自ら積極的にやれるようになったと聞いています。

卒園式では、園の毎年恒例である式の最後に、「園児が保護者のうち1人を呼び、感謝され、いろんな人に写真撮られつつ一緒に退室していく」という保護者の晴れ舞台を作るための時間がありました。普通その場で呼ばれるのは子育てに多くの時間を割いている「ママ」です。今年もクラスの大半が「ママ」を選択しました。しかしうちの息子は練習の時から「パパ」一択だったそうで、他の子に何を言われようが、先生に何を言われようが、ママ・パパに何を言われようが「パパ」にすると言っていました。息子は私が子育てに参加してることをしっかり認識してくれてることが本当に嬉しかったです。

念のため明記しておきますが、妻も世間一般のママと同じかそれ以上にしっかりと子育てに参加してます。私と同様に息子に愛情を注いでいる素晴らしいママです。

今後

卒園式と書いてある通り、4月から息子は小学生になります。また生活がガラッと変わりそうですが、Ubieは柔軟に働くことができる環境なので、問題が発生したら都度チームに相談して解決していこうと思います。

まとめ

リモートワーク、テレワーク、WFHも良いけど、フルフレックスのUbieならうまく時間を合わせられたら、子育て充実させられるよ!

さて、そのUbieですが、現在絶賛採用募集中です。Webエンジニア、QAエンジニア、SRE、MLエンジニア、データエンジニア全ポジション募集中です。どのポジションも事業成長スピードと比較して全然人が足らないので、興味のある方はぜひ

www.figma.com

ubie.life

また、このエントリーでは詳細説明しておりませんが、UACと言う組織が現在立ち上がりのフェーズに入っています。こちらも全然人が足らない!マーケティングやセールスが武器だ!と言える人ぜひ検討してください!!!

speakerdeck.com

IntelliJプラグインでAnnotate機能を作った

はじめに

これは Ubie Advent Calendar 2019 - Qiita の21日目の記事です。

想定読者

(開発方法セクションより前)

(開発方法セクション以降)

本題

まずはこれを見て欲しい。

https://raw.githubusercontent.com/shiraji/find-pull-request/master/website/images/list-pr.gif

以前から欲しいなーって思っていたのだけど、プライベートの時間の捻出が出来ず、主に眠気に勝てないのが原因で、なかなか腰が上がらなかったんですが、息子くんがすんなり寝てくれて2時間くらい時間が出来たので、さくっと作ってみた。その後Twitterに成果報告して寝た。

翌朝起きたら中々の反応がもらえてたので、ちょっとやる気が出て、そのまま勢いで作り切りました。余談ですが、自分の場合、こんな機能作った!ってツイートした場合、反応あるとすごくモチベーションが上がるので、欲しいな!って思ったらぜひいいねしてもらえると!

これ単体でプラグインとして出しても良いのですが、切り出すメリットがあまり見えなかったため、Find Pull Request プラグインのv1.7.0の機能として出しています。Repositoryはこちら

この機能により、Find pull requestプラグインは3つの機能を持つことになりました。

  1. 右クリックでpull requestページに飛ぶ
  2. 右クリックでpull requestページのURLをコピーする
  3. pull requestの一覧をエディタに記載する

まだまだ他にも欲しい機能があるので、ちょくちょく作っていこうと思います。他にも欲しい機能あればぜひissueにあげてください。ちなみにGitHub, GitHub Enterprise, GitLab, Bitbucketで動作します。他にも欲しいサービスあれば、issueテンプレート埋めていただければ対応します。

開発方法

せっかく作ったので、調べた知見を置いておきます。

ここから想定読者は 「IntelliJプラグインをがっつり開発したことがある・Annotate機能を作りたい人」とします。つまり基本的なプラグイン開発の説明は省略します。

参考ソース

普段プラグイン開発するなら他の似たようなプラグインソースコード読め。で終わるのですが、アイコンつけるのはいくつかありましたが、Annotate機能を使ったプラグインは誰も作ったことがないのではないか?と言うくらい見つけることが出来ませんでした。

その為、みなさんお馴染みのintellij-communityのrepoから仕様を把握することにしました。

github.com

その中でも唯一Annotate機能を提供している、 AnnotateToggleAction を見て開発を行ないました。

https://github.com/JetBrains/intellij-community/blob/master/platform/vcs-impl/src/com/intellij/openapi/vcs/actions/AnnotateToggleAction.java

Find pull requestプラグインは開発当時2019.1をサポートしているので、masterブランチではなく、2019.1のソースを見て開発していました。後述するけど、これが後に問題になります。

Annotateを出すには

色々読んだ結果、Annotateを出すには editor.gutter.registerTextAnnotation() を呼び出すことで実装が可能のようです。

  /**
   * Adds a provider for drawing custom text annotations in the editor gutter, with the
   * possibility to execute an action when the annotation is clicked.
   *
   * @param provider the provider instance.
   * @param action the action to execute when the annotation is clicked.
   */
  void registerTextAnnotation(@NotNull TextAnnotationGutterProvider provider, @NotNull EditorGutterAction action);

引数は二つ、一つ目の TextAnnotationGutterProvider はどんなテキストを出すのか?もう一つの EditorGutterAction はそのテキストに対してクリックした時どんなアクションを起こすのか?です。クリックなどのアクションが必要ない場合、二つめの引数は省略可です。

TextAnnotationGutterProvider

TextAnnotationGutterProviderで一番重要なメソッドはこの getLineText メソッドです。

/**
   * Returns the text which should be drawn for the line with the specified number in the specified editor.
   *
   * @param line   the line for which the text is requested.
   * @param editor the editor in which the text will be drawn.
   * @return the text to draw, or null if no text should be drawn.
   */
  @Nullable
  String getLineText(int line, Editor editor);

このメソッドは引数に行番号と表示するEditorが渡されます。戻り値にはその行に表示するStringです。

簡単なメソッドなのですが、一つ問題があります。それはこのメソッドが呼び出されるタイミングはAnnotateがレンダーされ得るたびと言うことです。

その何が問題なのかを説明する前に、Annotateの機能についておさらいします。AnnotateとはEditorの左横の部分を右クリックし、Annotateを選択したら、その行の最後のコミット(正確には少し違う)を表示します。

f:id:shiraji:20191215165849p:plain
Annotate

ここまではよく知られています。問題はエディタでの表示と言うことはAnnotate表示中も開発者がコードの更新をすることが可能です。つまりコード編集してもAnnotateを閉じず、正しい行に正しい情報を表示する必要があるということです。

f:id:shiraji:20191215170639g:plain
コードの変更・改行・行削除などにも対応する必要がある

registerTextAnnotation するのはアクションがcheckになった時のみです。そのため、registerTextAnnotation された時と getLineText が呼び出された時の表示されているエディタの状態は異なる可能性があることになります。

これを踏まえて、 getLineText の実装をします。最初に問題になるのが、 line の意味です。the line for which the text is requested. と記述されていますが、説明が圧倒的に足りていません。というか、説明通りに受け取ってはいけません。この lineAnnotate が表示された時の行番号に当たります。今まさにEditorで表示されている行番号ではありません。受け取った line をそのまま利用するのは危険です。 line が今表示されているエディターの行番号としてどこに当たるのかを確認し、その確認した行番号に対して情報を返さないといけません。(ここを読むような訓練されたプラグイン開発者なら「あーよくあるやつね」となるいつものやつです。)

line が今表示されているエディターの行番号のどこに当たるのか」は UpToDateLineNumberProviderImpl を利用して取得します。

        val upToDateLineNumberProvider = UpToDateLineNumberProviderImpl(editor?.document, editor?.project)
        val currentLine = upToDateLineNumberProvider.getLineNumber(line)

UpToDateLineNumberProviderImpl#getLineNumber にはドキュメントはありませんが、0未満の時には現在表示されているエディタにはその行は存在していないことを表しています。そのため今回は、0未満の値だった場合、表示されることはないため、空文字即returnにしてあります。

ここが呼び出される回数は思った以上に頻度が高いです。しかも各行呼び出されます。このプラグインではgitのハッシュ値からpull requestの番号を取得しますが、都度その処理を行うことは危険です。そのため、ハッシュ値とPR番号のマッピングregisterTextAnnotation する前に計算しておき、このメソッド内ではそのマッピングから表示する文字列を生成する簡単な処理に留めてあります。

EditorGutterAction

EditorGutterAction重要なのがdoAnnotateメソッドです。

/**
   * Processes the click on the specified line.
   *
   * @param lineNum the number of line in the document the annotation for which was clicked.
   */
  void doAction(int lineNum);

このメソッドのlineNumは現在表示されている行番号になります。 UpToDateLineNumberProviderImpl は利用する必要はありません。Editorは受け取れないことに注意してください。

TextAnnotationGutterProvider と同じようなデータやロジックを使います。そのため、今回の機能では、一つのクラスで二つとも実装することにしています。

FileAnnotation

AnnotateのようにFileAnnotationに依存するような情報を表示する場合、注意しなくてはならないのが、IntelliJのエディタ以外の部分からもファイルの変更が可能になる点です。何を言っているのかわからないと思います。例えばですが、ファイルの変更をして、コンソール上でコミットをするようなケースを考えてください。エディタの変更だけではなく、IntelliJ外部からのFileの変更(FileAnnotationの変更)などを検知し最新の情報を表示する必要があります。ちなみにAnnotateを開いたまま、改行し、コンソールでコミットしてみてください。Annotateが自動で閉じます。今回の機能もFileAnnotationに依存しているため、その変更を検知する必要があります。あまり深く仕様を考えるのは大変であるため、Annotateと同じようにFileAnnotationの変更が発生した場合、Annotateを閉じるようにします。

FileAnnotationの変更を検知するメソッドは二つあります。

  /**
   * Notify that annotations should be closed
   */
  public synchronized final void close() {
    myIsClosed = true;
    if (myCloser != null) {
      myCloser.run();

      myCloser = null;
      myReloader = null;
    }
  }

  /**
   * Notify that annotation information has changed, and should be updated.
   * If `this` is visible, hide it and show new one instead.
   * If `this` is not visible, do nothing.
   *
   * @param newFileAnnotation annotations to be shown or `null` to load annotations again
   */
  @CalledInAwt
  public synchronized final void reload(@Nullable FileAnnotation newFileAnnotation) {
    if (myReloader != null) myReloader.consume(newFileAnnotation);
  }

  /**
   * @see #close()
   */
  public synchronized final void setCloser(@NotNull Runnable closer) {
    if (myIsClosed) return;
    myCloser = closer;
  }

  /**
   * @see #reload(FileAnnotation)
   */
  public synchronized final void setReloader(@Nullable Consumer<? super FileAnnotation> reloader) {
    if (myIsClosed) return;
    myReloader = reloader;
  }

上記のようにコンソールでコミットする場合、FileAnnotateのcloseが呼び出されるため、setCloserメソッド内でAnnotateを閉じる処理を記述します。

        fileAnnotation.setCloser {
            UIUtil.invokeLaterIfNeeded {
                if (!project.isDisposed) editor.gutter.closeAllAnnotations()
            }
        }

同じようにsetReloaderメソッドを実装しようとしたのですが、思いつく限りの行動をしても、残念ながらreloadメソッドが呼び出されることがありませんでした。そのため、setReloaderの実装することができませんでした。AnnotationToggleActionから引数のFileAnnotationが最新のFileAnnotationのようですが、ここは謎です。。。誰か教えて。

2019.1 vs 2019.2

さてここまで実装して、よっしゃリリースだ!って思ったのですが、 closeAllAnnotations メソッドが気になっていました。このメソッド呼び出されるとAnnotateの部分に表示されている情報全てを閉じます。つまり、Annotateを表示し、List pull requestを表示し、 close annotationを選択するとAnnotateとList pull requestが両方とも閉じてしまします。片方だけ閉じることが出来ません。

f:id:shiraji:20191220231636g:plain

2019.1の場合、個々のAnnotateを閉じる方法が提供されておらず、さらに設定されているTextAnnotationの取得が行えません。残念だなーと思っていたのですが、ふとした拍子にこんなissueを見つけてしまいました。

Annotate action behave wrong if other annotations added https://youtrack.jetbrains.com/issue/IDEA-209722

このissueによると、192.2300から個々のAnnotateを閉じることができるようになったみたいです。つまり2019.1では出来ないけど、2019.2では出来ると。2019.1のソースコードばかり追っていたため、最新のコードでは出来る事を出来ないと諦めてしまっていました。

古いバージョンに引っ張られて、しょぼい機能を提供するのはなんだかなーって思ったので、2019.2での開発に切り替えることにしました。

ちなみにこのissueにはこんなことが書かれています。

We've run into that in the students' project, but it also can be actual for 3rd-party plugins developers.

要約すると「学生のプロジェクトやってる時に発覚した。3rdパーティのプラグイン開発者にも発生する可能性あるよ。」とのことです。3rdパーティのプラグイン開発者でAnnotate作って公開まで持っていったの、ひょっとすると相当少ないようです。どうりで似たようなプラグインが見つからない訳ですね・・・

2019.2対応

2019.2(ここでは192.5728以上とします)では新たに以下の二つのメソッドが提供されました。

  @NotNull
  List<TextAnnotationGutterProvider> getTextAnnotations();

  void closeTextAnnotations(@NotNull Collection<? extends TextAnnotationGutterProvider> annotations);

getTextAnnotations はそのEditorに設定されている全てのTextAnnotationsを取得し、 closeTextAnnotations は引数に渡された複数のTextAnnotationを閉じるメソッドです。

これを使えばOK!ということでさくっと実装してみました。上記の問題が解消されると思ったのですが、Exceptionが出ます。

java.lang.Throwable: Synchronous execution on EDT: /usr/bin/git -c ...
    at com.intellij.openapi.diagnostic.Logger.error(Logger.java:145)
    at com.intellij.execution.process.OSProcessHandler.checkEdtAndReadAction(OSProcessHandler.java:117)
    at com.intellij.execution.process.OSProcessHandler.waitFor(OSProcessHandler.java:62)
    at git4idea.commands.GitTextHandler.waitForProcess(GitTextHandler.java:145)
    at git4idea.commands.GitHandler.runInCurrentThread(GitHandler.java:383)
    at git4idea.commands.GitImplBase.doRun(GitImplBase.java:172)
    at git4idea.commands.GitImplBase.run(GitImplBase.java:148)
    at git4idea.commands.GitImplBase.runCommand(GitImplBase.java:46)
    at git4idea.commands.GitImpl.runCommand(GitImpl.java:41)

これは何かというと、UIスレッド時にgitのような外部コマンド叩くとエラーになるということです。IntelliJ 2019.2からこのような仕様に変わったようです。

Find Pull Requestプラグインは各所でgitコマンドを叩いているため、これの対応が必要になります。

外部コマンドを叩く場合、一番簡単な対処方法はバックグラウンドスレッドでの処理にしてしまうことです。バックグラウンドスレッドでの実行方法はいくつかあるのですが、簡単な方法として、Task.Backgroundableを使ってみます。

        object : Task.Backgroundable(project, "Listing Pull Request...") {
            override fun run(indicator: ProgressIndicator) {
                // 外部コマンドを叩くなどの処理+その後続処理
            }
        }.queue()

queue()の呼び出しを忘れがちになるので、注意してください。

さぁ対応終わったぞ!って思ったのですが、今度は別のエラーが出ます。

java.lang.Throwable: Read access is allowed from event dispatch thread or inside read-action only (see com.intellij.openapi.application.Application.runReadAction())
    at com.intellij.openapi.diagnostic.Logger.error(Logger.java:162)
    at com.intellij.openapi.application.impl.ApplicationImpl.assertReadAccessAllowed(ApplicationImpl.java:1027)
    at com.intellij.openapi.editor.impl.EditorImpl.assertReadAccess(EditorImpl.java:3254)
    at com.intellij.openapi.editor.impl.EditorImpl.getSettings(EditorImpl.java:920)

今度は何かっていうと、上でやったバックグラウンドスレッドはEditorへのRead権限がないため、あかんよってことです。上のバックグラウンド処理は必須であるため、read権限あるスレッドでEditorへのアクセスをするように修正します。

以下の公式ドキュメントにスレッド周りの話が書いてありますので、興味ある人は読んで下さい。 www.jetbrains.org

ドキュメントによると今回の場合は、invokeLaterを呼び出せば対処可能になります。つまりコードとしてはこんな感じに。

        object : Task.Backgroundable(project, "Listing Pull Request...") {
            override fun run(indicator: ProgressIndicator) {
                // 外部コマンドを叩くなどの処理+その後続処理
                ApplicationManager.getApplication().invokeLater {
                    // バックグラウンドスレッド内でeditorへアクセスするなどread権限が必要な処理を実行
                }
            }
        }.queue()

ようやく全てのコードがエラーなしで2019.2で動くようになりました。ほっと一安心。

さぁリリースです。

はいリリース後にユーザが多い、Android Studioの最新版3.5系(2019.1ベース)のサポートを勢い余って切ったことが発覚しました。最近Android開発してなかったし、完全に忘れてました。。。

複数バージョン対応

かと言って、2019.1のサポートに戻すと、問題解消されている2019.2のユーザにも問題ある挙動を強制する事になってしまいます。そこで、複数のバージョンのプラグインをリリースすることにしました。

  • 1.7.0 -> 2019.2以降のユーザ
  • 1.7.0-2019.1 -> 2019.1.xのユーザ(Android Studio含む)

対処方法は簡単で、plugin.xmlの idea-version のuntil-buildを設定し <idea-version since-build="191" until-build="192.5727"/>プラグインのバージョンを新しく 1.7.0-2019.1 として、リリースするだけです。(until-buildなどはplugin.xmlじゃなくて、gradle-intellij-pluginで設定可能になっているはずです。未検証) そうするとこんな感じで、IntelliJのバージョンによって、インストールできるバージョンが変わるようになります。

f:id:shiraji:20191221002340p:plain
複数バージョンがホストされる

まとめ

先日のUbieアドベントカレンダーLukas (@cvguy84) | Twitter さんのIntelliJプラグインの記事では

qiita.com

抽象的で考えるとIntelliJプラグインの開発は簡単です。

どこがやー!って思うのですが、

IntelliJプラグインは誰でも作ることができます。 Documentationを慎重に読んで、実装するしかないです。でもそれがOSS開発の一つの楽しみであると思います。

本当にこれです。ドキュメント(という名のソースコード)を読んで、うまく動いた時、すっごく楽しいし、それが人に使われると思うとやめられませんなーってなります。

ちなみに年内に出したいと思ったので、コピペの嵐でやり過ごしました。そのため、2019/12/21現在のrepositoryのコードは参考にしない方が良いです。。。時間とってがっつりフルスクラッチします。誰にも何も言われず、費用対効果ガン無視で思いっきり俺の考えた最強のコードがかけるのもOSSの醍醐味だと思います。

Spring+GraphQLからKtor+GraphQLに移行する

はじめに

これは Ubie Advent Calendar 2019 - Qiita の7日目の記事です。

6日目は かみな/Kaito Minatoya@Ubie (@kamina_zzz) | Twitter による 地球上の Kubernetes ユーザーは絶対使うべきツールたちを紹介するよ - Qiita でした。

想定読者

  • Spring + Kotlin + GraphQLを使っていて、Ktorに移行したくなっちゃった人 (需要🤔)

本題

UbieでAdvent CalendarをKtor縛りで書くぞ!となりました。じゃあ自分は何を書こうかなーって考えてたらGraphQLに決まってるじゃん?と振られたので、UbieではKtor+GraphQLは使ってないのですが、もしSpringのアプリケーションである https://github.com/ubie-inc/kotlin-graphql-sample をKtorに移し替えるとしたら何をするのかを調査しつつ、記事にすることにしました。

この記事では一旦動くところまでを目指します。

生かすコード、諦めるコード

フレームワークを載せ替えるとしてもそこまでガッツリ書き直す訳ではなく、生かすことが出来るコードが多々あります。今回は出来うる限りコードを生かす方向にしました。そのため、graphql-java-toolsを同様に採用します。

github.com

これを採用するため、domainquery resolver, resolver はSpringのアノテーションを消すだけで再利用出来ます。

import com.coxautodev.graphql.tools.GraphQLResolver
- import org.springframework.stereotype.Component

- @Component
class DiseaseResolver(...) : GraphQLResolver<Disease> {

jdbc も同様に修正するのですが、Springのjdbcを使っているため、思った以上にガッツリ修正が入ります。この辺りはKtorで利用するDBアクセスライブラリ次第になります。ここではあまり関係ないので、省略します。

スキーマファイルもSpringの時同様にresources直下に置きます。

再利用できるコードもありますが、完全に捨てるコードもあります。

例えば、JdbcConfigKotlinGraphQLSampleApplication これらはSpringのための設定なので再利用は出来ません。ただこのようなコードだけなのでほとんどのコードが再利用することが可能となります。

新規実装

IntelliJのウィザードで作成されるここから、新規の実装をしていきます。

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
}

最初にRoutingの定義です。 GraphQLなので /graphql にコードを書いていきます。GraphQLは GETとPOSTのサポートをしているので、一応、両方とものAPIを書きます。

fun Application.module(testing: Boolean = false) {
    routing {
        get("/graphql") {
            TODO()
        }

        post("/graphql") {
            TODO()
        }
    }
}

最初にリクエストをパースします。リクエストはこんなJSONです。

{
    "query":"{\n  drug(id:\"id\") {\n    name\n  }\n}",
    "variables":null,
    "operationName":null
}

これをこのままdata classにします。

data class GraphQLRequest(
    val query: String = "",
    val operationName: String? = "",
    val variables: Map<String, Any>? = mapOf()
)

Ktorでデフォルトで使える、 ObjectMapper を使ってパース処理を書くと。

fun Application.module(testing: Boolean = false) {
    routing {
        get("/graphql") {
            val stringRequest = call.receive<String>()
            val mapper = ObjectMapper()
            val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        }

        post("/graphql") {
            val stringRequest = call.receive<String>()
            val mapper = ObjectMapper()
            val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        }
    }
}

何度も同じ処理を二箇所に書くのは辛いので、 GraphQLController を作成します。

class GraphQLController {
    suspend fun handleGraphQL(call: ApplicationCall) {
        val stringRequest = call.receive<String>()
        val mapper = ObjectMapper()
        val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        TODO()
    }
}

これを使ってコード量減らします。

fun Application.module(testing: Boolean = false) {
    // Controller
    val graphQLController = GraphQLController()

    routing {
        get("/graphql") {
            graphQLController.handleGraphQL(call)
        }

        post("/graphql") {
            graphQLController.handleGraphQL(call)
        }
    }
}

さて次は実際に graphql-java を利用してコードです。

    val filePath = "kotlingraphqlsample.graphqls"
    val graphQLSchema = SchemaParserBuilder()
        .file(filePath)
        .resolvers(
            listOf(diseaseResolver, 
                DiseaseQueryResolver(diseaseService),
                // 他のResolverもここに記述する
                ))
        .build()
        .makeExecutableSchema()
    val graphQL = GraphQL.newGraphQL(graphQLSchema).build()

この graphQL インスタンスをController内で使い、それぞれのResolverにアクセスしていきます。graphQL インスタンスはDI使うなり、コンストラクタに差し込むなり、Controller内でインスタンス化するなり、好きなようにControllerに差し込みます。

    suspend fun handleGraphQL(call: ApplicationCall) {
        val stringRequest = call.receive<String>()
        val mapper = ObjectMapper()
        val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        val context = ConcurrentHashMap<String, Any>()
        // graphQLは上でインスタンス化したもの
        val result = graphQL.execute(
            ExecutionInput.newExecutionInput()
                .query(request.query)
                .operationName(request.operationName)
                .variables(request.variables ?: emptyMap())
                .context(context)
        )
        TODO()
    }

最後にresultをJSONにして、返します。

    suspend fun handleGraphQL(call: ApplicationCall) {
        val stringRequest = call.receive<String>()
        val mapper = ObjectMapper()
        val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        val context = ConcurrentHashMap<String, Any>()
        val result = graphQL.execute(
            ExecutionInput.newExecutionInput()
                .query(request.query)
                .operationName(request.operationName)
                .variables(request.variables ?: emptyMap())
                .context(context)
        )
        val json = mapper.writeValueAsString(result.toSpecification())
        call.respondText { json }
    }

これでAPIは完成。APIアクセスを簡単にするために、GraphiQLも導入します。 graphiql/packages/examples/graphiql-cdn at master · graphql/graphiql · GitHub この辺りのサンプルコードをそのまま拝借し、index.htmlとしてプロジェクト直下に置きます。

fun Application.module(testing: Boolean = false) {
    // Controller
    val graphQLController = GraphQLController()

    routing {
        static("/graphiql") {
            // GraphiQL
            default("index.html")
        }

        get("/graphql") {
            graphQLController.handleGraphQL(call)
        }

        post("/graphql") {
            graphQLController.handleGraphQL(call)
        }
    }
}

Runボタン押して、実際にGraphiQL http://localhost:8080/graphiql にアクセスしてみます。

f:id:shiraji:20191207224439p:plain

見えるようになりました。yay!

終わりに

いかがでしたでしょうか?(一回やってみたかった)

やってみて思ったんですが、Springですでに動いているのであれば、今のところKtorに移す必要ないなーと言うのが感想です。

今回はこんなレポジトリのコードを参考にしました。

あまりアクティブな開発がされていないので、今後どうなるのかわからないですが、もうちょっとKtorのGraphQL周りのプラグインが出来たら良いなーと。

自分で作れってことか 🙃

カナダから来たUbieのインターン生(Hiroくん)はすごかった

最初にこの記事はどの企業も個人も批判する目的はありません。もし批判を感じる文章があればご指摘して頂けると幸いです。


Hiroくんインターンを予定通り8月中旬まで無事に終わらせたので、彼がこの期間何をやったのか?などを書こうと思います。

f:id:shiraji:20190825222847p:plain
アイキャッチ画像のための無意味なスクショ

出会い

最初の彼との出会いは、Twitter上で、自分の経歴とほぼ同じ経歴を持っている学生がいるなー珍しいなーと思ったけど、関わりないだろうなーとスルーしたのを覚えています。

その数日後、彼のツイートは炎上していました。

朝ごはん食べてたら前見つけた子が炎上してるじゃん!あららーもっと先にフォローして、声かけちゃえば良かったかなー。と朝ごはん食べながら眺めてました。

最初、インターン先探しているということに対して、声かけるのはなしだよなーって思ったんですが、子供の朝ごはんを少しこぼしたので片付けをしていて、人ってミスするし、そこでダメって決めつけるのおかしい。優秀な子がこれでチャンス無くすのおかしい。弊社のバリューの一つが「launch x launch」(ロンロン)*1なのにグジグジ考えて行動起こさないのおかしい。と思い、声かけることにしました。

f:id:shiraji:20190826000401p:plain
声かけると決めた時の分報

声かけたあと、東京に来るとのことだったので、その時に会いましょうと言う約束をしました。予定を聞いて、最後の方ってことだったので、まぁ彼のキャリア的にも弊社はまだ魅力的には見えないだろうから優先度下げられたかーとちょっと残念な気分でした。

会ってみて

5月頭に彼は日本に一時帰国していて、会社に来てもらいました。 最初会ったとき、顔と体のギャップがすごい!って思ったのですが、話してみると本当に素晴らしい好青年でした。 素直に話は聞けるし、しっかりと意見出来ていたし、キャリアもしっかり考えていたし、事業への質問も的を得たものでした。 会う前に炎上してしまった経緯も説明してもらっていたのですが、本当に不可抗力だったのだろうなとそこでようやく納得できました。

唯一問題というと彼の今後の専門としたい分野がUXなどであること。当時彼の経験があるAndroidアプリやUnityのポジションは弊社にはありませんでした。 あるのはUXなにそれうまいの?CUIで良くね?と言っちゃう自分しかいないチームでのサーバサイドKotlinでした。 そこで、「すごくHiroくんは優秀だと感じたし、ぜひ働いて欲しいけど、今弊社に来るとHiroくんのキャリア的に遠回りになるかもしれない。本気でおすすめ出来ない。でも同じ界隈にいるだろうし、引き続き仲良くしてね!」と笑顔で送り出すことにしました。

彼が帰った後「すごく良さそうだったけど、今後の彼の専門や今のスキル的にポジションがほぼない。なので今回はなかったことで!」という評価を会社のメンバーに伝えておきました。

数日後

その数日後、TwitterのDMでこんなメッセージが来ました。

「色々週末考えたのですが、Kotinサーバーサイドとして入らせて頂くこと可能でしょうか?」

正直、まじか!空気読めない子か!ってフいたのを覚えてます。(そして今気づいたけど、typoしてるw) でも入りたい!と言ってきたのに、なにもせずにごめんなさいってするのはおかしいので、入社試験をしてもらいました。 一週間くらいかけて貰えばいいかなーって思ったところ、1,2回のラリーはあったものの、2日で作り切り、コードもしっかり読めていて、想像以上でした。 こちら側からお断りする理由もないし、彼のキャリア的に美味しいのか?と罪悪感を感じつつ、入社してもらうことにしました。

ちなみに入社後に彼が書いた下でも紹介している記事を読んで、すごく真剣に話を聞いてもらえたのがわかったし、めっちゃ嬉しかったのを覚えています。

入社前に

今のUbieはまだまだ小さな会社であり、事業を危険に晒すこと=即会社が潰れるということが十分あり得ます。そのため、彼の受け入れ条件としては公開して良い情報・ダメな情報を明確にすることをみんなに約束しました。と言っても、会った時にその話はしっかりすでにしていて、理解していたし、なんなら話す前から理解していたので、全く不安はありませんでした。

入社

一番の問題は彼が他の弊社メンバーに受け入れられず、ぎこちない関係になってしまうかも?という点でした。短い期間しかいないため、早めに打ち解けてもらいたかったです。そこでwelcomeランチがあったのですが、紹介一言目に「あの炎上した子です!」と全体に情報公開しました。彼はめっちゃ焦ってましたが、Ubieメンバーの器のデカさや彼が誠実に説明してるところを見て、みんな安心して受け入れてくれていました。あの早いタイミングでアイスブレイクしっかり出来たのは良かったなーと思います。彼いろんな会社のイベント参加してたし、入社日からUbieの一員になってくれました。

タスク

サーバサイドKotlinのポジションではあったのですが、せっかく来てもらう訳だし、色々吸収してもらおうと考えました。彼には複数のサービスの担当をしてもらい結果的に

  • サーバサイド(Kotlin/Python/Rails)
  • フロント(JS/TypeScript)
  • 実装設計
  • 単独での開発
  • スクラムでのチーム開発
  • デザイン

といろんなことをしてもらいました。全く経験がなかったのに、土日で勉強してきて、フロントの実装も素晴らしい速度でやっていたのは本当に驚きでした。

もちろん彼にもまだまだ未熟な部分はあって、そこは週1の1on1で話していたし、色々彼なりに頑張って改善していました。

結果今の彼のレジュメはぐちゃぐちゃになっただろうと思いますが、master resumeには残して、提出するresumeから外せばいいだけだし、好きにしたら良いと思う(責任放棄)

留学生のインターン

余談ですが、インターンインターンと言ってますが、留学生のインターンって日本で言う、学生の業務委託であり、使えなければクビ切られる覚悟を持っている人が多いです。彼もその考えを持っていました。

もし留学生をインターンとして受け入れる会社があるなら、学ばせるというより、キャリアを積ませる+プロの業務委託的な扱いをしてあげて欲しいです。なので、今回も学ばせると言うより、会社への貢献のためのタスクを選んでやってもらいました。

このあたり読んでもらえると良いのではないかなーと思います。

hiro-ca.hatenablog.com

なぜこの記事を書いたか?

インターン生や新卒などなど色んな人を見てきましたが、こんなブログを書くのは初めてで、今後もあんまり書かないだろうと考えています。しかし優秀な子が不本意なデジタルタトゥーにかわいそうな思いはして欲しくないので、彼はそんな人じゃないということをこの記事で明言しておきます。

彼はフルタイムで働いているため会社の様々な情報にリーチできる人材でしたが、インターン期間中一度も情報公開に関して指導する必要はありませんでしたし、信用できる人間です。

最後に

Hiroくん、お疲れ様でした。色々自分至らぬところがあったと思います。申し訳ないです。でも一緒に働けてすごく楽しかったし、Hiroくんが将来めっちゃ活躍してるところを早くみたいです。そして「わしが育てた」と言わせて下さい。Good luck!!!

*1:弊社の三つのバリューのうちの一つ。100の議論より1の実行に価値がある。