BBTAGレーティング対戦サイトの話(技術編)
12月13日のBBTAGアドベントカレンダー2021の記事になります。今日はBBTAGレーティング対戦サイト「TwinPeaks」において使用している技術などについて簡単に書いていきたいと思います。
Pythonで作りたい→Djangoを使うことに
作成当時Pythonにお熱があったこともあって、作るならPythonでやってみたいと思っていました。いくつかWebフレームワークがあったのですが汎用性や将来の資産になりやすいことも考えてDjangoを採用することにしました。
そして私は思い知ることになるのでした…Webページ開発がとんでもなく地道な作業であることに…
動的なWebページを作るために
そもそも「Webページ」を作るだけならHTMLの知識・CSSとJavaScriptにある程度通じていれば作成できます。ただこれは静的なページを作る場合に限ります。つまりレーティング対戦サイトを作るのであれば「ユーザーに合わせたWebページ」を表示する必要があります。URLが同じでもユーザーごとに違う情報が表示される、ということです。これを実現するために必要なのがWebフレームワークと呼ばれるパッケージになります。
では実際どのようにして動的なページを作成するのでしょうか。簡単には次のステップで処理が行われます。
1.HTTP Requestがサーバーに送信される(=リンクがクリックされる)
2.”url.py”が送信されたurlのパターンを認識し、”view.py”の適切な関数に処理を投げる
3.”views.py”はデータベースを使いやすい形で再構築した”models.py”を用いて必要なデータを引っ張ってくる。またレンダリング用のHTMLファイルを準備する
4.HTMLファイルをデータベースに合わせて再生成し、HTTP Responseを行う(=ページが表示される)
…分かりにくいですね。例として良く上げられるのはラーメン屋さんで、
「注文を受ける」
「厨房に分かりやすい形で伝える」
「注文に合わせた具材と麺、スープが準備される」
「調理がなされ、ラーメンが出来上がる」
これをDjangoが提供しているんだよ、というのが分かればOKです。実際、レーティング対戦のエントリー画面のHTMLファイルは次のようになっています(一部抜粋)。HTMLが汚いのは許して…。
{% if season.is_finished %}
<div class="card m-3 border-danger">
<div class="card-body">
シーズン ( {{season.season_name}} ) は終了しました。次のシーズンが始まるまでお待ちください。
</div>
</div>
{% else %}
<div class="accordion m-3" id="accordionRule">
<div class="card">
<div class="card-header" id="headingOne">
<h5 class="mb-0">
<button class="btn btn-outline-info" type="button" data-toggle="collapse" data-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
今シーズン ( {{season.season_name}} ) のルールと終了時間を確認する
</button>
</h5>
</div>
<div id="collapseOne" class="collapse" aria-labelledby="headingOne" data-parent="#accordionRule">
<div class="card-body">
終了時間:{{ season.finished_date }}<br><br>
{{season.rule_bbtag|linebreaksbr}}
</div>
</div>
</div>
</div>
<div class="container-fluid">
<div class="row">
<div class="col p-2">
<div class="card border-primary">
<div class="card-body">
<h5 class="card-title">BBTAG(PS4)</h5>
<p class="card-text">あなたのレート:{{status.rp_ps4|floatformat}}</p>
<p class="card-text">対戦中の部屋数:{{bb_ps_now|length}}<br>{{bb_ps|length}}人が対戦待ち受け中</p>
<div class="mt-1">
<a href="{% url 'room_create_ps4' %}" class="btn btn-primary">エントリー</a>
</div>
<div class="mt-1">
<a href="{% url 'room_create_ps4_accept_all' %}" class="btn btn-primary">無差別エントリー</a>
</div>
</div>
</div>
</div>
<div class="col p-2">
<div class="card border-danger">
<div class="card-body">
<h5 class="card-title">BBTAG(Switch)</h5>
<p class="card-text">あなたのレート:{{status.rp_switch|floatformat}}</p>
<p class="card-text">対戦中の部屋数:{{bb_sw_now|length}}<br>{{bb_sw|length}}人が対戦待ち受け中</p>
<div class="mt-1">
<a href="{% url 'room_create_switch' %}" class="btn btn-primary">エントリー</a>
</div>
<div class="mt-1">
<a href="{% url 'room_create_switch_accept_all' %}" class="btn btn-primary">無差別エントリー</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{{ }}で囲まれているところがデータベースから引っ張ってきた情報を挿入するところとなっています。プログラムのようにif文も使えて表示する情報を分けることができます。
レーティング(Glicko-2)
レーティング対戦サイトというからにはレーティングを決めるためのアルゴリズムが必要です。最初期はGlicko-2を使用していました。
折角だしソースコードも貼っちゃおう。
# -*- coding: utf-8 -*-
"""
Created on Thu Feb 18 11:31:53 2021
Glicko2レーティングアルゴリズム
@author: w_rhino
参考:https://github.com/sublee/glicko2/blob/master/glicko2.py
"""
import math
#初期パラメータ
TAU = 0.5
RATING = 1500
DEV = 350
VOL = 0.06
EPSILON = 0.000001
#マッチ勝敗の重みづけ
WIN = 1
DRAW = 0.5
LOSS = 0
class Rating(object):
def __init__(self, rating=RATING, deviation=DEV, volatility=VOL):
self.rating = rating
self.deviation = deviation
self.volatility = volatility
class Glicko2(object):
def __init__(self, rating=RATING, deviation=DEV, volatility=VOL, tau=TAU, epsilon=EPSILON):
self.rating = RATING
self.deviation = DEV
self.volatility = VOL
self.tau = TAU
self.epsilon = EPSILON
def create_rating(self, rating=None, deviation=None, volatility=None):
if rating is None:
rating = self.rating
if deviation is None:
deviation = self.deviation
if volatility is None:
volatility = self.volatility
return Rating(rating, deviation, volatility)
def get_weight(self, win=WIN, draw=DRAW, loss=LOSS):
return win, draw, loss
def scale_down(self, params, ratio=173.7178):
mu = (params.rating - self.rating)/ratio
phi = params.deviation/ratio
return self.create_rating(mu, phi, params.volatility)
def scale_up(self, params, ratio=173.7178):
mu = ratio*params.rating + self.rating
phi = params.deviation*ratio
return self.create_rating(mu, phi, params.volatility)
def g_phi(self, params):
return 1/math.sqrt((1+3*params.deviation**2)/math.pi**2)
def expect_mu(self, params, opponent):
return 1/(1+math.exp(-1*self.g_phi(opponent)*(params.rating - opponent.rating)))
def new_sigma(self, params, v, delta):
"""
sigma'を導出する。
Parameters
----------
params : Ratingクラス
対象プレイヤーのパラメータ
v : TYPE
Step3で導出したvの値
delta : TYPE
Step4で導出したdeltaの値
Returns
-------
新しいsigmaであるsigma'
"""
eps = EPSILON
alpha = math.log(params.volatility**2, math.e)
def func_(x):
first = (math.exp(x)*(delta**2 - params.deviation**2 - v - math.exp(x)))/2*(params.deviation + v + math.exp(x))**2
second = (x - alpha)/self.tau
return first - second
if delta**2 - params.deviation**2 - v <= 0:
k = 1
while True:
if func_(alpha - k*self.tau) < 0:
k = k + 1
else:
break
beta = alpha - k * self.tau
else:
beta = math.log(delta**2 - params.deviation**2 - v, math.e)
f_a = func_(alpha)
f_b = func_(beta)
while True:
if abs(beta - alpha) > eps:
#収束していないなら
gamma = alpha + (alpha - beta)*f_a/(f_b - f_a)
f_c = func_(gamma)
if f_b*f_c < 0:
alpha = beta
f_a = f_b
else:
f_a = f_a/2
beta = gamma
f_b = f_c
else:
break
return math.exp(alpha/2)
def compute(self, params, series):
"""
レーティングの導出を行う。
Parameters
----------
params : Rating
対象プレイヤーのパラメータ
series : List(match_result, {rating, deviation, volatility})
対戦してきたプレイヤーのレーティングデータと勝敗
ex : compute(player1, [(WIN, player2),(LOSS, player3)])
Returns
-------
変化後のパラメータ(Ratingクラスオブジェクト)
"""
#2:スケールを落とす。
myparams = self.scale_down(params)
#exception:前ラウンド区分で対戦を行わなかった場合の処理
if not series:
phi_star = math.sqrt(myparams.volatility**2 + myparams.deviation**2)
return self.scale_up(self.create_rating(myparams.rating, phi_star, myparams.volatility))
#3:vの導出を行う。
#4:Δを導出する。
v_inv = 0
delta = 0
for match_result, opponent in series:
opp_params = self.scale_down(self.create_rating(opponent.rating, opponent.deviation, opponent.volatility))
opp_g = self.g_phi(opp_params)
v_inv += opp_g**2 * self.expect_mu(myparams, opp_params) * (1 - self.expect_mu(myparams, opp_params))
delta += opp_g * (match_result - self.expect_mu(myparams, opp_params))
v = 1/v_inv
delta = delta*v
#5:sigma(volatility)'を導出する。
sigma_p = self.new_sigma(myparams, v, delta)
#6:phi(deviation)_starを導出する。
phi_star = math.sqrt(myparams.deviation**2 + myparams.volatility**2)
#7:phi',mu(rating)'を導出する。
phi_p = 1/math.sqrt((1/(phi_star**2)) + v_inv)
mu_p = myparams.rating + phi_p**2 * delta * v_inv
#8:スケールを元に戻す。
return self.scale_up(self.create_rating(mu_p, phi_p, sigma_p))
ただGlicko-2には欠点もありました。「最初に大きく変動し、徐々に振れ幅が小さくなって適正帯になる」というのが基本であるため、最初期に負けるとプレイヤーのやる気を削ぎやすい状態だったわけです。途中からGlicko-2はアークのような色ランクの導出のみに使用し、レーティング自体はかなりシンプルな計算式にしましたが、ここは「有名なアルゴリズムだから!」で採用してしまった私のミスですね…。大きな反省点です。
Twitterでログインしたい!
個人的に実装したかった機能の一つです。Twitter連携なら簡単にログインできるしユーザーの手間も少なくなりますからね。
こちらの記事が大変参考になりました。
Twitterに開発者申請をしてAPIを利用する権利を得たら後はDjangoがかなり強力なテンプレートを用意してくれているためそれに従って記述すれば簡単に出来ました。
ログイン時にユーザーのアクセストークン等も一緒に得ていますが、今回は使用しませんでした。ちなみにアクセストークンを利用するとそのアカウントのある程度の操作権を得るため、「対戦待ち受け中!」のような自動ツイートを行わせる、などが可能です。察しているかと思いますがアクセストークン、悪用されると危ないので変なアプリの連携はしないように!と言われている原因です。だからちゃんとした開発者申請が必要なのですが…。
チャットルームの仕組みとほぼ一緒な対戦部屋
さてメインコンテンツとなるレーティング対戦です。実は見出しにもあるように仕組みがほぼチャットルームと同様なんですね(結局のところ相手との相互送信ができればよく、結果送信は親となるユーザーがデータを送ればいい)。そしてこんなところで神のような解説記事がありました。
これを土台として色々手を加えたのが対戦部屋になりました。…が一番不具合の多かった機能でもありました。特に切断時の再入室処理が上手くいきませんでしたね。今思うとセッション(クッキーを始めとするユーザーごとの情報を保管する仕組み)に手を加えて「ユーザーの入室状態」を記録できるようにしていればもう少し問題点も見えやすかったのではないかなと思っています。ここは自身の経験不足によるところが大きいです。開発って難しいね。
戦績の管理
「ユーザーごとの情報を表示する」の最たるものであるマイページ。HTMLファイルはこんな感じになっていました。
<div class="card m-3">
<div class="card-header">
ユーザー情報(ID等)
</div>
<div class="card-body">
<ul class="list-group">
<li class="list-group-item">ニックネーム:{% if status.nickname %}{{status.nickname}}{% else %}データなし{% endif %}</li>
<li class="list-group-item">PSNオンラインID:{% if status.id_psn %}{{status.id_psn}}{% else %}データなし{% endif %}</li>
<li class="list-group-item">ニンテンドーアカウント名:{% if status.id_switch %}{{status.id_switch}}{% else %}データなし{% endif %}</li>
</ul>
<div class="mt-3">
<a href="{% url 'update' status.id %}" class="btn btn-info">ニックネーム・PSNID等を入力する</a>
</div>
</div>
</div>
<div class="card m-3">
<div class="card-header">
今シーズンのレーティング情報 <span class="badge badge-light">EXP:{{status.exp|floatformat}}</span>
</div>
<div class="card-body table-responsive">
<p> MMR低←
<span style="color:aqua">■</span>
<span style="color:blue">■</span>
<span style="color:green">■</span>
<span style="color:lime">■</span>
<span style="color:yellow">■</span>
<span style="color:red">■</span>
<span style="color:fuchsia">■</span>
→MMR高
<br>
MMRが安定するまでは「計測中」が表示されます。
</p>
<table class="table text-nowrap">
<thead class="thead-light">
<tr>
<th scope="col">タイトル</th>
<th scope="col">レーティング</th>
<th scope="col">MMR</th>
<th scope="col">戦績</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">BBTAG-PS4</th>
<td>{{status.rp_ps4|floatformat}}</td>
<td>
{% if status.deviation_ps4 <= 50 %}
{% if status.rating_ps4 < 1300 %}
<span style="color:aqua">■</span>
{% elif status.rating_ps4 < 1350 %}
<span style="color:blue">■</span>
{% elif status.rating_ps4 < 1450 %}
<span style="color:green">■</span>
{% elif status.rating_ps4 < 1550 %}
<span style="color:lime">■</span>
{% elif status.rating_ps4 < 1650 %}
<span style="color:yellow">■</span>
{% elif status.rating_ps4 < 1750 %}
<span style="color:red">■</span>
{% else %}
<span style="color:fuchsia">■</span>
{% endif %}
{% else %}
計測中
{% endif %}
</td>
<td>
{{ history_ps4|length }}戦{{ ps4_win|length }}勝
</td>
</tr>
<tr>
<th scope="row">BBTAG-Switch</th>
<td>{{status.rp_switch|floatformat}}</td>
<td>
{% if status.deviation_switch <= 50 %}
{% if status.rating_switch < 1300 %}
<span style="color:aqua">■</span>
{% elif status.rating_switch < 1350 %}
<span style="color:blue">■</span>
{% elif status.rating_switch < 1450 %}
<span style="color:green">■</span>
{% elif status.rating_switch < 1550 %}
<span style="color:lime">■</span>
{% elif status.rating_switch < 1650 %}
<span style="color:yellow">■</span>
{% elif status.rating_switch < 1750 %}
<span style="color:red">■</span>
{% else %}
<span style="color:fuchsia">■</span>
{% endif %}
{% else %}
計測中
{% endif %}
</td>
<td>
{{ history_switch|length }}戦{{ switch_win|length }}勝
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card m-3" id="history">
<div class="card-header">
今シーズンの対戦履歴
</div>
<div class="card-body">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="BBTAG-ps4-tab" data-toggle="tab" href="#BBTAG-ps4" role="tab" aria-controls="BBTAG-ps4" aria-selected="true">BBTAG-PS4</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="BBTAG-switch-tab" data-toggle="tab" href="#BBTAG-switch" role="tab" aria-controls="BBTAG-switch" aria-selected="false">BBTAG-Switch</a>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="table-responsive tab-pane fade show active" id="BBTAG-ps4" role="tabpanel" aria-labelledby="BBTAG-ps4-tab">
<table class="table text-nowrap">
<thead class="thead-light">
<tr>
<th scope="col">対戦終了時刻</th>
<th scope="col">対戦相手</th>
<th scope="col">勝敗</th>
<th scope="col">対戦前レーティング</th>
<th scope="col">対戦相手のレーティング</th>
</tr>
</thead>
<tbody>
{% for match in page_obj_ps4_each %}
<tr>
<td>{{ match.created_at|date:"Y/m/d H:i" }}</td>
<td>
{% if match.player1 == status %}
{{ match.player2|default_if_none:"退会済みユーザー" }}
{% else %}
{{ match.player1|default_if_none:"退会済みユーザー" }}
{% endif %}
</td>
<td>
{% if status == match.winner %}
勝ち
{% else %}
負け
{% endif %}
</td>
<td>
{% if match.player1 == status %}
{{ match.p1_rp|floatformat }}
{% else %}
{{ match.p2_rp|floatformat }}
{% endif %}
</td>
<td>
{% if match.player1 == status %}
{{ match.p2_rp|floatformat }}
{% else %}
{{ match.p1_rp|floatformat }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5"> まだ対戦していません。 </td>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="pagination justify-content-center">
<!-- 前へ の部分 -->
{% if page_obj_ps4.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj_ps4.previous_page_number }}#history">
<span aria-hidden="true">«</span>
</a>
</li>
{% endif %}
<!-- 数字の部分 -->
{% for num in page_obj_ps4.paginator.page_range %}
{% if page_obj_ps4.number == num %}
<li class="page-item active"><a class="page-link" href="#!">{{ num }}</a></li>
{% else %}
<li class="page-item"><a class="page-link" href="?page={{ num }}#history">{{ num }}</a></li>
{% endif %}
{% endfor %}
<!-- 次へ の部分 -->
{% if page_obj_ps4.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj_ps4.next_page_number }}#history">
<span aria-hidden="true">»</span>
</a>
</li>
{% endif %}
</ul>
</div>
<div class="table-responsive tab-pane fade" id="BBTAG-switch" role="tabpanel" aria-labelledby="BBTAG-switch-tab">
<table class="table text-nowrap">
<thead class="thead-light">
<tr>
<th scope="col">対戦終了時刻</th>
<th scope="col">対戦相手</th>
<th scope="col">勝敗</th>
<th scope="col">対戦前レーティング</th>
<th scope="col">対戦相手のレーティング</th>
</tr>
</thead>
<tbody>
{% for match in page_obj_switch_each %}
<tr>
<td>{{ match.created_at|date:"Y/m/d H:i" }}</td>
<td>
{% if match.player1 == status %}
{{ match.player2|default_if_none:"退会済みユーザー" }}
{% else %}
{{ match.player1|default_if_none:"退会済みユーザー" }}
{% endif %}
</td>
<td>
{% if status == match.winner %}
勝ち
{% else %}
負け
{% endif %}
</td>
<td>
{% if match.player1 == status %}
{{ match.p1_rp|floatformat }}
{% else %}
{{ match.p2_rp|floatformat }}
{% endif %}
</td>
<td>
{% if match.player1 == status %}
{{ match.p2_rp|floatformat }}
{% else %}
{{ match.p1_rp|floatformat }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5"> まだ対戦していません。 </td>
</tr>
{% endfor %}
</tbody>
</table>
<ul class="pagination justify-content-center">
<!-- 前へ の部分 -->
{% if page_obj_switch.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj_switch.previous_page_number }}#history">
<span aria-hidden="true">«</span>
</a>
</li>
{% endif %}
<!-- 数字の部分 -->
{% for num in page_obj_switch.paginator.page_range %}
{% if page_obj_ps4.number == num %}
<li class="page-item active"><a class="page-link" href="#!">{{ num }}</a></li>
{% else %}
<li class="page-item"><a class="page-link" href="?page={{ num }}#history">{{ num }}</a></li>
{% endif %}
{% endfor %}
<!-- 次へ の部分 -->
{% if page_obj_switch.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj_switch.next_page_number }}#history">
<span aria-hidden="true">»</span>
</a>
</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
<div class="card m-3">
<div class="card-header">
今シーズンのレーティングの推移
</div>
<div class="card-body">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="BBTAG-ps4-chart-tab" data-toggle="tab" href="#BBTAG-ps4-chart" role="tab" aria-controls="BBTAG-ps4" aria-selected="true">BBTAG-PS4</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="BBTAG-switch-chart-tab" data-toggle="tab" href="#BBTAG-switch-chart" role="tab" aria-controls="BBTAG-switch" aria-selected="false">BBTAG-Switch</a>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="BBTAG-ps4-chart" role="tabpanel" aria-labelledby="BBTAG-ps4-chart-tab">
<canvas id="chart-ps4"></canvas>
</div>
<div class="tab-pane fade" id="BBTAG-switch-chart" role="tabpanel" aria-labelledby="BBTAG-switch-chart-tab">
<canvas id="chart-switch"></canvas>
</div>
</div>
</div>
</div>
<div class="card m-3">
<div class="card-header">
各シーズンの最終レート
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table text-nowrap">
<thead class="thead-light">
<tr>
<th scope="col">シーズン</th>
<th scope="col">BBTAG-PS4</th>
<th scope="col">BBTAG-Switch</th>
</tr>
</thead>
<tbody>
{% for s in season %}
<tr>
<td>{{ s.season.season_name }}</td>
<td>{{ s.rp_ps4|floatformat }}</td>
<td>{{ s.rp_switch|floatformat }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3"> 記録がありません。 </td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
このページを表示するためには上部メニューからTwitterでログインしてください。
{% endif %}
{% endblock %}
{% block extra_script %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script>
<script>
let complexChartOption = {
responsive: true,
scales: {
yAxes: [{
id: "y-axis-rating", // Y軸のID
type: "linear", // linear固定
display: true,
position: "left", // どちら側に表示される軸か?
},],
}
};
renderChart("chart-ps4","bb_ps");
renderChart("chart-switch","bb_sw");
function renderChart(ctx_id, console)
{
let ctx = document.getElementById(ctx_id).getContext('2d');
let data = setData(console);
let chart = new Chart.Line(ctx, {
// The data for our dataset
data: {
labels: data[0],
datasets: [{
label: 'レーティング',
pointBackgroundColor: 'rgb(255, 99, 132)',
borderColor: 'rgb(255, 99, 132)',
fill: false,
lineTension: 0,
data: data[1],
yAxisID: 'y-axis-rating',
},]
},
// Configuration options go here
options: complexChartOption
});
}
function setData(console)
{
let label = [];
let data = [];
let rating = 0;
if(console == "bb_ps")
{
{% for match in history_ps4 reversed %}
label.push("{{ forloop.revcounter }}");
{% if match.player1 == status %}
rating = {{ match.p1_rp|floatformat }};
{% else %}
rating = {{ match.p2_rp|floatformat }};
{% endif %}
data.push(rating);
{% endfor %}
label.push("now");
data.push("{{status.rp_ps4|floatformat}}");
}
else
{
{% for match in history_switch reversed %}
label.push("{{ forloop.revcounter }}");
{% if match.player1 == status %}
rating = {{ match.p1_rp|floatformat }};
{% else %}
rating = {{ match.p2_rp|floatformat }};
{% endif %}
data.push(rating);
{% endfor %}
label.push("now");
data.push("{{status.rp_switch|floatformat}}");
}
return [label, data];
}
</script>
{{ }}によるデータの挿入文と{% %}によるプログラムとしての構文が大量にあることが分かるかと思います。実際に表示されるのは簡単な戦績ですが、実は裏ではこんなに書いていたんだと分かってもらえると幸いです。ここでちょっと面白いのがJavaScriptにも{{ }}と{% %}を駆使してJavaScriptを生成しています。プログラムからプログラムを生成している、なんとも奇妙に見えますね。
おわりに
というわけでざっと技術的な振り返りをしました。ページ一つ生成するのにも色んな要素があって地道な作業があって作成されているというのが身に染みて分かりました。Webエンジニアすげえ。
ここからさらに詳しく説明するとかなり専門的な知識が要求されるため、今回はできるだけ簡単な説明にとどめて書きました。(というか詳しく書くならQiita使うよね、はよちゃんと書け)
サイトについて気になる方は質問をいつでも受け付けていますのでよろしくお願いします!