もうキャッシュの実装は怖くない!

はじめに

こんにちは。エキサイト株式会社で長期インターンをさせていただいている岡崎です。

今回は私が学んだキャッシュについての記事を書かせていただきます。

「もう分かっているんだが?」というエンジニアの皆さん向けではなく、Spring Bootでキャッシュを初めて実装する人向けの記事となっております。

そもそもキャッシュとは?

キャッシュとは、アクセスしたwebページの情報を一時的に保存していく技術です。

キャッシュを用いることで1からページを読み込むことがないので、動作の速さが期待できるぞってことですね。

キャッシュの流れは以下の通りになっています。

  1. コントローラまたはサービスは@Cacheableがついたメソッドを呼び出す
  2. Cache AOPが提供する@Cacheableにキーが渡される。CacheManegarを利用して、Hash Mapからデータを取得する。キャッシュデータが取得できた場合はコントローラまたはサービスへキャッシュデータを返却し、キャッシュデータが取得できない場合は次を実行する。
  3. Cache AOPは引数を渡し、定義されたサービスメソッドを実行し、戻り値を取得する。Cache AOPは2で特定されたキャッシュキーで取得した戻り値をCache Manegerを利用してHash Mapへデータとして格納する。
  4. Cache AOPはコントローラまたはサービスへ戻り値を返却する。

Cache AOP

キャッシュ機能の入り口になるインターフェース

Cache Maneger

キャッシュ機能をコントロールしてくれる

キャッシュのデータはredisが保持してくれます。

このredisはデータをメモリに保存してくれる高速なデータストアのことです。

キャッシュの実装は?

はい、ようやく本題ですね。

じゃあ、それってどうやって実装するの?という話です。

Spring Bootではキャッシュ機能のためのライブラリがすでに用意されています。

 キャッシュする必要があるメソッドに@Cacheableをつける

ますはここです。

この@Cacheableとは、キャッシュを有効化にするためのアノテーションです。

キャッシュを行いたいメソッドに対し、

@Cacheable(

cachename = “名前を任意でつける”

key = “キーがあればキーをつける。なければなくてよい”

condition = “キャッシュをする条件があればここでつける。なくてもよい”

)

public void sample(Integer sample, String sample) {

とつけます。

これだけでいい場合もあるのですが、現在だとそれだけではないときも多いと思います。

散々見たエラーはCould not read JSON: Cannot construct instance ofというものでした。

データのやり取りのイメージは以下です。

f:id:ooo-ka999:20210907105750p:plain

このアノテーションをつけることだけでうまくいく場合もありますが、そうでないこともあります。

例えばLocalDateTimeです。 うまくデフォルトのものだと変換ができなくて落ちてしまいます。 なので、

@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime sampleTime;

のようにアノテーションをつけてあげる必要があります。

そのほかにも、こんな事例がありました。 Jacksonでは、publicのフィールド、publicのゲッターを対象としてしまうので、対象にしたくないものは@JsonIgnoreをつける必要がありました。これで対象から外すためです。

使用例は以下の通りです。

@JsonIgnore
public Boolean isHogeHoge(String hogehoge) {
 return this.hogehoge == hogehoge;
}

また、デシリアライズ時にデフォルトコンストラクタが使用されてしまうので、デフォルトコンストラクタでデシリアライズしてほしくない場合は、@JsonCreatorを使ってデシリアライズ時のコンストラクタを使用してあげる必要があります。また、このときにprivateなインスタンス変数を使う場合は@JsonPropertyを用いてマッピングしたいキーの名前を指定してあげる必要がありました。

使用例は以下の通りです。

@JsonCreator
public SampleModel(@JsonProperty("sample") String sample) {
 this.sample=sample;
}

まとめ

キャッシュはキャッシュの対象にきちんと必要なアノテーションをつけてあげることが大切でした。キャッシュって奥深いんだなと今回では学びました。 まだまだ未熟なエンジニアですが、どんどん成長していきたいと思います。

また、余談ですがエキサイトのインターンはとても楽しく、充実しています! 興味ある人にはとてもおすすめしたいです。私はまだ引き続きインターンは続くので、残りの時間も成長できるように頑張っていきたいです。

ここまで読んでいただき、ありがとうございました

PHPDoc の @uses を使って、可変関数の未使用警告を抑制する

エキサイト株式会社の武藤です。

PHP の可変関数

PHP には可変関数という機能があります。

www.php.net

関数名を変数にすることで、動的に呼び出したい関数を変更できます。

<?php

class Demo {

    function foo() {
        echo "foo";
    }
}

$functionName = 'foo';
$demo = new Demo();

$demo->$functionName();

使い方によっては有用な機能かもしれません。

しかし、intelliJ (PhpStrom) でファイルを開いてみると、未使用関数としてグレーアウトされてしまいます。

f:id:excite-mthiroshi:20210913201930p:plain
intelliJ で可変関数呼び出しをすると未使用関数として扱われる

未使用のコードは、運用開発の際にノイズになるので、可能な限り削除してしまう方がよいでしょう。しかし、可変関数呼び出しのコードがある場合、プロジェクト全体に検索をかけて、使われていないことの確認が必要です。 また、未使用関数の場合、定義元へコードジャンプも効かないため、修正するファイルの切り替え等の作業が煩雑になってしまいます。

PHPDoc の @uses を使おう

intelliJ で可変関数がグレーアウトされてしまうのは、静的解析を基に未使用と判断されるからです。 そこで、 PHPDoc の @uses を使って適切に呼び出し関係を補足してあげましょう。

f:id:excite-mthiroshi:20210913202215p:plain
呼び出し元で @uses をつける

可変関数が未使用関数ではなくなり、色が付きました。また、これでコードジャンプもできるようになりました。

最後に

PHP の可変関数について、intelliJ で表示される未使用警告の回避方法を説明しました。

長年運用されてきたソフトウェアは、使われなくなったコードが溜まってきてしまいます。Doc 以外にも、多くの言語で静的解析ツールが用意されていますので、それらを上手く駆使して無駄がないコードを保っていきましょう。

参考

PhpStorm の静的解析機能をさらに活用するための3つのアノテーション | バシャログ。

@uses & @used-by — phpDocumentor

Spring Bootでクエリパラメータの順序が異なるURIを比較する

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 UriComponentsのreplaceQueryParam()を使用してクエリパラメータの値を書き換えたときに、書き換える前と書き換えた後とでクエリパラメータの順序が異なってしまい、 単体テストで落ちてしまいました。 本記事では、クエリパラメータの順序が異なるURIを比較する方法について共有します。

概要

クエリパラメータの順序が異なる2つのURIを用意します。 このとき、uri1とuri2を比較するとfalseになります。

URI uri1 = URI.create("https://example.com?page=2&per_page=10");
URI uri2 = URI.create("https://example.com?per_page=10&page=2");

System.out.println(Objects.equals(uri1, uri2)); // → false

これは、URIがクエリパラメータを文字列で保持しており、equalsで比較したときにクエリパラメータは"page=2&per_page=10""per_page=10&page=2"の文字列比較を行っているため、falseになってしまうからです。 クエリパラメータの順序が異なったとしても同一のものとして比較したいときは、少し工夫する必要があります。

解決策

URIをUriComponentsに変換することで容易に比較することができるようになります。 UriComponentsではクエリパラメータをMap<K, List<V>>のラッパーであるMultiValueMapで保持しているため、クエリパラメータの順序が異なるものを比較したときにtrueを返します。

UriComponents u1 = UriComponentsBuilder.fromUri(uri1).build();
UriComponents u2 = UriComponentsBuilder.fromUri(uri2).build();

System.out.println(Objects.equals(u1, u2)); 
// → true

実際にUriComponentsのクエリパラメータの中身を見てみると、確かにMap形式で保持されていることが確認できます。

System.out.println(u1.getQueryParams());
// → {page=[2], per_page=[10]}

注意点

MultiValueMapは1つのキーに対して複数の値を扱うことができます。 そのため、クエリパラメータに同一のキーが複数指定されている場合、Listに変換されるため、falseを返す可能性があります。

URI uri3 = URI.create("https://example.com?page=2&per_page=10&page=3");
URI uri4 = URI.create("https://example.com?page=3&per_page=10&page=2");

UriComponents u3 = UriComponentsBuilder.fromUri(uri3).build();
UriComponents u4 = UriComponentsBuilder.fromUri(uri4).build();

System.out.println(u3.getQueryParams());
// → {page=[2, 3], per_page=[10]}

System.out.println(u4.getQueryParams());
// → {page=[3, 2], per_page=[10]}

System.out.println(Objects.equals(u3, u4));
// → false

したがって、同一のキーが複数指定される場合は注意しなくてはなりません

おわりに

UriComponentsを利用してクエリパラメータの順序が異なるURIを比較する方法についてまとめました。 クエリパラメータの順序が異なるURIを比較したいときって一度はあるのかなと思います。 URIをUriComponentsにして比較を行うことで、クエリパラメータ順序が異なっていたとしてもtrueを返すようになります。 「クエリパラメータの順序が異なるURIでも同じものとして扱いたい!」といった方に本記事が参考になれば幸いです。

参考

docs.oracle.com

spring.pleiades.io

mysqlslap で MySQL の負荷エミュレーションをしてみる

エキサイト株式会社の武藤です。

オンプレにあるDBを稼働中のRDSに統合する際に、mysqlslapを使った負荷エミュレーションをしたので、使い方を紹介します。

mysqlslapとは

mysqlslapとは、負荷エミュレーションをするコマンドです。クライアントの接続数やクエリ数などを設定して、実行時間を計測してくれます。 dev.mysql.com

mysqlCLIクライアントをインストールした際に一緒にいくつかの便利コマンドが入っているみたいです。mysqlslapもその一つです。

実行例

75クライアントから75クエリーのUPDATE文の実行を10回繰り返す実行例です。

DB設定

DBホスト : demo_db
mysqlユーザ : demo_user
mysqlパスワード : demo_password
ポート : 3306
mysqlslap \
--no-defaults \
--auto-generate-sql \
--engine=innodb \
--create-schema=demo_schema \
--password=demo_password \
--host=demo_db \
--user=demo_user \
--port=3306 \
--number-char-cols=3 \
--iterations=10 \
--concurrency=75 \
--auto-generate-sql-write-number=10 \
--auto-generate-sql-add-autoincrement \
--auto-generate-sql-load-type=update \
--number-of-queries=75
mysqlslap: [Warning] Using a password on the command line interface can be insecure.
Benchmark
    Running for engine innodb
    Average number of seconds to run all queries: 1.200 seconds
    Minimum number of seconds to run all queries: 1.068 seconds
    Maximum number of seconds to run all queries: 1.371 seconds
    Number of clients running queries: 75
    Average number of queries per client: 1

1回の試行の実行時間の平均が1.200秒、最小値が1.068秒、最大値が1.371秒でした。 このように、パラメータを調整して実行時間を計測します。

設定したオプションの説明です。

--no-defaults : デフォルト値を読み込まない
--engine : 使用するストレージエンジン
--create-schema : スキーマ
--password : DBのパスワード
--host : DBのホスト
--user : DBのユーザ
--port : ポート番号
--iterations : 試行回数
--concurrency : クライアント数
--auto-generate-sql : SQLを自動で生成
--auto-generate-sql-write-number : 各スレッドで実行する行挿入の回数
--auto-generate-sql-add-autoincrement : AUTO_INCREMENT カラムを自動生成されたテーブルに追加
--auto-generate-sql-load-type : テストの負荷タイプを指定
--number-of-queries : 各クライアントのクエリー数の指定
--number-char-cols : --auto-generate-sql が指定されている場合に使用する VARCHAR カラムの数

これ以外にも様々なオプションがありますので、公式ドキュメントを御覧ください。

検証したこと

今回は、オンプレDBからRDSで稼働中のDBに相乗りさせる形で移行を考えていました。その場合、オンプレDB分の負荷がRDSにそのままかかってきますので、それに耐えられるかを検証しました。

まずは、オンプレDBにかかっている負荷をzabbixから確認しました。それを基にmysqlslapでRDSに負荷をかけて、実行時間を確認していきました。最終的には、zabbixで確認した負荷より大幅に高い負荷をかけても実行時間が十分に短かったので、移行しても耐えられると判断しました。

そして、実際にRDSへ統合してCloudWatchで負荷を確認したところ、詰まることなく捌けていました。

事前に負荷検証をすることで、安全にDB移行ができました。

最後に

オンプレDBのAWS移行にあたって、RDSに対して負荷検証をしてみました。 mysqlslapで簡単に負荷検証ができるので、ぜひ使ってみてください。

第4回定期勉強会「Flutter勉強会」

こんにちは。 エキサイトの三浦です。

毎月恒例の定期勉強会が今月も開催されました。 今回は「Flutter勉強会」ということで、エキサイトでも使われ始めたFlutterの話になります。

なお過去開催分は以下になりますので、よければ御覧ください。

tech.excite.co.jp

tech.excite.co.jp

tech.excite.co.jp

Flutter勉強会

Flutterは、公式サイトで以下のように紹介されています。

Flutter is Google's UI toolkit for building beautiful, natively compiled applications for mobile, web, desktop, and embedded devices from a single codebase.

端的に言えば、「単一コードからモバイルアプリやwebなど様々なデバイス用のアプリケーションを生成できるフレームワーク」です。 その利便性から徐々にいろいろなサービスに取り入れられていっており、エキサイトでも導入され始めています。

今回は実際にエキサイト内でFlutterに触っているエンジニア3名に、

  • Flutterとは何か
  • どんな特徴があるのか
  • 触ってみた所感

を発表してもらいました。

Flutter自体の説明は以上の通りで、その利便性は言うまでもないですが、実際に使っているからこそわかる

  • 具体的なディレクトリ構造やコードはどうなっているのか
  • Swiftなどのネイティブコードの開発と比べた時のメリット・デメリット
  • 広告など、実際にアプリケーションコードを書く上で必要になってくる要素をどのように実装すべきなのか

などを教えてもらうことができました。

単に「Flutterっていいよね」だけではなく、こんな面倒な点がある、デメリットがあるというところも発表されていて、Flutterを技術選定の選択肢に入れる上で有意義な勉強会になったと感じています。 こうした生の声は、導入しはじめの技術では特に共有のメリットが大きいので、Flutter以外の技術でもぜひ勉強会をしていきたいところです。

最後に

定期勉強会は第4回目ということで、ついに四半期分をやったということになりました。 今後も半年、1年、そしてそれ以上と続いていったらと思います。

Spring Bootでコンポーネント名が重複してエラーになったときの解決策

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 Spring Bootでコンポーネント名が重複したときに、ConflictingBeanDefinitionExceptionが発生してエラーとなってしまいました。 その原因と解決策についてまとめました。

別のパッケージの同一のコンポーネント

例としてItemに関するItemServiceについて考えてみます。 ItemServiceはWebとアプリで処理が少し異なるため、それぞれで実装します。

  • WebのService
package com.sample.service.item;

import org.springframework.stereotype.Service;

@Service
public class ItemServiceImpl implements ItemService {
    @Override
    public Item getItemByID(Long id) {
      /* 処理 */
    }
}
  • アプリのService
package com.sample.app.service.item;

import org.springframework.stereotype.Service;

@Service
public class ItemServiceImpl implements ItemService {
    @Override
    public Item getItemByID(Long id) {
      /* アプリ固有の処理 */
    }
}

上記2つは似ていますが、パッケージが異なることに注意が必要です。 通常、同一のクラス名であったとしても、パッケージが異なれば問題なく利用できます。 しかし、ConflictingBeanDefinitionExceptionが発生しコンパイルできずにエラーとなってしまいました。

ConflictingBeanDefinitionException: 
Annotation-specified bean name 'itemServiceImpl' for bean class [com.sample.app.service.item.ItemServiceImpl] conflicts with existing,
non-compatible bean definition of same name and class [com.sample.service.item.ItemServiceImpl

原因

コンポーネントの場合、同一のクラス名が2つ以上存在するときにコンパイルできずにエラーとなってしまいます。 これは、Spring BootがDIするときに、どちらのBean名もItemServiceImplとなってしまうからです。 そのため、どちらのSpring BootがどちらのItemServiceImplをDIしてよいのか判別できなかったため、ConflictingBeanDefinitionExceptionが発生してしまいました。

解決策1:コンポーネントプレフィックスに文字列を付与する

一番単純でわかりやすいのはクラス名の前にAppAdminなどの文字列を付与することです。 これで問題なくコンパイルおよび実行することができます。

package com.sample.app.service.item;

import org.springframework.stereotype.Service;

@Service
public class AppItemServiceImpl implements AppItemService {
    @Override
    public void getItemByID(Long id) {
      /* アプリ固有の処理 */
    }
}

解決策2:アノテーションの引数に文字列を指定する

@Controller@Service@Repository@Componentなどのアノテーションの引数に独自のBean名を定義します。 これにより、同じクラス名のコンポーネントが複数存在しても問題なく実行することができるようになります。

package com.sample.service.item;

import org.springframework.stereotype.Service;

@Service("AppItemServiceImpl")
public class ItemServiceImpl implements ItemService {
    @Override
    public void getItemByID(Long id) {
      /* 処理 */
    }
}

このとき、アノテーションの引数には変数を渡すことができます。 これで管理するのもよいかもしれません(ただし面倒だとは思います)。

@Service(ComponentName.APP_SERVICE_IMPL)

おわりに

Spring Bootでコンポーネント名が重複してエラーになったときの原因と2つの解決策についてまとめました。 個人的には、命名規則を決めて、管理者用ならAdminを、アプリ用ならAppプレフィックスに付与したほうがよいのかなと考えています。 例えば、解決策2で運用したときにItemServiceIDEでファイル検索したときに、ファイルパスまで含めて見ないとたどり着けないため、少し不便に感じるからです。 ただ、どちらの実装方法も大きく異なることはないため、最終的には好みになりそうです。

どこまでアプリケーションを「完璧」にすべきか

こんにちは。 エキサイト株式会社の三浦です。

アプリケーションを作るときや改修するとき、すべてのエンジニアが「完璧なアプリケーションを作ろう」と一度は思うはずです。

しかし、シンプルなコードで済むアプリケーションならともかく、複雑になればなるほど「完璧」を達成するのは難しくなっていきます。

今回は、どこまで「完璧」を目指すべきか、持論を語っていきたいと思います。 なおこちら、あくまで持論であり、必ずしも会社の方針と100%一致するとは限らないので、あらかじめご了承下さい。

完璧なアプリケーションとは

まず最初に、「完璧なアプリケーション」とは何でしょうか? それはもちろん、「いついかなる時も問題が起きない」アプリケーションです。

例えば、「2つの値を渡すとその合計値を返す」というアプリケーションの場合、正の数・負の数・非整数でも計算が可能であるとか、(仕様次第ですが)IntegerやLong値の限界を超えても問題なく動作する、などが「完璧」の条件として挙げられます。

この程度であれば問題ありませんが、ではWebサイトであればどうでしょうか? 考えられる条件としては、

  1. サイトにアクセスすれば、必ず想定通りの情報がサイトに載っている
  2. いつサイトにアクセスしても、一定以下の時間でページが表示される

でしょうか。 一見条件も少なく、簡単に達成できるような気もしてきますが、Webサイトというのは様々な状況下に陥ることが多々あります。 例えば、

  • 急に大量のアクセスが来る
  • 外部からの攻撃を受ける
  • ごく限られた条件下でしか起きない、コード内の潜在的なバグが発火する
  • 運用上のミス等で、DBなどのデータソース内のデータに不整合が生じる
  • インフラ周りに物理的な障害が発生する
  • クラウドを使用している場合、クラウド側に問題が発生する

などが挙げられるでしょう。 これら(もちろんここで挙げていないその他様々な原因も)をすべて解決し、「完璧なアプリケーション」を作り上げることは可能なのでしょうか?

「完璧」でなく「ベストエフォート」なアプリケーションを作る

個人的には、これら全てを完全に解決することはかなり難しいと考えています。

もちろん、例えば「急に大量のアクセスが来る」の対処法として「サーバ/コンテナが自動スケールするようにする」ですとか、「外部からの攻撃を受ける」の対処法として「セキュリティ対策を万全にする」など様々な解決策はありますが、それでも自動スケールが完了するまでの数分間はページ速度の急激な低下、もしくはタイムアウトはどうしても発生してしまうでしょうし、外部からの攻撃も新しい種類のものが出てきてしまえばまた新しく対処法を考える必要が出てきます。

さらに言えば、例えばごく限られた条件でしか起きない問題に対してどこまで対応するべきでしょうか? 1年に1~2回、しかも1回あたり数秒程度しか起きない問題に対して、何日も何週間もかけて原因調査・対応をするべきなのでしょうか?

私もしばらくこういった問題に頭を悩ませてきましたが、他のエンジニアと相談したり、色々と考えた結果、「完璧」ではなく「ベストエフォート」こそ目指すべきアプリケーションなのではないか、と考えるようになりました。

アプリケーション、及びそれらの上で展開されるサービスには、もちろん譲れない点があります。 例えば、「1日6時間はエラーでサイトが見られない」などといったサービスは、いくらなんでも受け入れられないでしょう。 可能な限り、いつでも見られるようにする必要はあります。

しかし、例えば「年間で99.9%の時間のサイトの動作は保証する(0.1%の時間はエラーでサイトが見られない可能性がある)」だとしたらどうでしょう? この程度であれば許容範囲、というサービスならあるのではないでしょうか? なおこれは、1年 = 365日 = 8760時間の0.1%なので、8.7時間ほどエラーが許容される、ということになります。

考えてみると、そもそもインターネットという仕組みそのものがベストエフォートという考え方で作られています。 今回の例であるWebサイトでいえば、その上で作られているアプリケーションがベストエフォートを目指すのは、ある意味自然なのかもしれません。

また有名なAWSも、サービスレベルアグリーメントという形で、「完璧」ではなく「ベストエフォート」でサービスを作っていることを宣言しています。

もちろん、サービスの内容によってどこまで許容されるかは変わってきます。 ニュースメディアサイトと医療用のオンライン手術サービスのようなものであれば、許容されるエラーのレベルは全く違うことでしょう。

ただしなんにせよ、やみくもに「完璧」なアプリケーションを作ることを考えるのではなく、まずはそのサービスでどこまでエラーが許容されるかを考えるべき、というのはすべてのアプリケーションに共通しているのではないでしょうか。 そして、許容されるエラーレベルに対して、適切に「ベストエフォート」なアプリケーションを作っていくことが、開発速度と開発精度の両方を兼ね備えた開発方法なのかもしれません。

最後に

AWSのように非常に大きく、インフラそのものをサービスとして提供するサービスであれば、最初から「~~%までの正常動作は保証する」というように宣言をしています。 一方で、いわゆる一般的なサービス・アプリケーションであれば、暗黙的に「まれに障害が発生することもある」という認識はありつつも、どこまでエラーが許容されるかという話し合いをすることは少ないのではないでしょうか? 特に自社開発であれば、その傾向は大きなものとなるでしょう。

ですがエラーや障害というのは、そのサービスが大きくなればなるほどいつか必ず起きるものとなっていきます。 例え自社開発のサービスであっても、一度はエラー許容レベルに関して話し合って決めることで、開発速度と精度のバランスを取ることができ、長期的に見てサービスの開発に良い影響を与えるのではないでしょうか。

はじめてのFlutter

iXIT株式会社の堀です。

新しい事をはじめたいと思い、グループ会社でも採用が決まったFlutterの勉強をはじめました。
2018年末にGoogleからFlutterがリリースされ、2年以上も経つのでネット上にも情報がたくさん載っています。 同じようにこれから始める人の参考になればと思い記事書かせて頂きます。(技術的要素はほぼありません)

何本か入門記事を読んだあと、環境構築。
Android StudioXcodeもすでにインストール済みだったので、あっという間に環境が出来上がりました。
最近のフレームワークは手とり足取り感満載ですね!
その後は、毎度おなじみ「Hello World」かと思いきや、用意されているサンプルアプリはカウンターアプリでした。
ソース内にコメントもいっぱい!(英語だけど)

カウンターアプリ
カウンターアプリ

Dartの入門書を見ながら、軽くソースを解析。 これだけのステップ数でスタイルもまとめて出来ちゃうなんてFlutterすごいかも。 (ただし、規定外のデザインにする時大変そうな気が・・・)

次の1歩として新しいアプリを作ろうと思い、見つけたのがこちら。

eh-career.com

解説も細かく書かれており、入門アプリにはちょうど良さそう!
早速、記事を見ながらプログラミング・・・
あれ?記事通りに書いてるのにエラーが・・・
スペルミスかなっと思いコピペ。それでもエラーが解消されないので、エラーメッセージで検索。 いっぱい出てきました。「その書き方もう使えないよ〜」ってコメントでした。 最近よく聞く「破壊的変更」ってやつですね。
下位互換はせず、新しく作り直してシンプルにする!って方針は賛成ですが、古い記事に惑わされて情報の海で溺れないように要注意です。

上記の解説ページとバージョン違いで出たエラーを潰しながら、どうにか完成。
Githubリポジトリ見てもつまらないので、Excite Tech Blogを表示できるように改良。

記事一覧アプリ
記事一覧アプリ

もうちょっとステップアップしてFirebaseと絡めたアプリを作りたいなと思っていたところ、程よいサンプルアプリ見つけました!

github.com

Github上にソースだけでなくFlowchartや利用するAPIリンクなどもキレイに纏められています。
Youtube3本立てのTutorialもあるのでこちらを見ながら実装。
(解説は英語ですが聞き取りやすい話し方ですし、コード見ながらなので分かりやすいです)
バージョン違いによるエラーを解消しつつ、Tutorial#2まで完了。

投票アプリ
投票アプリ

引き続き、完成を目指して進めて行きたいと思います。

YoutubeにはTutorialがたくさん載っています。
英語だからと諦めてしまうには勿体ないくらい素敵な動画も多いので、みなさんもぜひ挑戦してみてください!

エキサイトは「iOSDC Japan 2021」に協賛します!

今週金曜日から開催される「iOSDC Japan 2021」に、エキサイトはシルバースポンサー、Tシャツスポンサーとして協賛します。 イベント概要については、以下をご覧ください。

iOSDC Japan 2021

開催日時 : 2021年9月17日(金)〜 9月19日(日)

開催場所 : オンライン開催

公式サイト :

iosdc.jp

公式Twitter :

twitter.com

最後に

エキサイトでは一緒に働いてくれる仲間を絶賛募集しております!

また、長期インターンも歓迎していますので、ご連絡お待ちしております!!🙇‍♀️

www.wantedly.com

そして、iOSDCトークンはこちらになります!

#エキサイト

それでは皆さま、 iOSDC Japan 2021を一緒に楽しんでいきましょう!

DefaultErrorAttributesについて

エキサイト株式会社の中尾です。

RestControllerAdviceで拾えない例外処理を拾う方法を記載します。

よくある方法は、ExceptionHandlerで全てcatchする方法だと思います。

    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleNotFoundException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return "all Exception catch";
    }

しかし、これだとResponseStatusが全てINTERNAL_SERVER_ERRORになります。 ResponseStatusを指定しない場合、ステータスOKになってしまいます。

なぜこのようなことしないといけないのでしょうか?

そもそもデフォルトのエラーレスポンスはいかになっています。

{
  "timestamp": "2021-09-04T20:25:31.581+09:00",
  "status": 404,
  "error": "Not Found",
  "trace": "org.springframework.web.servlet.NoHandlerFoundException: No handler found for GET /aaa\n\tat org.springframework.web.servlet.DispatcherServlet.noHandlerFound(DispatcherServlet.java:1278)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1041)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:626)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:733)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.servlet.resource.ResourceUrlEncodingFilter.doFilter(ResourceUrlEncodingFilter.java:67)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:94)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.session.web.http.SessionRepositoryFilter.doFilterInternal(SessionRepositoryFilter.java:142)\n\tat org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:82)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:374)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1707)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:834)\n",
  "message": "No handler found for GET /aaa",
  "path": "/aaa"
}

エラー情報ですぎですよね。 本番環境でこれだと困ると思います。 以下の設定を追加することで、traceログを非表示にできます。

server.error.include-message: never
server.error.include-binding-errors: never
server.error.include-stacktrace: never
server.error.include-exception: false

traceとmessageが消えました。

shogo.nakao@localhost: $ curl http://localhost:8080/aaa | jq                                                                                                    [/Users/shogo.nakao]
{
  "timestamp": "2021-09-04T20:34:27.516+09:00",
  "status": 404,
  "error": "Not Found",
  "path": "/aaa"
}

しかし、アプリケーションによって、エラーレスポンスの形は変わると思います。 アプリケーションに合わせてエラーレスポンスを変えてあげましょう。

@Component
@Profile("!local")
public class CustomErrorAttributes extends DefaultErrorAttributes {

    /**
     * error response default setting 
     *
     * @param webRequest
     * @param options
     * @return
     */
    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        final String message = this.getMessage(webRequest, this.getError(webRequest));
        Map<String, Object> customErrorAttribute = new HashMap<>();
        customErrorAttribute.put("error",
                new ErrorResponse.ErrorData()
                        .setMessage(message));
        return customErrorAttribute;
    }
}
{
  "error": {
    "message": 404
   }
}

解説します。

DefaultErrorAttributesを継承することで、デフォルトエラーレスポンスの型を変えられます。 @Profile("!local")を設定することでローカル環境以外の場合に適応させます。 ローカル環境では詳細なトレースログみたいですからね。 どっちにしてもコンソールログに詳細なログは出力されますが。

final String message = this.getMessage(webRequest, this.getError(webRequest));

こちらで、this.getMessageとthis.getErrorはDefaultErrorAttributesの実装を見てください。

RestControllerAdviceについて

エキサイト株式会社の中尾です。

SpringBootでexceptionが発生したとき、特定のエラーレスポンスで返す方法を記載します。

以下、コードになります。

@RestControllerAdvice
@Slf4j
public class ExceptionController {
    @ExceptionHandler({BadRequestException.class})
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String handleBindException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return exception.getMessage();
    }

    @ExceptionHandler({NoHandlerFoundException.class})
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleNotFoundException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return "404 Not Found";
    }

    private void writeLog(StackTraceElement[] stackTraceElements){
        Arrays.stream(stackTraceElements)
                .limit(10L)
                .forEach(e -> log.error(e.toString()));
    }
}
public class BadRequestException extends RuntimeException {
    public BadRequestException(String s) {
        super(s);
    }
}
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  resources:
    add-mappings: false

解説します。

RestControllerAdviceはRestControllerと違ってGetMapping/PostMappingとかは記載せず、ExceptionHandlerで該当のExceptionが発生したときResponseStatusを返します、

spring.pleiades.io

動作検証

shogo.nakao@localhost: $ curl http://localhost:8080                                                                                     [/Users/shogo.nakao]
XXXX must not be empty

404の場合、springの設定ファイルにthrow-exception-if-no-handler-found: trueとadd-mappings: falseをプロパティファイルに設定した上でRestControllerAdviceに追加してください。

shogo.nakao@localhost: $ curl http://localhost:8080/aaa                                                                                                         [/Users/shogo.nakao]
404 Not Found

うまくいきました。

なお、以下のようにExceptionHandlerになにも指定しない場合、全てのエラーをキャッチすることができます。

    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleNotFoundException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return "all Exception catch";
    }

RestControllerAdviceには関係ありませんが、writeLogにstackTraceのエラーを10行ぐらいまで出力する設定になります。

        Arrays.stream(stackTraceElements)
                .limit(10L)
                .forEach(e -> log.error(e.toString()));

GitHub上でVisual Studio Codeを起動してコードレビューをする

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 少し前にGitHub上でVisual Studio Codeを起動してコードレビューをすることができるようになりました。 実際に試してみたので共有します!

Visual Studio Codeを起動する

プルリクエストのページから「.(ドット)」を押すことで、ブラウザ上でVisual Studio Codeを起動することができます。

f:id:excite-kazuki:20210824095702p:plain

下記画像のページが出てきて少し待ちます。

f:id:excite-kazuki:20210905222910p:plain

その後、エディタが開きました!

f:id:excite-kazuki:20210905222945p:plain

プルリクエストを確認する

左のタブ一覧から「GitHub Pull Request」をクリックします。

f:id:excite-kazuki:20210905224229p:plain

ファイルとファイルの差分を見ることができます。 f:id:excite-kazuki:20210905224354p:plain

実際にGitHub上で見る「File Changed」と同じことが確認できます。

f:id:excite-kazuki:20210905224516p:plain

URLを見ると、トップレベルドメインGitHub.comVisual Studio Code.devになっていることが確認できます。

コードレビューをする

行番号の横のプラスボタンをクリックするとコメントをすることができます。 ただし、複数行選択してコメントができないことや、斜体や太字、箇条書きなどのボタンが用意されていないため、 完全上位互換にはならないことに注意が必要です。

f:id:excite-kazuki:20210905231217p:plain

実際にプルリクエストのページを見ると、コメントされていることが確認できます。

f:id:excite-kazuki:20210905231227p:plain

ファイル単位で差分を確認できるため、GitHub上でのみコードレビューをしている人にとっては、新たな選択肢が増えたのかなと思います。

ローカル環境でコードレビューをする方法

ローカルのVisual Studio Codeであれば拡張機能GitHub Pull Requests and Issues」を導入することでプルリクエストの作成や、コードレビュー、Issueの作成をすることができます。

f:id:excite-kazuki:20210824092038p:plain

また、JetBrains IDEであればPull Requestsタブから同様のことができます。

f:id:excite-kazuki:20210824092354p:plain

おわりに

GitHub上でVisual Studio Codeを起動してコードレビューする方法についてかんたんにまとめてみました。 コードレビューの選択肢が増えたのはとても嬉しいです!

監視とアラートとTwilio

概要

エキサイトの川崎です。

弊社の一部のサービスには通話機能があり、 その通話機能(以降 call systemと呼びます)をTwilioを使って作っています。

call systemを作成運用する上で、困ったこと/悩んだこと はいろいろあるのですが、 今回はcall systemのアラートについて書きます。

call systemをリリースしてからアラートにはたくさん助けられてありがたみも感じていますが、 どんな時にどんなものをアラート対象とすべきで、どのように通知すべきなのか今も迷走しています。 (本記事における「アラート対象」というのは、発生した際にメールやslackなど何らかの形で関係者に通知を送る事象のことです)

「やばそうなもの」はどこかに通知する or 出来るだけ自動化して対処すればいい程度の認識でいましたが、 唐突に、監視について教科書的な知識がない(っていうか監視の定義って何?アラートってそもそも何?な状態)のはまずいな、と感じて下記の本を読み始めました。 www.oreilly.co.jp

主にアラートメインの話が書いてある3章を読んでみた上で、学んだことや、その上でどんな対応したかを できる限り自分の言葉にして書いていきます。

当たり前だろな内容の予感もしますが、 あー確かにねと認識をお手伝いできたり、新しい視点や違う視点を提供できたらいいなと思います。

監視とは

あるものについて、その振る舞いや出力を確認し続けること。

本を読むまでは「悪いものを見つけること」だと思っていました。 「悪いものを見つけること」でも間違ってないとは思うのですが、 個人的にはこんな感じで捉えることでなぜかしっくり来ました。(字面のまんまじゃん)

監視している中で「やばい、動いてない」とはどういう状態か

ユーザがサービスを使った時に困る状態。

たまに、「問題がある」とか「やばい、動いてない」の定義が分からなくなることがあります。もしかして私だけでしょうか( ;∀;) そんな時上記の言葉を読むと頭を整理できて、 本当に問題があるのかを判定できるようになります。 これだけが定義じゃないと思うのですが、サービスはユーザが使うものなのでまずはこれ中心に考えることにしました。

例えば、「ユーザに電話をかけた」という出来事の記録に失敗することはぱっと見良くなさそうで、誰かに言いたくなりますが、 ユーザにとっては通話ができれば問題ないかもしれません。 (アラート対象とはせずにログに残す程度でいい)

アラートとは

監視してて、何かあった時に誰かに警告してくれる(いまだに不明瞭で草生えない)

アラート対象の種類

1. 緊急性が高くすぐに対処すべき事象

2. 確認はすべきだけど、緊急性が低くすぐに対処しなくてもいい事象

本で上記のような2種類が書かれていました。 この2つ+「何かあったら確認したいけど誰かに通知するほどでもない事象」の3つを明確に分けられていなかった私は、 このいずれかに意識的に分類してみることで、頭の中で整理がしやすくなりました。

例えば、通話できることで成り立っているサービスにおいて、 「通話ができない状態」は、大事故なのでアラート対象(すぐに関係者に通知すべき)、というのはすぐにわかりますが、 「原因不明の通話作成の失敗1回(もちろんユーザには適切な画面表示をしています)」はどうでしょうか。なんらかの問題に気がつけるきっかけなので、詳細確認はしたいですが、サービス全体において致命的ではありません、何これアラート対象?どうすべきなの?っていうかアラートってなんだっけ?と、こういうパターンでは毎回頭の中がごちゃごちゃになっていました。

「原因不明の通話作成の失敗1回」は「2. 緊急性は低い事象」と認識し、原因はよく調べたところいくつかのパターンを含んでいたので、一部のパターンはシステム改修で対応し、どうしようもないものは、ログに残すだけにして、関係者への通知は行わないようにしました。(場合によっては緊急性が低い用の通知場所に通知してもいいと思います)

もはや気持ちの問題ですね。その時に応じて頭を使うのは変わらないのですが、アラート対象の分類のしかたを1つ知っておくだけで頭の中の整理のしやすさがかなり変わりました。

アラートの精査

迷うならとりあえず入れておいて、現状をみて減らしていくのもアリ

(本に書いてあったかは謎。。。)

実際にやる前はそんな雑なことしていいかな、定期的に担当者(自分)Twilioのデバッガー見にいけばいいのでは?とモヤモヤソワソワしていて、 実際にやってみて「アリ!」って思えたので書いておきます。

call systemを使用しているサービスのことを考えると、call systemが安定した稼働をすることはかなり重要だったので、 何か問題があった時にすぐ気がつけるようにしています。

call systemリリース時には、

  • 「アプリケーションがあげるエラー」
  • 「サーバー自体のエラー」

の中で重要なものは当然アラート対象としつつ、何が起こるか未知数だった

  • 「TwilioのDebuggerが上げてくれるエラー(これらのうちのいくつか)」

については全てアラート対象としました。 (とはいえ、大量に来たら迷惑すぎるので初期だけは1時間に1回まとめて通知にしていました。)

しばらく運用していると「TwilioのDebuggerが上げてくれるエラー」について、下記のようにいい感じに間引くことができました。

  • call systemを使っているサービスに害がない→アラート対象から外す
    例) Warning 32015 - Twilio

  • ユーザの環境依存で一時的に起こってしまうがcall systemとしては対処できない→call systemを使っているサービス側で対策を考えた上でアラート対象から外す
    例) Warning 32014 - TwilioError 52103 - Twilio

現状を知りながら適切なアラート設定ができたので、一旦アラート対象にして順番に対処する形にしてよかったです。 (いつ起こるかわからないものを、定期的確認しに行くのはしんどかったと思う)

おわり。

List.ofがJacksonでdeserializeできない話

こんにちは。 エキサイト株式会社の三浦です。

JavaにはJacksonというライブラリがあり、Javaコード上のデータをJSONに変換したり(serialize)、逆にJSONJavaコード上のデータに変換したり(deserialize)してくれます。 今回は、このJacksonを使った時にある条件下で詰まった話をしていきます。

Jackson

Jacksonは、Githubのページで以下のように定義されています。

Jackson has been known as "the Java JSON library" or "the best JSON parser for Java". Or simply as "JSON for Java".

端的に言えばJava用のJSONライブラリで、JSONJavaコード上データの相互変換等をしてくれます。 例えば、APIでリクエスト元にデータを返す時にJavaコード上のデータをJSONに変換してから返したり、キャッシュ保存時にデータをJSON化してから保存し、逆にキャッシュからデータを取得する時にJSONJavaコード上のデータに変換する、などで使われたりします。

大体のデータであればJacksonを使えば相互変換ができるのですが、実はまだ対応していない条件があります。 それが List.of です。

List.of と Jackson

List.of はJava9から追加されたメソッドで、immutableなリストを提供してくれます。 すなわち、 List.of で定義したリストは追加や削除、更新することができず、安全に取り扱えるため、非常に使い勝手がよいわけです。

List<String> sampleList = List.of("a", "b");

// 追加できない
sampleList.add("c");

ただ、残念ながらこの List.of はJacksonには対応していません。

// SAMPLE_KEYを使ってキャッシュする
@Cacheable(cacheNames = CacheKeyType.SAMPLE_KEY)
public List<String> getSampleList() {
    return List.of("a", "b");
}

このようにキャッシュをすると、キャッシュからデータを取得する時に Could not read JSON が発生します。 キャッシュデータを見ると以下のように保存されています。

["a", "b"]

一見問題ないように見えますが、実はJacksonではJSON化する際に変換元データの型も保存するようになっており、それをJSONから戻す際に使用するという仕様になっています。 List.of では型を保存してくれないので、Jacksonでは戻すことができないのです。

また、 List.of の使い方によっては型を保存してくれる場合もあるのですが、どうやら List.of で作られた型はまだJacksonで対応されていない型のようで、いずれにしろエラーが起きてしまいます。

解決方法

もっとも安易な解決方法は、 List.of を使わないことでしょう。 例えば上記であれば、 List.of の代わりに Arrays.asList を使うことができます。

// SAMPLE_KEYを使ってキャッシュする
@Cacheable(cacheNames = CacheKeyType.SAMPLE_KEY)
public List<String> getSampleList() {
    return Arrays.asList("a", "b");
}

こちらだとキャッシュでは、以下のように保存されます。

[
    "java.util.Arrays$ArrayList",
    ["a", "b"]
]

こちらであればJacksonに対応している型が保存されるため、Jacksonで問題なく戻すことができます。

可能であれば、

// 空リストを作るとき
Collections.emptyList();

// 1件だけのリストを作るとき
Collections.singletonList("a");

// 2件以上のリストを作るとき
Arrays.asList("a", "b");

とできると、空・1件のみのリストはimmutableになるのでおすすめです。

最後に

List.of は非常に便利ですが、このように落とし穴があるため気をつける必要があります。 状況に合わせて使い分けていきましょう。

なお、2021年9月6日現在では List.of がJacksonで使用できませんが、今後のアップデートで使用できるようになる可能性があります。 また、見つけられていないだけで、実は現状でも使えるようになる設定がある可能性もあります。

あらかじめご了承下さい。

Nimを使ってGUIアプリケーションを作成してみる

今回のあらすじ

前回の記事、「Nim言語を使って簡単に文章の類似度を計算してみる」の続きになります。
今回は文章の類似度を計算するGUIアプリケーションに挑戦していきます。

NiGUI

NiGUIは「cross-platform desktop GUI toolkit」ということで、
Windows, Mac, Linuxで動作するGUI用のライブラリです。
https://github.com/trustable-code/NiGui

アプリケーションを作成する

NiGUIのインストール

nimble install nigui

Macの場合

MacでNiGUIを利用しようとすると、
could not load: libgtk-3.0.dylib
と怒られることがあります。

その場合はbrewなどで必要なライブラリを入れてあげます。

brew install libgtk

準備

せっかくなので前回のコードを使いまわしましょう。
Nim言語を使って簡単に文章の類似度を計算してみるのコードを「ngram.nim」、
今回のコードを「nigui_test.nim」として以下のように保存します。

f:id:taanatsu:20210830183349p:plain

ngram.nimの中身

一応コピペができるように前回のコードをこちらに記載しておきます。
まだ読んでない方はぜひNim言語を使って簡単に文章の類似度を計算してみるを読んでみてくださいね!

import unicode
import tables
import math

proc createNGram*(n: int, text: string): seq[string] =
    ##
    ## n-gramデータを作成します
    ## 
    ## n: n-gramのnに当たる数値
    ## text: n-gramに分解(コーパス)したい文字列
    
    # マルチバイト文字列を扱えるようにテキストをルーン化する(Goとかにもある、アレ)
    let runeText = text.toRunes()

    # 何文字目まで連結させたかを保持しておく変数(Runeは1文字ずつ扱うため、indexでどこまで扱ったかをカウントしておく)
    var index = 0
    # n-gramで何文字ずつの文字列(コーパス)にするかを決めるためのカウント変数
    var cnt = 0
    # n-gramでの文字列を作成する際に利用するtmp変数
    var tmp: string

    while true:
        # cntがn-gramの指定文字数を超えたらそこで切り出す(安全のため<=としている)
        if n <= cnt:
            # resultは暗黙変数(nimでは返り値を定義すると自動的にresult変数が生成される)
            result.add(tmp)

            tmp = ""
            cnt = 0

            # n-gramの特性上一つ前の文字をもう一度利用するので、n-1をしている
            index = index - (n - 1)
            
        
        if text.runeLen() <= index:
            break
        
        # 1文字ずつ連結していく
        tmp = tmp & $runeText[index]

        cnt = cnt + 1
        index = index + 1


proc tf*(corpus: seq[string]): Table[string, int] =
    ##
    ## コーパスの中のTFを計算します
    ## 
    ## corpus: コーパスが格納されたseq配列を指定します

    for c in corpus:
        # 連想配列にその単語があれば1加算、なければその連想配列のキーを作成し、1を代入
        if result.hasKey(c):
            result[c] += 1
        else:
            result[c] = 1

proc cosineSimilarity*(text1: string, text2: string, ngramNum: int): float =
    ##
    ## 文章の類似度を調べます
    ## 
    ## text1: 1つ目の文章
    ## text2: 2つ目の文章
    ## ngramNum: 何gramにテキストを分解するか
    ##

    # 文章をそれぞれコーパスに分解します
    let text1Copus = createNGram(ngramNum, text1)
    let text2Copus = createNGram(ngramNum, text2)

    # text2のTF値を求めます
    let text2Tf = tf(text2Copus)

    # コサイン類似度の計算に必要な分子分母の変数
    var c = 0.0
    var m1 = 0.0
    var m2 = 0.0

    for t1c in text1Copus:
        # text2のコーパスにtext1のコーパスがあるかないかで類似度を計算することにします
        # text2のコーパスにtext1のコーパスがあれば1、なければ0を使います
        var n = 0.0
        if text2Tf.hasKey(t1c):
            n = 1.0
        
        # コサイン類似度に利用する分子分母の数値を計算
        c += (1 * n)
        m1 += 1 * 1
        m2 += n * n
    
    # コサイン類似度の計算
    if m1 == 0 or m2 == 0:
        return 0
    result = c / round(sqrt(m1) * sqrt(m2))

GUIの作成

NiGUIを使ってGUIを作成していきます。

import nigui

app.init()

# ウインドウの作成
var window = newWindow("テキストの類似度を計算する")
# ウインドウのサイズを設定
window.width = 600.scaleToDpi
window.height = 265.scaleToDpi

# ボタンなどを表示する領域の作成
var container = newContainer()
window.add(container)

# テキストエリアを作成
var textArea1 = newTextArea()
container.add(textArea1)
textArea1.x = 0
textArea1.y = 0
textArea1.width = 290
textArea1.height = 200

# テキストエリアを作成
var textArea2 = newTextArea()
container.add(textArea2)
textArea2.x = 310
textArea2.y = 0
textArea2.width = 290
textArea2.height = 200

# 類似度計算ボタンを作成
var calcButton = newButton("類似度の計算")
container.add(calcButton)
calcButton.x = 480
calcButton.y = 220
calcButton.width = 100
calcButton.height = 35

window.show()
app.run()

上記のコードを実行してみます。

$ nim c -r nigui_test.nim

すると、以下のような画面が生成されます。
f:id:taanatsu:20210830183542p:plain

これでGUIの基盤ができました。

ボタンを押したら類似度を計算し、表示させる

Nim言語を使って簡単に文章の類似度を計算してみるのコード(ngram.nim)を読み込みます。

import ngram

次にボタン押下時の処理を追加します。

# 類似度計算ボタン押下時の処理
calcButton.onClick = proc(event: ClickEvent) =
  let textArea1Text = textArea1.text
  let textArea2Text = textArea2.text

  # 2つの文章の類似度を計算
  let similarity = ngram.cosineSimilarity(textArea1Text, textArea2Text, 2)

  # メッセージボックスで、計算した類似度を表示
  window.alert("2つの文章の類似度は" & $similarity & "です。")

これで準備が完了です。
全体のコードを以下に記載いたします。

import nigui
import ngram


app.init()

# ウインドウの作成
var window = newWindow("テキストの類似度を計算する")
# ウインドウのサイズを設定
window.width = 600.scaleToDpi
window.height = 265.scaleToDpi

# ボタンなどを表示する領域の作成
var container = newContainer()
window.add(container)

# テキストエリアを作成
var textArea1 = newTextArea()
container.add(textArea1)
textArea1.x = 0
textArea1.y = 0
textArea1.width = 290
textArea1.height = 200

# テキストエリアを作成
var textArea2 = newTextArea()
container.add(textArea2)
textArea2.x = 310
textArea2.y = 0
textArea2.width = 290
textArea2.height = 200

# 類似度計算ボタンを作成
var calcButton = newButton("類似度の計算")
container.add(calcButton)
calcButton.x = 480
calcButton.y = 220
calcButton.width = 100
calcButton.height = 35

# 類似度計算ボタン押下時の処理
calcButton.onClick = proc(event: ClickEvent) =
  let textArea1Text = textArea1.text
  let textArea2Text = textArea2.text

  # 2つの文章の類似度を計算
  let similarity = ngram.cosineSimilarity(textArea1Text, textArea2Text, 2)

  # メッセージボックスで、計算した類似度を表示
  window.alert("2つの文章の類似度は" & $similarity & "です。")


window.show()
app.run()

動作

f:id:taanatsu:20210830183438p:plain

おわりに

さて、一通りソフトウェアの開発ができました。
Nimはなかなかおもしろい言語ですので、よかったらはまってみてください!
(Excite内でもはまっている人もいます!)

では、また次回!