脱PHPプロジェクトが完了しました

こんにちは。

テックリードのTerukiです。

あけましておめでとうございます。
今年も毎週何かしら技術記事と呼べるものを投稿していこうと思っていますのでよろしくお願いします！

今年一発目の記事は、以前書いた脱PHPプロジェクトの完了報告です。

https://qiita.com/omt_teruki/items/7aa6db2530c8dad3dafa

なぜ脱PHPしようとしたのかは前回の記事をご参照ください。

# 大変だったところ

LaravelからASP.NET Coreへ作り変えていく過程で大変だった点やハマったところなどいくつかあるので紹介します。
どこかの誰かの役に立てば。

||移行前|移行後|
|-|-|-|
|フレームワーク|Laravel 9|ASP.NET Core 8|
|ORM|Eloquent|EntityFramework Core 8|


## データベース関連
Oh my teethではデータベースにAurora MySQL 3が使われています。

テーブルはLaravelのマイグレーション機能で作られていたので、それらに対してASP.NET Core側のEntityFramework Coreからアクセスできるようにする必要があるわけです。

EF Coreのスキャフォールディング機能でエンティティクラスやDbContextを生成して使うことになるわけですが、実際に実行すると下記のようなエンティティクラスが吐き出されました。

```cs
[Table("users")]
public class User {
    [Key]
    [Column("id", TypeName = "int(10) unsigned")]
    public uint Id { get; set; }

    [Column("created_at", TypeName = "timestamp")]
    public DateTime? CreatedAt { get; set; }

    [Column("updated_at", TypeName = "timestamp")]
    public DateTime? UpdatedAt { get; set; }

    // ほか多数のカラムがありますが省略
}
```
カラム名がスネークケースなのは良しとするとして、Idはunsignedなint、created_atとupdated_atはnullableなDateTimeになっています。

### タイムゾーン問題

created_atなどがnullableなのはLaravelがデフォルトでnullableなカラムを作るからなのですが、CREATE TABLE段階でcreated_atなどを作る場合はnullであるほうが面倒です。

Laravelとしては、後からcreated_atなどが追加された場合にDDLがエラーにならないように配慮しているのだと推察していますが、EF Core経由で使う時は毎回 `CreatedAt!.Value` みたいなことをしないといけなくなるのがだいぶ面倒です。

これは面倒なだけなのでまだ良いですが、型がDateTimeのためタイムゾーンに気をつけないと9時間ズレたデータがINSERTされてしまいます。

DB側のタイムゾーンを気にしなくて良いように、エンティティクラス側をDateTimeOffsetに変更することでDB側には必ずUTCで保存させることができますが、Laravel側がJSTで保存している場合はやはり9時間ズレてしまいます:sob:

私達は一部のテーブルについてはJSTで保存されているデータをUTCに直すようなスクリプトを書いて凌ぐことにしました。

EF Coreで日付を扱う際はDateTimeOffsetにしてしまったほうが楽だと個人的には感じます。

### unsigned問題

主キーがunsignedのため、LINQでSQLを作る際に気をつけないとインデックスが使われない危険なクエリが生成されてしまいます。

例えばこんな感じのメソッドがあったとします。

```cs
public async Task<User?> GetUserAsync(int userId) {

    var user = await DbContext.Users.SingleOrDefaultAsync(s => s.Id == userId).ConfigureAwait(false);

    // なんらかの処理
    
    return user;
}
```

パッと見は普通ですが、メソッドの引数がintのため実際に実行されるSQLは下記のようになります。

```sql
SELECT `t`.`id`, `t`.`created_at`, `t`.`updated_at`
FROM `users` AS `t`
WHERE CAST(`t`.`id` AS signed) = 12345678;
```

これで無事フルスキャンされるクエリが実行されます。
上記の例ではさすがに気づけそうな雰囲気がありますが、実際のコードはもうちょっと複雑なのでこのクエリ問題に気づくことができず、ピークタイム時にRDSからアラートが飛びまくるということがありました。。

これはLaravelが悪いわけではないですが、unsignedとsignedの確認をしておかないと危ないという教訓になりました。

## Salesforce関連

Oh my teethではCRMとしてSalesforceを使用しています。

データ連携など、様々な箇所で呼び出されておりこの移行はなかなか大変でした。

Laravelでは下記のライブラリが非常に便利で活用させていただいていました。

https://github.com/omniphx/forrest

.NETで使えるSalesforceのライブラリをかなり探した結果、下記のものを採用しました。

https://github.com/anthonyreilly/NetCoreForce

PHP時代では、連想配列のキーのTypoによって障害となるケースが度々発生していたためORM的なことができるのがありがたい点でした。

Salesforceは可用性に難があり、度々500台のエラーや謎の406エラーを返すためリトライ処理などを入れ込んだHttpClientの使用は必須です。

具体的には下記のようなリトライポリシーを書いていました。

```cs
public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy(IServiceProvider services) {
    return HttpPolicyExtensions.HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.NotAcceptable)
        .WaitAndRetryAsync(5, (retryAttempt, response, context) => {
            // Unauthorizedの時はSleepさせずに即座に再試行する
            if (response.Result.StatusCode == HttpStatusCode.Unauthorized) {
                return TimeSpan.Zero;
            }
            return TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
        },
        async (response, delay, retryCount, context) => {
            var salesforceService = (SalesforceService)services.GetRequiredService<ISalesforceService>();

            // 401 Unauthorizedの場合はアクセストークンを再取得する
            if (response.Result.StatusCode == HttpStatusCode.Unauthorized) {
                var accessToken = await salesforceService.RefreshAccessTokenAsync().ConfigureAwait(false);
                // このリクエストは手動で書き換える
                response.Result.RequestMessage!.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            } else {

                // アクセストークン再取得以外のエラーの場合はログに記録する                    
                salesforceService.Logger.LogWarning("SFDCへのHTTPリクエストを再試行しています。 ステータスコード: {StatusCode} リトライ数: {RetryCount}",
                    response.Result.StatusCode.ToString(), retryCount);
            }
        });
}
```

DI登録時に下記のように指定することで、指定したリトライポリシーを組み込んだHttpClientをコンストラクタ経由で注入することができます。

```cs
services.AddHttpClient<ISalesforceService, SalesforceService>()
    .AddPolicyHandler((services, request) => SalesforceService.GetRetryPolicy(services));
```

---

そんなこんなで移行を進めていた時、事件は起きました。

https://help.salesforce.com/s/articleView?id=003876113&language=ja&type=1

11月15日にSalesforceで障害が発生しOh my teethでもユーザー影響が発生しました。

システム的にはアクセストークンを発行するログインエンドポイントのみが503を返しており、データ取得などのエンドポイントは生きている状態でした。

アクセストークンは時間で失効するため、キャッシュしているアクセストークンが失効したインスタンスからじわじわとエラーが上がるようになり、ピーク時はエラー通知が鳴り止まないほどでした。

幸いすべてのインスタンスのアクセストークンが失効する前に障害が復旧したため、部分的なリカバリをすれば問題ない状態でした。

以前から私としては可用性について問題意識がありましたが、今回の件で全社的に脱Salesforceを行う決定がなされました。

実はEF CoreのようにLINQを使ってSalesforceへクエリを実行できるライブラリを開発する社内プロジェクトを動かしており、ある程度完成したらOSSにしようと考えていたのでそのプロジェクトも止まってしまったのが個人的には悲しみポイントです:sob:

障害対応などに邪魔されつつも、12月31日の朝方についにすべての切り替え作業を終えることができました:tada:

前回の記事で紹介しましたが、毎日Slackチャンネルに残りの行数が投稿されるようにしていたので0行になった画像が送られてきた時は達成感がありました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/521f2958-095e-ee66-1911-b73377934c3c.png)

このチャンネルはTechチームだけでなく、他部署の人もよく見るチャンネルであったので完了時には経営陣含めいろいろな方から労いの言葉をもらい嬉しかったです。

# おわりに

今回の移行プロジェクトは副業メンバー含む約9人で進めていました。
当然ですが、私だけでは絶対に成し遂げられなかったので最後まで諦めずに移行を進めてくれたメンバーに感謝です。

脱PHPの次は脱SFDCという流れになっていますが、引き続き技術負債と戦いより良いユーザー体験を提供できるようにしていきたいと思っています。


# Oh my teethについて
Oh my teethでは未来の歯科体験を創るために日々活動しています。

Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。

一緒に未来の歯科体験を創りませんか？興味がある方は是非こちらを確認してください。

https://oh.careers/

カジュアル面談も可能なので気軽に応募してみてください！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/0138b492-e02b-852e-61fa-fc065b53d285.png" width="450" />