新人エンジニアのつぶやき

日々の学びをアウトプットするためのブログです

【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_uliduser_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というライブラリが紹介されていた
  • 小さく始めることは大事

どの企業も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を作成したこと

今後は破壊的変更をしないような仕組みを作ってバージョンアップを進めるとのことでした。 中でも興味を持ったのが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への移行の鍵は、作業を因数分解し、小さく始めることだと認識しました

最後に

初めての社外オフラインテックイベントでしたが、とても勉強になりました。得たことを業務に活用していきたいと思います。

【EOL対応】MySQLのバージョンアップについての備忘録

この記事について

  • MySQL 5系のEOLが迫っています。Oracleは過ぎていますね。AWS RDS はあと数ヶ月です。

~ Oracle ~

MySQL 5.7は2023年10月21日にサポート終了 (EOL) を迎えます。これはMySQLの背後にある企業であるOracle社が、MySQL 5.7の公式アップデート、バグ修正、セキュリティパッチを提供しなくなることを意味します。

~ AWS RDS ~

5.7系 RDS 標準サポート終了日 2023 年 12 月

  • 業務でチーム内のMySQLのバージョンを5系から8系にバージョンアップしました。手順や苦労、MySQL8系になったらできることをまとめようと思います。
  • この記事について
    • 先に要点を3つ
  • ローカル環境での移行作業
    • 移行手順
    • 動作確認
  • AWSでの移行作業
  • MySQL8系での変更点およびできること
    • 変更点
    • できること
  • 最後に
続きを読む

【OJT】私が担当者として進めてきたOJT

  • この記事について
  • 取り組みの概要
    • 人事制度・キャリアパス、エンジニア報酬制度について改めて確認する
    • 目標設定 (慣れの定義)
      • こんな経験はありませんか?
      • だから慣れの定義が必要であると考えています。
    • チーム内共有
    • 目標で設定したことに向けて業務を遂行
    • 定期的に振り返り
    • チーム内共有 & 目標Fix
  • 最後に
続きを読む

【MyBatis】ネストしたリストに値をマッピングする方法 ~ アノテーションは大事 ~

この記事について

  • MyBatisでネストしたリスト(階層構造)をマッピングする際は注意
  • MyBatisでネストしたリストをマッピングする場合、結果を格納するクラスには@NoArgsConstructorを付与しないといけない

この問題でとても時間を要したので、備忘録を込めてまとめます。

MyBatisとは

前提

  • 以下のような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とは

  • Vue3対応のUIライブラリ
  • Vuetifyと同じくマテリアルコンポーネントを提供してくれる
  • MITライセンスである

導入方法

  1. インストールする
# NPM
$ npm install element-plus --save

# Yarn
$ yarn add element-plus
  1. 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");
  1. tsconfig.jsonを修正する
{
  "compilerOptions": {
    // ...
    "types": ["element-plus/global"]
  }
}
  1. 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()],
    }),
  ],
})
  1. 使ってみる
<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との比較

ダウンロード数の比較

ダウンロード数の推移

ダウンロード数の比較

Vuetifyの方がシェアは大きいようです。

書き方の比較

ボタンコンポーネントで比較します

<template>
  <div class="flex">
    <v-btn density="compact" icon="mdi-plus" />
  </div>
</template>

<script setup lang="ts">
</script>
<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-calendarv-tableが挙げられます。v-tableは使っているチームも多そうです。Element PlusではTableCalendarなどすでに準備されています。 テーブルコンポーネントに関しては、Virtualized Tableも用意されているようなので、 バーチャルスクロールも容易に実装できそうです。(まだベータ版ですけどね。。。)

また、Vuetify2からVuetify3への移行の際に、破壊的変更への対応に苦しめられると思います。自分が所属しているチームがそうでした。

導入する際の懸念点

頼れるのは公式ドキュメント + githubのみと思います。日本語の記事、関連記事は少ないなと感じました。中国のチームが作っているライブラリのようなので、 中国語の記事は多いかもしれません。公式ドキュメントが日本語に対応される日は来るのかもわかりませんし。。。

最後に

Vuetify一強は続くような気がしますが、Element Plusの今後の動向もチェックしようと思います。とは言いつつも、求めるコンポーネントを素早く自力で作ることができるくらいのスキルを身につけられるように日々精進しようと思います。