プリザンターをバックエンドにした Web アンケート
プリザンターの編集画面でもフォームらしいものは作れますが、回答に応じた分岐やページ分割・進捗バーはできず、社外の回答者には画面が固いという課題があります。一方で、回答をプリザンターのレコードとして持てれば、一覧・集計・グラフ・CSV エクスポートがそのまま使えます。 このページでは、回答画面は Forms 相当、保存先はプリザンターという独立したアプリ(プリザンター本体には手を入れず、標準 Web API だけを使う)の構成と、プリザンターと外部アプリをつなぐときに効く設計判断をまとめます。
ソースは GitHub で公開されています: vehiclevisionjp/VehicleVision.PleasanterTools.Questionnaire

構成
図を読み込み中…
技術スタックはプリザンター本体(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)。追加するのはアプリ 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 です。バージョンが古ければ入れ直しが必要です。
dotnet --list-runtimesでMicrosoft.AspNetCore.App 10.が出ることを確認します - 接続文字列や API キーはアプリケーションプール単位の環境変数として登録します(IIS Manager の Configuration Editor で
system.applicationHost/applicationPoolsのenvironmentVariables)。同じサーバのプリザンターと設定が混ざりません
WARNING
IIS の役割を追加した後に Hosting Bundle を入れてください。順序が逆だと ASP.NET Core Module が IIS へ登録されず、Hosting Bundle を修復インストールすることになります。
Azure App Service の場合
- 同一の App Service プラン上に Web アプリをもう 1 つ作ります。 プランは共有できるので、多くの場合は追加費用なしで載せられます
- デプロイスロットは同一 Web アプリの複製を作る仕組みで、別アプリを載せるためのものではありません。プリザンターのスロットに置く構成は取れません
- API キーとプリザンターの URL はアプリケーション設定(環境変数)で外部化し、**スロット設定(swap 時に入れ替えない設定)**にします。ステージングを検証用プリザンターへ向けたまま swap すると、接続先を取り違えたまま入れ替わります
WEBSITE_TIME_ZONEにTokyo Standard Timeを設定します(理由は「タイムゾーン」の節)
共通の注意点
- アプリ専用の DB を作る。 DB サーバはプリザンターと共用してかまいませんが、同じデータベースへアプリのマイグレーションを適用してはいけません。アプリはプリザンターの DB へ直結せず、標準 Web API だけを使います
- プリザンターをインターネットへ晒す必要はない。 サーバ間通信なので、アプリから到達できれば足ります。回答者に見せるのはアプリの URL だけです
- マイグレーションは起動時に自動適用しない。 未適用のものがあるとアプリは起動を中止します
- 配置用の ZIP と SHA-256 は GitHub Release に添付されています
なぜサーバ(BFF)を挟むのか
プリザンターの API キーはリクエストボディの JSON に ApiKey として載せる方式で、X-API-Key や Authorization: Bearer のようなヘッダ方式は(標準 Web API には)実装されていません。Context.cs が、リクエストボディを Api として解釈し、ApiKey が空でなければ Rds.UsersWhere().ApiKey(ApiKey) で利用者を特定しています(Context.cs)。例外はファイルのみのアップロード(/api/binaries/upload)で、こちらは Authorization: Bearer ヘッダで受け取ります(BinariesController.cs)。
ブラウザから直接 API を叩く構成にすると API キーを匿名の回答者へ配ることになるため、アプリのサーバが API キーを代理保持して中継します。
その副作用として、プリザンター上の作成者(Creator)は API キーの持ち主に固定されます。回答者を区別したい場合は、氏名やメールを設問として持つしかありません(このアプリは完全匿名と割り切っています)。
INFO
MCP サーバは標準 API と異なり、ヘッダ X-API-Key(または Authorization: Bearer)で認証します。詳しくは プリザンターの MCP を参照してください。
設問定義はアプリ側、列は保存先
設問定義をプリザンターのサイト設定に置くと、サイト設定で表現できる範囲がそのままアプリの上限になり、分岐・ページ分割・選択肢の下限上限などの置き場所がありません。そこで、
- 設問定義の主はアプリの DB
- プリザンターの列は回答の保存先として扱い、送信時にマッピングする
としています。GetSite は回答画面を組み立てるためではなく、マッピング先としてどんな列が使えるかを調べるために使います。SiteSettings.Columns から読み取っている情報は次のとおりです。
Column のプロパティ | 用途 |
|---|---|
ColumnName | 送信時のキー |
ControlType | コントロール種別(TextBoxNumeric / Attachments など) |
ChoicesControlType | 選択肢の表示形式(DropDown / Radio など) |
MultipleSelections | 複数選択の可否 |
ChoicesText | 選択肢の定義(生テキスト) |
MaxLength | 文字数上限 |
WARNING
Column.ChoiceHash(解決済みの選択肢辞書)には [NonSerialized] が付いています。API 応答に載るかどうかはソースだけでは確定できないため、ChoicesText を自前で解析する経路も用意しています。ChoicesText には固定リストのほか、他サイトへのリンクや部署・ユーザ参照といった動的な指定も書けます。
回答の正本は JSON、列は派生
複数選択の「A と C」を "A,C" として 1 列に保存すると、選択肢に読点が含まれる場合に編集時の復元が怪しくなります。自由記述に特定の語があれば CheckD を立てる、といった変換は原理的に戻せません。そこで、回答そのものを JSON にして 1 列(Description 系)へ保存し、マッピングされた列は一覧・集計用の派生値と割り切っています。
| 列 | 値 | 役割 |
|---|---|---|
DescriptionZ | {"q1":["5"],...} | 回答の正本 |
NumA | 5 | 派生 |
ClassB | "A,C" | 派生 |
CheckA | true | 派生 |
代償として Description 列を 1 本消費し、同じ情報がプリザンター上の 2 か所に出ます。派生列を手で書き換えても正本は変わらないため、運用手順に明記します。この予約列の割り当ては任意ですが、省略すると回答の編集ができなくなるため、管理アプリで保存前にその旨を伝えます。
設問から列へのマッピング
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 本、入力が複数なら変換は必須です。これで循環・合流の衝突・評価順序の問題が構造的に起こらなくなり(評価順序は入力の宣言順)、編集 UI も「ソース → 変換 → ターゲット」の表で済みます。
図を読み込み中…
1 つの設問を複数の列へ写す場合は、同じ設問を入力にした割り当てを列の数だけ並べます。
図を読み込み中…
列の本数という上限
標準の拡張項目は型ごとに 26 本(ClassA〜ClassZ など)で、1 設問が複数列を消費する形式もあるため、設問数の上限は実質的に列の本数で決まります。
- 選択グリッドは行数ぶんの列を消費する
- 「その他」つきの選択肢は、選択肢列と自由記述列の 2 列に展開する
Class列は 1024 文字が上限。複数選択の連結は、選択肢が多いと溢れる
そのため管理画面で残りの列数と超過を必ず表示し、桁溢れは黙って切らずに拒否しています。
INFO
Enterprise Edition の項目拡張を使うと Class001〜Class999 が追加で使えるようになり、型ごとの本数を増やせます。
回答は必ず一度自前の DB に置いてから送る
回答は受け取ったらすぐプリザンターへ投げるのではなく、必ずアプリの DB(送信待ちテーブル)へ書いてから非同期で送ります。完全匿名では、送信に失敗した回答は再入力してもらう以外に取り戻せないためです。「失敗したときだけ退避する」ではなく経路を 1 本にすることで、退避と再送が毎回動き、常に検証された状態になります。
図を読み込み中…
送信待ちテーブルはキューではありません。回答ごとに 1 行で、同じ回答が編集されたら上書きします。送信先は「新規なら Create、対応表に ReferenceId があれば Update」で決まるため、最新の状態だけ送れば足り、送信順序の保証や同一回答への更新の競合が不要になります。
その代わりに気をつけていることです。
- 読み出しも 2 段構え。 未送信の回答はプリザンターにまだ無いので、編集で戻ってきた回答者には送信待ちテーブルを先に見て返す
- 一時保管であって蓄積先ではない。 正本はプリザンター。送信できたら消し、集計・エクスポートの対象にしない
- 個人情報が必ず自前 DB を経由する。 送信できたら必ず消し、ログや監視へ回答本文を出さない
- 取り出しの排他は必須。
Createは冪等でないため、スケールアウトすると複数インスタンスが同じ回答を拾って二重登録になる - 滞留を見える化する。 管理画面に未送信件数・最古の滞留時刻・送信できなかった件数を表示し(回答の中身は出さない)、滞留が上限を超えたら受付を自動停止、追いつくと再開する
完全匿名での多重投稿抑止
回答ごとに推測不能なトークンを発行し、「トークン → プリザンターのレコード ID」の対応をアプリの DB に持ちます。GUID を Class 列へ入れて Upsert する案は、設問に回せる列を 1 本消費するため採用していません。
その引き換えに送信の冪等性を失うため、Create の応答が返る前に切れた場合は、正本 JSON に必ず入れてある回答トークンで検索して照合し、見つかればその ReferenceId を控える形で解決しています。
ReferenceIdをブラウザへ渡さない。 レコード ID は連番なので、渡すと他人の回答を書き換えられる- ブラウザが持つのはトークンだけ。 暗号論的乱数から作る
- トークンを URL・メール・ログへ載せない
- 回答用 URL は
/f/{PublicId}にし、サイト ID を URL に出さない。 総当たりで他のアンケートへ到達されるのを防ぐ
限界
これは端末単位・ブラウザ単位の抑止であって、本人確認ではありません。Cookie や Web Storage の削除、別ブラウザ、シークレットウィンドウで回避できます。厳密な一意性が必要なら、匿名前提を捨てて URL トークン方式へ変える必要があります。
bot 対策
認証なしの公開フォームでは、匿名の第三者が書き込み・添付・編集を行え、DB とストレージを消費させられます。外部の CAPTCHA サービスは、費用・インターネットに出られないイントラでの動作・完全匿名との両立の理由から既定では使わず、既定は ALTCHA(MIT ライセンス、自前設置できる proof-of-work)にし、設定で reCAPTCHA・Turnstile・hCaptcha へ切り替えられるようにしています。
判定は 4 つ重ねています。
| 手段 | 内容 |
|---|---|
| 送信チケット | 画面を開くと署名付きのチケットと回答トークンを返し、送信時に検証する |
| 投稿までの最短時間 | 発行時刻を署名に含め、サーバに何も覚えずに経過時間を測る(既定 3 秒) |
| ハニーポット項目 | 画面にも読み上げにも出さない項目。埋まっていたら bot |
| proof-of-work | 同じチケットの口で ALTCHA の課題も返す。使い終えた課題は覚え、同じ解答を 2 度通さない |
対策自体が情報を漏らさないよう、次の点に気を配っています。
- チケットの発行で DB を見ない。 応答速度から公開 ID の実在が分かるため、存在しない公開 ID にも同じようにチケットを返し、受付の段で断る
- bot の判定は DB を見る前に行う
- 断った理由は外へ返さない。 理由はログにだけ残す
- 黙って捨てない。 ハニーポットに引っかかった回答にも
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を確認し、設定ファイルに記録しておく - 回答者のタイムゾーンは表示にのみ使い、保存値の解釈には使わない
DANGER
TimeZone も WEBSITE_TIME_ZONE も、運用開始後に変更してはいけません。過去の回答の見え方がすべてずれます。
作らなかったもの
- 集計・グラフ・CSV エクスポート — プリザンター本体の機能を使う
- 回答のコピーの自動返信メール — 対象外(送信基盤は差し込める形にしてある)
- 回答者の認証 — 完全匿名
- プリザンター本体のコードの取り込み — ソースは事実確認の参照専用
試してみるには
開発・検証環境は Docker で完結し、ホストに .NET SDK・Node・DB クライアントは不要です。プリザンター・RDBMS・アプリをまとめて起動します。
git clone https://github.com/vehiclevisionjp/VehicleVision.PleasanterTools.Questionnaire.git
cd VehicleVision.PleasanterTools.Questionnaire
git submodule update --init --recursivedocker 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 |
INFO
Windows の Git Bash から実行する場合は MSYS_NO_PATHCONV=1 を付けてください。パスが勝手に変換されます。
ライセンス
デュアルライセンスで、SPDX 表記は AGPL-3.0-or-later OR LicenseRef-PMC-Commercial です。
| ライセンス | 想定する利用 | |
|---|---|---|
| A | GNU AGPL v3 以降 | オープンソースとして利用・改変・再配布する場合 |
| B | PMC 商用ライセンス | AGPL の義務を負わずに利用したい場合(個別契約) |
WARNING
AGPL v3 の第 13 条はネットワーク越しの利用にも及びます。回答画面を公開する用途なので、改変して使う場合は「ネットワーク越しのサービス提供」に該当しやすい点に注意してください。
このデュアルライセンスは、プリザンター本体(AGPL v3)のコードを取り込んでいないことが前提です。アプリは標準 Web API 越しにのみ通信し、DB へも直接接続しません。
関連ツール:LimeSurvey を Azure App Service で動かす
回答をプリザンターのレコードとして持つ必要がなければ、アンケート専用の OSS を別に立てる方法もあります。LimeSurvey は、豊富な質問タイプ・条件分岐・多言語対応を備えた OSS のオンラインアンケート・調査プラットフォームです。回答データは LimeSurvey 自身の DB に保存します。
Docker イメージ(martialblog/limesurvey)を App Service の Linux コンテナで動かすと、バージョンアップをイメージタグの変更と再起動だけで済ませられます。
図を読み込み中…
構築の要点は次のとおりです(PostgreSQL フレキシブルサーバー・ストレージアカウント・App Service プランの作成は通常どおり)。
# Web アプリの作成(タグにバージョンを明示する)
az webapp create \
--resource-group rg-limesurvey \
--plan plan-limesurvey \
--name app-limesurvey \
--deployment-container-image-name martialblog/limesurvey:6-apache
# アップロードファイルを Azure Files に永続化(STORAGE_KEY はストレージアカウントのアクセスキー)
az webapp config storage-account add \
--resource-group rg-limesurvey \
--name app-limesurvey \
--custom-id limesurvey-uploads \
--storage-type AzureFiles \
--account-name stlimesurvey \
--share-name limesurvey-uploads \
--access-key $STORAGE_KEY \
--mount-path /var/www/html/uploadタグ 6-apache は LimeSurvey 6.x 系の最新版(Apache ベース)を指します。特定バージョンに固定するなら 6.6.4-apache のようにパッチバージョンまで指定します。
アプリケーション設定(環境変数)で DB 接続と初期管理者を指定します。
| 変数名 | 値 |
|---|---|
DB_TYPE | pgsql |
DB_HOST / DB_PORT / DB_NAME | psql-limesurvey.postgres.database.azure.com / 5432 / limesurvey |
DB_USERNAME / DB_PASSWORD | limeadmin / <パスワード> |
DB_TABLE_PREFIX | lime_ |
LIMESURVEY_ADMIN_USER / LIMESURVEY_ADMIN_PASSWORD | admin / <管理者パスワード> |
LIMESURVEY_ADMIN_NAME / LIMESURVEY_ADMIN_EMAIL | Administrator / admin@example.com |
ALLOW_UPDATEDB | true |
WEBSITES_PORT | 8080 |
ALLOW_UPDATEDB=true にすると、コンテナ起動時にデータベースのマイグレーションが自動実行されます。初回起動時は DB の初期化に数分かかることがあり、az webapp log tail で Database update successfully のようなメッセージが出れば完了です。
バージョンアップは、事前にバックアップを取ってからイメージタグを変えて再起動するだけです。
az postgres flexible-server backup create \
--resource-group rg-limesurvey \
--name psql-limesurvey \
--backup-name "pre-upgrade-$(date +%Y%m%d)"
az webapp config container set \
--resource-group rg-limesurvey \
--name app-limesurvey \
--docker-custom-image-name martialblog/limesurvey:6.6.5-apache
az webapp restart \
--resource-group rg-limesurvey \
--name app-limesurveyWARNING
メジャーバージョンをまたぐアップグレード(例: 5.x → 6.x)では、事前に LimeSurvey の公式リリースノートで破壊的変更がないか確認してください。PostgreSQL のファイアウォール規則(0.0.0.0 の許可は Azure サービス全体からのアクセス許可)は、本番では App Service の送信 IP を固定して厳密に制限します。