lelelemon’s blog

カメの歩みでのんびり学んでいます。

【React】簡易的なWebチャットアプリを作成

チャットアプリのようなものを作ってみたいと思い、React で簡易的なWebチャットアプリを作成してみました。

 

ソースコードの全量は以下です。

 

 

作成したアプリ

動画キャプチャ↓

 

 

システム構成

以下の機能を持つアプリを作成しました。

ホーム画面
  • メッセージの送信元と送信先のユーザーをそれぞれ選択
  • 「チャットルームへ」のリンクをクリックすることでチャットルーム画面に遷移する
チャットルーム画面
  • 画面を2分割し、ホーム画面で選択した送信元と送信先のユーザーそれぞれのメッセージエリアを表示
  • それぞれのユーザーはメッセージの送信が可能
  • 送信したメッセージは自身のメッセージエリアに追記されていく

 

主な使用技術

フロントエンド
  • Node.js (v16.20.2)
  • React (v18.2.0)
  • TypeScript (v4.9.5)
  • Apollo Client (v3.9.5)
  • tailwindcss 
バックエンド

実装について

バックエンド

バックエンドは golang で GraphQL サーバーを構築し、メッセージの送受信を行うようにしました。

メッセージが投稿されたら瞬時に画面に反映するにはどうすればよいか考えた時に、GraphQL の Subscription でメッセージの受信を監視すればよいのではないかと思い、試してみました。

 

なお、golang で GraphQL の作成は下記とても詳しく書いてくださっていました。

こちらを大変参考にさせていただきました。

 

www.ohitori.fun

 

スキーマ定義
schema.graphqls

# GraphQL schema example
#
# https://gqlgen.com/getting-started/

type Message {
  id: ID!
  text: String!
  createdAt: String!
  userId: ID!
}

type User {
  id: ID!
  name: String!
  createdAt: String!
  deletedAt: String!
}

type Query {
  messages: [Message!]!
}

input NewMessage {
  text: String!
  userId: ID!
}

type Mutation {
  postMessage(input: NewMessage!): Message!
}

type Subscription {
  messagePosted(userId: ID!): Message!
}

 

各種スキーマを定義しています。

  • Message: やり取りされるチャットメッセージ
  • User: ユーザー情報
  • Mutation: メッセージの投稿処理、NewMessage 型のメッセージ内容を受け付ける処理を実装していきます。
  • Subscription: メッセージの購読処理、指定のユーザーIDについて投稿されたメッセージを購読する処理を実装していきます。

※ Query はスキーマ定義していますが、今回は使っていません

リゾルバー
schema.resolvers.go
package graph

// This file will be automatically regenerated based
// on the schema, any resolver implementations
// will be copied through when generating and any unknown code
// will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.44

import (
    "backend/graph/model"
    "context"
    "fmt"
    "log"
    "time"

    "github.com/segmentio/ksuid"
)

// PostMessage is the resolver for the postMessage field.
func (r *mutationResolver) PostMessage(ctx context.Context,
input model.NewMessage) (*model.Message, error) {
    message := &model.Message{
        ID:        ksuid.New().String(),
        CreatedAt: time.Now().Format(time.RFC3339),
        UserID:    input.UserID,
        Text:      input.Text,
    }

    // 投稿されたメッセージを保存し、subscribeしている全てのコネクションに
//ブロードキャスト
    r.mutex.Lock()
    r.messages = append(r.messages, message)
    for _, ch := range r.subscribers {
        ch <- message
    }
    r.mutex.Unlock()

    return message, nil
}

// Messages is the resolver for the messages field.
func (r *queryResolver) Messages(ctx context.Context) (*model.Message, error) {
    return r.messages, nil
}

// MessagePosted is the resolver for the messagePosted field.
func (r *subscriptionResolver) MessagePosted(ctx context.Context, userID string)
(<-chan *model.Message, error) {
    r.mutex.Lock()
    defer r.mutex.Unlock()

    // すでにサブスクライブされているかチェック
    if _, ok := r.subscribers[userID]; ok {
        err := fmt.Errorf("`%s` has already been subscribed", userID)
        log.Print(err.Error())
        return nil, err
    }

    // チャンネルを作成し、リストに登録
    ch := make(chan *model.Message, 1)
    r.subscribers[userID] = ch

    log.Printf("`%s` has been subscribed!", userID)

    // コネクションが終了したら、このチャンネルを削除する
    go func() {
        <-ctx.Done()
        r.mutex.Lock()
        defer r.mutex.Unlock()

        delete(r.subscribers, userID)

        close(ch)
        log.Printf("`%s` has been unsubscribed.", userID)
    }()

    // このチャンネルが利用するメッセージを選択するためのフィルタリング
    filteredCh := make(chan *model.Message)

    go func() {
        for msg := range ch {
            // メッセージのユーザーIDとサブスクライバーのユーザーIDを比較
            if msg.UserID == userID {
                filteredCh <- msg
            }
        }
    }()

    return filteredCh, nil
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

// Subscription returns SubscriptionResolver implementation.
func (r *Resolver) Subscription() SubscriptionResolver {
return &subscriptionResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }

 

  • (Mutation) PostMessage: メッセージ内容を受け取り、受け取ったメッセージを Subscriber に送信。Mutex を使い排他制御を行っています
  • (Subscription) MessagePosted: メッセージ受信用のチャネルを生成し、userID をキーに購読チャネルリストに追加。受信されたメッセージが購読対象の userID と一致する場合にメッセージが受信されます
GraphQL サーバー
server.go
package main

import (
    "backend/graph"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/handler/transport"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/gorilla/websocket"
)

const defaultPort = "8080"

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

    srv := handler.New(
        graph.NewExecutableSchema(
            graph.Config{Resolvers: graph.NewResolver()}),
    )

    // add ws transport configured by ourselves
    srv.AddTransport(transport.Options{})
    srv.AddTransport(transport.GET{})
    srv.AddTransport(transport.POST{})
    srv.AddTransport(transport.MultipartForm{})
    srv.AddTransport(&transport.Websocket{
        Upgrader: websocket.Upgrader{
            //ReadBufferSize:  1024,
            //WriteBufferSize: 1024,
            CheckOrigin: func(r *http.Request) bool {
                // add checking origin logic to decide return true or false
                return true
            },
        },
        KeepAlivePingInterval: 10 * time.Second,
    })

    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", cors(srv.ServeHTTP))

    log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

func cors(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "*")
        w.Header().Set("Access-Control-Allow-Headers", "*")
        h(w, r)
    }
}

 

  • 「http://localhost:8080/query」のエンドポイントで GraphQL サーバーを立ち上げる
  • 「srv.AddTransport(&transport.Websocket」の箇所で WebSocket 通信ができるように定義しています (フロント側から Subscription できるようにする)

 

フロントエンド

フロントエンドは React + TypeScript で作成しています。

個人的に画面作るときは React を使うことが多いです。

 

今回はバックエンドで作成した GraphQL サーバーに対して、

React 側が GraphQL クライアントになるわけですが、

Apollo Client が有名なようなので使ってみました。

公式ドキュメントが充実していてわかりやすかったです。

 

www.apollographql.com

 

GraphQL クライアント定義
index.tsx
const httpLink = new HttpLink({
});

const wsLink = new GraphQLWsLink(
  createClient({
    url: "ws://localhost:8080/query",
  })
);

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLink
);

const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);

 

  • 公式ドキュメントの例に従い、ApolloClient を定義
  • ApolloClientProvider でラップすることで、App コンポーネント以下で useQuery など GraphQL フックが使えるようになるようです。
  • httpLink は Query ã‚„ Mutation のエンドポイントとして、wsLink は Subscription のエンドポイントとして使われます。
メッセージ投稿 
mutation.ts
import { gql } from "@apollo/client";

export const POST_MESSAGE_MUTATION = gql`
  mutation ($input: NewMessage!) {
    postMessage(input: $input) {
      id
      userId
      text
      createdAt
    }
  }
`;
ChatTextField.tsx
import { useMutation } from "@apollo/client";
import { useState } from "react";
import { POST_MESSAGE_MUTATION } from "../../apis/mutation";

function ChatTextField({ targetUserID }: { targetUserID: string }) {
  const [message, setMessage] = useState("");

  const [postMessage, { data, loading, error }] = useMutation(
    POST_MESSAGE_MUTATION
  );

  return (
    <div className="flex p-3">
      <input
        type="text"
        onChange={(e) => setMessage(e.target.value)}
        className="flex-auto mr-2 rounded-l p-3"
      />
      <button
        onClick={() => {
          postMessage({
            variables: { input: { text: message, userId: targetUserID } },
          });
        }}
        className="rounded-r p-3 bg-blue-500 text-white
         transition duration-300 hover:bg-blue-600"
      >
        送信
      </button>
    </div>
  );
}

export default ChatTextField;

 

  • gql でラップして Query ã‚„ Mutation, Subscription が定義できます。
  • useMutation フックに上記の Mutation 定義を渡すことで、Mutation を実行する関数、およびその結果変数が得られます。
  • useMutation フックを定義しただけでは実行はされず、以下のように onClick をトリガーに関数呼び出しのように呼び出すことで実行することができます。
<button
        onClick={() => {
          postMessage({
            variables: { input: { text: message, userId: targetUserID } },
          });
        }}

 

メッセージリアルタイム受信
subscription.ts
import { gql } from "@apollo/client";

export const MESSAGE_POSTED_SUBSCRIPTION = gql`
  subscription ($userID: ID!) {
    messagePosted(userId: $userID) {
      id
      userId
      text
      createdAt
    }
  }
`;
ChatMessage.tsx


||<

import { useSubscription } from "@apollo/client";
import { MESSAGE_POSTED_SUBSCRIPTION } from "../../apis/subscription";
import { formatDate } from "../../util/date_util";
import { useEffect, useState } from "react";
import { userIconMap } from "../../constant/Constant";

function ChatMessage({
  userID,
  className,
}: {
  userID: string;
  className?: string;
}) {
  const [messages, setMessages] = useState<any>([]);

  const { data, loading } = useSubscription(MESSAGE_POSTED_SUBSCRIPTION, {
    variables: { userID },
  });

  useEffect( () => {
    if (data && data.messagePosted) {
      setMessages( (prevMessages) => [...prevMessages, data.messagePosted]);
    }
  }, [data]);

  return (
    <div className={`${className} p-3`}>
      {!loading && (
        <>
          {messages.map( (data) => (
            <>
              <p className="text-xs p-3">
                {formatDate(data.createdAt, "MM月dd日(E) HH:mm")}
              </p>
              <div className="flex h-8">
                <div className="mr-6">{userIconMap[userID] || null}</div>

                <p className="flex-auto h-8 bg-blue-500 text-white
                 rounded-lg p-3 font-bold flex items-center">
                  {data.text}
                </p>
              </div>
            </>
          ))}
        </>
      )}
    </div>
  );
}

export default ChatMessage;
 
 

 

  • useSubscription フックに Subscription 定義を渡すことでコンポーネントレンダリング時に購読が開始されます。
  • Mutation が実行されてブロードキャストされると、ここで購読している data 変数に値が受信され、コンポーネントが再描画されます。
  • 上記の例では、状態管理している messages 変数に受信されたメッセージを追加しています。

 

以上になります。

Apollo Client を使うことでシンプルに GraphQL クライアントを構築できるのが良い発見になりました。