ASP.NET Core で GraphQL API

GraphQL API が必要になったので、ASP.NET Core で実装できるか試してみた。ライブラリは現状 GraphQL 一択。

www.nuget.org

GraphiQL っていう、GraphQL を試すのに便利な Web UI があるので、それも利用した。ASP.NET Core のミドルウェアを使えば組み込むのは簡単。

www.nuget.org

必要になったのはクエリだけで、ミューテーションはまだ。なので今回は、ASP.NET Core MVC で GraphQL のエンドポイントを用意し、Entity Framework Core と組み合わせてデータベースに保存されたデータを取得するところまでをやってみた。

using GraphiQl;
using GraphQL;
using GraphQL.Types;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace HelloGraphQL
{
    //============================================
    // 1. Entity Framework Core で使うクラスを定義
    //============================================

    public class Team
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public IList<Player> Players { get; set; }
    }

    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Position { get; set; }
        public int Number { get; set; }
        public int TeamId { get; set; }

        public Team Team { get; set; }
    }

    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        public DbSet<Team> Teams => Set<Team>();

        public DbSet<Player> Players => Set<Player>();

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Team>(e =>
            {
                e.Property(x => x.Id).IsRequired();
                e.Property(x => x.Name).IsRequired();
                e.HasKey(x => x.Id);
                e.HasMany(x => x.Players);
            });
            modelBuilder.Entity<Player>(e =>
            {
                e.Property(x => x.Id).IsRequired();
                e.Property(x => x.Name).IsRequired();
                e.Property(x => x.Number).IsRequired();
                e.Property(x => x.Position).IsRequired();
                e.Property(x => x.TeamId).IsRequired();
                e.HasKey(x => x.Id);
                e.HasOne(x => x.Team);
            });
        }

        // テスト用のデータを登録する
        public void EnsureSeedData()
        {
            if (!Teams.Any())
            {
                Teams.Add(new Team
                {
                    Name = "バルセロナ",
                    Players = new List<Player>
                    {
                        new Player
                        {
                            Name = "メッシ",
                            Number = 10,
                            Position = "FW",
                        },
                        new Player
                        {
                            Name = "スアレス",
                            Number = 9,
                            Position = "FW",
                        },
                    },
                });
                Teams.Add(new Team
                {
                    Name = "レアルマドリード",
                    Players = new List<Player>
                    {
                        new Player
                        {
                            Name = "ベイル",
                            Number = 11,
                            Position = "FW",
                        },
                        new Player
                        {
                            Name = "モドリッチ",
                            Number = 10,
                            Position = "MF",
                        },
                    },
                });

                SaveChanges();
            }
        }
    }

    //==============================
    // 2. GraphQL で使うクラスを定義
    //==============================

    // Player に対応する GraphQL の型
    public class PlayerType : ObjectGraphType<Player>
    {
        public PlayerType()
        {
            Field(x => x.Id);
            Field(x => x.Name);
            Field(x => x.Position);
            Field(x => x.Number);
            Field(x => x.TeamId);
        }
    }

    // Team に対応する GraphQL の型
    public class TeamType : ObjectGraphType<Team>
    {
        public TeamType()
        {
            Field(x => x.Id);
            Field(x => x.Name);
            Field<ListGraphType<PlayerType>>("players");
        }
    }

    // クエリを定義
    public class HelloGraphQLQuery : ObjectGraphType
    {
        public HelloGraphQLQuery(ApplicationDbContext context)
        {
            Field<ListGraphType<TeamType>>(
                "teams",
                arguments: new QueryArguments(
                    new QueryArgument<IntGraphType>
                    {
                        Name = "id",
                    }),
                resolve: ctx =>
                {
                    var query = context.Teams.AsQueryable();
                    if (ctx.HasArgument("id"))
                    {
                        var id = ctx.GetArgument<int>("id");
                        query = query.Where(b => b.Id == id);
                    }
                    var teams = query.Include(b => b.Players)
                        .ToList();
                    return teams;
                });
        }
    }

    // GraphQL のスキーマを定義
    public class HelloGraphQLSchema : Schema
    {
        public HelloGraphQLSchema(IDependencyResolver dependencyResolver)
            : base(dependencyResolver)
        {
            // 今回はクエリだけ。ミューテーションは後で。
            Query = dependencyResolver.Resolve<HelloGraphQLQuery>();
        }
    }

    //====================================================
    // 3. ASP.NET Core MVC で GraphQL エンドポイントを実装
    //====================================================

    // クライアントから送信されてきた GraphQL をバインドするモデル
    public class GraphQLModel
    {
        public string OperationName { get; set; }

        public string Query { get; set; }
    }

    [Route("graphql")]
    [ApiController]
    public class GraphQLController : ControllerBase
    {
        readonly IDocumentExecuter documentExecuter;

        readonly ISchema schema;

        public GraphQLController(IDocumentExecuter documentExecuter, ISchema schema)
        {
            this.documentExecuter = documentExecuter;
            this.schema = schema;
        }

        [HttpPost]
        public async Task<IActionResult> Post([FromBody] GraphQLModel model)
        {
            var result = await documentExecuter.ExecuteAsync(new ExecutionOptions
            {
                Schema = schema,
                OperationName = model.OperationName,
                Query = model.Query
            });
            return Ok(result);
        }
    }

    //==============================================================
    // 4. いつものように ASP.NET Core 用の Startup と Program を作成
    //==============================================================

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            //--------------------
            // ここから GraphQL 用
            //--------------------

            services.AddTransient<IDocumentExecuter, DocumentExecuter>();
            services.AddTransient<ISchema, HelloGraphQLSchema>();
            services.AddTransient<HelloGraphQLQuery>();

            // IDependencyResolver が使うために GraphQL 用のクラスを
            // DI コンテナに登録しておく必要があるみたい。
            services.AddTransient<TeamType>();
            services.AddTransient<PlayerType>();
            var sp = services.BuildServiceProvider();
            services.AddSingleton<IDependencyResolver>(x => new FuncDependencyResolver(sp.GetService));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ApplicationDbContext context)
        {
            // GraphiQL をホストして手軽に GraphQL を試せるようにする
            app.UseGraphiQl();

            app.UseMvc();

            // テスト用データベースが無ければ作る
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();
            context.EnsureSeedData();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>();
    }
}

デバッグ実行して /graphql に Web ブラウザでアクセスすると、GraphiQL の UI が表示される。全チーム取得するクエリを実行してみると、ちゃんとデータベースからデータを引っ張ってこれた。余談だけど、GraphiQL は補完が効いて、これを使わないなんてとんでもない、ってレベル。

f:id:griefworker:20181101152856p:plain

次に、指定した id に該当するチームだけ取得するクエリを実行してみると、こちらも上手く動いた。

f:id:griefworker:20181101152916p:plain

クエリの解析は GraphQL.NET がやってくれるけど、データベースからデータを引っ張ってくるところは自分で書く必要があって、なかなかツラミがあった。

あと、今回みたいな簡単なクエリを実装するだけでも結構苦労した。GraphQL 自体の仕様だけでなく、ライブラリの使い方も知る必要があったし、何より、REST API と GraphQL API では考え方が違いすぎて、切り替えるのが大変。GraphQL 脳にならないといけないな。

GraphQL API だと URL を考える必要は無くなったけど、どんなクエリやミューテーションを提供するか考える必要があるので、API 提供側の苦労はさほど変わらない印象だ。