👌

Azure OpenAI Service を使って VB から C# にコードを変換する

2024/05/10に公開

はじめに

よくある話ですが、VB から C# や、ちょっと古い言語から新しめの言語にコードを変換したいということがあります。また、その時に今流行りの OpenAI を使って一気に出来ないの?という話はよく聞くネタだと思います。

ということで、Azure OpenAI Service を使って VB から C# にコードを変換してみましょう。
まずは Azure OpenAI Service の画面から叩いてみて雰囲気を掴んでみましょう。

VB は正直書いたことは無いので文法間違ってるかもしれないですが、動きを見る限りいい感じに変換してくれそうです。

やってみたコード

実際に変換コードを書いてみました。ソースコードは GitHub に上げています。

https://github.com/runceel/ProgramingLanguageTranslatorSample/

動かすと C:\Temp\Source フォルダー配下にある VB のコードを C# に変換して C:\Temp\Target フォルダー配下に出力をしてくれます。
動かすためには Azure OpenAI Service に gpt-4 の JSON モードが使えるモデルを gpt-4 という名前でデプロイをして appsettings.json に以下のようにエンドポイントを設定してください。

appsettings.json
{
  "OpenAIClient": {
    "Endpoint": "https://リソース名.openai.azure.com/"
  }
}

そして Azure CLI でサインインしているアカウントに対して Cognitive Service OpenAI ユーザー のロールをリソースに対して付与してください。

このコードを使って eShopOnWeb in VB.NET (.NET Core 3.1) のコードを C# に変換してみました。
全部を載せると長いので一部だけ載せますが、こんな感じで変換されました。

Startup.vb がぱっと見一番長そうだったので、それを抜粋します。

Startup.vb(変換前)
Imports Ardalis.ListStartupServices
Imports MediatR
Imports Microsoft.AspNetCore.Builder
Imports Microsoft.AspNetCore.Diagnostics.HealthChecks
Imports Microsoft.AspNetCore.Hosting
Imports Microsoft.AspNetCore.Http
Imports Microsoft.AspNetCore.Identity
Imports Microsoft.AspNetCore.Mvc.ApplicationModels
Imports Microsoft.EntityFrameworkCore
Imports Microsoft.eShopWeb.ApplicationCore.Interfaces
Imports Microsoft.eShopWeb.ApplicationCore.Services
Imports Microsoft.eShopWeb.Infrastructure.Data
Imports Microsoft.eShopWeb.Infrastructure.Identity
Imports Microsoft.eShopWeb.Infrastructure.Logging
Imports Microsoft.eShopWeb.Infrastructure.Services
Imports Microsoft.eShopWeb.Web.Interfaces
Imports Microsoft.eShopWeb.Web.Services
Imports Microsoft.Extensions.Configuration
Imports Microsoft.Extensions.DependencyInjection
Imports Microsoft.Extensions.Diagnostics.HealthChecks
Imports Microsoft.Extensions.Hosting
Imports Newtonsoft.Json
Imports Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation
Imports System.Net.Mime
Imports Microsoft.eShopWeb.Web.ViewModels
Imports Microsoft.eShopWeb.ApplicationCore

Public Class Startup
    Private _services As IServiceCollection

    Public Sub New(ByVal configuration As IConfiguration)
        Me.Configuration = configuration
    End Sub

    Public ReadOnly Property Configuration As IConfiguration

    Public Sub ConfigureDevelopmentServices(ByVal services As IServiceCollection)
        ConfigureInMemoryDatabases(services)
    End Sub

    Private Sub ConfigureInMemoryDatabases(ByVal services As IServiceCollection)
        services.AddDbContext(Of CatalogContext)(Function(c) c.UseInMemoryDatabase("Catalog"))
        services.AddDbContext(Of AppIdentityDbContext)(Function(options) options.UseInMemoryDatabase("Identity"))
        ConfigureServices(services)
    End Sub

    Public Sub ConfigureProductionServices(ByVal services As IServiceCollection)
        services.AddDbContext(Of CatalogContext)(Function(c) c.UseSqlServer(Configuration.GetConnectionString("CatalogConnection")))
        services.AddDbContext(Of AppIdentityDbContext)(Function(options) options.UseSqlServer(Configuration.GetConnectionString("IdentityConnection")))
        ConfigureServices(services)
    End Sub

    Public Sub ConfigureTestingServices(ByVal services As IServiceCollection)
        ConfigureInMemoryDatabases(services)
    End Sub

    Public Sub ConfigureServices(ByVal services As IServiceCollection)
        ConfigureCookieSettings(services)
        CreateIdentityIfNotCreated(services)
        services.AddMediatR(GetType(BasketViewModelService).Assembly)
        services.AddScoped(GetType(IAsyncRepository(Of)), GetType(EfRepository(Of)))
        services.AddScoped(Of ICatalogViewModelService, CachedCatalogViewModelService)()
        services.AddScoped(Of IBasketService, BasketService)()
        services.AddScoped(Of IBasketViewModelService, BasketViewModelService)()
        services.AddScoped(Of IOrderService, OrderService)()
        services.AddScoped(Of IOrderRepository, OrderRepository)()
        services.AddScoped(Of CatalogViewModelService)()
        services.AddScoped(Of ICatalogItemViewModelService, CatalogItemViewModelService)()
        services.Configure(Of CatalogSettings)(Configuration)
        services.AddSingleton(Of IUriComposer)(New UriComposer(Configuration.[Get](Of CatalogSettings)()))
        services.AddScoped(GetType(IAppLogger(Of)), GetType(LoggerAdapter(Of)))
        services.AddTransient(Of IEmailSender, EmailSender)()
        services.AddMemoryCache()
        services.AddRouting(
                 Sub(options) options.ConstraintMap("slugify") = GetType(SlugifyParameterTransformer)
            )
        services.AddMvc(
                Sub(options) options.Conventions.Add(New RouteTokenTransformerConvention(New SlugifyParameterTransformer()))
          )
        services.AddRazorPages(
               Sub(options) options.Conventions.AuthorizePage("/Basket/Checkout")
            )
        services.AddControllersWithViews(). ' Enable Vazor
        AddRazorRuntimeCompilation(
             Sub(options) options.FileProviders.Add(New Vazor.VazorViewProvider())
        )
        services.AddHttpContextAccessor()
        services.AddSwaggerGen(Sub(c) c.SwaggerDoc("v1", New OpenApi.Models.OpenApiInfo With {
        .Title = "My API",
        .Version = "v1"
    }))
        services.AddHealthChecks()
        services.Configure(Of ServiceConfig)(Function(config)
                                                 config.Services = New List(Of ServiceDescriptor)(services)
                                                 config.Path = "/allservices"
                                             End Function)
        _services = services
    End Sub

    Private Shared Sub CreateIdentityIfNotCreated(ByVal services As IServiceCollection)
        Dim sp = services.BuildServiceProvider()

        Using scope = sp.CreateScope()
            Dim existingUserManager = scope.ServiceProvider.GetService(Of UserManager(Of ApplicationUser))()

            If existingUserManager Is Nothing Then
                services.AddIdentity(Of ApplicationUser, IdentityRole)().AddDefaultUI().AddEntityFrameworkStores(Of AppIdentityDbContext)().AddDefaultTokenProviders()
            End If
        End Using
    End Sub

    Private Shared Sub ConfigureCookieSettings(ByVal services As IServiceCollection)
        services.Configure(Of CookiePolicyOptions)(Function(options)
                                                       options.CheckConsentNeeded = Function(context) True
                                                       options.MinimumSameSitePolicy = SameSiteMode.None
                                                   End Function)
        services.ConfigureApplicationCookie(Function(options)
                                                options.Cookie.HttpOnly = True
                                                options.ExpireTimeSpan = TimeSpan.FromHours(1)
                                                options.LoginPath = "/Account/Login"
                                                options.LogoutPath = "/Account/Logout"
                                                options.Cookie = New CookieBuilder With {
                                                    .IsEssential = True
                                                }
                                            End Function)
    End Sub

    Public Sub Configure(ByVal app As IApplicationBuilder, ByVal env As IWebHostEnvironment)

        ZML.ZmlPages.Compile() ' Erase this statement when you fisnish converting all .zml files to vazor files
        Vazor.VazorSharedView.CreateAll()

        app.UseHealthChecks("/health", New HealthCheckOptions With {
            .ResponseWriter = Async Function(context, report)
                                  Dim result = JsonConvert.SerializeObject(New With {
                                               Key .status = report.Status.ToString(),
                                               Key .errors = report.Entries.[Select](Function(e) New With {
                                               Key .key = e.Key,
                                               Key .value = [Enum].GetName(GetType(HealthStatus), e.Value.Status)
                                          })
                                      })
                                  context.Response.ContentType = MediaTypeNames.Application.Json
                                  Await context.Response.WriteAsync(result)
                              End Function
        })

        If env.IsDevelopment() Then
            app.UseDeveloperExceptionPage()
            app.UseShowAllServicesMiddleware()
            app.UseDatabaseErrorPage()
        Else
            app.UseExceptionHandler("/Error")
            app.UseHsts()
        End If

        app.UseStaticFiles()
        app.UseRouting()
        app.UseHttpsRedirection()
        app.UseCookiePolicy()
        app.UseAuthentication()
        app.UseAuthorization()
        app.UseSwagger()
        app.UseSwaggerUI(Function(c)
                             c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1")
                         End Function)
        app.UseEndpoints(Function(endpoints)
                             endpoints.MapControllerRoute("default", "{controller:slugify=Home}/{action:slugify=Index}/{id?}")
                             endpoints.MapRazorPages()
                             endpoints.MapHealthChecks("home_page_health_check")
                             endpoints.MapHealthChecks("api_health_check")
                         End Function)
    End Sub
End Class

変換後のコードは以下のようになりました。コンパイルして動くところまでは確認していませんが、一応変換は出来ていそうです。

Startup.cs(変換後)
using Ardalis.ListStartupServices;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.EntityFrameworkCore;
using Microsoft.eShopWeb.ApplicationCore.Interfaces;
using Microsoft.eShopWeb.ApplicationCore.Services;
using Microsoft.eShopWeb.Infrastructure.Data;
using Microsoft.eShopWeb.Infrastructure.Identity;
using Microsoft.eShopWeb.Infrastructure.Logging;
using Microsoft.eShopWeb.Infrastructure.Services;
using Microsoft.eShopWeb.Web.Interfaces;
using Microsoft.eShopWeb.Web.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Newtonsoft.Json;
using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
using System.Net.Mime;
using Microsoft.eShopWeb.Web.ViewModels;
using Microsoft.eShopWeb.ApplicationCore;

public class Startup
{
    private IServiceCollection _services;

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

    public IConfiguration Configuration { get; }

    public void ConfigureDevelopmentServices(IServiceCollection services)
    {
        ConfigureInMemoryDatabases(services);
    }

    private void ConfigureInMemoryDatabases(IServiceCollection services)
    {
        services.AddDbContext<CatalogContext>(c => c.UseInMemoryDatabase("Catalog"));
        services.AddDbContext<AppIdentityDbContext>(options => options.UseInMemoryDatabase("Identity"));
        ConfigureServices(services);
    }

    public void ConfigureProductionServices(IServiceCollection services)
    {
        services.AddDbContext<CatalogContext>(c => c.UseSqlServer(Configuration.GetConnectionString("CatalogConnection")));
        services.AddDbContext<AppIdentityDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("IdentityConnection")));
        ConfigureServices(services);
    }

    public void ConfigureTestingServices(IServiceCollection services)
    {
        ConfigureInMemoryDatabases(services);
    }

    public void ConfigureServices(IServiceCollection services)
    {
        ConfigureCookieSettings(services);
        CreateIdentityIfNotCreated(services);
        services.AddMediatR(typeof(BasketViewModelService).Assembly);
        services.AddScoped(typeof(IAsyncRepository<>), typeof(EfRepository<>));
        services.AddScoped<ICatalogViewModelService, CachedCatalogViewModelService>();
        services.AddScoped<IBasketService, BasketService>();
        services.AddScoped<IBasketViewModelService, BasketViewModelService>();
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<CatalogViewModelService>();
        services.AddScoped<ICatalogItemViewModelService, CatalogItemViewModelService>();
        services.Configure<CatalogSettings>(Configuration);
        services.AddSingleton<IUriComposer>(new UriComposer(Configuration.Get<CatalogSettings>()));
        services.AddScoped(typeof(IAppLogger<>), typeof(LoggerAdapter<>));
        services.AddTransient<IEmailSender, EmailSender>();
        services.AddMemoryCache();
        services.AddRouting(options => options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));
        services.AddMvc(options => options.Conventions.Add(new RouteTokenTransformerConvention(new SlugifyParameterTransformer())));
        services.AddRazorPages(options => options.Conventions.AuthorizePage("/Basket/Checkout"));
        services.AddControllersWithViews()
            .AddRazorRuntimeCompilation(options => options.FileProviders.Add(new Vazor.VazorViewProvider()));
        services.AddHttpContextAccessor();
        services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApi.Models.OpenApiInfo { Title = "My API", Version = "v1" }));
        services.AddHealthChecks();
        services.Configure<ServiceConfig>(config =>
        {
            config.Services = new List<ServiceDescriptor>(services);
            config.Path = "/allservices";
        });
        _services = services;
    }

    private static void CreateIdentityIfNotCreated(IServiceCollection services)
    {
        var sp = services.BuildServiceProvider();

        using (var scope = sp.CreateScope())
        {
            var existingUserManager = scope.ServiceProvider.GetService<UserManager<ApplicationUser>>();

            if (existingUserManager == null)
            {
                services.AddIdentity<ApplicationUser, IdentityRole>().AddDefaultUI().AddEntityFrameworkStores<AppIdentityDbContext>().AddDefaultTokenProviders();
            }
        }
    }

    private static void ConfigureCookieSettings(IServiceCollection services)
    {
        services.Configure<CookiePolicyOptions>(options =>
        {
            options.CheckConsentNeeded = context => true;
            options.MinimumSameSitePolicy = SameSiteMode.None;
        });
        services.ConfigureApplicationCookie(options =>
        {
            options.Cookie.HttpOnly = true;
            options.ExpireTimeSpan = TimeSpan.FromHours(1);
            options.LoginPath = "/Account/Login";
            options.LogoutPath = "/Account/Logout";
            options.Cookie = new CookieBuilder { IsEssential = true };
        });
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        ZML.ZmlPages.Compile(); // Erase this statement when you finish converting all .zml files to vazor files
        Vazor.VazorSharedView.CreateAll();

        app.UseHealthChecks("/health", new HealthCheckOptions
        {
            ResponseWriter = async (context, report) =>
            {
                var result = JsonConvert.SerializeObject(new
                {
                    status = report.Status.ToString(),
                    errors = report.Entries.Select(e => new
                    {
                        key = e.Key,
                        value = Enum.GetName(typeof(HealthStatus), e.Value.Status)
                    })
                });
                context.Response.ContentType = MediaTypeNames.Application.Json;
                await context.Response.WriteAsync(result);
            }
        });

        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseShowAllServicesMiddleware();
            app.UseDatabaseErrorPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }

        app.UseStaticFiles();
        app.UseRouting();
        app.UseHttpsRedirection();
        app.UseCookiePolicy();
        app.UseAuthentication();
        app.UseAuthorization();
        app.UseSwagger();
        app.UseSwaggerUI(c =>
        {
            c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
        });
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute("default", "{controller:slugify=Home}/{action:slugify=Index}/{id?}");
            endpoints.MapRazorPages();
            endpoints.MapHealthChecks("home_page_health_check");
            endpoints.MapHealthChecks("api_health_check");
        });
    }
}

工夫したところ

OpenAI のモデルは一度に受け取れるトークン数に制限があるので、長いコードを一気に変換することは難しいです。そのため、長いコードを変換する場合は適当に分割して変換する必要があります。
文章の要約とかも同じ感じですね。

その際に単純に 100 行とかで分割して、その単位で変換を行うと以下のようにうまくいかないことがありました。例えば Startup.vb の 100 行目は以下のようにメソッド定義をしている行で終わります。

    Private Shared Sub CreateIdentityIfNotCreated(ByVal services As IServiceCollection) ' ここが 100 行目!
        Dim sp = services.BuildServiceProvider()

なので、100 行目まで C# に変換して 101 行目から先を C# に変換して結合すると以下のような感じに { が抜けてしまいました…。

    private static void CreateIdentityIfNotCreated(IServiceCollection services) // ここが 100 行目!
        var sp = services.BuildServiceProvider(); // あれ!?メソッド開始の括弧がないよ!?

という事なので、以下のような方針で変換を行いました。

ある一定行数でソースコードを分割をして変換を依頼する際に以下の付帯情報も一緒に追加した上で変換を行うようにした。

  • 変換対象のソースコードの塊
  • 変換対象の前のソースコードの塊
  • 変換対象の後ろのソースコードの塊
  • 変換対象の前のソースコードの塊を C# に変換した結果

具体的には以下のような JSON 形式で渡しています。

{
  "fileName": "Startup.vb",
  "prevChunkConverted": [
    "using System;",
    "using System.Collections.Generic;",
    "using System.Linq;"
  ],
  "prevChunk": [
    "Imports System",
    "Imports System.Collections.Generic",
    "Imports System.Linq"
  ],
  "currentChunk": [
    "",
    "Console.WriteLine(\"Hello, World!\")"
    ""
  ],
  "nextChunk": [
    "Sub Class Hoge"
    "",
    "End Class"
  ]
}

出力は JSON モードを使って以下のような JSON で出力するように依頼しています。

{
  "prevChunk": [
    "using System;",
    "using System.Collections.Generic;",
    "using System.Linq;"
  ],
  "currentChunk": [
    "",
    "Console.WriteLine(\"Hello, World!\");"
    ""
  ],
  "nextChunk": [
    "class Hoge"
    "{",
    "}"
  ]
}

この JSON から currentChunk の部分だけ抜き取ってファイルに保存をしています。こうすることで前後のコードの流れをくみ取って変換を行うので { の抜けとかが起きにくくなりました。

ただ、それでも抜けることがあったので以下のような内容を注意事項としてプロンプトに追加をしています。

- VB は C# と異なりクラスやメソッドの開始や終了が `{``}` で括られていません。`Sub`, `Class`, `Function` などのキーワードで関数が始まり、`End Sub`, `End Class`, `End Function` などのキーワードで終わります。そのため括弧の開始忘れや閉じ括弧忘れが無いように細心の注意を払ってください。
- VB はクラスやメソッドの開始や終了が End Sub や End Function で終わる。そのため C# に変換する際には { と } で囲む必要がある。メソッド開始時点の { は忘れがちなので "prevChunk" や "nextChunk" や "prevChunkConverted" のソースコードを参考にして忘れないように変換してください。特に変換後の "currentChunk" と "prevChunkConverted" を結合したときに { の抜けが無いかどうかという観点で確認をしてください。
- "nextChunk" が空の配列の場合はファイルの終端になります。そのためクラスや名前空間の終了の閉じ括弧が必要になります。

ここまでやることで、なんとか VB をそのまま C# に変換するといったところはうまく動くような雰囲気になりました。多分、コードによってはまだ上手く出来ないところがあると思うので、そこは都度都度プロンプトの調整が待ってると思います。

最後に実際に OpenAI に投げられているプロンプトの例を以下に示します。このプロンプトの message タグが Chat Completion API の 1 つのメッセージに相当します。

    <message role="system">
    ### あなたへの指示
    プログラムのソースコードを別のプログラミング言語に変換するプロフェッショナルとして行動してください。
    変換元の言語は "Visual Basic (VB)" です。
    変換先の言語は "C#" です。
    変換元のプログラミング言語のソースコードは以下のような JSON 形式で提供されます。
    
    ```json:渡されるプログラミング言語のソースコードの JSON スキーマ
    {
      '$schema': 'http://json-schema.org/draft-04/schema#',
      'title': 'AIInput',
      'type': 'object',
      'additionalProperties': false,
      'properties': {
        'fileName': {
          'type': 'string',
          'description': '変換処理中のファイル名'
        },
        'prevChunkConverted': {
          'type': 'array',
          'description': '変換対象の手前の VB のソースコードを C# に変換したもの',
          'items': {
            'type': 'string'
          }
        },
        'prevChunk': {
          'type': 'array',
          'description': '変換対象の手前の VB のソースコード',
          'items': {
            'type': 'string'
          }
        },
        'currentChunk': {
          'type': 'array',
          'description': '変換対象の VB のソースコード',
          'items': {
            'type': 'string'
          }
        },
        'nextChunk': {
          'type': 'array',
          'description': '変換対象の後続の VB のソースコード',
          'items': {
            'type': 'string'
          }
        }
      }
    }
    ```
    
    ソースコードは "prevChunk" と "currentChunk" と "nextChunk" に分割されていますが、実際には連続したソースコードになります。
    "prevChunk" と "currentChunk" と "nextChunk" のソースコードを結合して変換してください。
    変換の際に "prevChunk" の部分の変換結果のソースコードの "prevChunkConverted" に続くように変換してください。
    変換結果は以下のような JSON 形式で提供してください。
    
    ```json:出力結果の JSON スキーマ
    {
      '$schema': 'http://json-schema.org/draft-04/schema#',
      'title': 'AIOutput',
      'type': 'object',
      'additionalProperties': false,
      'properties': {
        'prevChunk': {
          'type': 'array',
          'description': '変換後の手前の C# のソースコード',
          'items': {
            'type': 'string'
          }
        },
        'currentChunk': {
          'type': 'array',
          'description': '変換後の C# のソースコード',
          'items': {
            'type': 'string'
          }
        },
        'nextChunk': {
          'type': 'array',
          'description': '変換後の後続の C# のソースコード',
          'items': {
            'type': 'string'
          }
        }
      }
    }
    ```
    
    ### 変換の際の注意点
    - VB は C# と異なりクラスやメソッドの開始や終了が `{` と `}` で括られていません。`Sub`, `Class`, `Function` などのキーワードで関数が始まり、`End Sub`, `End Class`, `End Function` などのキーワードで終わります。そのため括弧の開始忘れや閉じ括弧忘れが無いように細心の注意を払ってください。
    - VB はクラスやメソッドの開始や終了が End Sub や End Function で終わる。そのため C# に変換する際には { と } で囲む必要がある。メソッド開始時点の { は忘れがちなので 'prevChunk' や 'nextChunk' や 'prevChunkConverted' のソースコードを参考にして忘れないように変換してください。特に変換後の 'currentChunk' と 'prevChunkConverted' を結合したときに { の抜けが無いかどうかという観点で確認をしてください。
    - 'nextChunk' が空の配列の場合はファイルの終端になります。そのためクラスや名前空間の終了の閉じ括弧が必要になります。
    </message>
    <message role="user">
    {
      'fileName': 'CatalogSettings.vb',
      'prevChunkConverted': [
        'using System;',
        ''
      ],
      'prevChunk': [
        'Option Explicit On',
        'Option Infer On',
        'Option Strict On'
      ],
      'currentChunk': [
        '',
        'Public Class CatalogSettings',
        '    Public Property CatalogBaseUrl As String'
      ],
      'nextChunk': [
        'End Class'
      ]
    }
    </message>

まとめ

とりあえず、やってみて動くたたき台が出来たので、これをベースにもうちょっと使いやすくしたり、コンテキストを足せるようにしたりカスタマイズしていこうと思います。

Microsoft (有志)

Discussion