概要
Web アプリケーションのプロトタイプを容易に作ることができる環境整備を個人的に進めています。この記事では、その計画の途中経過を共有します。
下記のようなマークダウン形式のテーブル定義をもとに、シンプルな Web アプリケーションを自動生成できる環境を構築しました。
# Tables
## Users
- id: number
- name: string
- email: string
- created_at: Date
- updated_at: Date
## Tasks
- id: number
- name: string
- description: string
- due_date: Date
- priority: number
- created_at: Date
- updated_at: Date
用意する必要があるのは erd.md だけで、スクリプトを実行するとローカル環境でこのようなページが立ち上がります。
画面左側のサイドバーで操作したいテーブルを選択すると、登録されているデータの一覧が表示され、新規登録ボタンからデータを追加したり、削除ボタンでデータを削除したりできます。データの更新は今後実装する予定です。見た目も整えていきたいです。
目的
自動生成による Web アプリケーション開発環境を整備しているのは、何かのプロトタイプを作りたくなった時に簡単に実現できるようにすることが目的です。
このようなプロトタイプ開発には Claude など LLM を利用していきたいと考えています。以前 Claude を用いてプロトタイプ開発を行ったのですが、(安直な方法で進めると) システムが大きくなったときに以下のような問題に遭遇しました。
- システム全体のコードをプロンプトに含めるとトークン数の消費が激しい
- プロンプトに含めるコードを減らすと他のファイルとの整合性を保つことが難しい
その解決策として、開発タスクを疎結合に分解しそれぞれを独立したプロンプトとして LLM を利用する方法を考えました。その方法を試してみると確かに上記の課題の解消に繋がったのですが、各システム特有のコードだけではなく単純な CRUD の API など同じようなコードも毎回 LLM で生成する点が気になりました。
(割とうまく行きますが) 毎回同じようなコードにも関わらずデバッグが必要になることがあるのは面倒だと感じたので、ルールベースで生成できる部分はテンプレートエンジンを用いて自動生成したいと考え、この記事に書いたような環境整備を進めています。
自動生成でアプリケーションのベースを作成できるようになったら、そこにシステム特有の仕様を LLM で開発してマージすることによって、快適でスケーラブルなプロトタイプ開発環境を実現できるのではないかと考えています。
主な使用技術
- バックエンド: Ruby on Rails, SQLite
- フロントエンド: Vue, Typescript
- 自動生成スクリプト: Python
自動生成
erd.md から自動生成は Python の Jinja2 というテンプレートエンジンを利用しています。マークダウンをパースしてテーブル名・カラム名・型を取得し、テンプレートをもとにファイルを生成します。テーブル名とカラム名は単数形・複数形や snake_case, PascalCase, camelCase に対応するものを作成し、型は Typescript, Ruby, SQLite のものを作成します。
テンプレートファイルの例
module Api
module V1
class {{ table.plural_name | pascalcase }}Controller < ApplicationController
before_action :set_{{ table.name | lower }}, only: [:show, :update, :destroy]
# GET /api/v1/{{ table.plural_name | lower }}
def index
{{ table.plural_name | lower }} = {{ table.name | pascalcase }}.all
render json: {
status: 'success',
items: {{ table.plural_name | lower }},
count: {{ table.plural_name | lower }}.count
}
end
# GET /api/v1/{{ table.plural_name | lower }}/:id
def show
render json: {
status: 'success',
data: @{{ table.name | lower }}
}
end
# POST /api/v1/{{ table.plural_name | lower }}
def create
{{ table.name | lower }} = {{ table.name | pascalcase }}.new({{ table.name | lower }}_params)
if {{ table.name | lower }}.save
render json: {
status: 'success',
data: {{ table.name | lower }}
}, status: :created
else
render json: {
status: 'error',
message: {{ table.name | lower }}.errors.full_messages
}, status: :unprocessable_entity
end
end
# PATCH/PUT /api/v1/{{ table.plural_name | lower }}/:id
def update
if @{{ table.name | lower }}.update({{ table.name | lower }}_params)
render json: {
status: 'success',
data: @{{ table.name | lower }}
}
else
render json: {
status: 'error',
message: @{{ table.name | lower }}.errors.full_messages
}, status: :unprocessable_entity
end
end
# DELETE /api/v1/{{ table.plural_name | lower }}/:id
def destroy
@{{ table.name | lower }}.destroy
render json: {
status: 'success',
message: '{{ table.name | pascalcase }} was successfully deleted'
}
end
private
def set_{{ table.name | lower }}
@{{ table.name | lower }} = {{ table.name | pascalcase }}.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: {
status: 'error',
message: '{{ table.name | pascalcase }} not found'
}, status: :not_found
end
def {{ table.name | lower }}_params
params.require(:{{ table.name | lower }}).permit(:name, :email)
end
end
end
end
erd.md から自動生成されるファイル一覧
自動生成されるファイルの一覧は Users 関連だと以下の通りです。自分がローカル環境で動かす前提なので、今のところエラーハンドリングなどはほとんど実装しない方針です。
バックエンド
routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
get 'health_check', to: 'health#check'
resources :users
resources :tasks
end
end
end
user.rb
class User < ApplicationRecord
end
users_controller.rb
module Api
module V1
class UsersController < ApplicationController
before_action :set_user, only: [:show, :update, :destroy]
# GET /api/v1/users
def index
users = User.all
render json: {
status: 'success',
items: users,
count: users.count
}
end
# GET /api/v1/users/:id
def show
render json: {
status: 'success',
data: @user
}
end
# POST /api/v1/users
def create
user = User.new(user_params)
if user.save
render json: {
status: 'success',
data: user
}, status: :created
else
render json: {
status: 'error',
message: user.errors.full_messages
}, status: :unprocessable_entity
end
end
# PATCH/PUT /api/v1/users/:id
def update
if @user.update(user_params)
render json: {
status: 'success',
data: @user
}
else
render json: {
status: 'error',
message: @user.errors.full_messages
}, status: :unprocessable_entity
end
end
# DELETE /api/v1/users/:id
def destroy
@user.destroy
render json: {
status: 'success',
message: 'User was successfully deleted'
}
end
private
def set_user
@user = User.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: {
status: 'error',
message: 'User not found'
}, status: :not_found
end
def user_params
params.require(:user).permit(:name, :email)
end
end
end
end
{timestamp}_create_users.rb
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
end
フロントエンド
database.ts
import { RouteRecordRaw } from 'vue-router'
import BaseDataView from '@/views/BaseDataView.vue'
import UsersDataView from '@/views/UsersDataView.vue'
import TasksDataView from '@/views/TasksDataView.vue'
export const databaseRoutes: RouteRecordRaw = {
path: '/database',
component: BaseDataView,
children: [
{
path: 'users',
name: 'users',
component: UsersDataView
},
{
path: 'tasks',
name: 'tasks',
component: TasksDataView
},
]
}
user.ts
export class User {
constructor(init?: Partial<User>) {
Object.assign(this, init);
}
id?: number;
name?: string;
email?: string;
createdAt?: Date;
updatedAt?: Date;
}
api.ts
import apiClient from './client';
import { User } from '../models/user';
import { Task } from '../models/task';
export class Api {
// User API methods
async getUser(id: number): Promise<User> {
const response = await apiClient.get<User>(`/users/${id}`);
return new User(response.data);
}
async getUsers(): Promise<User[]> {
const response = await apiClient.get<User[]>('/users');
return response.items.map(item => new User(item));
}
async updateUser(id: number, user: Partial<User>): Promise<User> {
const response = await apiClient.put<User>(`/users/${id}`, user);
return new User(response.data);
}
async createUser(user: Omit<User, 'id'>): Promise<User> {
const response = await apiClient.post<User>('/users', user);
return new User(response.data);
}
async deleteUser(id: number): Promise<void> {
await apiClient.delete(`/users/${id}`);
}
// Task API methods
async getTask(id: number): Promise<Task> {
const response = await apiClient.get<Task>(`/tasks/${id}`);
return new Task(response.data);
}
async getTasks(): Promise<Task[]> {
const response = await apiClient.get<Task[]>('/tasks');
return response.items.map(item => new Task(item));
}
async updateTask(id: number, task: Partial<Task>): Promise<Task> {
const response = await apiClient.put<Task>(`/tasks/${id}`, task);
return new Task(response.data);
}
async createTask(task: Omit<Task, 'id'>): Promise<Task> {
const response = await apiClient.post<Task>('/tasks', task);
return new Task(response.data);
}
async deleteTask(id: number): Promise<void> {
await apiClient.delete(`/tasks/${id}`);
}
}
export default Api;
UsersDataView.vue
<script setup lang="ts">
import { useApi } from '@/composables/use_api';
import { User } from '@/models/user';
import { onMounted, ref } from 'vue';
import DataTable from '@/components/DataTable.vue';
import DataForm from '@/components/DataForm.vue';
const api = useApi();
const users = ref<User[] | null>(null);
const showForm = ref(false);
const onGetUsers = async () => {
users.value = await api.getUsers();
};
const onCreateUser = async (user: User) => {
await api.createUser(user);
showForm.value = false;
users.value = await api.getUsers();
}
const onDeleteUser = async (id: number) => {
await api.deleteUser(id);
users.value = await api.getUsers();
}
onMounted(async () => {
await onGetUsers();
});
</script>
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900">Users</h1>
<button
@click="showForm = !showForm"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{{ showForm ? '新規作成をキャンセル' : '新規作成' }}
</button>
</div>
<DataForm
v-if="showForm"
:data="new User()"
:submit="onCreateUser"
/>
<DataTable
v-if="users"
:data="users"
:delete="onDeleteUser"
/>
</div>
</template>
コンポーネントの共通化
フロントのデータ一覧テーブルやデータ登録フォームは、スキーマごとに生成するのではなく、抽象化してオブジェクトを受け取ることができるコンポーネントを作成しました。静的ファイルにすることでスキーマ修正のたびに差分が生まれるのを防いでいます。
やや複雑なコンポーネントですが、Claude に大部分を生成させて結構楽をすることができました。
DataTable.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
interface TableHeader {
key: string
label: string
sortable?: boolean
}
const props = defineProps<{
data: Record<string, any>[],
headers?: TableHeader[],
delete?: (id: any) => Promise<void>,
}>();
// ヘッダー情報の自動生成
const generatedHeaders = computed<TableHeader[]>(() => {
if (props.headers) return [...props.headers, { key: 'actions', label: 'アクション', sortable: false }]
if (props.data.length === 0) return []
// オブジェクトの最初の要素からキーを取得
const firstItem = props.data[0]
return [
...Object.keys(firstItem).map(key => ({
key,
label: formatLabel(key),
sortable: true
})),
{ key: 'actions', label: 'アクション', sortable: false } // アクション列を追加
]
})
// キャメルケースやスネークケースを人が読みやすい形式に変換
const formatLabel = (key: string): string => {
return key
.replace(/[_-]/g, ' ')
.replace(/([A-Z])/g, str => ' ' + str)
.replace(/^./, str => str.toUpperCase())
.trim()
}
// ソート関連の状態管理
const sortColumn = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
// ソート関数
const sortBy = (key: string) => {
if (key === 'actions') return // アクション列はソート不可
if (sortColumn.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortColumn.value = key
sortDirection.value = 'asc'
}
}
// ソート済みデータの計算
const sortedData = computed(() => {
if (!sortColumn.value) return props.data
return [...props.data].sort((a, b) => {
const aValue = a[sortColumn.value]
const bValue = b[sortColumn.value]
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sortDirection.value === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue)
}
return sortDirection.value === 'asc'
? aValue > bValue ? 1 : -1
: aValue < bValue ? 1 : -1
})
})
// 削除ハンドラー
const handleDelete = (item: Record<string, any>) => {
if (props.delete && item.id) {
props.delete(item.id)
}
}
</script>
<template>
<div class="w-full overflow-x-auto">
<table v-if="data.length > 0" class="w-full min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
v-for="header in generatedHeaders"
:key="header.key"
@click="header.sortable && sortBy(header.key)"
class="px-6 py-3 text-left text-sm font-semibold text-gray-600"
:class="[
header.sortable && 'cursor-pointer hover:bg-gray-100 transition-colors',
sortColumn === header.key && 'bg-gray-100'
]"
>
<div class="flex items-center space-x-1">
<span>{{ header.label }}</span>
<span v-if="header.sortable" class="text-gray-400">
<span v-if="sortColumn === header.key" class="ml-1">
{{ sortDirection === 'asc' ? '↑' : '↓' }}
</span>
<span v-else class="ml-1 opacity-0 group-hover:opacity-50">↑</span>
</span>
</div>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(item, index) in sortedData"
:key="index"
class="hover:bg-gray-50 transition-colors"
>
<td
v-for="header in generatedHeaders"
:key="header.key"
class="px-6 py-4 text-sm text-gray-600 whitespace-nowrap"
>
<template v-if="header.key === 'actions'">
<button
v-if="delete"
@click="handleDelete(item)"
class="text-red-600 hover:text-red-800 font-medium"
>
削除
</button>
</template>
<template v-else>
{{ item[header.key] }}
</template>
</td>
</tr>
</tbody>
</table>
<div
v-else
class="w-full p-8 text-center text-gray-500 bg-gray-50 rounded-lg"
>
データがありません
</div>
</div>
</template>
DataForm.vue
<script setup lang="ts">
import { computed, ref } from 'vue';
interface FormField {
key: string;
type: string;
value: any;
}
const props = defineProps<{
data: Record<string, any>,
submit: (d: typeof props.data) => Promise<void>,
}>();
const formData = ref({ ...props.data });
const isSubmitting = ref(false);
const error = ref('');
const getInputType = (value: any): string => {
switch (typeof value) {
case 'number':
return 'number';
case 'boolean':
return 'checkbox';
case 'string':
return 'text';
default:
return 'text';
}
};
const fields = computed((): FormField[] => {
return Object.entries(props.data).map(([key, value]) => ({
key,
type: getInputType(value),
value
}));
});
const onSubmit = async () => {
console.log(formData.value);
try {
isSubmitting.value = true;
error.value = '';
await props.submit(formData.value);
} catch (e) {
error.value = e instanceof Error ? e.message : '送信中にエラーが発生しました';
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<form @submit.prevent="onSubmit" class="space-y-4">
<div v-for="field in fields" :key="field.key" class="form-field">
<label :for="field.key" class="block text-sm font-medium text-gray-700">
{{ field.key }}
</label>
<input
v-if="field.type !== 'checkbox'"
:id="field.key"
:type="field.type"
v-model="formData[field.key]"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<input
v-else
:id="field.key"
type="checkbox"
v-model="formData[field.key]"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</div>
<div v-if="error" class="text-red-600 text-sm mt-2">
{{ error }}
</div>
<button
type="submit"
:disabled="isSubmitting"
class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{{ isSubmitting ? '送信中...' : '保存' }}
</button>
</form>
</template>
まとめ
簡単にプロトタイプ開発ができる環境を整備する過程の1つとして、マークダウン形式のテーブル定義をもとに、 CRUD 操作ができる シンプルな Web アプリケーションを自動生成しました。
ルールベースの生成で実現できる部分の整備がかなり進んだので、今後は自動生成したアプリケーションに LLM で生成したコードをマージすることによって、ある程度複雑な仕様でも簡単にプロトタイプ開発ができるような環境にしていく予定です。