試験的にC#(WPF) と Go を GRPC を用いて連携させる

最近、趣味のコーディングは Go を使ってやることが多かったりして、Goでなんでも書きたい欲が強くなるんだけども、やはりGUIアプリを作成するときにはWindowsならC#一択になる傾向があるのが、私の最近なのですが、C#で記述する部分をできるだけなくして、それ以外をGoで書けないかなぁと模索している中で、MVVMのM(Model)の部分をGoで記述して、V-VMの部分のみをWPFで記述し、間をプロセス間通信でどうにかすれば、WebアプリのJavascriptとバックエンドの関係のように記述できないかなぁ?と思ったりしています。

なので、今回はプロセス間通信にGRPCを使って、GUI部分をC#で、ロジック部分をGoでコーディングしたらどうなるか?という試験的なアプリを作ってみました。

アプリを雑なイメージにするとこんな感じです。

f:id:bamch0h:20190924231444p:plain

C#側をGRPCのクライアント、Go側をサーバーにします。アプリ側にはクライアントを2つ用意しておきます。GRPCの通信インターフェースには SayHello()StreamMessage() があり、SayHello()で任意の文字列を送ると、送った文字列の頭にHello を返して送り返してきます。StreamMessage() はストリームで繋がっているインターフェースで、このインターフェースを使って、SayHello() されたときの返信文字列を別のチャンネルにも流しています。例えば、C#側にはクライアントCH1とCH2があります。CH1のSayHello()を呼び出すと、CH2のStreamMessage()と経由して、CH1のメッセージが届きます。逆に、CH2のSayHello()を呼び出すと、CH1のStreamMessage()を経由してCH2のメッセージが届きます。

proto ファイルは以下のようになっています。

// Copyright 2015 gRPC authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";
option objc_class_prefix = "HLW";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}

  rpc StreamMessage (OpenRequest) returns (stream Message) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
  string message = 2;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

message OpenRequest {
  string name = 1;
}

message Message {
    string text = 1;
}

Go側の実装はこんな感じ。

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "net"

    pb "./helloworld"
    "google.golang.org/grpc"
)

const (
    address = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
    Streams  map[string]pb.Greeter_StreamMessageServer
    Channels map[string]chan string
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    log.Printf("Received: %v, %v", in.GetName(), in.GetMessage())

    if _, ok := s.Streams[in.GetName()]; !ok {
        return nil, errors.New("stream does not open")
    }

    if ch, ok := s.Channels[in.GetName()]; ok {
        ch <- "Hello " + in.GetMessage()
    }

    return &pb.HelloReply{Message: "Hello " + in.GetMessage()}, nil
}

func (s *server) StreamMessage(in *pb.OpenRequest, stream pb.Greeter_StreamMessageServer) error {
    if _, ok := s.Streams[in.GetName()]; !ok {
        if s.Streams == nil {
            s.Streams = make(map[string]pb.Greeter_StreamMessageServer)
        }
        s.Streams[in.GetName()] = stream

        if s.Channels == nil {
            s.Channels = make(map[string]chan string)
        }
        s.Channels[in.GetName()] = make(chan string, 1)
    }

    log.Println(in.GetName())

    for {
        greeting := <-s.Channels[in.GetName()]
        for ch, stream := range s.Streams {
            if ch == in.GetName() {
                continue
            }

            msg := pb.Message{
                Text: greeting,
            }
            if err := stream.Send(&msg); err != nil {
                log.Println(err)
                return err
            }
        }
    }
    return nil
}

func main() {
    lis, err := net.Listen("tcp", fmt.Sprintf("%s", address))
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    s := grpc.NewServer()
    pb.RegisterGreeterServer(s, &server{})

    fmt.Println("start server")

    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

C#側の実装はこんな感じ。

using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Grpc.Core;
using Helloworld;

namespace WpfApp9
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            this.DataContext = new MainWindowVM();
        }

        private void Tb1_TextChanged(object sender, TextChangedEventArgs e)
        {
            var vm = (MainWindowVM)this.DataContext;
            var tb = (TextBox)sender;
            vm.SayHello("ch1", tb.Text);
        }

        private void Tb2_TextChanged(object sender, TextChangedEventArgs e)
        {
            var vm = (MainWindowVM)this.DataContext;
            var tb = (TextBox)sender;
            vm.SayHello("ch2", tb.Text);
        }
    }

    public class MainWindowVM : INotifyPropertyChanged
    {
        #region Const
        const string CH1_NAME = "ch1";
        const string CH2_NAME = "ch2";
        #endregion

        #region Fields
        Channel channel1;
        Channel channel2;
        Greeter.GreeterClient greeter1;
        Greeter.GreeterClient greeter2;
        string _ch1_greeting;
        string _ch2_greeting;
        string _ch1_subscription;
        string _ch2_subscription;
        #endregion

        #region Events
        public event PropertyChangedEventHandler PropertyChanged;
        #endregion


        #region Constructor
        public MainWindowVM()
        {
            _ch1_greeting = "";
            _ch1_subscription = "";
            channel1 = new Channel("127.0.0.1:50051", ChannelCredentials.Insecure);
            greeter1 = InitChannel(channel1, CH1_NAME, new Action<string>((text) => Channel1Subscription = text));

            _ch2_greeting = "";
            _ch2_subscription = "";
            channel2 = new Channel("127.0.0.1:50051", ChannelCredentials.Insecure);
            greeter2 = InitChannel(channel2, CH2_NAME, new Action<string>((text) => Channel2Subscription = text));
        }
        #endregion

        #region Properties
        public string Channel1Greeting
        {
            get
            {
                return _ch1_greeting;
            }

            set
            {
                if (_ch1_greeting == value)
                    return;

                _ch1_greeting = value;
                NotifyPropertyChanged();
            }
        }

        public string Channel1Subscription
        {
            get
            {
                return _ch1_subscription;
            }

            set
            {
                if (_ch1_subscription == value)
                    return;

                _ch1_subscription = value;
                NotifyPropertyChanged();
            }
        }

        public string Channel2Greeting
        {
            get
            {
                return _ch2_greeting;
            }

            set
            {
                if (_ch2_greeting == value)
                    return;

                _ch2_greeting = value;
                NotifyPropertyChanged();
            }
        }

        public string Channel2Subscription
        {
            get
            {
                return _ch2_subscription;
            }

            set
            {
                if (_ch2_subscription == value)
                    return;

                _ch2_subscription = value;
                NotifyPropertyChanged();
            }
        }
        #endregion

        #region Method
        public void SayHello(string ch, string message)
        {
            HelloReply reply;
            switch(ch)
            {
                case CH1_NAME:
                    reply = greeter1.SayHello(new HelloRequest { Name = CH1_NAME, Message = message});
                    Channel1Greeting = string.Format("Greeting: {0}", reply.Message);
                    break;
                case CH2_NAME:
                    reply = greeter2.SayHello(new HelloRequest { Name = CH2_NAME, Message = message});
                    Channel2Greeting = string.Format("Greeting: {0}", reply.Message);
                    break;
            }
        }

        private void NotifyPropertyChanged([CallerMemberName] String propertyName = "")
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        private Greeter.GreeterClient InitChannel(Channel ch, string name, Action<string> act)
        {
            var g = new Greeter.GreeterClient(ch);

            Task.Run(async () =>
            {
                using (var stream = g.StreamMessage(new OpenRequest() { Name = name }))
                {
                    try
                    {
                        while (await stream.ResponseStream.MoveNext())
                        {
                            var message = stream.ResponseStream.Current;
                            act(message.Text);
                        }
                    }
                    catch (InvalidOperationException ex)
                    {
                        Console.WriteLine(ex.Message);
                    }
                }
            });

            return g;
        }
        #endregion
    }

    public class SayHelloCommand : ICommand
    {
        private static readonly Action EmptyExecute = () => { };
        private static readonly Func<bool> EmptyCanExecute = () => true;
        private Action execute;
        private Func<bool> canExecute;

        public SayHelloCommand(Action execute, Func<bool> canExecute)
        {
            this.execute = execute ?? EmptyExecute;
            this.canExecute = canExecute ?? EmptyCanExecute;
        }

        public void Execute()
        {
            this.execute();
        }

        public bool CanExecute()
        {
            return this.canExecute();
        }

        bool ICommand.CanExecute(object parameter)
        {
            return this.CanExecute();
        }

        public event EventHandler CanExecuteChanged;
        public void RaiseCanExecuteChanged()
        {
            this.CanExecuteChanged?.Invoke(this, EventArgs.Empty);
        }

        void ICommand.Execute(object parameter)
        {
            this.Execute();
        }
    }
}

XAMLはこんな感じ。

<Window x:Class="WpfApp9.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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp9"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel>
        <TextBlock Text="Channel1" />
        <DockPanel>
            <TextBlock Text="Text Box" Margin="0,0,10,5" />
            <TextBox TextChanged="Tb1_TextChanged" />
        </DockPanel>
        <DockPanel>
            <TextBlock Text="Greeting itself" Margin="0,0,10,5" />
            <TextBlock Text="{Binding Channel1Greeting}" Background="AliceBlue"/>
        </DockPanel>
        <DockPanel>
            <TextBlock Text="Greeting from other" Margin="0,0,10,5" />
            <TextBlock Text="{Binding Channel1Subscription}" Background="#FF8D9AC7" />
        </DockPanel>
        <Border Margin="10"/>

        <TextBlock Text="Channel2" />
        <DockPanel>
            <TextBlock Text="Text Box" Margin="0,0,10,5" />
            <TextBox TextChanged="Tb2_TextChanged" />
        </DockPanel>
        <DockPanel>
            <TextBlock Text="Greeting itself" Margin="0,0,10,5" />
            <TextBlock Text="{Binding Channel2Greeting}" Background="AliceBlue"/>
        </DockPanel>
        <DockPanel>
            <TextBlock Text="Greeting from other" Margin="0,0,10,5" />
            <TextBlock Text="{Binding Channel2Subscription}"  Background="#FF8D9AC7"/>
        </DockPanel>
    </StackPanel>
</Window>

protoファイルのビルド

Go編

protoファイルがある場所で以下のコマンドを実行。helloworld のフォルダが既にあることが前提。

proto -I . helloworld.proto --go_out=plugins:grpc:helloworld

C#

  1. クラスライブラリ(.Net Standard) のプロジェクトを作成
  2. NuGetで、GrpcGrpc.ToolsGoogle.Protobuf をインストール
  3. proto ファイルをプロジェクトに追加
  4. csproj に以下の行を追加 (PackageReferenceのItemGroupとは別に作成する)
  <ItemGroup>
    <Protobuf Include="helloworld.proto" Link="helloworld.proto" />
  </ItemGroup>
  1. ビルドを実行すると、dllが作成される。

NOTE

.Net Framework を使ってWPFアプリを作成する場合は、上記で作成したクラスライブラリのほかに、GrpcGoogle.Protobuf をパッケージに追加する必要があるので、NuGetを使ってインストールしておくこと。.Net Standard の場合は クラスライブラリの中のパッケージを使用するようで、新たに追加する必要はなかった。

最終動作イメージ

以下のような動作となる。意外とシームレス?

f:id:bamch0h:20190924234054g:plain