【npmパッケージ】あいまい検索を簡単に実装できるFuse.jsの紹介とプチ性能評価
この記事について
あいまいなワードで住所をマッチングさせたいと思いました。既存実装ではいくつかパターンに沿ってマッチングさせていました。しかし、時間がかかりすぎる!!!!!解決したい!!!! ということで今回は、あいまい検索ライブラリであるfuse.jsについて紹介します。
先に結論を3つ
- fuse.jsはあいまい検索ライブラリの中で最も多くダウンロードされているライブラリである
- 様々なoptionがあるので簡単に導入できる
- 部分一致に特化しているが、optionの設定次第では、完全一致の検索候補を抽出できると考えられる
詳細
導入
// JavaScript
>>> yarn add fuse.js
// TypeScript
>>> yarn add @type/fuse
サンプルコード
import Fuse, { FuseResult } from 'fuse.js' /** * @param targetList 検索対象のリスト * @param word 検索したいワード * @return 検索結果 */ const fuseSearch = (targetList: string[], word: string) => { const options = { includeScore: true, isCaseSensitive: true, threshold: 0.4 } const fuse = new Fuse(targetList, options); const resultList = fuse.search(word); return resultList; }
- 検索結果例
// wordしたワード "word": "丸の内1-1" // resultListの中身 [ { "item": "東京都千代田区丸の内1-1" // ヒットした値 "refIndex": 1, // 比較に使用したアイテムのリスト内のインデックス "score": 0.15609486447437038 // ヒットしたスコア } ]
option
を設定することであいまい検索の条件を編集することができる。使用頻度が高いと思われるoptionを以下に示します。includeScore
: 検索結果のオブジェクトにscoreの値を含めることができる。スコアが低いほどマッチ度が高い。isCaseSensitive
: 検索が大文字と小文字を区別するかどうかを制御できる。デフォルトはfalse。threshold
: スコアの閾値を設定することで、そのスコア未満の結果のみを抽出することができる この値を低く設定すれば、完全一致に近い検索候補をヒットさせることができるincludeMatches
: 検索結果内でどの部分がマッチしたかを結果に含めることができる。minMatchCharLength
: ここで設定した文字数以下でマッチした結果を無視することができる- たとえば、結果内の単一文字の一致を無視したい場合は、これを2に設定する
minMatchCharLength: 3
としておくと、2文字以下で一致した結果を無視することができる
shouldSort
: 検索結果をスコア順にソートすることができる。デフォルトはtrue
最後に
- fuse.jsであいまい検索を実装しました。今回はシンプルなoptionのみで検索しました。他にもoptionがいくつかあり、組み合わせ次第では、複雑な条件での検索もできそうなので、今後も試していこうと思います。
Appendix
- 既存の実装とfuse.jsで出力結果がどのように変わったかをまとめる
前提
- 処理すべきデータ数:6,207,355通り
- 検索対象のリストのデータ数:16,123個
- 検索ワード数:385個
結果
閾値 | 既存 | 0.1 | 0.2 | 0.25 | 0.28 | 0.3 |
---|---|---|---|---|---|---|
ヒットした数 | 149 | 121 | 121 | 124 | 154 | 235 |
時間(s) | 41.554 | 5.239 | 7.744 | 9.505 | 10.319 | 10.294 |
データ数を変えてみる
前提
- 処理すべきデータ数:77,260,450通り
- 検索対象のリストのデータ数:14,605個
- 検索ワード数:5,290個
結果
閾値 | 既存 | 0.1 |
---|---|---|
ヒットした数 | 149 | 121 |
時間(s) | 530.052 | 86.03 |
【MySQL】CHECK制約を使って不正なデータからテーブルを守ろう
この記事について
- 最近、チーム内のMySQLのバージョンを5系から8系にバージョンアップしました。ですが、MySQL8系の恩恵といえば、Geographic Information System(GIS)以外受けてないような気がするなと思いました。
しかし、最近恩恵を受けたのでそちらをまとめようと思います。
先に結論を3つ
- MySQL 8.0.16からCHECK 制約が追加された
- 記述した条件式に合わない行の挿入・更新を防ぐことができる
- 適用されないパターンもあるので使う時は注意が必要
CHECK 制約
- CHECK制約は、テーブルにデータを挿入、または更新する際に条件を満たすか検証し、もし満たさない場合はエラーにしてしまう機能です。例えば、以下のような事例で活用できます。
- 0~255の数値を扱うカラムを0~10までしか挿入しないようにしたい
- 未成年のユーザーが登録されることを防ぐために、年齢を18歳未満の値を弾きたい
- 特定の文字列は受け付けないようにしたい
この時にCHECK制約を使うことができます。
CHECK制約の使い方
- CHECK制約はテーブルに対して設定します。
- テーブル作成時に使用する場合
CREATE TABLE `company_user` (
`company_ulid` VARCHAR(26) NOT NULL COMMENT '企業のULID'
`user_ulid` VARCHAR(26) NOT NULL COMMENT 'ユーザーのULID',
, `created_id` BIGINT(20) COMMENT '登録者id'
, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登録日時'
, PRIMARY KEY (`company_ulid`, `user_ulid`)
, FOREIGN KEY (`company_ulid`) REFERENCES `company` (`ulid`)
, FOREIGN KEY (`user_ulid`) REFERENCES `user` (`ulid`)
, CHECK (`制約を記載する`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci comment='企業とユーザーの紐付けテーブル';
既存のテーブルに制約を追加する場合
ALTER TABLE `company_user` ADD CONSTRAINT `user_check` CHECK (`制約を記載する`);
実践
制約の定義
- 今回は複合主キーのカラムに同じ値が入らないような制約をつけてみます
CREATE TABLE `company_user` (
`company_ulid` VARCHAR(26) NOT NULL COMMENT '企業のULID'
`user_ulid` VARCHAR(26) NOT NULL COMMENT 'ユーザーのULID',
, `created_id` BIGINT(20) COMMENT '登録者id'
, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登録日時'
, PRIMARY KEY (`company_ulid`, `user_ulid`)
, FOREIGN KEY (`company_ulid`) REFERENCES `company` (`ulid`)
, FOREIGN KEY (`user_ulid`) REFERENCES `user` (`ulid`)
, CHECK (`company_ulid` <> `user_ulid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci comment='企業とユーザーの紐付けテーブル';
- CHECK (
company_ulid
<>user_ulid
)でcompany_ulid
とuser_ulid
が等しくないことを制約として設定することができます
動作確認
- 以下のクエリを実行し、結果がどうなるかチェックします
INSERT INTO company_user ( company_ulid, user_ulid, created_id ) VALUES ( "01G0CADQF1A9MKQ0QAVBF24E6D", "01G0CADQF1A9MKQ0QAVBF24E6D", 1 );
mysql > ERROR 3819 (HY000): Check constraint 'company_user_chk_1' is violated.
- 同じ値が挿入されることを弾いてくれました。これを活用することで、アプリケーション側で万が一弾けなかった値もSQL側で弾くことが可能です
適用されないパターン
- InnoDBストレージエンジンが使用されていない場合
- 単一の CHECK 制約には単一の条件しか指定できません。以下のように複数の列に対する複合条件を指定することはできません。
CREATE TABLE table_name ( column1 INT, column2 INT, CHECK (column1 > 0 AND column2 < 100) );
独立させれば問題ないです
CREATE TABLE table_name ( column1 INT CHECK (column1 > 0), column2 INT CHECK (column2 < 100) );
公式ドキュメントより
- AUTO_INCREMENT 属性を持つカラムおよび他のテーブルのカラムを除き、生成されていないカラムおよび生成されたカラムは許可されます。
- リテラル、決定的組込み関数および演算子を使用できます。 関数は、テーブル内の同じデータが指定された場合、接続ユーザーとは関係なく、複数の起動で同じ結果が生成される場合は決定論的です。 非決定的で、この定義に失敗する関数の例: CONNECTION_ID(), CURRENT_USER(), NOW()。
- ストアドファンクションおよびユーザー定義関数は使用できません。
- ストアドプロシージャおよびストアドファンクションのパラメータは使用できません。
- 変数 (システム変数、ユーザー定義変数およびストアドプログラムローカル変数) は使用できません。
- サブクエリーは許可されません。
最後に
- 今回はMySQL8系で追加された CHECK制約についてまとめました。
- PostgreSQLでも同様の機能が存在するようです。
- 今後も活用していきたいですし、MySQL8系の恩恵を受けられるように調査を続けます。
【Vue.js】Vue Fes Japan 2023 での学び ~EOL対応の鍵は小さく始めること~
この記事について
今回はVue Fes Japan 2023に参加したので、そこで得たものについて書こうと思います。最近Vue3移行をチームで実施したので、その観点で学んだことを書こうと思います。
先に要点を3つ
- どの企業もVue3移行には苦しめられており、企業ごとに工夫しながら、移行作業を進めている
- 破壊的変更が加わっているVuetifyの代替ライブラリとして、
Prime Vue
というライブラリが紹介されていた - 小さく始めることは大事
- この記事について
- 先に要点を3つ
- どの企業もVue3移行には苦しめられており、企業ごとに工夫しながら、移行作業を進めている
- 破壊的変更が加わっているVuetifyの代替ライブラリとして、Prime Vueというライブラリが紹介されていた
- 小さく始めることは大事
- 最後に
どの企業もVue3移行には苦しめられており、企業ごとに工夫しながら、移行作業を進めている
弁護士ドットコム株式会社 クラウドサインの事例
弁護士ドットコム株式会社 のクラウドサインというプロダクトではVue2.7を使用しているそうです。現在Vue3移行に向けて少しずつ進めているようです。まだアップデートしていない理由としてはビジネス面、技術面の問題があるからのようです。特に技術面の問題の中でも@vue/composition-api
を取り上げていました。
- Vue2.6では
composition-api
を使うために@vue/composition-api
を使う必要があるが、Vue2.7以降は@vue/composition-api
を剥がす必要がある @vue/composition-api
を使用したファイルは900ファイル以上で、1度に剥がすと特大のマージリクエストができてしまい、デグレなどの懸念点が多い
上記の観点から、変更を極小化するためのモジュールを作成しました。 詳しく方法は走りながらエンジンを交換する大規模プロダクトを成長させつつVue3にするには【クラウドサイン(弁護士ドットコム株式会社)篠田 貴大】をご覧ください。
その結果、差分を5ファイルにした状態でVue2.7に移行することができたようです。 この方法はVue2.6からVue3系にアップデートしようとしているチームは活用できそうだなと感じました。
メドピア株式会社 の事例
メドピア株式会社では複数のプロダクトを開発しており、それぞれ状況が違うようです。そのため、プロダクトにあった方法で移行作業を進めていました。 - MedPeerというプロダクトではeslintの設定にVue2系を排除するルールを追加し、Vue2の記述を排除した - ヤクメド遠いうプロダクトでは、Vue2系とVue3系を共存させ、徐々に移行を進めていった
詳しく方法はVue 2のEOLまで二ヶ月ですが進捗どうですか?【メドピア株式会社 小林和弘】をご覧ください。
破壊的変更が加わっているVuetifyの代替ライブラリとして、Prime Vue
というライブラリが紹介されていた
- オープニングトークでは、Evan YouさんがVue3の開発に関する総評を述べていました
- mistake
- 1度に全てのものを置き換えようとしたこと
- バージョンアップにより使えなくなるライブラリが多数あること
- 一緒にすべてをリリースしてしまったこと
- right
- Typescriptへの親和性を高めたこと
- Composition APIを導入したこと
- Vite やVolarを作成したこと
- mistake
今後は破壊的変更をしないような仕組みを作ってバージョンアップを進めるとのことでした。
中でも興味を持ったのがPrime Vueというライブラリです。The Most Complete UI Suite for Vue.js
です。ドキュメントも丁寧にまとまっています。使い方はVuetifyとそこまで大差な印象です。
少し手間だなと思うのは、コンポーネントの利用するには使用したいコンポーネントを明示的にインポートして登録する必要がある
ことです。
- アプリケーション全体で使用する場合
import Button from "primevue/Button" // 使用するコンポーネントをインポート const app = createApp(App) app.use(PrimeVue, { locale: ja }) app.component("Button", Button) // コンポーネントを登録 app.mount("#app")
- 個別のコンポーネントで使用する場合
<script setup lang="ts"> import { defineComponent } from "vue" import InputText from "primevue/Button" // 使用するコンポーネントをインポート </script> <template> <Button /> </template>
以前、UIライブラリ】Vuetify3 の対応追いついてないけどどうする?という記事でElement Plusを紹介しましたが、そちらよりも日本人が使ってそうなので、もしVuetifyの対応が辛く、置き換えたい場合は検討するのもありかと思います。
小さく始めることは大事
今回印象に残った登壇者に共通していたのは、小さく始めることが大事ということです。Evan YouさんがVue3の開発において、1度に全てのものを置き換えようとしたことを失敗だったと述べていました。弁護士ドットコム株式会社、メドピア株式会社の方々もデグレを最小限にするように工夫しながら、少しずつVue3への移行作業を進めていました。
Vue3への移行の鍵は、作業を因数分解し、小さく始めることだと認識しました
最後に
初めての社外オフラインテックイベントでしたが、とても勉強になりました。得たことを業務に活用していきたいと思います。
【MyBatis】ネストしたリストに値をマッピングする方法 ~ アノテーションは大事 ~
この記事について
- MyBatisでネストしたリスト(階層構造)をマッピングする際は注意
- MyBatisでネストしたリストをマッピングする場合、結果を格納するクラスには
@NoArgsConstructor
を付与しないといけない
この問題でとても時間を要したので、備忘録を込めてまとめます。
MyBatisとは
- XML、またはアノテーションを使用してSQL文とオブジェクトをマッピングするフレームワークのこと(O/R マッパー)です
- JavaのO/Rマッパーについては、Java O/Rマッパーの種類と選定方法について調べたことのメモを参考にすると良いかと思います。
- 通常のCRUD操作が可能です
- パラメータの状態により動的にSQLを作成することも可能です
前提
- 以下のようなentityクラスに対応する情報をMyBatisを用いてDBから取得したい
- クラスは全て仮の呼び名です。特に意味はありません。
- 旅行の計画のクラスとその旅行にホテルが紐づいているイメージです
entityクラス
import lombok.AllArgsConstructor; import lombok.Getter; @Getter @AllArgsConstructor public class TravelPlan implements Serializable { private static final long serialVersionUID = 1L; private String ulid; private int status; private String name; private int area; private List<TravelPlanHotelCompanyBelonging> farmerList; private BigInteger createdId; private LocalDateTime createdAt; }
@Data public class TravelPlanHotelCompanyBelonging implements Serializable { private static final long serialVersionUID = 1L; private String travelPlanId; private BigInteger HotelCompanyId; private int planProposalStatus; private BigInteger createdId; private LocalDateTime createdAt; private BigInteger updatedId; private LocalDateTime updatedAt; }
Repositoryクラス
import org.apache.ibatis.annotations.Mapper; @Mapper public interface TravelPlanRepository { TravelPlan selectByTravelPlanId(String travelPlanId); }
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace="TravelPlanRepository"> <resultMap id="TravelPlanMap" type="TravelPlan" autoMapping="true"> <id property="ulid" column="ulid" /> <result property="status" column="status" /> <result property="name" column="name" /> <result property="area" column="area"/> <result property="createdId" column="created_id"/> <result property="createdAt" column="created_at"/> <collection property="hotelCompanyList" resultMap="HotelCompanyMap" /> </resultMap> <resultMap id="HotelCompanyMap" type="TravelPlanHotelCompanyBelonging"> <id property="travelPlanId" column="hotel_travel_plan_id" /> <id property="hotelCompanyId" column="company_id" /> <result property="planProposalStatus" column="farmer_plan_proposal_status" /> </resultMap> <sql id="selectTravelPlan"> SELECT tp.ulid , tp.status , tp.name , tp.created_id , tp.created_at , hotel.travel_plan_id AS hotel_travel_plan_id , hotel.company_id , hotel.plan_proposal_status AS travel_plan_proposal_status FROM travel_plans as tp LEFT JOIN travel_plan_hotel_company_belonging as hotel ON hotel.travel_plan_id = tp.ulid </sql> <select id="selectByTravelPlanId" resultMap="TravelPlanMap"> <include refid="selectTravelPlan"/> WHERE tp.ulid = #{TravelPlanId} </select> </mapper>
問題
- こんなエラー文が出た
Caused by: org.apache.ibatis.executor.result.ResultMapException:
Error attempting to get column 'hotel_travel_plan_id' from result set.
Cause: java.lang.NumberFormatException: For input string: "01HBYRVACPMDR2NMVYF69C8W6E"
- データセットから
hotel_travel_plan_id
カラムを取得し、クラスにセットしようとしているところでエラーが出ていました - セットする値である
hotel_travel_plan_id
の型が違うのかと思い、確認するが、きちんとString
で定義しており、エラーメッセージにもFor input string
と表示されていました
解決方法
- 色々漁ってみました
結果
- 結果を格納する親クラスに
@NoArgsConstructor
を付与し、デフォルトコンストラクタを定義する!!!
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @AllArgsConstructor @NoArgsConstructor public class TravelPlan implements Serializable { private static final long serialVersionUID = 1L; private String ulid; private int status; private String name; private int area; private List<TravelPlanHotelCompanyBelonging> farmerList; private BigInteger createdId; private LocalDateTime createdAt; }
- 値をセットするための箱を用意してあげる必要があり、そこにMyBatis側でマッピングしていると想定しています
- 少し実験してみました
値をセットする@Setter
やセッターを使えるようになる@Data
など、値をセットするようなアノテーションを使うことでマッピングできるのかが気になったので、実験してみました。以下がその結果です。
# | @Data | @NoArgsConstructor | @Getter | @Setter | マッピングできるか |
---|---|---|---|---|---|
1 | ○ | ○ | - | - | ○ |
2 | ○ | - | - | - | × |
3 | - | ○ | ○ | - | ○ |
4 | - | ○ | ○ | ○ | ○ |
5 | - | - | ○ | ○ | × |
上記の結果より、@NoArgsConstructor
がないとマッピングはできなさそうです。
最後に
- Spring Bootのアノテーションは未だに使いこなせていない気がしています。使いこなすと便利なので、まだまだ勉強を続けていこうと思います。
【UIライブラリ】 Vuetify3 対応追いついてないけどどうする?
この記事について
いいコードはいい心から
Vue3移行したはいいものの、Vuetify3の対応が追いついておらず、Vue3に移行するのを渋っている方々も一定数いそう、、、 ということで、Element Plusの紹介をします
結論は以下の2つです
- そこまでVuetify変わらずに使える、Vue3と親和性も高そうなので、移行も1つの手かなと思います。
- 中国のチームが作っているライブラリのようなので、日本語の記事が少ないです。
Element Plusとは
導入方法
- インストールする
# NPM $ npm install element-plus --save # Yarn $ yarn add element-plus
- main.ts (main.js)で読み込む
import { createApp } from "vue"; import ElementPlus from "element-plus"; // 追加 import "element-plus/dist/index.css"; // 追加 import App from "./App.vue"; const app = createApp(App); app.use(ElementPlus); // 追加 app.mount("#app");
- tsconfig.jsonを修正する
{ "compilerOptions": { // ... "types": ["element-plus/global"] } }
- vite.config.tsを修正する
// vite.config.ts import { defineConfig } from 'vite' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ // ... plugins: [ // ... AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], })
- 使ってみる
<template> <div class="flex"> <el-button type="primary" :icon="Edit" /> </div> </template> <script setup lang="ts"> import { Edit} from '@element-plus/icons-vue' </script>
上記のようなボタンが完成しました
Vuetifyとの比較
ダウンロード数の比較
- npm trendsで比較してみました
Vuetifyの方がシェアは大きいようです。
書き方の比較
ボタンコンポーネントで比較します
- Vuetify
<template> <div class="flex"> <v-btn density="compact" icon="mdi-plus" /> </div> </template> <script setup lang="ts"> </script>
- Element Plus
<template> <div class="flex"> <el-button type="primary" :icon="Edit" /> </div> </template> <script setup lang="ts"> import { Edit} from '@element-plus/icons-vue' </script>
そこまで書き方は変わらなさそうですね。チュートリアルを見る限り、Element Plus
の方が用意されている部品が多いように感じます。
Vuetify3との比較
Vuetify3はVuetify2で使用できていたコンポーネントの対応が追いついていないです。
例えば、v-calendar
やv-table
が挙げられます。v-table
は使っているチームも多そうです。Element Plus
ではTableやCalendarなどすでに準備されています。
テーブルコンポーネントに関しては、Virtualized Tableも用意されているようなので、
バーチャルスクロールも容易に実装できそうです。(まだベータ版ですけどね。。。)
また、Vuetify2からVuetify3への移行の際に、破壊的変更への対応に苦しめられると思います。自分が所属しているチームがそうでした。
導入する際の懸念点
頼れるのは公式ドキュメント + githubのみと思います。日本語の記事、関連記事は少ないなと感じました。中国のチームが作っているライブラリのようなので、 中国語の記事は多いかもしれません。公式ドキュメントが日本語に対応される日は来るのかもわかりませんし。。。
最後に
Vuetify一強は続くような気がしますが、Element Plusの今後の動向もチェックしようと思います。とは言いつつも、求めるコンポーネントを素早く自力で作ることができるくらいのスキルを身につけられるように日々精進しようと思います。