1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターをバックエンドにしたWebアンケートアプリを作ってみる

1
Last updated at Posted at 2026-09-16

はじめに

プリザンターで社外向けのアンケートを取りたい、という相談は定番です。テーブルを作って項目を並べれば、確かにフォームらしい画面はできます。ところが実際に使ってみると、Google Forms や Microsoft Forms に慣れた回答者からは物足りない、という話になります。

  • 「はい」と答えた人にだけ次の設問を出す、という回答に応じた分岐ができない
  • 設問を数ページに分けて、進捗バーを出しながら進めることができない
  • 画面が業務システムの編集画面そのままで、社外の回答者には固い

一方で、集めた回答をプリザンターのレコードとして持てるのは大きな利点です。一覧・集計・グラフ・CSV エクスポートといった仕組みが最初から揃っていますし、他の業務データと同じ場所に並びます。Forms で集めて CSV を手で取り込む運用に戻りたくはありません。

そこで、回答画面は Forms 相当、保存先はプリザンターという組み合わせのアプリを作ってみました。今回はその構成と、設計で悩んだところを紹介します。プリザンター本体には一切手を入れず、標準 Web API だけを使う独立したアプリです。

ソースは GitHub で公開しています。

どんなアプリなの?

回答者はブラウザで公開 URL を開き、Forms のような画面で答えます。裏側では本アプリのサーバがプリザンターの標準 API を叩いて、回答をレコードとして保存します。

回答画面

回答者から見えるのは、業務システムらしさのない普通のフォームです。ラジオボタン・直線尺度・長文といった設問が並び、必須項目には印が付きます。スマートフォンでも同じ定義から表示されます。

スマートフォンでの回答画面

構成は次のようになっています。

技術スタックはプリザンター本体(Pleasanter_1.5.7.0)に揃えました。同じ土俵に乗せておくと、参照実装としてソースを読むときに迷いません。

層 採用
ランタイム .NET 10(net10.0)
サーバ C# / ASP.NET Core
フロントエンド TypeScript + Vite + Svelte + SCSS
プリザンターとの接続 標準 Web API

1 アンケート = 1 プリザンターサイトにしています。こうするとサイトの SiteSettings がそのまま 1 枚分のフォームに対応するので、「どの列がどのアンケートの設問なのか」を判定する仕組みを本アプリ側に持たなくて済みます。

既存のプリザンター環境へ足せる

紹介の順番としては後回しにしがちなのですが、実はここが一番聞かれるところなので先に書いておきます。

本アプリはプリザンター本体を改造しない独立した ASP.NET Core アプリで、動く環境もプリザンター本体と同じ土俵に揃えてあります。.NET・Windows・IIS または Azure App Service、対応 RDBMS も SQL Server / PostgreSQL / MySQL の 3 種類です。そのため、すでにプリザンターが動いている環境へ、本体を止めずに横へ足せます。

追加するのはアプリ 1 つと専用 DB 1 つだけです。プリザンター側で必要なのは、回答を入れるサイトを用意することと、API キーを 1 本発行することだけになります。

オンプレミス IIS の場合

プリザンターが動いている Windows Server に、専用のサイトとアプリケーションプールを 1 組追加する形です。

項目 設定
.NET CLR version No Managed Code
Managed pipeline mode Integrated
Enable 32-Bit Applications False
物理パス 配置用 ZIP を展開したフォルダ

前提になるのは .NET 10 Hosting Bundle です。プリザンターが動いているサーバなら Hosting Bundle は既に入っているはずですが、バージョンが古ければ入れ直しが要ります。dotnet --list-runtimes で Microsoft.AspNetCore.App 10. が出ることを確認してください。

接続文字列や API キーといった設定は、アプリケーションプール単位の環境変数として登録します(IIS Manager の Configuration Editor で system.applicationHost/applicationPools の environmentVariables)。こうすると同じサーバのプリザンターと設定が混ざりません。

IIS の役割を追加した後に Hosting Bundle を入れてください。順序が逆だと ASP.NET Core Module が IIS へ登録されないので、Hosting Bundle を修復インストールすることになります。

Azure App Service の場合

同一の App Service プラン上に、Web アプリをもう 1 つ作ります。 プランは共有できるので、多くの場合は追加費用なしで載せられます。

ここで注意したいのがデプロイスロットとの違いです。スロットは同一 Web アプリの複製を作る仕組みであって、別のアプリを載せるためのものではありません。プリザンターのスロットに本アプリを置く構成は取れないので、Web アプリを別に作ってください。

API キーとプリザンターの URL は アプリケーション設定(環境変数) で外部化し、スロット設定(swap 時に入れ替えない設定) にしておきます。ステージングを検証用プリザンターへ向けている状態で swap すると、接続先を取り違えたまま入れ替わって事故になります。

そして WEBSITE_TIME_ZONE に Tokyo Standard Time を設定してください。理由は後半のタイムゾーンの節に書きます。

どちらの場合も共通すること

  • 本アプリ専用の DB を作る。 DB サーバはプリザンターと共用してかまいませんが、同じデータベースへ本アプリのマイグレーションを適用してはいけません。本アプリはプリザンターの DB へ直結せず、標準 Web API だけを使います
  • プリザンターをインターネットへ晒す必要はない。 本アプリ側とはサーバ間通信なので、本アプリから到達できれば足ります。回答者に見せるのは本アプリの URL だけです
  • マイグレーションは起動時に自動適用しません。 未適用のものがあるとアプリは安全のため起動を中止します。更新時は明示的に適用してください
  • 配置用の ZIP と SHA-256 は GitHub Release に添付しています。ZIP の直下がそのまま配置先になります

なぜサーバを 1 枚挟むのか

ブラウザから直接プリザンターの API を叩けば、サーバは要らないのではないか。最初にそう考えたのですが、プリザンターの API キーの渡し方で無理だと分かりました。

プリザンターの API キーは、リクエストボディの JSON に ApiKey として載せる方式です。X-API-Key や Authorization: Bearer のようなヘッダ方式は実装されていません。Implem.Pleasanter/Libraries/Requests/Context.cs の SetUserProperties が、リクエストボディを Api として解釈し、ApiKey が空でなければ Rds.UsersWhere().ApiKey(ApiKey) で利用者を特定しています。

つまり、ブラウザから直接叩く構成にすると API キーを匿名の回答者へ配ることになるわけです。それはできないので、本アプリのサーバが API キーを代理保持して中継します。BFF が要るのは体験のためではなく、資格情報の置き場所の都合です。

これには副作用があります。代理投稿なので、プリザンター上の作成者(Creator)は API キーの持ち主に固定されるのです。全レコードが同じ利用者の作成物として並びます。回答者を区別したいなら、氏名やメールを「設問」として持つしかありません。今回は完全匿名と割り切りました。

設問定義はどこに置くのか

ここが一番迷ったところです。素直に考えると、設問定義はプリザンターのサイト設定に置き、本アプリはそれを画面に写すだけの薄い層にしたくなります。

ただ、それだとプリザンターのサイト設定で表現できる範囲が、そのままアプリの上限になります。分岐も、ページ分割も、選択肢の下限・上限も、置き場所がありません。標準の編集画面で足りる範囲を作り直すだけになってしまいます。

そこで逆にしました。

  • 設問定義の主は本アプリの DB
  • プリザンターの列は回答の保存先として扱い、送信時にマッピングする

管理アプリの設問エディタは、Forms の編集画面に近い形にしています。設問ごとに形式・必須・補足を設定し、選択肢ごとに「選んだときの行き先」 を持たせることで分岐を表現します。表示条件、入力形式(正規表現)、選択肢の順序の入れ替えといった、サイト設定に置き場所がないものはすべてこちら側です。

設問エディタの全体(縦に長いので折りたたみ)

設問エディタの全体

GetSite は使いますが、用途は「回答画面を組み立てるため」ではなく「マッピング先としてどんな列が使えるかを調べるため」です。管理アプリで保存先の列を選ばせるときの候補元になります。

SiteSettings.Columns から読み取っている情報は次のあたりです。

Column のプロパティ 用途
ColumnName 送信時のキー
ControlType コントロール種別(TextBoxNumeric / Attachments など)
ChoicesControlType 選択肢の表示形式(DropDown / Radio など)
MultipleSelections 複数選択の可否
ChoicesText 選択肢の定義(生テキスト)
MaxLength 文字数上限

Column.ChoiceHash(解決済みの選択肢辞書)には [NonSerialized] が付いています。API 応答に載るかどうかはソースだけでは確定できないので、ChoicesText を自前で解析する経路も用意しました。ChoicesText には固定リストのほか、他サイトへのリンクや部署・ユーザ参照といった動的な指定も書けます。

回答の正本は JSON、列は派生

設問と列を 1 対 1 で対応させると、回答の編集で詰みます。

たとえば複数選択の「A と C」を "A,C" という 1 列に連結して保存したとします。回答者が編集で戻ってきたとき、この文字列から「A と C にチェックが入っていた」を復元するのは、選択肢に読点が含まれていれば怪しくなります。自由記述に特定の語が含まれていたら CheckD を立てる、といった変換は原理的に戻せません。

そこで、回答そのものを JSON にして 1 列(Description 系)へ保存し、マッピングされた列は一覧・集計に使う派生値と割り切ることにしました。

プリザンター側の 1 レコードは、こういう持ち方になります。

列 値 役割
DescriptionZ {"q1":["5"],...} 回答の正本
NumA 5 派生
ClassB "A,C" 派生
CheckA true 派生

編集で戻ってきた回答者には JSON から復元して見せるので、変換の表現力を落とさずに済みます。「回答を消した」も JSON 側で表現できます。

代償もあります。Description 列を 1 本消費しますし、プリザンター上で同じ情報が 2 か所に出ます。派生列を手で書き換えても正本は変わらないので、この点は運用手順に明記しました。

なお、この予約列の割り当ては任意にしています。列を設問へ全部使いたい場合や、回答内容を JSON で丸ごと 1 列に持ちたくない場合があるためです。ただし省略すると回答の編集ができなくなるので、管理アプリで保存前にその旨を伝えるようにしました。公開してから気づくと、既に集まった回答は編集できないままになります。

設問から列へのマッピング

派生列を作るには変換が要ります。「1 対 1 の写し」で足りないケースを並べてみます。

変換 例
選択肢 → コード 「とても満足」→ 5
複数選択 → 連結 「A, C」→ "A,C" を 1 列へ
複数選択 → 複数のチェック列 「A, C」→ CheckA=true / CheckB=false / CheckC=true
数値 → 選択肢へ射影 回答 4 → ClassA="満足"
尺度・星評価 → 数値 星 4 つ → NumA=4

自由に繋げるノードグラフにすると強力ですが、循環参照の検出、同じ列へ複数の値が流れ込む合流の衝突、評価順序の非決定性を全部自前で解くことになります。そこで取り得る形を 2 通りだけに絞りました。

形 意味
1 : 0 : 1 入力 1 つ、変換なし、出力 1 本。そのまま写す
N : 1 : 1 入力 N 個、変換 1 つ、出力 1 本

出力は必ず 1 本、そして入力が複数なら変換は必須(どうまとめるかが決まらないため)です。

「姓」と「名」を空白で連結して 1 列へ入れる場合は、次のようになります。

出力を 1 本に固定すると、先ほどの 3 つの問題が構造的に起こらなくなります。循環は作れず、合流の衝突も起き得ず、評価順序は入力の宣言順で決まります。失うのは変換の連鎖(A→B→C)だけですが、これはスクリプト変換で 1 段に書けます。

1 つの設問を複数の列へ写したいときは、同じ設問を入力にした割り当てを列の数だけ並べます。

副産物として、編集 UI が表で済みました。 当初は FBD(ファンクションブロックダイアグラム)風のノードエディタを検討していたのですが、出力が必ず 1 本なら線を引いて繋ぐ必要がありません。1 行が「ソース → 変換 → ターゲット」の 3 列にそのまま収まります。

マッピングエディタ

表なら印刷もできますし、外部のグラフ部品を増やさずに済みました。上部に Class: 1 / 26 列(標準) のように残りの列数を出しているのですが、これには理由があります。

列の本数という上限

プリザンターの標準の拡張項目は型ごとに 26 本(ClassA〜ClassZ など)です。1 設問が複数列を食う形式があるので、設問数の上限は実質的に列の本数で決まります。

  • 選択グリッドは行数ぶんの列を食う
  • 「その他」つきの選択肢は、選択肢列と自由記述列の2 列に展開する
  • Class 列は 1024 文字が上限。複数選択を連結する方式は、選択肢が多いと溢れる

そのため残りの列数と超過を必ず表示するようにしました。保存時に初めて失敗すると、アンケートの作り直しになります。桁溢れは黙って切らずに拒否します。切ってしまうと、回答の一部が失われたことに誰も気づけません。

拡張項目は Community Edition では ClassA〜ClassZ の 26 個です。Enterprise Edition の項目拡張を使うと Class001〜Class999 が追加で使えるようになり、型ごとの本数を増やせます。

回答は必ず一度自前の DB に置く

回答を受け取ったらそのままプリザンターへ投げる、という素直な作りにはしませんでした。回答は必ず本アプリの DB(送信待ちテーブル)へ書いてから、非同期で送ります。

完全匿名なので、送信に失敗した回答は再入力してもらう以外に取り戻す手がありません。何ページも書いた末に失われるのは、Forms なら起きない壊れ方です。

「失敗したときだけ退避する」でも良さそうに見えますが、そうしませんでした。稀にしか通らない経路は、本番で初めて動いて壊れます。 障害時にだけ動く仕組みは、肝心の障害時に動きません。経路を 1 本にすれば、退避も再送も毎回動いているので常に検証されている状態になります。引き換えにプリザンターへの反映は常に少し遅れますが、回答者にとっては受付完了が返れば済む話です。

この送信待ちテーブルはキューではありません。回答ごとに 1 行で、同じ回答が編集されたら上書きします。送信先は「新規なら Create、対応表に ReferenceId があれば Update」で決まるので、途中経過を順番に送る必要がなく、最新の状態だけ送れば足ります。これで送信順序の保証も、同一回答に対する更新の競合も消えます。

代わりに気をつけることが増えました。

  • 読み出しも 2 段構え。 未送信の回答はプリザンターにまだ無いので、編集で戻ってきた回答者に「さっき送った内容」を見せるには、送信待ちテーブルを先に見る必要があります
  • 一時保管であって蓄積先ではない。 回答の正本はプリザンターなので、送信できたら消します。集計・エクスポートの対象にはしません
  • 個人情報が常に自前 DB を経由する。 氏名・メール・自由記述が必ず一度は載ります。送信できたら必ず消し、ログや監視へ回答本文を出さないようにしました
  • 取り出しの排他は必須。 Create は冪等でないので、スケールアウトすると同じ回答を複数インスタンスが拾って二重登録になります

そして、溜まっていることに気づけないのが一番まずいので、管理画面に送信状況の画面を置きました。

送信状況の画面

出しているのは未送信の件数と最古の滞留時刻、それに送信できなかった回答の件数です。回答の中身は表示しません。滞留が上限を超えたら受付を自動で停止し、送信が追いつくと再開します。プリザンターが止まっている間に無限に溜め続けるより、断ったほうが後始末が楽だという判断です。

完全匿名で多重投稿をどう抑えるか

サーバ側で回答者を識別できないので、回答ごとに推測不能なトークンを発行し、「トークン → プリザンターのレコード ID」の対応を本アプリの DB に持つことにしました。

当初は回答ごとの GUID を Class 列へ入れて Upsert するつもりでしたが、設問へ回せる列を 1 本消費するのは割に合いません。本アプリの DB の行は安いので、対応表はこちらに持ちます。

引き換えに失ったのが送信の冪等性です。Create を投げて応答が返る前に切れると、レコードができたのか分からず、そのまま再送すると二重登録になります。ここは正本 JSON に必ず入れてある回答トークンを検索して照合し、見つかればその ReferenceId を控える形で解決しました。

守っていることを挙げておきます。

  • ReferenceId をブラウザへ渡さない。 プリザンターのレコード ID は連番なので、渡すと他人の回答を書き換えられます
  • ブラウザが持つのはトークンだけ。 暗号論的乱数から作ります
  • トークンを URL・メール・ログへ載せない。 知っている人はその回答を書き換えられます
  • 回答用 URL は /f/{PublicId} にし、サイト ID を URL に出さない。 素で晒すと、総当たりで他のアンケートへ到達し得ます

そして、限界を隠さないことが大事だと思っています。これは端末単位・ブラウザ単位の抑止であって、本人確認ではありません。Cookie や Web Storage を消す、別のブラウザを使う、シークレットウィンドウを使えば回避できます。

もっとも、これは本アプリだけの制約ではありません。Microsoft Forms でも「1 人 1 回」を選べるのは組織内限定のときだけで、完全匿名の公開フォームで確実に止める手段は、あちらにもありません。厳密な一意性が要るなら、匿名前提を捨てて URL トークン方式へ変える必要があります。

bot 対策は最初から入れる

完全匿名の公開フォームを認証なしでインターネットへ晒すわけなので、ここは後回しにできませんでした。読み取りだけの公開ページとは違い、匿名の第三者が書き込み・添付・編集を行えます。DB とストレージを消費させられる面が開いているということです。

外部の CAPTCHA サービスは既定では使わないことにしました。理由は 3 つです。

  1. 費用をかけたくない。 無償枠のあるサービスもありますが、規模と条件に縛られます
  2. インターネットへ出られないイントラでも動かしたい。 外部の CAPTCHA は配信元から取得することが必須のものが多く、閉じた環境では動きません
  3. 完全匿名と両立しにくい。 判定のために回答者の IP や操作の癖を第三者へ送ります

既定は ALTCHA(MIT ライセンス、自前設置できる proof-of-work)にし、設定で reCAPTCHA・Turnstile・hCaptcha へ切り替えられるようにしました。「信頼できるから外部のものを使いたい」という導入先もあるためです。

そのうえで、判定は 4 つ重ねています。

手段 内容
送信チケット 画面を開くと署名付きのチケットと回答トークンを返し、送信時に検証する
投稿までの最短時間 発行時刻を署名に含めてあるので、サーバに何も覚えずに経過時間を測れる(既定 3 秒)
ハニーポット項目 画面にも読み上げにも出さない項目。埋まっていたら bot
proof-of-work 同じチケットの口で ALTCHA の課題も返す。使い終えた課題は覚え、同じ解答を 2 度通さない

この設計で気を遣ったのは、対策そのものが情報を漏らさないようにすることです。

  • チケットの発行で DB を見ない。 見ると応答の速さから公開 ID の実在が分かります。存在しない公開 ID にも同じようにチケットを返し、受付の段で断ります
  • bot の判定は DB を見る前に行う。 書いてから弾いても消費は起きています。この順序のおかげで、bot 対策で断った応答は公開 ID の実在を漏らしません
  • 断った理由は外へ返さない。 返すと bot にどこを直せばよいか教えることになります。理由はログにだけ残します
  • 黙って捨てない。 ハニーポットに引っかかった回答にも 403 を返します。正規の回答者が自動入力で巻き込まれたときに、気づけないまま消えるのが一番まずいです

proof-of-work だけはアンケートごとに切り替えられるようにしました。社内向けの短いアンケートでは要らない一方、公開の窓口では要るので、アプリ全体の設定では足りません。ここでも画面が「要らない」と言ってきても信じず、DB の旗で判定します。信じると、解答を付けずに投げるだけで外せてしまいます。

タイムゾーンで 9 時間ずれる

最後に、実機で確認して肝を冷やした話です。この構成ではタイムゾーンが 3 つ別々に存在し得ます。

# 何のタイムゾーンか 既定
1 本アプリの実行環境 Azure App Service は既定 UTC
2 API キーに紐づくプリザンターの利用者 その利用者の Users.TimeZone
3 回答者のブラウザ 端末の設定

Context.TimeZoneInfo の既定は Environments.TimeZoneInfoDefault ですが、利用者が特定できた場合は userModel.TimeZoneInfo で上書きされます。API キー認証でもこの経路を通るので、Context のタイムゾーンは「API キーの持ち主」のものになります。

実機で確認すると、DB にはプリザンターサーバの OS ローカル時刻で書かれ、API の境界で「API キー保有ユーザの TimeZone」との間で変換されるという挙動でした。同じレコードが、キー保有ユーザの設定を変えるだけで 09:00 にも 18:00 にも見えました。

対処は次のようにしています。

  • Azure App Service ではアプリ設定 WEBSITE_TIME_ZONE を Tokyo Standard Time にし、実行環境を JST へ寄せる
  • それに依存しきらず、アプリ内では DateTimeOffset で扱う
  • API キー保有ユーザの TimeZone を確認し、設定ファイルに記録しておく
  • 回答者のタイムゾーンは表示にのみ使い、保存値の解釈には使わない

そして何より、TimeZone も WEBSITE_TIME_ZONE も運用開始後に変更してはいけません。過去の回答の見え方が全部ずれます。

作らなかったもの

線を引いたところも書いておきます。

  • 集計・グラフ・CSV エクスポートは作りません。 プリザンター本体の機能を使います。ここを自前で作り始めると、プリザンターをバックエンドにする意味が薄れます
  • 回答のコピーの自動返信メールは今回は対象外。 ただし送信基盤を差し込める形にしてあります
  • 回答者の認証は持ちません。 いまは完全匿名です。厳密な一意性が要るようになったら URL トークン方式へ変えます
  • プリザンター本体のコードは取り込みません。 ソースは事実確認の参照専用です

試してみるには

開発・検証環境は Docker で完結するようにしてあります。ホストに .NET SDK も Node も DB クライアントも要りません。 プリザンター・RDBMS・本アプリをまとめて起動します。

git clone https://github.com/vehiclevisionjp/VehicleVision.PleasanterTools.Questionnaire.git
cd VehicleVision.PleasanterTools.Questionnaire
git submodule update --init --recursive
docker compose --profile sqlserver up -d --wait   # SQL Server で動かす
docker compose --profile postgres  up -d --wait   # PostgreSQL で動かす
docker compose --profile mysql     up -d --wait   # MySQL で動かす
docker compose --profile "*" down -v              # 後片付け
到達先 URL
本アプリ http://localhost:8081
プリザンター http://localhost:8080

Windows の Git Bash から実行する場合は MSYS_NO_PATHCONV=1 を付けてください。パスが勝手に変換されます。

ライセンス

デュアルライセンスで提供しています。SPDX 表記は AGPL-3.0-or-later OR LicenseRef-PMC-Commercial です。

ライセンス 想定する利用
A GNU AGPL v3 以降 オープンソースとして利用・改変・再配布する場合
B PMC 商用ライセンス AGPL の義務を負わずに利用したい場合(個別契約)

AGPL v3 の第 13 条はネットワーク越しの利用にも及びます。本アプリは回答画面を公開する用途なので、改変して使う場合は「ネットワーク越しのサービス提供」に該当しやすい点にご注意ください。

このデュアルライセンスは、プリザンター本体(AGPL v3)のコードを取り込んでいないことが前提になっています。標準 Web API 越しにのみ通信し、DB へも直接接続しません。記事の冒頭で「本体には一切手を入れない」と書いたのは、作りやすさの話だけではなく、ライセンス上の必然でもあるわけです。

まとめ

プリザンターをバックエンドにした Web アンケートアプリの構成を紹介しました。設計で効いた判断は次のとおりです。

  • 設問定義の主は本アプリの DB に置き、プリザンターの列は保存先として扱う。 サイト設定の表現力を上限にしない
  • 回答の正本は JSON で 1 列に持ち、マッピングされた列は派生と割り切る。 戻せない変換を安心して使える
  • マッピングは N : 1 : 1 に絞る。 循環・合流の衝突・評価順序の問題が構造的に消え、編集 UI も表で済む
  • 回答は必ず一度自前の DB へ置いてから非同期で送る。 障害時にだけ動く経路を作らない
  • 多重投稿の抑止は端末単位だと認めて書く。 完全匿名では原理的にそれ以上できない
  • bot 対策は最初から多層で入れ、対策自体が情報を漏らさないようにする
  • 動く環境をプリザンター本体と同じ土俵に揃える。 既存環境へ横に足せることが、そのまま導入のしやすさになる

プリザンターの列は有限(型ごとに 26 本)で、1 設問が複数列を食う形式もあります。設問数の上限が列の本数で決まるという制約は、この手の作りでは必ずぶつかるところなので、残り列数を画面に出すところまで作り込むのがおすすめです。

API キーがリクエストボディ方式であることや、タイムゾーンが API の境界で変換されることは、プリザンターと外部アプリを繋ぐときに共通して効く話です。似たような連携を作る方の参考になればうれしいです。

ソースは GitHub にあります。動かしてみた感想や、ここはこうしたほうがよいという指摘をいただけると助かります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?