はじめに
現在進行形でC#のみを使って個人でソシャゲ作りを試しているyoship1639です。
本記事はQiita夏祭り2020「〇〇(言語)のみを使って、今△△(アプリ)を作るとしたら」のテーマに沿った内容となっています。
近年のソーシャルゲーム界隈は多様化が進んでクライアントサイドだけではなくサーバーサイドもあらゆる言語やフレームワークが試みられていますが、クライアントもサーバーも統一の言語で構成されているのはほとんどないかと思われます。言語にはその言語の得意分野があると思うので。
しかし、今まさに私が開発中の環境が好きな言語で開発しやすいという理由でクライアントもサーバーもC#で構成した作りになっているので、どのような構成でどうすれば最低限のソシャゲの基盤が作れるかを、解説が長くなり過ぎないようにまとめることが出来ればと思います。
三部構成で、クライアント実装、サーバー実装、AWS EC2へのデプロイまで解説できればと思っています。
Let's、C#のみでソシャゲを作ろう!
ソシャゲの概要
内容に入る前に、ソシャゲがどの様な流れで動作するのかを軽く説明します。
ソシャゲは基本的にクライアント(スマホ端末)とサーバーとのやり取りで動いています。サーバーが動いていないとクライアントは基本動作しません。これはクライアント側で不正にデータの書き換えをされると運営が困るからです。
サーバー側は大体以下の様なAPI機能を備えています。
- アプリバージョン判定
- マスターデータ・アセットバンドル更新判定
- ログイン (セッション管理)
- アカウント作成
- クエスト開始・終了
- ガチャ
- etc...
挙げたらきりがないくらいにはサーバーにはやらなければならない仕事があります。それだけクライアントとサーバーは適所で通信しています。こうすることで、例えばクライアントのデータが紛失したとしてもサーバーから復元することが出来ますし、クライアント側で不正があったらサーバー側で検知してBANすることもできますし、ユーザーのアプリ上での動向から問い合わせにも対応することができるようになりますし、課金周りのレシート検証もサーバー側で正確に行えるので、課金したのに石が反映されないみたいな場面でも補填対応することが出来るようになります。基本ユーザにとっても運営にとってもメリットしかないです。
近年バックエンドはBaas(PlayFab、Firebase、GameSparks、GS2など)が鎬を削っており態々バックエンドを自前で準備しなくてもBaasを使うという手段がありますが、ドキュメントが英語のみだったり痒いところに手が届かなかったりと一長一短なので、どうしてもサーバーサイドを触りたくないという訳ではないのであれば個人的にはまだ自前で準備したほうが良いかな感はあります。
ソシャゲの動作の最初の流れとしては以下の様になります。
- アプリバージョンを検証
- ログイン (ログインできなかったらアカウント作成)
- 更新データ確認 (アセバン、マスターデータ)
- 以降アプリによって色々
今回は最低限の基盤だけ考えるので、2番の「アカウント作成」と「ログイン」機能を作りたいと思います。
構成の全体像
今回作るサンプルは、C#のみで構成するソシャゲの最低限の基盤で以下の構成となっています。
クライアント:C#(Unity2019.X)
サーバー:C#(.NetCore3.1)
デプロイ:AWS EC2(Amazon Linux 2)
サーバー <--> クライアント:MagicOnion(HTTP/2, gRPC)
クライアントは皆大好きUnity、サーバーはプラットフォーム関係なく動かせる.NetCore、デプロイはEC2、クライアントとサーバーのやり取りは巷で噂のMagicOnion(gRPCのC#ラッパー+α)です。最低限の構成であれば全部無料で準備できます。
本来であれば、DB用意したり、直じゃなくDockerコンテナでデプロイとかすべきですが、本記事から内容が逸れそうなので簡単な構成にしています。
まずは、クライアントサイドから作ってみます。
クライアントサイド
クライアントサイドはエンジンとしてUnity2019.Xを使います。言語は当然C#です。
実装手順としては以下の通りとなります。
① MagicOnion, MessagePack, grpc をUnityにインポートする
② Serviceを定義
③ NetworkManagerを実装
④ ログインテストコードを実装
① MagicOnion, MessagePack, grpc をUnityにインポートする
まず、MagicOnion、MessagePack、grpcをUnityにインポートします。サーバーと通信するのに必要なものです。
これらを簡単に説明すると、
- MagicOnion: リアルタイム/API通信フレームワーク。gRPCをC#で使いやすいようにラップしたイメージ。
- MessagePack: 高効率のバイナリ形式のシリアライズフォーマット。JSONよりすごいやつ。MagicOnionに必要。
- grpc: googleが作ったRPCフレームワーク。MagicOnionの中身はこれ。
となっています。
なんでMagicOnionを使うかというと、以下のメリットがあるからです。
- HTTP/2の恩恵を受け、かつ通信データが高効率で圧縮されるため通信が早い。
- インターフェースベースの通信が実現されるのでデータフォーマットを考えなくていい。
- エンドポイントやAPIスキーマを考えなくていい。
- APIだけでなくリアルタイム通信としても使える。
使うには十分すぎるメリットではないかと思います。MagicOnionの詳細は解説しないので、各自調べていただければと思います。
まず、MagicOnionをインポートします。
https://github.com/Cysharp/MagicOnion/releases
こちらのリリースページにある「MagicOnion.Client.Unity.unitypackage」をダウンロードしUnityにインポートしてください。色々足りないと怒られますが気にせず次へいきます。
次に、MessagePackをインポートします。
https://github.com/neuecc/MessagePack-CSharp/releases
こちらのリリースページにある「MessagePack.Unity.XXXXX.unitypackage」をダウンロードしUnityにD&Dしてください。最新のリリースで問題なく動作するはずです。
この時、Pluginsフォルダ内のdllが既に取り込まれているよと警告されるので、Pluginsフォルダのチェックを外してインポートしてください。
最後に、grpcをインポートします。
https://packages.grpc.io/
こちらのページの最新のコミットのBuild IDをクリックし、C#欄にある「grpc_unity_package.XXXXX-dev.zip」をダウンロード、解凍します。
解凍すると「Plugins」フォルダがあるはずなので、Pluginsフォルダの中身をUnityのAssets/Pluginsフォルダに入れてインポートします。
それでもまだ怒られると思うので、エラーを解決していきます。
- System.Buffersが被っているので、どちらかを削除
- System.Memoryが被っているので、どちらかを削除
- System.Runtime.CompilerServices.Unsafeが被っているので、どちらかを削除
- unsafeコードが許可されていないぞ☆って怒られるのでunsafeコードを許可
これでエラーは出なくなるはずです。
② Serviceを定義
諸々インポートが完了したらServiceを定義します。ServiceとはWebAPIと同様のものと考えていただければと思います。
ソーシャルゲームは基本的に特定の動作ごとにサーバーにAPIを投げてそのレスポンスを基にクライアントを動かします。
本来、API定義を考える場合「https://〇〇〇〇/create_account」みたいなエンドポイントやらスキーマやらを考えなくてはいけませんが、MagicOnionの場合はインターフェース定義自体がそれに当たります。これメチャクチャ便利です。
アカウント作成とログインの機能は、以下の様に定義できます。
using MagicOnion;
// アカウント周りのサービスを定義するインターフェース
public interface IAccountService : IService<IAccountService>
{
// アカウント作成
UnaryResult<(string userId, string password)> CreateAccount();
// ログイン
UnaryResult<string> Login(string userId, string password);
}
CreateAccountはサーバー側で作成されたユーザIDとパスワードを返し、Loginは引数にユーザIDとパスワードを入力するとログイン中であるセッション情報(string)を返します。
クライアントはIAccountServiceだけを知っていればいいので、IAccountServiceの実態はサーバー側で実装します。
③ NetworkManagerを実装
Serviceの定義が終わったら実際にサーバーと通信する処理を担当するNetworkManagerを実装します。
クライアントはこのNetworkManagerを使ってサーバーとのやり取りをします。
using System;
using System.Threading.Tasks;
using Grpc.Core;
using MagicOnion.Client;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
[SerializeField] private string applicationHost = "localhost";
[SerializeField] private int applicationPort = 12345;
private IAccountService accountService;
private string session;
void Start()
{
var channel = new Channel(applicationHost, applicationPort, ChannelCredentials.Insecure);
accountService = MagicOnionClient.Create<IAccountService>(channel);
}
// アカウント作成
public async Task<(string userId, string password)> CreateAccount()
{
try
{
// サーバーにアカウント作成を要求、レスポンスは作成されたユーザIDとパスワード
return await accountService.CreateAccount();
}
catch (Exception e)
{
Debug.Log(e);
return (null, null);
}
}
// ログイン
public async Task<bool> Login(string userId, string password)
{
try
{
// ユーザIDとパスワードをサーバーに投げてログイン、レスポンスはセッション情報
session = await accountService.Login(userId, password);
return session != null;
}
catch (Exception e)
{
Debug.Log(e);
session = null;
return false;
}
}
}
applicationHostはlocalhost
にしてありますが、後でデプロイ先のエンドポイントに切り替えます。
セキュリティの関係からsslにすべきですが、今回は割愛です。
④ ログインテストコードを実装
実際にログインのテストコードを記述してみます。
処理内容はとても単純で、まずローカルに保存してあるユーザー情報(ユーザーID、パスワード)を読み込みます。ユーザー情報そのものがなかったらアカウントを作成し作成されたユーザー情報を保存します。次に、ユーザー情報を元にログインし、通った時と通らなかった時で処理を分けるという形です。
using System.IO;
using MessagePack;
using UnityEngine;
[MessagePackObject]
public class UserData
{
[Key(0)]
public string userId;
[Key(1)]
public string password;
}
public class LoginTest : MonoBehaviour
{
async void Start()
{
// ネットワークマネージャ取得
var network = GetComponent<NetworkManager>();
// 保存してあるユーザーデータ情報を読み込み
UserData userData = null;
try
{
userData = MessagePackSerializer.Deserialize<UserData>(File.ReadAllBytes(Application.persistentDataPath + "/userData.dat"));
}
catch { }
// ユーザーデータが存在しなかったらアカウント作成
if (userData == null)
{
Debug.Log("アカウント作成開始");
var res = await network.CreateAccount();
if (res.userId == null || res.password == null)
{
// TODO: アカウント作成失敗時の処理
Debug.LogWarning("アカウント作成失敗。。。");
return;
}
userData = new UserData();
userData.userId = res.userId;
userData.password = res.password;
// ユーザー情報保存(※本来は暗号化等する事!)
var data = MessagePackSerializer.Serialize(userData);
File.WriteAllBytes(Application.persistentDataPath + "/userData.dat", data);
Debug.Log("アカウント作成成功");
}
// ログイン
Debug.Log("ログイン中...");
var loginResult = await network.Login(userData.userId, userData.password);
if (!loginResult)
{
// TODO: ログイン失敗時の処理
Debug.LogWarning("ログイン失敗。。。");
return;
}
// TODO: ログインが通った後の処理
Debug.Log("ログイン成功!");
}
}
本来ならばもっと厳密にログイン処理を行うべきですが、今回はテストなので超単純に作っています。
ここを通ればログインに成功したことになるので、後はクライアント側は煮るなり焼くなりするだけです。
次に、サーバーサイドの実装に移ります。
サーバーサイド
サーバーサイドはフレームワークとして.NetCore3.1を使います。言語は当然C#です。
.NetFrameworkを使ってしまうとデプロイ周りで苦労することになるので、サーバーサイドC#は.NetCoreを使ってください。
サーバーの実装手順としては以下の様になります。
① プロジェクトの準備、MagicOnionのインストール
② Mainプログラムの記述
③ AccountServiceの実装
④ ローカル環境で動作確認
① プロジェクトの準備、MagicOnionのインストール
まず、プロジェクトを作成します。プロジェクトは「コンソール アプリ(.NET Core)」を選択してください。プロジェクト名は何でもいいです。私はとりあえず「Qiita2020TestServer」にしました。
プロジェクトの作成が終わったら、クライアントとの通信に必要なコンポーネントをNuget経由でインストールします。
プロジェクトのコンテキストメニューの「Nuget パッケージの管理(N)...」からMagicOnion.Hosting
をインストールします。バージョンは最新の安定板で大丈夫です。
一応、Unityで使われている型をサーバーでも扱えるようにMessagePack.UnityShims
もインストールしておきます。
これでサーバーサイドに必要なコンポーネントがインストールできました。
② Mainプログラムの記述
サーバーを起動するMainプログラムを記述します。
やっていることはとても単純で、ログ出力先をコンソールに指定し、ホストとポートを指定して起動しているだけです。
using Grpc.Core;
using MagicOnion.Hosting;
using MagicOnion.Server;
using Microsoft.Extensions.Hosting;
using System.Threading.Tasks;
namespace Qiita2020TestServer
{
class Program
{
static async Task Main(string[] args)
{
// コンソールにログ出力するように設定
GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());
// MagicOnionを使ってホスト作成、起動
await MagicOnionHost.CreateDefaultBuilder()
.UseMagicOnion(
new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true),
new ServerPort("0.0.0.0", 12345, ServerCredentials.Insecure))
.RunConsoleAsync();
}
}
}
一応これだけでもサーバーを起動することはできます。デバッグ実行すると以下の様な画面が出るはずです。
これだけでは何の機能もない張りぼてサーバーなので、クライアント側で実装した「アカウント作成」と「ログイン」機能を実装していきます。
③ AccountServiceの実装
サーバー側のアカウント作成とログイン機能の実装をします。
クライアントで定義したIAccountService.csが必要なので、予め丸々コピーしておいてください。(本来は、submodule等用いてソースコードの共有をすることをお勧めします。)
アカウント作成は、ランダムなハッシュ値を用います。ユーザーIDは20桁、パスワードは12桁にしておきます。ログインは作成されたユーザーIDとパスワードを検証し、一致したら以降のAPIを呼び出すことが出来るセッションを返します。セッションも一先ずランダムな20桁のハッシュ値を返します。
using MagicOnion;
using MagicOnion.Server;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace Qiita2020TestServer
{
class AccountService : ServiceBase<IAccountService>, IAccountService
{
// セッション情報管理(本来はRedis等用いる事!)
private static Dictionary<string, (string userId, DateTime expireAt)> sessions = new Dictionary<string, (string userId, DateTime expireAt)>();
private static object lockObject = new object();
// アカウント作成
public async UnaryResult<(string userId, string password)> CreateAccount()
{
Logger.Info("CreateAccount Request");
var userId = GenerateHash(20);
var password = GenerateHash(12);
// アカウント情報を仮でファイルに保存(本来はDBに入れる事!)
try
{
if (!Directory.Exists("accounts")) Directory.CreateDirectory("accounts");
File.WriteAllText("accounts/" + userId, password);
}
catch (Exception e)
{
Logger.Error(e, "CreateAccount Error");
return (null, null);
}
Logger.Info($"CreateAccount UserId:{userId}, Password:{password}");
return (userId, password);
}
// ログイン
public async UnaryResult<string> Login(string userId, string password)
{
Logger.Info("Login Request");
try
{
// アカウントがない
if (!File.Exists("accounts/" + userId)) return null;
// パスワードが一致しない
if (File.ReadAllText("accounts/" + userId) != password)
{
Logger.Warning("Login failed: " + (userId, password));
return null;
};
}
catch (Exception e)
{
Logger.Error(e, "Login Error");
return null;
}
// セッション情報作成
var session = GenerateHash(20);
lock (lockObject)
{
// 一先ず1日有効なセッションを保存
sessions[session] = (userId, DateTime.UtcNow.AddDays(1));
}
Logger.Info("【" + userId + "】Login succeeded!");
// セッションを返す
return session;
}
// 指定の長さのランダムハッシュ値を取得
private static string GenerateHash(int length)
{
return Sha256(Guid.NewGuid().ToString("N")).Substring(0, length).ToLower();
}
// Sha256ハッシュ
private static string Sha256(string str)
{
var input = Encoding.ASCII.GetBytes(str);
var sha = new SHA256CryptoServiceProvider();
var sha256 = sha.ComputeHash(input);
var sb = new StringBuilder();
for (int i = 0; i < sha256.Length; i++)
{
sb.Append(string.Format("{0:X2}", sha256[i]));
}
return sb.ToString();
}
}
}
これで、アカウント作成とログイン機能を備えたサーバープログラムが整いました。
④ ローカル環境で動作確認
ここまでで、ローカル環境で動作確認をすることが出来るようになったので、確認してみます。
サーバーをデバッグ実行してローカルサーバーを立ち上げ、クライアントをデバッグ実行します。
問題がなければクライアントは以下の様に表示されるはずです。
サーバー側は以下の様に表示されます。
ローカルで問題なく動作できていることが確認できました。
最後に、実際にクラウド上にデプロイして確認してみたいと思います。
デプロイ
ローカル環境で問題なく動作させることが確認できれば、本来はデプロイまでは頑張らなくてもいいですが、せっかくなのでEC2へのデプロイまでやってみたいと思います。CI/CDやDockerコンテナでもよかったのですが解説が逸れそうなので直デプロイします。
手順としては、以下の様になります。
① awsでEC2インスタンスを用意、起動する
② ターミナルでEC2インスタンスにログイン
③ .NetCore3.1をインストール
④ サーバープロジェクトを配置、実行
⑤ クライアント動作確認
① awsでEC2インスタンスを用意、起動する
最初にAWSコンソールにサインインします。アカウントを持ってない人は作ってください。
サインインしたらEC2を選択します。EC2は仮想サーバーみたいなものだと思ってください。
名前を「Qiita2020TestServer」(名前は何でもいいです)にして、「キーペアを作成」をクリックします。
キーペアが作成されppkファイルがダウンロードされます。
このキーは後で作るインスタンスへのログインに必要なので、大切に保管しましょう。
何のインスタンスを作るか聞かれるので、「Amazon Linux 2 AMI」を選択してください。
どのスペックの仮想マシンを立ち上げるか聞かれるので、無料で使える「t2.micro」を選択し、「次のステップ:インスタンスの詳細と設定」をクリック。
いろんな設定項目がありますが、ここでは「自動割り当てパブリックIP」を「有効」にします。
有効にしたら「次のステップ:ストレージの追加」へ。
ストレージは30GBまで無料らしいので一先ず30GBに設定し「次のステップ:タグの追加」へ。
タグの追加を押し、キーに「Name」、値に「Qiita2020TestServer」と入力します。(値はわかれば何でもいいです)
入力したら「次のステップ:セキュリティグループの設定」へ。
「ルールの追加」を押し、以下の様に入力し12345ポートを解放します。
分かりやすいように説明も入れておきましょう。(※画像は日本語ですが、日本語の説明ではインスタンスが作成できなかったので、英語で入力してください!)
入力したら「確認と作成」をクリック。
確認画面で「すべてのIPからインスタンスにアクセスできるよ、いいの?」と警告されますが気にせず「起動」を押します。
インスタンスに安全に接続するためのキーペアを選びます。先ほど作成した「Qiita2020TestServer」キーペアを選んで、「インスタンスの作成」をクリックします。
インスタンス作成中の画面が表示されるので、右下の「インスタンスの表示」をクリックしてください。
すると、作成されたインスタンス一覧が表示されます。問題がなければインスタンスはそのまま起動します。
これで、インスタンスの準備は整いました。
作成したインスタンスの「IPv4パブリックIP」はターミナル接続先なので控えておいてください。
② ターミナルでEC2インスタンスにログイン
EC2インスタンスにログインします。
ターミナルソフトは何でもいいですが、私は「TeraTerm」を使って解説します。
TeraTermを起動したらホストに先ほど作成したインスタンスのパブリックIPを入力してOKをクリック。
SSH認証が必要なので、ユーザ名に「ec2-user」、認証方式には最初の方に作成したキーペアの秘密鍵「Qiira2020TestServer.ppk」を指定します。
問題なくSSH接続できたら以下の様に表示されます。ここからはいつものターミナルです。
③ .NetCore3.1をインストール
必要なパッケージをインストールします。今回は.NetCoreを動かすためのランタイム「.NetCoreRuntime」をインストールすればOKです。
まずはパッケージ更新
$ sudo yum update
Microsoftパッケージリポジトリを追加。
$ sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
.NetCore3.1SDKインストール
$ sudo yum install dotnet-sdk-3.1
.NetCore3.1ランタイムインストール
$ sudo yum install dotnet-runtime-3.1
これでEC2上で.NetCore3.1プロジェクトが実行できるようになりました。
④ サーバープロジェクトを配置、実行
サーバープロジェクトを配置します。配置方法は何でもいいですが個人的にはgitを使うのが一番楽です(余力のある方はGithubActions等を使ったCI/CDをお勧めします。)。サーバープロジェクトをgithubでリモート管理し、git cloneでそのまま配置します。こうすると何が良いかというと、サーバープロジェクトの更新が入った時にgit pullするだけで更新できます。
ただ今回はプロジェクトをgithubに配置しないので、おとなしくsftpで配置します。sftpの解説はしません。各自良い感じにサーバープロジェクトを根こそぎ持ってきてください。
配置場所はec2-user
フォルダ内に「Qiita2020TestServer」を作ります。
$ mkdir Qiita2020TestServer
この中にプロジェクトを配置します。
配置したら、Qiita2020TestServer.csprojがあるフォルダまで移動します。
.Netはcsprojをそのままdotnet run
で実行することが出来るので、実行してみます。
$ dotnet run
これで以下の様にEC2上でサーバーが立ち上がりました。
直に立ち上げるとSSHを終了した段階でサーバープログラムが止まってしまうのでscreen
を使うと色々捗ります。
最後にクライアントからEC2上にアクセスできるか確認します。
⑤ クライアント動作確認
クライアントのapplicationHostがlocalhost
のままなので、NetworkManagerのこの部分をEC2インスタンスのパブリックIPに置き換えてください。
置き換えて、クライアントをデバッグ実行します。そして、以下の表示が出たらEC2との接続に成功です!
これで、C#のみで作ったソシャゲの最低限の基盤ができました。
ここからマスターデータやアセバン管理、クエスト処理やガチャ処理等を生やしていけば、C#のみで作るソシャゲの出来上がりです。お疲れまでした。
おわりに
いかがでしょうか、意外と簡単にソシャゲの最低限の基盤を作ることができたのではないかと思います。もちろん本物はこれだけじゃ済まないボリュームですが、基礎を捉えるのは大きな前進になるのではないかと思います。
今回は省きまくりましたが、セキュリティだけはしっかり設定してください。ソシャゲはユーザの大事な情報を管理するので、ガバガバ設定では当然許されません。最低でも、ユーザーデータの暗号化、SSL、サーバー監視はしっかりするように!
皆様はもちろんC#信者だと思うので、その熱意をUnityだけではなくそのままサーバーサイドにも向けてみてはいかがでしょうか。私もまだまだサーバーサイドは勉強中なので偉そうなことは言えませんが、クライアントサイドとはまた別の面白さがあるので、やりがいはいっぱいあるかと思います。
頑張れば、一人でもソシャゲが作れる時代です。
最後まで読んでいただき、ありがとうございました。