ASP.NET + Entity Framework における初回アクセス時のオーバーヘッドと Go や Node.js との比較

ASP.NET Core と Entity Framework Core を使った Web アプリケーションの初回アクセスが非常に遅かったので、Go や Node.js と比較してみました。

なお、初回アクセスが遅い要因としては、DB 接続のような処理を ```起動時ではなく初回アクセス時に実施している``` 点にあると考えられます。

基本的には（今のところ）どうしようも無さそうだったので、場合によっては運用等でカバーする事になりそうです。（Kubernetes の Startup Probe 利用とか）

# 1. ASP.NET の場合

まずは、ASP.NET 単体で確認してみます。

## (1a) ASP.NET Core サンプル

サンプルの内容は次の通りです。

```csharp:sample1_aspnet/Program.cs
var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.Map("/", () => "aspnet-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());

app.Run();
```

```json:sample1_aspnet/appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Error"
    }
  },
  "Kestrel": {
    "Endpoints": {
      "Http": {
        "Url": "http://0.0.0.0:3000"
      }
    }
  }
}
```

```xml:sample1_aspnet/sample1.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
```

このアプリケーションを Ubuntu でコンテナ実行しました。

##### 実行
```sh
$ nerdctl run -it --rm -p 3001:3000 -v $(pwd)/sample1_aspnet:/app mcr.microsoft.com/dotnet/sdk:7.0 bash

/# cd /app
/app# dotnet run
```

curl によるアクセスを 2回実施すると次のような結果となりました。

##### アクセス結果
```sh
$ time curl http://127.0.0.1:3001/
aspnet-1685191500163
real    0m0.081s
user    0m0.000s
sys     0m0.011s

$ time curl http://127.0.0.1:3001/
aspnet-1685191514600
real    0m0.013s
user    0m0.005s
sys     0m0.006s
```

この処理内容で、初回アクセスに 0.081 秒もかかるのは遅いように思います。

## (1b) Go サンプル

同様の処理を Go で実装してみました。

```go:sample1_go/main.go
package main

import (
	"log"
	"net/http"
	"strconv"
	"time"
)

func main() {
	http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
		rw.Write([]byte("golang-" + strconv.FormatInt(time.Now().UnixMilli(), 10)))
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

同じように実行します。

##### 実行
```sh
$ nerdctl run -it --rm -p 3002:3000 -v $(pwd)/sample1_go:/app golang bash

/go# cd /app
/app# go run main.go
```

##### アクセス結果
```sh
$ time curl http://127.0.0.1:3002/
golang-1685192370027
real    0m0.010s
user    0m0.000s
sys     0m0.009s

$ time curl http://127.0.0.1:3002/
golang-1685192382231
real    0m0.011s
user    0m0.005s
sys     0m0.005s
```

## (1c) Node.js サンプル

Node.js で実装してみました。

```javascript:sample1_node/index.js
const http = require('http')

const server = http.createServer((_req, res) => {
    res.end('nodejs-' + Date.now())
})

server.listen(3000)
```

こちらも同様に実行します。

##### 実行
```sh
$ nerdctl run -it --rm -p 3003:3000 -v $(pwd)/sample1_node:/app node bash

/# cd /app
/app# node index.js
```

##### アクセス結果
```sh
$ time curl http://127.0.0.1:3003/
nodejs-1685192755965
real    0m0.025s
user    0m0.011s
sys     0m0.001s

$ time curl http://127.0.0.1:3003/
nodejs-1685192769965
real    0m0.013s
user    0m0.005s
sys     0m0.006s
```

## 結果

結果をまとめると次のようになります。

|言語|利用ライブラリ|初回アクセス|2回目アクセス|
|---|---|---|---|
|C#|ASP.NET Core|0.081s|0.013s|
|Go||0.010s|0.011s|
|Node.js||0.025s|0.013s|

ASP.NET だけが、たまたま遅かった可能性もあるので数回試してみましたが、それでも初回アクセスは ```0.058 秒``` や ```0.065 秒``` で明らかに他とは見劣りする結果でした。

# 2. ASP.NET + Entity Framework の場合

次に、MySQL を検索するアプリケーションで試してみます。

## MySQL 実行

まずは、コンテナで MySQL を実行し、テーブル作成やデータ登録をしておきます。

```sql:db.sql
CREATE DATABASE sample;

USE sample;

CREATE TABLE items (
    id VARCHAR(10) NOT NULL,
    price INT NOT NULL,
    PRIMARY KEY (id)
);

INSERT INTO
    items(id, price)
VALUES
    ('item-1', 20),
    ('item-2', 68),
    ('item-3', 15),
    ('item-4', 43),
    ('item-5', 37);
```

検証に使うだけなので、パスワードは無しにします。

##### MySQL 実行
```sh
$ nerdctl run -d --name mysql1 -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
```

##### データ登録 （SQL ファイル実行）
```sh
$ nerdctl run -it --rm -v $(pwd):/work mysql sh -c "mysql -h mysql1 -u root -e 'source /work/db.sql'"
```

## (2a) ASP.NET Core + Entity Framework サンプル

MySQL で簡単な検索を実施するサンプルです。

```csharp:sample2_aspnet/Program.cs
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;

var dsn = "server=mysql1;database=sample;user=root";

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ItemContext>(opt => {    
    opt.UseMySQL(dsn);
});

var app = builder.Build();

app.MapPost("/find", (ItemContext db, FindItem input) => 
    db.Items.Where(r => r.Price >= input.Price).OrderBy(r => r.Price)
);

app.Run();


record FindItem(int Price);

[Table("items")]
public class Item
{
    [Column("id")]
    public required string Id { get; set; }
    
    [Column("price")]
    public int Price { get; set; }
}

public class ItemContext : DbContext
{
    public DbSet<Item> Items => Set<Item>();

    public ItemContext(DbContextOptions<ItemContext> opts) : base(opts) {}
}
```

```xml:sample2_aspnet/sample2.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="MySql.EntityFrameworkCore" Version="7.0.2" />
  </ItemGroup>
</Project>
```

先ほどと同じように実行します。

##### 実行
```sh
$ nerdctl run -it --rm -p 3001:3000 -v $(pwd)/sample2_aspnet:/app mcr.microsoft.com/dotnet/sdk:7.0 bash

/# cd /app
/app# dotnet build
/app# dotnet run
```

##### アクセス結果
```sh
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3001/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real    0m0.528s
user    0m0.004s
sys     0m0.007s

$ time curl -H "Content-Type: application/json" http://127.0.0.1:3001/find -d '{"price":20}'
[{"id":"item-1","price":20},{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real    0m0.034s
user    0m0.007s
sys     0m0.000s
```

Entity Framework を組み合わせる事で初回アクセスが非常に遅くなりました。

ちなみに、このようなシンプルなモデルでは ```dotnet ef dbcontext optimize``` によるコンパイル済みモデルを適用してもほんの少し改善する程度でした。

他にも ```EnableThreadSafetyChecks``` を false にするとか、[高度なパフォーマンスのトピック](https://learn.microsoft.com/ja-jp/ef/core/performance/advanced-performance-topics) 等に書かれているような事も試してみましたが、このサンプルでは微々たる効果しか得られないようでした。

### Entity Framework 初回処理のオーバーヘッド

次に、HostedService を使って起動時にダミーの検索を実施しておくと、どの程度変わるのか試してみました。

```csharp:sample2_aspnet/Program.cs 変更版
...省略

var builder = WebApplication.CreateBuilder(args);
// WarmUpService の適用
builder.Services.AddHostedService<WarmUpService>();

builder.Services.AddDbContext<ItemContext>(opt => {    
    opt.UseMySQL(dsn);
});

...省略

public class WarmUpService : IHostedService
{
    private readonly ItemContext _db;

    public WarmUpService(ItemContext db)
    {
        _db = db;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // ダミーの検索処理
        _db.Items.SingleOrDefault(r => r.Id == "");
        
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}
```

初回アクセスの結果は次のようになり、かなり改善しました。

##### 初回アクセス結果
```sh
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3001/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real    0m0.114s
user    0m0.000s
sys     0m0.009s
```

## (2b) Go サンプル

MySQL 接続に [Go-MySQL-Driver](https://github.com/go-sql-driver/mysql) を使いました。

```go:sample2_go/main.go
package main

import (
	"database/sql"
	"encoding/json"
	"log"
	"net/http"

	_ "github.com/go-sql-driver/mysql"
)

type Item struct {
	ID    string `json:"id"`
	Price int    `json:"price"`
}

type FindItem struct {
	Price int `json:"price"`
}

func main() {
	dsn := "root@tcp(mysql1)/sample"

	db, err := sql.Open("mysql", dsn)

	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	http.HandleFunc("/find", func(rw http.ResponseWriter, r *http.Request) {
		var input FindItem
		err := json.NewDecoder(r.Body).Decode(&input)

		if err != nil {
			rw.WriteHeader(http.StatusBadRequest)
		} else {
			rows, err := db.Query(
				"SELECT id, price FROM items WHERE price >= ? ORDER BY price",
				input.Price,
			)

			if err != nil {
				rw.WriteHeader(http.StatusInternalServerError)
				return
			}
			defer rows.Close()

			var (
				res   []Item
				id    string
				price int
			)

			for rows.Next() {
				err := rows.Scan(&id, &price)

				if err == nil {
					res = append(res, Item{id, price})
				}
			}

			json.NewEncoder(rw).Encode(res)
		}
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

実行結果は次の通りです。

##### 実行
```sh
$ nerdctl run -it --rm -p 3002:3000 -v $(pwd)/sample2_go:/app golang bash

/go# cd /app
/app# go run main.go
```

##### アクセス結果
```sh
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3002/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]

real    0m0.015s
user    0m0.005s
sys     0m0.006s

$ time curl -H "Content-Type: application/json" http://127.0.0.1:3002/find -d '{"price":20}'
[{"id":"item-1","price":20},{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]

real    0m0.014s
user    0m0.000s
sys     0m0.011s
```

## (2c) Node.js サンプル

[fastify](https://github.com/fastify/fastify) と [MariaDB Node.js connector](https://github.com/mariadb-corporation/mariadb-connector-nodejs) で実装してみました。

```javascript:sample2_node/index.js
const fastify = require('fastify')()
const mariadb = require('mariadb')

const pool = mariadb.createPool({ host: 'mysql1', user: 'root', database: 'sample' })

fastify.post('/find', async (req, _rep) => {
    const input = req.body

    const conn = await pool.getConnection()

    try {
        const rows = await conn.query(
            'SELECT id, price FROM items WHERE price >= ? ORDER BY price', 
            [ input.price ]
        )

        return Object.values(rows)

    } finally {
        conn.release()
    }
})

fastify
    .listen({ host: '0.0.0.0', port: 3000 })
    .catch(err => {
        fastify.log.error(err)
        process.exit(1)
    })
```

実行結果は次の通りです。

##### 実行
```sh
$ nerdctl run -it --rm -p 3003:3000 -v $(pwd)/sample2_node:/app node bash

/# cd /app
/app# npm install
/app# node index.js
```

##### アクセス結果
```sh
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3003/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real    0m0.017s
user    0m0.008s
sys     0m0.000s

$ time curl -H "Content-Type: application/json" http://127.0.0.1:3003/find -d '{"price":20}'
[{"id":"item-1","price":20},{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real    0m0.013s
user    0m0.000s
sys     0m0.010s
```

## 結果

結果をまとめると次のようになります。

|言語|利用ライブラリ|初回アクセス|2回目アクセス|
|---|---|---|---|
|C#|ASP.NET Core, Entity Framework Core|0.528s|0.034s|
|Go|Go-MySQL-Driver|0.015s|0.014s|
|Node.js|fastify, MariaDB Node.js connector|0.017s|0.013s|

更に、上記とは別のもっと低性能な環境（Ubuntu で podman 利用）で試してみたところ次のような結果となりました。

こちらでは、より顕著な差が出ています。

|言語|利用ライブラリ|初回アクセス|2回目アクセス|
|---|---|---|---|
|C#|ASP.NET Core, Entity Framework Core|2.302s|0.170s|
|Go|Go-MySQL-Driver|0.022s|0.019s|
|Node.js|fastify, MariaDB Node.js connector|0.047s|0.022s|

O-R マッパーというハンデがあるとはいえ、Go や Node.js に大きく見劣りする結果となりました。
特に初回アクセスの遅さは要注意だと思います。