PHP 5.2からLaravel 11への大規模リニューアル #3 データ移行戦略と文字コード問題への対処
はじめに
前回の記事では、新システムのアーキテクチャ設計と技術選定について解説しました。
※前回の記事リンク:https://zenn.dev/abg/articles/041e12df4015b0
今回は、プロジェクトで最も困難だったデータ移行について詳しくご紹介します。
単純なデータのコピーではなく、データ構造の根本的な見直しが必要でした。
特にEUC-JPからUTF-8mb4への文字コード変換、パーソナリティのデータベース化、イベント管理の戦略的判断など、データ設計の再考が移行成功の鍵となりました。
データ移行の全体戦略
移行対象の規模
総レコード数: 約14,500件
- ユーザーデータ: 859件
- 番組情報: 1,666件
- ニュース記事: 116件
- ゲスト情報: 11,191件
- 放送局: 約200件
- 番組スケジュール: 約500件
段階的移行アプローチ
ユーザーデータをあえて移行しなかった理由
実は今回のリニューアルで、ユーザーデータは意図的に移行しないという大胆な判断をしました:
-
セキュリティの根本的な改善
- MD5ハッシュのパスワードをそのまま移行するのはセキュリティリスクが高い
- 新システムでbcryptを採用するため、パスワードの再設定が必要
-
認証方式の現代化
- ソーシャルログイン(Google/X)の実装を優先
- アイパス(ID/Password)方式だけでは現代のUXに適合しない
-
アクティブユーザーの再定義
- 859件のユーザーデータのうち、実際にアクティブなユーザーは限定的
- 新規登録により、真にアクティブなユーザーベースを構築
この判断により、技術的負債を完全にリセットし、より安全で使いやすい認証システムを構築できました。
文字コード問題の深刻さ
EUC-JPの制約
レガシーシステムではEUC-JPを使用していたため、以下の問題がありました:
// 旧システムでの文字化けの例
$name = "髙橋"; // 「髙」が正しく扱えない
$emoji = "😊"; // 絵文字は使用不可
$special = "Ⅲ"; // ローマ数字が文字化け
文字化けパターンの分析
移行前に、実際のデータから文字化けパターンを抽出:
// app/Console/Commands/AnalyzeEncodingIssues.php
class AnalyzeEncodingIssues extends Command
{
public function handle()
{
$issues = [];
// 旧データベースから読み込み
$oldDb = DB::connection('old_mysql');
$records = $oldDb->select('SELECT * FROM programs');
foreach ($records as $record) {
// 複数のエンコーディングで変換を試みる
$conversions = [
'EUC-JP' => mb_convert_encoding($record->title, 'UTF-8', 'EUC-JP'),
'SJIS' => mb_convert_encoding($record->title, 'UTF-8', 'SJIS'),
'JIS' => mb_convert_encoding($record->title, 'UTF-8', 'JIS'),
];
// 文字化けの検出
foreach ($conversions as $encoding => $converted) {
if ($this->containsMojibake($converted)) {
$issues[] = [
'original' => $record->title,
'encoding' => $encoding,
'converted' => $converted,
'pattern' => $this->detectMojibakePattern($converted),
];
}
}
}
$this->generateReport($issues);
}
private function containsMojibake($str): bool
{
// 文字化けの典型的なパターン
$patterns = [
'/[?]{3,}/', // ???の連続
'/\x{FFFD}/u', // Unicode置換文字
'/[☐⬜□]/u', // 豆腐文字
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $str)) {
return true;
}
}
return false;
}
}
パーソナリティ名の表記揺れという最大の難関
文字コード問題と並んで最も苦戦したのが、パーソナリティ名の表記揺れ問題でした。
実際に直面した表記揺れの例
旧システムでは、ゲスト情報が自由記述のテキストフィールドで管理されていました。その結果、同じパーソナリティでも以下のような多様な表記が存在:
// データベースから抽出した実際の表記例
$variations = [
// ユニット表記のバリエーション
'やまとなでしこ(田村ゆかり、堀江由衣)',
'やまとなでしこ(堀江由衣・田村ゆかり)',
'やまとなでしこ(田村ゆかり/堀江由衣)',
// 改名・表記変更パターン
'石田燿子',
'石田よう子',
'栗林みな実',
'Minami',
'minami(栗林みな実)',
// 区切り文字の違い
'Aqours(高槻かなこ、小林愛香)',
'Aqours(高槻かなこ/小林愛香)',
'Aqours:高槻かなこ&小林愛香',
];
ユニット所属の複雑性
特に悩ましかったのが、ユニットメンバーの部分参加パターンです:
// 実際のデータ例とその解釈の難しさ
$complexCases = [
[
'original' => 'Aqours(斉藤朱夏、小宮有紗)',
'interpretation' => [
'案1: Aqoursから2名のみ参加',
'案2: Aqours代表として2名',
'案3: たまたま2名の名前を記載',
]
],
[
'original' => 'Aqours',
'interpretation' => [
'案1: 9名全員が参加',
'案2: メンバー不明',
'案3: ユニットとしての参加',
]
],
[
'original' => 'μ\'s(新田恵海他)',
'interpretation' => [
'案1: 新田恵海を中心に複数名',
'案2: 全員参加だが代表名のみ記載',
]
],
];
解決策:個人とユニットの柔軟な階層構造
この問題を解決するため、パーソナリティを以下の3タイプに分類:
// app/Enums/PersonalityType.php
enum PersonalityType: string
{
case INDIVIDUAL = 'individual'; // 個人
case UNIT = 'unit'; // ユニット
case SPECIAL = 'special'; // 特殊(番組キャラクター等)
}
// データベース設計
Schema::create('personalities', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('name_kana')->nullable();
$table->enum('type', ['individual', 'unit', 'special']);
$table->json('aliases')->nullable(); // 別名・旧名を保存
$table->timestamps();
});
Schema::create('personality_units', function (Blueprint $table) {
$table->id();
$table->foreignId('unit_id')->constrained('personalities');
$table->foreignId('member_id')->constrained('personalities');
$table->date('joined_at')->nullable();
$table->date('left_at')->nullable();
$table->timestamps();
});
ユニットの入れ子構造への対応
さらに複雑なケースとして、ユニットに所属するユニットにも対応しました:
// 実際の例:Aqours内のサブユニット構造
$unitHierarchy = [
'Aqours' => [
'members' => ['伊波杏樹', '逢田梨香子', '諏訪ななか', ...],
'subunits' => [
'CYaRon!' => ['伊波杏樹', '斉藤朱夏', '降幡愛'],
'AZALEA' => ['諏訪ななか', '小宮有紗', '高槻かなこ'],
'Guilty Kiss' => ['逢田梨香子', '小林愛香', '鈴木愛奈'],
]
],
];
// キャラクター名での登録パターン(一人ユニットとして実装)
$characterAsUnit = [
'高海千歌(CV.伊波杏樹)' => [
'type' => 'unit', // 一人ユニットとして登録
'members' => ['伊波杏樹'],
],
'津島善子' => [
'type' => 'unit',
'members' => ['小林愛香'],
],
];
// personality_unitsテーブルの工夫
// member_idには個人だけでなくユニットも設定可能
$examples = [
['unit_id' => 'Aqours', 'member_id' => 'Guilty Kiss'], // ユニット→ユニット
['unit_id' => 'Guilty Kiss', 'member_id' => '小林愛香'], // ユニット→個人(声優)
['unit_id' => '津島善子', 'member_id' => '小林愛香'], // キャラクター(一人ユニット)→声優
];
この複雑な構造により、声優名での登録とキャラクター名での登録の両方に対応。キャラクター名は「一人ユニット」として扱うことで、データ構造の一貫性を保ちました。
この設計により、「Guilty Kiss」が番組にゲスト出演した場合でも、Aqoursの一部として、またはGuilty Kiss単独として、柔軟に記録できるようになりました。
名寄せアルゴリズムの実装
Claude Codeと協働して、表記揺れを吸収する名寄せアルゴリズムを開発:
// app/Services/PersonalityMatcher.php
class PersonalityMatcher
{
public function findOrCreate(string $rawName): array
{
$parsed = $this->parseGuestString($rawName);
$results = [];
// ユニット名が含まれる場合
if ($parsed['unit']) {
$unit = $this->findUnitByVariations($parsed['unit']);
if ($unit) {
$results[] = $unit;
}
}
// 個人名の処理
foreach ($parsed['individuals'] as $name) {
$personality = $this->findByNameVariations($name);
if (!$personality) {
// 新規作成(手動確認フラグ付き)
$personality = Personality::create([
'name' => $name,
'type' => 'individual',
'needs_review' => true,
]);
}
$results[] = $personality;
}
return $results;
}
private function parseGuestString(string $str): array
{
// 括弧内の処理
if (preg_match('/^(.+?)[(\(](.+?)[)\)]/', $str, $matches)) {
$unit = trim($matches[1]);
$members = preg_split('/[、・,\/&&]/', $matches[2]);
return [
'unit' => $unit,
'individuals' => array_map('trim', $members),
];
}
// 単独名の場合
return [
'unit' => null,
'individuals' => [$str],
];
}
}
移行後の手動確認プロセス
完全な自動化は不可能だったため、以下の確認プロセスを実装:
- 自動マッチング率: 約70%が自動で正しく分類
- 要確認フラグ: 30%に手動確認フラグを設定
- 管理画面での統合機能: 重複や誤分類を手動で修正
AIでは解決できない同姓同名問題
さらに困難だったのが、同一表記の別人物の判別です:
// 実際に直面した同姓同名の例
$ambiguousCases = [
'前田愛' => [
'声優の前田愛(AiM)', // アニメラジオ番組に出演
'女優の前田愛(まえだあい)', // ドラマ・映画関連番組に出演
// 両方ともラジオ番組に出演する可能性があり判別困難
],
'田中理恵' => [
'声優の田中理恵', // アニメ・ゲーム番組に多数出演
// 体操選手の田中理恵はアニラジには出演しないため、実質声優のみ登録
],
];
// 番組傾向による判別を試みたが...
class PersonalityDisambiguator
{
public function disambiguate(string $name, Program $program): ?Personality
{
// 番組カテゴリからの推測
if ($program->category === 'anime') {
// アニメ番組なら声優の可能性が高い
return $this->findByNameAndType($name, 'voice_actor');
}
// しかし、完全な判別は不可能
// 声優がバラエティ番組に出演することもあるため
return null; // 手動確認が必要
}
}
この問題は技術的に完全解決は不可能で、現在も過去のデータを見ながら継続的に修正作業を行っています。データ移行は「完了」ではなく、継続的な改善プロセスであることを痛感しました。
この経験から学んだのは、完璧を求めすぎず、7割の自動化と3割の手動作業の組み合わせが現実的ということでした。
イベントレポートの移行戦略
独自ファイルからデータベースへの移行
旧システムでは、イベントレポートは独自のHTMLファイルで管理していました:
// 旧システムのイベントレポート構造
events/
├── 2020/
│ ├── 0312_animejapan/
│ │ ├── index.html // 親記事
│ │ ├── stage1.html // 子記事(ステージイベント)
│ │ ├── stage2.html // 子記事(別ステージ)
│ │ └── images/ // 写真フォルダ
│ └── 1225_comiket/
│ └── ...
データベース化の設計判断
イベントレポートの移行で直面した課題と解決策:
// 新システムでの階層構造対応
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->foreignId('parent_id')->nullable()
->constrained('events')->onDelete('cascade'); // 親子関係
$table->date('event_date');
$table->timestamps();
});
Schema::create('event_photos', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained();
$table->string('file_path');
$table->string('caption')->nullable();
$table->integer('display_order');
$table->timestamps();
});
必須要件だった画像掲載機能と親子階層構造を実現しました。
イベント情報の管理範囲の決断
重要な設計判断として、すべてのイベント情報を管理しないことを選択:
// 管理対象の判定基準
class EventScope
{
public function shouldImport(array $eventData): bool
{
// アニラジが主体となるイベントのみ
if ($eventData['type'] === 'radio_event') {
return true;
}
// アニメイベントでもラジオブースがある場合のみ
if ($eventData['has_radio_booth']) {
return true;
}
// それ以外は管理対象外
return false;
}
}
この決断により、管理の複雑化を避け、アニラジに特化したサイトとしての独自性を維持しました。
Eventernoteとの連携による解決
管理対象外のイベント情報については、Eventernoteとの連携で補完:
// app/Services/EventernoteService.php
class EventernoteService
{
public function linkActor(int $actorId, int $personalityId): void
{
// EventernoteのActor IDとパーソナリティIDを紐付け
DB::table('eventernote_actor_links')->insert([
'eventernote_actor_id' => $actorId,
'personality_id' => $personalityId,
'created_at' => now(),
]);
}
public function getEventInfo(string $eventCode): ?array
{
// EventernoteのAPIから情報取得(読み取り専用)
return Http::get("https://api.eventernote.com/events/{$eventCode}")
->json();
}
}
// 実際の連携例
// - Aniradi → Eventernote: Actor情報の参照、イベント詳細の取得
// - Eventernote → Aniradi: 連携なし(一方向の参照のみ)
この連携により、イベント情報の網羅性を保ちつつ、管理コストを最小限に抑えることができました。
Artisanコマンドによる移行自動化
データ移行の自動化にあたり、Claude Codeと協働して65個のArtisanコマンドを生成しました。しかし、AIが生成したコードをそのまま使用するのではなく、段階的な検証が重要でした。
AI駆動開発の注意点
AIとの協働開発において学んだ重要なポイント:
-
提案の検証を必ず行う
- AIの提案するコードは論理的に正しく見えても、実データで問題が発生することがある
- 特に文字コード変換では、実際のデータでテストが必須
-
段階的な実装とテスト
- 一度に全データを移行せず、少量のサンプルデータで検証
- 問題が発生した場合のロールバック戦略を準備
-
エッジケースの明示的な指示
- AIは一般的なケースは得意だが、業務特有の仕様は明示的に伝える必要がある
- 例:30時間制、和暦変換、特殊文字の扱い
統合移行コマンドの実装
// app/Console/Commands/MigrateAllData.php
class MigrateAllData extends Command
{
protected $signature = 'aniradi:migrate-all {--fresh : 既存データを削除}';
public function handle()
{
$this->info('Aniradi Network データ移行開始');
DB::beginTransaction();
try {
// 移行順序は依存関係を考慮
$this->call('aniradi:migrate-stations');
$this->call('aniradi:migrate-personalities');
$this->call('aniradi:migrate-programs');
$this->call('aniradi:migrate-schedules');
$this->call('aniradi:migrate-users');
$this->call('aniradi:migrate-news');
$this->call('aniradi:migrate-guests');
$this->call('aniradi:migrate-events');
DB::commit();
$this->info('全データ移行完了!');
// レポート生成
$this->call('aniradi:generate-migration-report');
} catch (\Exception $e) {
DB::rollback();
$this->error('移行エラー: ' . $e->getMessage());
return 1;
}
}
}
文字コード変換の実装
Claude Codeとの対話で、文字化けパターンの収集と対策を効率化できました。ただし、実際のデータベースからサンプルを抽出し、変換結果を目視確認することが重要でした。
// app/Services/EncodingConverter.php
class EncodingConverter
{
private array $conversionMap = [];
public function __construct()
{
// よくある文字化けの修正マップ
$this->conversionMap = [
'高' => '髙', // はしごだか
'Ⅲ' => 'III', // ローマ数字
'㈱' => '(株)', // 株式会社記号
'①' => '(1)', // 丸数字
];
}
public function convert(string $text): string
{
// 基本変換
$converted = mb_convert_encoding($text, 'UTF-8', 'EUC-JP');
// NULL文字の除去
$converted = str_replace("\0", '', $converted);
// 制御文字の除去
$converted = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $converted);
// 既知の文字化けパターンを修正
foreach ($this->conversionMap as $from => $to) {
$converted = str_replace($from, $to, $converted);
}
// 半角カナを全角に変換
$converted = mb_convert_kana($converted, 'KV', 'UTF-8');
return $converted;
}
public function detectAndConvert($text)
{
// 文字エンコーディングを自動検出
$encoding = mb_detect_encoding($text, ['EUC-JP', 'SJIS', 'JIS', 'UTF-8'], true);
if (!$encoding) {
// 検出失敗時のフォールバック
$this->logEncodingError($text);
return $this->fallbackConvert($text);
}
if ($encoding === 'UTF-8') {
return $text;
}
return $this->convert($text);
}
}
特殊なデータ移行ケース
HTMLコンテンツの移行
ニュース記事にはHTMLタグが混在していました:
// app/Console/Commands/MigrateNews.php
class MigrateNews extends Command
{
public function migrateNewsContent($oldContent)
{
// HTMLエンティティのデコード
$content = html_entity_decode($oldContent, ENT_QUOTES, 'UTF-8');
// 旧システムの独自タグを変換
$content = str_replace('<font color="red">', '<span class="text-danger">', $content);
$content = str_replace('</font>', '</span>', $content);
// 絶対パスを相対パスに変換
$content = str_replace('http://old.aniradi.com/', '/', $content);
// XSS対策のためのサニタイズ
$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);
return $purifier->purify($content);
}
}
日付データの正規化
// 様々な形式の日付を統一
class DateNormalizer
{
public function normalize($dateString)
{
// 和暦の変換
if (preg_match('/平成(\d+)年(\d+)月(\d+)日/', $dateString, $matches)) {
$year = 1988 + (int)$matches[1];
return sprintf('%04d-%02d-%02d', $year, $matches[2], $matches[3]);
}
// 令和の変換
if (preg_match('/令和(\d+)年(\d+)月(\d+)日/', $dateString, $matches)) {
$year = 2018 + (int)$matches[1];
return sprintf('%04d-%02d-%02d', $year, $matches[2], $matches[3]);
}
// その他の形式
$formats = [
'Y/m/d',
'Y-m-d',
'Y年m月d日',
'd/m/Y',
'm/d/Y',
];
foreach ($formats as $format) {
try {
$date = Carbon::createFromFormat($format, $dateString);
return $date->format('Y-m-d');
} catch (\Exception $e) {
continue;
}
}
// パースできない場合
throw new \Exception("日付形式を認識できません: {$dateString}");
}
}
パーソナリティ名の名寄せ
同一人物が異なる表記で登録されている問題:
// app/Services/PersonalityMerger.php
class PersonalityMerger
{
public function findDuplicates()
{
$personalities = Personality::all();
$duplicates = [];
foreach ($personalities as $p1) {
foreach ($personalities as $p2) {
if ($p1->id >= $p2->id) continue;
// 表記揺れを考慮した比較
if ($this->isSamePerson($p1, $p2)) {
$duplicates[] = [$p1, $p2];
}
}
}
return $duplicates;
}
private function isSamePerson($p1, $p2): bool
{
// 完全一致
if ($p1->name === $p2->name) {
return true;
}
// スペースの違いを無視
$name1 = str_replace([' ', ' '], '', $p1->name);
$name2 = str_replace([' ', ' '], '', $p2->name);
if ($name1 === $name2) {
return true;
}
// ひらがな/カタカナの違いを無視
$kana1 = mb_convert_kana($name1, 'c', 'UTF-8');
$kana2 = mb_convert_kana($name2, 'c', 'UTF-8');
if ($kana1 === $kana2) {
return true;
}
// レーベンシュタイン距離で類似度チェック
$distance = levenshtein($name1, $name2);
$similarity = 1 - ($distance / max(strlen($name1), strlen($name2)));
return $similarity > 0.9;
}
}
データ整合性チェック
移行後の検証
// app/Console/Commands/CheckDataIntegrity.php
class CheckDataIntegrity extends Command
{
public function handle()
{
$checks = [
'レコード数の検証' => $this->checkRecordCounts(),
'文字化けチェック' => $this->checkEncodingIssues(),
'外部キー整合性' => $this->checkForeignKeys(),
'NULL値チェック' => $this->checkNullValues(),
'重複データチェック' => $this->checkDuplicates(),
];
$this->table(
['チェック項目', '結果', 'エラー数'],
collect($checks)->map(function ($result, $check) {
return [
$check,
$result['passed'] ? '✅ OK' : '❌ NG',
$result['errors'],
];
})->toArray()
);
}
private function checkRecordCounts()
{
$expected = [
'users' => 3000,
'programs' => 1500,
'news' => 15000,
];
$errors = 0;
foreach ($expected as $table => $count) {
$actual = DB::table($table)->count();
if (abs($actual - $count) > $count * 0.01) { // 1%の誤差を許容
$errors++;
}
}
return [
'passed' => $errors === 0,
'errors' => $errors,
];
}
}
パフォーマンスの最適化
バルクインサートの実装
// 大量データの効率的な挿入
class BulkDataMigrator
{
private const CHUNK_SIZE = 1000;
public function migrate($sourceTable, $targetTable, $transformer)
{
$oldDb = DB::connection('old_mysql');
$oldDb->table($sourceTable)
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($records) use ($targetTable, $transformer) {
$data = [];
foreach ($records as $record) {
$data[] = $transformer($record);
}
// バルクインサート
DB::table($targetTable)->insert($data);
// メモリ解放
unset($data);
$this->info("処理済み: " . count($records) . "件");
});
}
}
メモリ管理
// メモリ効率的な処理
ini_set('memory_limit', '512M');
// ジェネレータを使用した省メモリ処理
function readLargeFile($file)
{
$handle = fopen($file, 'r');
while (!feof($handle)) {
yield fgets($handle);
}
fclose($handle);
}
トラブルシューティング
よくあった問題と解決策
問題 | 原因 | 解決策 |
---|---|---|
文字化け | 複数のエンコーディング混在 | 自動検出と個別対応 |
外部キー制約エラー | 参照先データの欠損 | 移行順序の調整 |
メモリ不足 | 大量データの一括処理 | チャンク処理の実装 |
重複データ | 一意性制約の欠如 | 事前チェックと統合処理 |
まとめ
データ移行は予想以上に困難でしたが、Claude Codeとの協働により効率的に解決できました:
- 段階的移行により、リスクを最小化
- 文字コード問題に対する多層的な対策をAIと共に構築
- 65個のArtisanコマンドを自動生成し、再現性を確保
- データ整合性チェックで、品質を保証
- パフォーマンス最適化で、現実的な時間内に完了
AI駆動開発で得られた教訓
- AIは強力な補助ツール:複雑な変換ロジックの実装を大幅に効率化
- 人間の検証は必須:特に文字コードやデータ整合性は目視確認が重要
- 段階的なアプローチ:AIの提案を小さく試し、問題があれば修正を繰り返す
- ドメイン知識の重要性:業務特有の仕様はAIに明示的に伝える必要がある
特に文字コード問題は、日本のレガシーシステムに共通する課題です。AIツールを活用することで、従来なら数週間かかる作業を数日で完了できました。
次回予告
次回の記事では、以下の内容をお届けします:
- リアルタイムチャット機能の実装
- AI連携によるゲスト情報解析
- Twitter API v2との連携
- 週単位時間システムの実装
お楽しみに!
Discussion