$runners 축 실행기 목록 (컨테이너 태그 주입) */ public function __construct( private readonly BenchmarkProfileRegistry $registry, private readonly BenchmarkReporter $reporter, private readonly iterable $runners, ) { parent::__construct(); } /** * 커맨드를 실행합니다. * * @return int 종료 코드 */ public function handle(): int { $this->applyDatabaseOverride(); if ($this->option('list-profiles')) { return $this->listProfiles(); } [$profiles, $selectionError] = $this->selectProfiles(); if ($selectionError !== null) { $this->error($selectionError); return self::FAILURE; } if ($profiles === []) { $this->error('계측할 프로파일이 없습니다. --profile / --axis / --all 중 하나를 지정하세요.'); return self::FAILURE; } $options = $this->runOptions(); // 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다 if (! $this->option('json')) { foreach ($this->reporter->environment() as $name => $value) { $this->line(sprintf(' %-14s %s', $name, $value)); } $this->newLine(); } $this->reportCollectionWarnings(); $results = []; foreach ($profiles as $profile) { $results[] = $this->runProfile($profile, $options); } return $this->output($results, $options); } /** * 계측용 데이터베이스 지정을 반영합니다. * * 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 합니다. * config 가 캐시된 환경에서는 `.env` / `--env` 로 연결을 바꿀 수 없어 런타임 치환이 * 유일한 수단입니다. */ private function applyDatabaseOverride(): void { $database = (string) $this->option('database'); if ($database === '') { return; } $connection = config('database.default'); config([ "database.connections.{$connection}.database" => $database, "database.connections.{$connection}.write.database" => $database, "database.connections.{$connection}.read.database" => $database, ]); DB::purge($connection); } /** * 등록된 프로파일 목록을 출력합니다. * * @return int 종료 코드 */ private function listProfiles(): int { $profiles = $this->registry->all(); if ($this->option('json')) { $this->line((string) json_encode( array_map(fn (BenchmarkProfile $profile) => $profile->toArray(), array_values($profiles)), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )); return self::SUCCESS; } $this->table( ['프로파일', '축', '출처', '설명'], array_map(fn (BenchmarkProfile $profile) => [ $profile->qualifiedKey(), $profile->axis->value, $profile->sourceKind, $profile->label ?? '-', ], array_values($profiles)) ); $this->reportCollectionWarnings(); return self::SUCCESS; } /** * 실행할 프로파일을 고릅니다. * * @return array{0: array, 1: string|null} [실행 대상, 실패 사유] */ private function selectProfiles(): array { $keys = array_filter((array) $this->option('profile'), 'strlen'); if ($keys !== []) { $selected = []; foreach ($keys as $key) { [$profile, $error] = $this->registry->resolve((string) $key); if ($error !== null) { return [[], $error]; } $selected[] = $profile; } return [$selected, null]; } $axisOption = (string) $this->option('axis'); if ($axisOption !== '') { $axis = BenchmarkAxis::tryFrom($axisOption); if ($axis === null) { return [[], "알 수 없는 축: {$axisOption} (허용: ".implode('|', BenchmarkAxis::values()).')']; } return [array_values($this->registry->byAxis($axis)), null]; } return [$this->option('all') ? array_values($this->registry->all()) : [], null]; } /** * 커맨드 옵션을 실행 옵션으로 해석합니다. * * @return BenchmarkRunOptions 실행 옵션 */ private function runOptions(): BenchmarkRunOptions { $asUser = (string) $this->option('as'); return new BenchmarkRunOptions( offsets: $this->parseOffsets(), runs: max(1, (int) $this->option('runs')), seed: max(0, (int) $this->option('seed')), fresh: (bool) $this->option('fresh'), explain: (bool) $this->option('explain'), allowWrite: (bool) $this->option('allow-write'), asUser: $asUser === '' ? null : $asUser, perPage: max(1, (int) $this->option('per-page')), ); } /** * OFFSET 목록을 파싱합니다. * * @return array 오름차순 정렬된 OFFSET 목록 */ private function parseOffsets(): array { $offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen')); sort($offsets); return $offsets === [] ? [0] : $offsets; } /** * 프로파일 1건을 계측합니다. * * @param BenchmarkProfile $profile 대상 프로파일 * @param BenchmarkRunOptions $options 실행 옵션 * @return BenchmarkResult 계측 결과 */ private function runProfile(BenchmarkProfile $profile, BenchmarkRunOptions $options): BenchmarkResult { $runner = $this->runnerFor($profile->axis); if ($runner === null) { return BenchmarkResult::skipped($profile, "축 실행기가 없습니다: {$profile->axis->value}"); } if (! $this->option('json')) { $this->line(sprintf('▶ %s (%s)', $profile->qualifiedKey(), $profile->axis->label())); } $onProgress = $this->option('json') ? null : fn (string $message) => $this->line(' '.$message); return $runner->run($profile, $options, $onProgress); } /** * 축에 대응하는 실행기를 찾습니다. * * @param BenchmarkAxis $axis 대상 축 * @return BenchmarkAxisRunner|null 실행기 (없으면 null) */ private function runnerFor(BenchmarkAxis $axis): ?BenchmarkAxisRunner { foreach ($this->runners as $runner) { if ($runner->axis() === $axis) { return $runner; } } return null; } /** * 무시된 프로파일 선언을 알립니다. * * 선언 오류를 조용히 버리면 그 대상이 계측 사각으로 남으므로 매 실행에 드러냅니다. */ private function reportCollectionWarnings(): void { if ($this->option('json')) { return; } foreach ($this->registry->warnings() as $warning) { $this->warn('무시된 프로파일 선언 — '.$warning); } } /** * 계측 결과를 출력합니다. * * @param array $results 계측 결과 * @param BenchmarkRunOptions $options 실행 옵션 * @return int 종료 코드 */ private function output(array $results, BenchmarkRunOptions $options): int { $warnings = $this->registry->warnings(); if ($this->option('json')) { $this->line($this->reporter->toJson($results, $options, $warnings)); } else { foreach ($results as $result) { $this->newLine(); $this->line(sprintf('%s (%s)', $result->profile->qualifiedKey(), $result->profile->axis->label())); if ($result->skipped) { $this->warn(' 측정하지 않음: '.$result->skipReason); continue; } $this->table($result->headers, $result->rows); foreach ($result->notes as $note) { $this->line(' '.$note); } } } if ($this->wantsReport()) { $path = $this->writeReport($results, $options, $warnings); $this->newLine(); $this->info('리포트 저장: '.$path); } // 전부 건너뛴 실행을 성공으로 보고하면 "측정했다"로 읽히므로 실패로 돌려준다 $measured = array_filter($results, fn (BenchmarkResult $result) => ! $result->skipped); return $measured === [] ? self::FAILURE : self::SUCCESS; } /** * 리포트 출력이 요청되었는지 판정합니다. * * `--report` 는 값이 선택이라 `option()` 만으로는 "미지정"과 "값 없이 지정"을 * 구분할 수 없으므로 원시 입력을 확인합니다. * * @return bool 리포트 출력 여부 */ private function wantsReport(): bool { return $this->input->hasParameterOption('--report') || $this->input->hasParameterOption('--report=') || (string) $this->option('report') !== ''; } /** * 마크다운 리포트를 저장합니다. * * @param array $results 계측 결과 * @param BenchmarkRunOptions $options 실행 옵션 * @param array $warnings 프로파일 수집 경고 * @return string 저장된 절대 경로 */ private function writeReport(array $results, BenchmarkRunOptions $options, array $warnings): string { $given = (string) $this->option('report'); $path = $given !== '' ? $given : storage_path(self::REPORT_DIR.'/bench-'.now()->format('Ymd-His').'.md'); $directory = dirname($path); if (! is_dir($directory)) { mkdir($directory, 0755, true); } file_put_contents($path, $this->reporter->toMarkdown($results, $options, $warnings)); return $path; } }