ASP.NET Core 7でGraphQLを使うサーバークライアントサンプル

この記事は公開から1年以上経過しています。

ASP.NET Core 7とWPF(.NET 7)で.NETのGraphQLライブラリであるHot Chocolate v13Strawberry Shake v13を使う最小構成サンプル。

サーバーからクライアントへのプッシュ通知処理への対応と、複数のクエリ/ミューテーション/サブスクリプション登録の考慮も行っています。

ポート番号は環境によって変わる可能性があるので適宜合わせてください。
SSLを使う場合は通常のASP.NET Core開発同様に、ローカルのCA証明書のインストールが必要です。


動作イメージ

サーバーを起動すると1秒間隔でカウンタをインクリメントするサービスが起動します。

クライアントを起動するとサブスクリプションによって通知されたカウンタ値がリアルタイムで上段テキストボックスに反映されます。
Getボタンを押下で下段テキストボックスにクエリで取得した現在のカウンタ値を設定、Resetボタン押下でミューテーションによるカウンタ値のリセットを行います。


サンプルソース

サーバーサイド

ASP.NET Core 7(.NET 7)の空のプロジェクトをベースに作成しています。

簡略化のためシングルトンのリポジトリ部分が非スレッドセーフですのでご注意ください。

NuGet:

HotChocolate.AspNetCore

データ型定義:/Types/CounterData.cs

namespace Server.Types
{
    // カウンターデータ
    public record CounterData(int count);
}

クエリ定義:/Queries/CounterQuery.cs

using Server.Repositories;
using Server.Types;

namespace Server.Queries
{
    [ExtendObjectType(OperationTypeNames.Query)]
    public class CounterQuery
    {
        // カウンター取得
        public CounterData GetCounter([Service] ICounterRepository counterRepo)
        {
            var counter = counterRepo.Get();
            return new CounterData(counter);
        }
    }
}

ミューテーション定義:/Mutations/CounterMutation.cs

using HotChocolate.Subscriptions;
using Server.Repositories;
using Server.Subscriptions;
using Server.Types;

namespace Server.Mutations
{
    [ExtendObjectType(OperationTypeNames.Mutation)]
    public class CounterMutation
    {
        // カウンターリセット
        public async ValueTask<CounterData> ResetCounter([Service] ICounterRepository counterRepo, [Service] ITopicEventSender sender)
        {
            counterRepo.Reset();
            var counterData = new CounterData(0);

            // カウンター変更イベント発行
            await sender.SendAsync(
                nameof(CounterSubscription.OnCounterChanged), counterData);

            return counterData;
        }
    }
}

サブスクリプション:/Subscriptions/CounterSubscription.cs

using Server.Types;

namespace Server.Subscriptions
{
    [ExtendObjectType(OperationTypeNames.Subscription)]
    public class CounterSubscription
    {
        // カウンター変更イベント
        [Subscribe]
        public CounterData OnCounterChanged([EventMessage] CounterData data) => data;
    }
}

リポジトリ:/Repositories/CounterRepository.cs

namespace Server.Repositories
{
    // カウンタリポジトリI/F
    public interface ICounterRepository
    {
        // カウント取得
        int Get();

        // カウント加算
        void Increment();

        // カウント初期化
        void Reset();
    }

    // カウンタリポジトリ
    public class CounterRepository : ICounterRepository
    {
        private int _count = 0;

        // カウント取得
        public int Get() => _count;

        // カウント加算
        public void Increment() => _count += 1;

        // カウント初期化
        public void Reset() => _count = 0;
    }
}

サービス:/Services/CountUpService.cs

using HotChocolate.Subscriptions;
using Server.Repositories;
using Server.Subscriptions;
using Server.Types;

namespace Server.Services
{
    // カウントアップサービス
    public class CountUpService : BackgroundService
    {
        private ITopicEventSender _eventSender;

        private ICounterRepository _counterRepo;

        public CountUpService([Service] ITopicEventSender sender, ICounterRepository counterRepo)
        {
            _eventSender = sender;
            _counterRepo = counterRepo;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            // 1秒毎にカウント値を通知
            while (!stoppingToken.IsCancellationRequested)
            {
                var counter = _counterRepo.Get();

                await _eventSender.SendAsync(
                    nameof(CounterSubscription.OnCounterChanged),
                    new CounterData(counter));

                // 1秒待機
                await Task.Delay(1000);

                _counterRepo.Increment();
            }
        }
    }
}

メイン:/Program.cs

using Server.Mutations;
using Server.Queries;
using Server.Repositories;
using Server.Services;
using Server.Subscriptions;

namespace Server
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            RegisterGraphQLServices(builder);

            RegisterAdditionalServices(builder);

            var app = builder.Build();

            ConfigureMiddleware(app, builder);

            app.Run();
        }

        // GraphQL関連のサービスなどを登録
        private static void RegisterGraphQLServices(WebApplicationBuilder builder)
        {
            builder.Services
                .AddGraphQLServer()
                .AddQueryType()
                    .AddTypeExtension<CounterQuery>()
                .AddMutationType()
                    .AddTypeExtension<CounterMutation>()
                .AddSubscriptionType()
                    .AddTypeExtension<CounterSubscription>()
                .AddInMemorySubscriptions();
        }

        // その他(GraphQL以外)のサービスなどを登録
        private static void RegisterAdditionalServices(WebApplicationBuilder builder)
        {
            builder.Services
                .AddSingleton<ICounterRepository, CounterRepository>()
                .AddSingleton<CountUpService>()
                .AddHostedService<CountUpService>();
        }

        // ミドルウェア関連の設定
        private static void ConfigureMiddleware(WebApplication app, WebApplicationBuilder builder)
        {
            if (builder.Environment.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting()
                .UseWebSockets()
                .UseEndpoints(endpoints =>
                {
                    endpoints.MapGraphQL();
                });
        }
    }
}

クライアントサイド

WPF(.NET 7)をベースに作成しています。

簡略化のためPDSを考慮せずコードビハインドにコード記述していますのでご注意ください。

NuGet:

StrawberryShake.Server
StrawberryShake.Transport.WebSockets

dotnet tools

StrawberryShake.Tools

GraphQL設定:/GraphQL/.graphqlrc.json

graphql initで作成したGraphQL設定ファイルにnamespaceの行を手動で追加。

{
  "schema": "schema.graphql",
  "documents": "**/*.graphql",
  "extensions": {
    "strawberryShake": {
      "name": "Client",
      "namespace": "Client.GraphQL",
      "url": "https://localhost:7052/graphql",
      "records": {
        "inputs": false,
        "entities": false
      },
      "transportProfiles": [
        {
          "default": "Http",
          "subscription": "WebSocket"
        }
      ]
    }
  }
}

クエリ:/GraphQL/Queries/GetCounter.graphql

query GetCounter {
  counter {
    count
  }
}

ミューテーション:/GraphQL/Mutations/ResetCounter.graphql

mutation ResetCounter {
  resetCounter {
    count
  }
}

サブスクリプション:/GraphQL/Subscriptions/OnCounterChanged.graphql

subscription OnCounterChanged {
  onCounterChanged {
    count
  }
}

App.xaml

スタートアップ設定のMainWindowを削除しています。

<Application
    x:Class="Client.App"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:Client">
    <Application.Resources />
</Application>

App.xaml.cs

using System;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;

namespace Client
{
    public partial class App : Application
    {
        private const string HTTPS_ENDPOINT = "https://localhost:7052/graphql/";
        private const string WS_ENDPOINT = "wss://localhost:7052/graphql/";

        private IServiceProvider? _serviceProvider;

        protected override void OnStartup(StartupEventArgs e)
        {
            var sc = new ServiceCollection();

            sc
                .AddSingleton<MainWindow>()
                .AddClient()
                .ConfigureHttpClient(c => c.BaseAddress = new Uri(HTTPS_ENDPOINT))
                .ConfigureWebSocketClient(c => c.Uri = new Uri(WS_ENDPOINT));

            _serviceProvider = sc.BuildServiceProvider();

            var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();

            mainWindow.Show();

            base.OnStartup(e);
        }
    }
}

MainWindow.xaml

検証に必要なテキストボックスとボタンを適当に並べただけです。

<Window
    x:Class="Client.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:Client"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="MainWindow"
    Width="500"
    Height="250"
    WindowStartupLocation="CenterScreen"
    mc:Ignorable="d">
    <Grid>
        <Grid Width="320" Height="50">
            <Grid.RowDefinitions>
                <RowDefinition />
                <RowDefinition />
            </Grid.RowDefinitions>
            <TextBox
                x:Name="state"
                Grid.Row="0"
                VerticalAlignment="Center"
                VerticalContentAlignment="Center"
                Text="State"
                TextAlignment="Center" />
            <TextBox
                x:Name="get"
                Grid.Row="1"
                VerticalAlignment="Center"
                VerticalContentAlignment="Center"
                Text=""
                TextAlignment="Center" />

        </Grid>
        <Grid
            Width="120"
            Height="30"
            Margin="0,0,0,50"
            HorizontalAlignment="Center"
            VerticalAlignment="Bottom">
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>
            <Button
                x:Name="GetBtn"
                Grid.Column="0"
                Click="OnGetBtnClick"
                Content="Get" />
            <Button
                x:Name="SetBtn"
                Grid.Column="1"
                Click="OnSetBtnClick"
                Content="Reset" />
        </Grid>
    </Grid>
</Window>

using System;
using System.Windows;

using Client.GraphQL;
using StrawberryShake.Extensions;

namespace Client
{
    public partial class MainWindow : Window
    {
        private IClient _client;
        private IDisposable? _disSystemStateChanged;

        public MainWindow(IClient client)
        {
            InitializeComponent();

            // コードジェネレーターにより生成されたClientサービスインスタンスをDIで取得
            _client = client;

            // サブスクリプションイベントを購読
            _disSystemStateChanged = _client
                .OnCounterChanged
                .Watch()
                .Subscribe(r =>
                {
                    Dispatcher.Invoke(() =>
                    {
                        state.Text = r.Data?.OnCounterChanged.Count.ToString();
                    });
                });
        }

        protected override void OnClosed(EventArgs e)
        {
            _disSystemStateChanged?.Dispose();

            base.OnClosed(e);
        }

        private async void OnGetBtnClick(Object sender, EventArgs e)
        {
            var ret = await _client.GetCounter.ExecuteAsync();
            get.Text = ret.Data?.Counter.Count.ToString();
        }

        private async void OnSetBtnClick(Object sender, EventArgs e)
        {
            var ret = await _client.ResetCounter.ExecuteAsync();
        }
    }
}

業務のアーキテクチャ検討でHot Chocolate/Strawberry Shakeを用いてGraphQLを導入しようと考えたのですが、このライブラリにはサブスクリプションの通信断を検知する仕組みが存在しないようです。

クライアント側だけGraphQL.Netのような別ライブラリを使うか、WebSocketを使い監視するなどすればできないこともなさそうですが、折角サーバー/クライアントのスィートになっているので別々に使うのも少々微妙ですね…。
特にStrawberry Shakeはコードジェネレーターによって*.graphqlファイルからクライアントクラスが自動生成されるのが便利なので、悩ましいところです。

参考ウェブサイトなど

以上です。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする