1: <?php declare(strict_types=1);
2:
3: namespace Salient\Container;
4:
5: use Salient\Cache\CacheStore;
6: use Salient\Console\Target\StreamTarget;
7: use Salient\Contract\Cache\CacheInterface;
8: use Salient\Contract\Console\ConsoleMessageType as MessageType;
9: use Salient\Contract\Container\ApplicationInterface;
10: use Salient\Contract\Core\MessageLevel as Level;
11: use Salient\Contract\Core\MessageLevelGroup as LevelGroup;
12: use Salient\Contract\Curler\Event\CurlerEventInterface;
13: use Salient\Contract\Curler\Event\CurlRequestEventInterface;
14: use Salient\Contract\Curler\Event\ResponseCacheHitEventInterface;
15: use Salient\Contract\Sync\SyncClassResolverInterface;
16: use Salient\Core\Facade\Cache;
17: use Salient\Core\Facade\Config;
18: use Salient\Core\Facade\Console;
19: use Salient\Core\Facade\Err;
20: use Salient\Core\Facade\Event;
21: use Salient\Core\Facade\Profile;
22: use Salient\Core\Facade\Sync;
23: use Salient\Curler\CurlerHarRecorder;
24: use Salient\Sync\SyncStore;
25: use Salient\Utility\Exception\FilesystemErrorException;
26: use Salient\Utility\Exception\InvalidEnvironmentException;
27: use Salient\Utility\Arr;
28: use Salient\Utility\Env;
29: use Salient\Utility\File;
30: use Salient\Utility\Format;
31: use Salient\Utility\Get;
32: use Salient\Utility\Inflect;
33: use Salient\Utility\Package;
34: use Salient\Utility\Regex;
35: use Salient\Utility\Sys;
36: use DateTime;
37: use DateTimeZone;
38: use LogicException;
39: use Phar;
40: use RuntimeException;
41:
42: /**
43: * A service container for applications
44: */
45: class Application extends Container implements ApplicationInterface
46: {
47: private const PATH_CACHE = 0;
48: private const PATH_CONFIG = 1;
49: private const PATH_DATA = 2;
50: private const PATH_LOG = 3;
51: private const PATH_TEMP = 4;
52: private const PARENT_CONFIG = 0;
53: private const PARENT_DATA = 1;
54: private const PARENT_STATE = 2;
55:
56: /**
57: * [ Index => [ name, parent, child, Windows child, source child ], ... ]
58: */
59: private const PATHS = [
60: self::PATH_CACHE => ['cache', self::PARENT_STATE, 'cache', 'cache', 'var/cache'],
61: self::PATH_CONFIG => ['config', self::PARENT_CONFIG, null, 'config', 'var/lib/config'],
62: self::PATH_DATA => ['data', self::PARENT_DATA, null, 'data', 'var/lib/data'],
63: self::PATH_LOG => ['log', self::PARENT_STATE, 'log', 'log', 'var/log'],
64: self::PATH_TEMP => ['temp', self::PARENT_STATE, 'tmp', 'tmp', 'var/tmp'],
65: ];
66:
67: private string $AppName;
68: private string $BasePath;
69: private string $WorkingDirectory;
70: private bool $RunningFromSource;
71:
72: /**
73: * @var array<self::PATH_*,string|null>
74: */
75: private array $Paths = [
76: self::PATH_CACHE => null,
77: self::PATH_CONFIG => null,
78: self::PATH_DATA => null,
79: self::PATH_LOG => null,
80: self::PATH_TEMP => null,
81: ];
82:
83: private bool $OutputLogIsRegistered = false;
84: private ?int $HarListenerId = null;
85: private ?CurlerHarRecorder $HarRecorder = null;
86: private string $HarFilename;
87:
88: // --
89:
90: /** @var Level::* */
91: private static int $ShutdownReportLevel;
92: private static bool $ShutdownReportResourceUsage;
93: private static bool $ShutdownReportRunningTimers;
94: /** @var string[]|string|null */
95: private static $ShutdownReportMetricGroups;
96: private static ?int $ShutdownReportMetricLimit;
97: private static bool $ShutdownReportIsRegistered = false;
98:
99: /**
100: * Creates a new Application object
101: *
102: * If `$basePath` is `null`, the value of environment variable
103: * `app_base_path` is used if present, otherwise the path of the root
104: * package is used.
105: *
106: * If `$appName` is `null`, the basename of the file used to run the script
107: * is used after removing common PHP file extensions and recognised version
108: * numbers.
109: *
110: * If `$configDir` exists and is a directory, it is passed to
111: * {@see Config::loadDirectory()} after `.env` files are loaded and values
112: * are applied from the environment to the running script.
113: *
114: * @api
115: *
116: * @param int-mask-of<Env::APPLY_*> $envFlags Values to apply from the
117: * environment to the running script.
118: * @param string|null $configDir A path relative to the application's base
119: * path, or `null` if configuration files should not be loaded.
120: */
121: public function __construct(
122: ?string $basePath = null,
123: ?string $appName = null,
124: int $envFlags = Env::APPLY_ALL,
125: ?string $configDir = 'config'
126: ) {
127: parent::__construct();
128:
129: static::setGlobalContainer($this);
130:
131: $this->AppName = $appName ?? Regex::replace(
132: '/-v?[0-9]+(\.[0-9]+){0,3}(-[0-9]+)?(-g?[0-9a-f]+)?$/i',
133: '',
134: Sys::getProgramBasename('.php', '.phar'),
135: );
136:
137: if ($basePath === null) {
138: $explicitBasePath = false;
139: $basePath = Env::get('app_base_path', null);
140: if ($basePath === null) {
141: $basePath = Package::path();
142: $defaultBasePath = true;
143: } else {
144: $defaultBasePath = false;
145: }
146: } else {
147: $explicitBasePath = true;
148: $defaultBasePath = false;
149: }
150:
151: if (!is_dir($basePath)) {
152: $exception = $explicitBasePath || $defaultBasePath
153: ? FilesystemErrorException::class
154: : InvalidEnvironmentException::class;
155: throw new $exception(sprintf('Invalid base path: %s', $basePath));
156: }
157:
158: $this->BasePath = File::realpath($basePath);
159:
160: $this->WorkingDirectory = File::getcwd();
161:
162: $this->RunningFromSource = !extension_loaded('Phar')
163: || Phar::running() === '';
164:
165: if ($this->RunningFromSource) {
166: $files = [];
167: $env = Env::getEnvironment();
168: if ($env !== null) {
169: $files[] = $this->BasePath . '/.env.' . $env;
170: }
171: $files[] = $this->BasePath . '/.env';
172: foreach ($files as $file) {
173: if (is_file($file)) {
174: Env::loadFiles($file);
175: break;
176: }
177: }
178: }
179:
180: if ($envFlags) {
181: Env::apply($envFlags);
182: }
183:
184: Console::registerStdioTargets();
185:
186: Err::register();
187:
188: $adodb = Package::getPackagePath('adodb/adodb-php');
189: if ($adodb !== null) {
190: Err::silencePath($adodb);
191: }
192:
193: if ($configDir !== null) {
194: if (!File::isAbsolute($configDir)) {
195: $configDir = $this->BasePath . '/' . $configDir;
196: }
197: if (is_dir($configDir)) {
198: Config::loadDirectory($configDir);
199: }
200: }
201: }
202:
203: /**
204: * @inheritDoc
205: */
206: public function unload(): void
207: {
208: $this->stopCache();
209: $this->stopSync();
210: if ($this->HarListenerId !== null) {
211: Event::removeListener($this->HarListenerId);
212: $this->HarListenerId = null;
213: } elseif ($this->HarRecorder) {
214: $this->HarRecorder->close();
215: $this->HarRecorder = null;
216: unset($this->HarFilename);
217: }
218: parent::unload();
219: }
220:
221: /**
222: * @inheritDoc
223: */
224: final public function getAppName(): string
225: {
226: return $this->AppName;
227: }
228:
229: /**
230: * @inheritDoc
231: */
232: public function isProduction(): bool
233: {
234: $env = Env::getEnvironment();
235:
236: return $env === 'production'
237: || ($env === null && (
238: !$this->RunningFromSource
239: || !Package::hasDevPackages()
240: ));
241: }
242:
243: /**
244: * @inheritDoc
245: */
246: final public function getBasePath(): string
247: {
248: return $this->BasePath;
249: }
250:
251: /**
252: * @inheritDoc
253: */
254: final public function getCachePath(bool $create = true): string
255: {
256: return $this->getPath(self::PATH_CACHE, $create);
257: }
258:
259: /**
260: * @inheritDoc
261: */
262: final public function getConfigPath(bool $create = true): string
263: {
264: return $this->getPath(self::PATH_CONFIG, $create);
265: }
266:
267: /**
268: * @inheritDoc
269: */
270: final public function getDataPath(bool $create = true): string
271: {
272: return $this->getPath(self::PATH_DATA, $create);
273: }
274:
275: /**
276: * @inheritDoc
277: */
278: final public function getLogPath(bool $create = true): string
279: {
280: return $this->getPath(self::PATH_LOG, $create);
281: }
282:
283: /**
284: * @inheritDoc
285: */
286: final public function getTempPath(bool $create = true): string
287: {
288: return $this->getPath(self::PATH_TEMP, $create);
289: }
290:
291: /**
292: * @param self::PATH_* $index
293: */
294: private function getPath(int $index, bool $create): string
295: {
296: if ($this->Paths[$index] !== null) {
297: return $this->Paths[$index];
298: }
299:
300: [$name, $parent, $child, $winChild, $srcChild] = self::PATHS[$index];
301: $varName = sprintf('app_%s_path', $name);
302:
303: $path = Env::get($varName, null);
304: if ($path !== null) {
305: if (trim($path) === '') {
306: throw new InvalidEnvironmentException(sprintf(
307: 'Directory disabled (empty %s in environment)',
308: $varName,
309: ));
310: }
311: if (!File::isAbsolute($path)) {
312: $path = $this->BasePath . '/' . $path;
313: }
314: } elseif (
315: !$this->isProduction()
316: && File::isCreatable($this->BasePath . '/' . $srcChild)
317: ) {
318: $path = $this->BasePath . '/' . $srcChild;
319: } elseif (Sys::isWindows()) {
320: switch ($parent) {
321: case self::PARENT_CONFIG:
322: case self::PARENT_DATA:
323: $path = Env::get('APPDATA');
324: break;
325:
326: case self::PARENT_STATE:
327: $path = Env::get('LOCALAPPDATA');
328: break;
329: }
330:
331: $path = Arr::implode('/', [$path, $this->AppName, $winChild], '');
332: } else {
333: $home = Env::getHomeDir();
334: if ($home === null || !is_dir($home)) {
335: throw new InvalidEnvironmentException('Home directory not found');
336: }
337:
338: switch ($parent) {
339: case self::PARENT_CONFIG:
340: $path = Env::get('XDG_CONFIG_HOME', $home . '/.config');
341: break;
342:
343: case self::PARENT_DATA:
344: $path = Env::get('XDG_DATA_HOME', $home . '/.local/share');
345: break;
346:
347: case self::PARENT_STATE:
348: $path = Env::get('XDG_CACHE_HOME', $home . '/.cache');
349: break;
350: }
351:
352: $path = Arr::implode('/', [$path, $this->AppName, $child], '');
353: }
354:
355: if (!File::isAbsolute($path)) {
356: // @codeCoverageIgnoreStart
357: throw new RuntimeException(sprintf(
358: 'Absolute path to %s directory required',
359: $name,
360: ));
361: // @codeCoverageIgnoreEnd
362: }
363:
364: if ($create) {
365: File::createDir($path);
366: $this->Paths[$index] = $path;
367: }
368:
369: return $path;
370: }
371:
372: /**
373: * @inheritDoc
374: */
375: final public function logOutput(?string $name = null, ?bool $debug = null)
376: {
377: if ($this->OutputLogIsRegistered) {
378: throw new LogicException('Output log already registered');
379: }
380:
381: $name ??= $this->AppName;
382: $target = StreamTarget::fromPath($this->getLogPath() . "/$name.log");
383: Console::registerTarget($target, LevelGroup::ALL_EXCEPT_DEBUG);
384:
385: if ($debug || ($debug === null && Env::getDebug())) {
386: $target = StreamTarget::fromPath($this->getLogPath() . "/$name.debug.log");
387: Console::registerTarget($target, LevelGroup::ALL);
388: }
389:
390: return $this;
391: }
392:
393: /**
394: * @inheritDoc
395: */
396: final public function exportHar(
397: ?string $name = null,
398: ?string $creatorName = null,
399: ?string $creatorVersion = null,
400: $uuid = null
401: ) {
402: if ($this->HarListenerId !== null || $this->HarRecorder) {
403: throw new LogicException('HAR recorder already started');
404: }
405:
406: $this->HarListenerId = Event::getInstance()->listen(function (
407: CurlerEventInterface $event
408: ) use ($name, $creatorName, $creatorVersion, $uuid): void {
409: if (
410: !$event instanceof CurlRequestEventInterface
411: && !$event instanceof ResponseCacheHitEventInterface
412: ) {
413: return;
414: }
415:
416: $filename = sprintf(
417: '%s/%s-%s-%s.har',
418: $this->getLogPath(),
419: $name ?? $this->AppName,
420: (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d-His.v'),
421: Get::value($uuid ?? Get::uuid()),
422: );
423:
424: if (file_exists($filename)) {
425: throw new RuntimeException(sprintf('File already exists: %s', $filename));
426: }
427:
428: File::create($filename, 0600);
429:
430: $recorder = new CurlerHarRecorder(
431: $filename,
432: $creatorName,
433: $creatorVersion,
434: );
435: $recorder->start($event);
436:
437: /** @var int */
438: $id = $this->HarListenerId;
439: Event::removeListener($id);
440: $this->HarListenerId = null;
441: $this->HarRecorder = $recorder;
442: $this->HarFilename = $filename;
443: });
444:
445: return $this;
446: }
447:
448: /**
449: * @phpstan-impure
450: */
451: final public function getHarFilename(): ?string
452: {
453: if ($this->HarListenerId === null && !$this->HarRecorder) {
454: throw new LogicException('HAR recorder not started');
455: }
456:
457: return $this->HarRecorder
458: ? $this->HarFilename
459: : null;
460: }
461:
462: /**
463: * @inheritDoc
464: */
465: final public function startCache()
466: {
467: if (Cache::isLoaded()) {
468: if ($this->checkCache(Cache::getInstance())) {
469: return $this;
470: }
471: throw new LogicException('Cache store already started');
472: }
473:
474: Cache::load(new CacheStore($this->getCacheDb()));
475:
476: return $this;
477: }
478:
479: /**
480: * @inheritDoc
481: */
482: final public function resumeCache()
483: {
484: return file_exists($this->getCacheDb(false))
485: ? $this->startCache()
486: : $this;
487: }
488:
489: /**
490: * @inheritDoc
491: */
492: final public function stopCache()
493: {
494: if (
495: Cache::isLoaded()
496: && $this->checkCache($cache = Cache::getInstance())
497: ) {
498: $cache->close();
499: }
500: return $this;
501: }
502:
503: private function checkCache(CacheInterface $cache): bool
504: {
505: return $cache instanceof CacheStore
506: && File::same($this->getCacheDb(false), $cache->getFilename());
507: }
508:
509: private function getCacheDb(bool $create = true): string
510: {
511: return $this->getCachePath($create) . '/cache.db';
512: }
513:
514: /**
515: * @inheritDoc
516: */
517: final public function startSync(?string $command = null, ?array $arguments = null)
518: {
519: $syncDb = $this->getSyncDb();
520:
521: if (Sync::isLoaded()) {
522: $store = Sync::getInstance();
523: if ($store instanceof SyncStore) {
524: $file = $store->getFilename();
525: if (File::same($syncDb, $file)) {
526: return $this;
527: }
528: }
529: throw new LogicException(sprintf(
530: 'Entity store already started: %s',
531: $file ?? get_class($store),
532: ));
533: }
534:
535: /** @disregard P1006 */
536: Sync::load(new SyncStore(
537: $syncDb,
538: $command ?? Sys::getProgramName($this->BasePath),
539: $arguments ?? ($command === null
540: ? (\PHP_SAPI === 'cli'
541: ? array_slice($_SERVER['argv'], 1)
542: : ['_GET' => $_GET, '_POST' => $_POST])
543: : [])
544: ));
545:
546: return $this;
547: }
548:
549: /**
550: * @inheritDoc
551: */
552: final public function stopSync()
553: {
554: if (
555: Sync::isLoaded()
556: && ($store = Sync::getInstance()) instanceof SyncStore
557: && File::same($this->getSyncDb(false), $store->getFilename())
558: ) {
559: $store->close();
560: }
561: return $this;
562: }
563:
564: private function getSyncDb(bool $create = true): string
565: {
566: return $this->getDataPath($create) . '/sync.db';
567: }
568:
569: /**
570: * @inheritDoc
571: */
572: final public function registerSyncNamespace(
573: string $prefix,
574: string $uri,
575: string $namespace,
576: ?SyncClassResolverInterface $resolver = null
577: ) {
578: if (!Sync::isLoaded()) {
579: throw new LogicException('Entity store not started');
580: }
581: Sync::registerNamespace($prefix, $uri, $namespace, $resolver);
582:
583: return $this;
584: }
585:
586: /**
587: * @inheritDoc
588: */
589: final public function getWorkingDirectory(): string
590: {
591: return $this->WorkingDirectory;
592: }
593:
594: /**
595: * @inheritDoc
596: */
597: final public function restoreWorkingDirectory()
598: {
599: if (File::getcwd() !== $this->WorkingDirectory) {
600: File::chdir($this->WorkingDirectory);
601: }
602: return $this;
603: }
604:
605: /**
606: * @inheritDoc
607: */
608: final public function setWorkingDirectory(?string $directory = null)
609: {
610: $this->WorkingDirectory = $directory ?? File::getcwd();
611: return $this;
612: }
613:
614: /**
615: * @inheritDoc
616: */
617: final public function registerShutdownReport(
618: int $level = Level::INFO,
619: bool $includeResourceUsage = true,
620: bool $includeRunningTimers = true,
621: $groups = null,
622: ?int $limit = 10
623: ) {
624: self::$ShutdownReportLevel = $level;
625: self::$ShutdownReportResourceUsage = $includeResourceUsage;
626: self::$ShutdownReportRunningTimers = $includeRunningTimers;
627: self::$ShutdownReportMetricGroups = $groups;
628: self::$ShutdownReportMetricLimit = $limit;
629:
630: if (self::$ShutdownReportIsRegistered) {
631: return $this;
632: }
633:
634: register_shutdown_function(
635: static function () {
636: self::doReportMetrics(
637: self::$ShutdownReportLevel,
638: self::$ShutdownReportRunningTimers,
639: self::$ShutdownReportMetricGroups,
640: self::$ShutdownReportMetricLimit,
641: );
642: if (self::$ShutdownReportResourceUsage) {
643: self::doReportResourceUsage(self::$ShutdownReportLevel);
644: }
645: }
646: );
647:
648: self::$ShutdownReportIsRegistered = true;
649:
650: return $this;
651: }
652:
653: /**
654: * @inheritDoc
655: */
656: final public function reportResourceUsage(int $level = Level::INFO)
657: {
658: self::doReportResourceUsage($level);
659: return $this;
660: }
661:
662: /**
663: * @param Level::* $level
664: */
665: private static function doReportResourceUsage(int $level): void
666: {
667: [$peakMemory, $elapsedTime, $userTime, $systemTime] = [
668: memory_get_peak_usage(),
669: microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'],
670: ...Sys::getCpuUsage(),
671: ];
672:
673: Console::print(
674: "\n" . sprintf(
675: 'CPU time: **%.3fs** elapsed, **%.3fs** user, **%.3fs** system; memory: **%s** peak',
676: $elapsedTime,
677: $userTime / 1000000,
678: $systemTime / 1000000,
679: Format::bytes($peakMemory),
680: ),
681: $level,
682: MessageType::UNFORMATTED,
683: );
684: }
685:
686: /**
687: * @inheritDoc
688: */
689: final public function reportMetrics(
690: int $level = Level::INFO,
691: bool $includeRunningTimers = true,
692: $groups = null,
693: ?int $limit = 10
694: ) {
695: self::doReportMetrics($level, $includeRunningTimers, $groups, $limit);
696: return $this;
697: }
698:
699: /**
700: * @param Level::* $level
701: * @param string[]|string|null $groups
702: */
703: private static function doReportMetrics(
704: int $level,
705: bool $includeRunningTimers,
706: $groups,
707: ?int $limit
708: ): void {
709: $groupCounters = Profile::getInstance()->getCounters((array) $groups);
710: foreach ($groupCounters as $group => $counters) {
711: // Sort by counter value, in descending order
712: uasort($counters, fn(int $a, int $b) => $b <=> $a);
713:
714: $maxValue = $totalValue = 0;
715: $count = count($counters);
716: foreach ($counters as $value) {
717: $totalValue += $value;
718: $maxValue = max($maxValue, $value);
719: }
720:
721: if ($limit !== null && $limit < $count) {
722: $counters = array_slice($counters, 0, $limit, true);
723: }
724:
725: $lines = [];
726: $lines[] = Inflect::format(
727: $totalValue,
728: "Metrics: **{{#}}** {{#:event}} recorded by %s in group '**%s**':",
729: Inflect::format($count, '**{{#}}** {{#:counter}}'),
730: $group,
731: );
732:
733: $valueWidth = strlen((string) $maxValue);
734: $format = " %{$valueWidth}d ***%s***";
735: foreach ($counters as $name => $value) {
736: $lines[] = sprintf($format, $value, $name);
737: }
738:
739: if ($hidden = $count - count($counters)) {
740: $width = $valueWidth + 3;
741: $lines[] = sprintf("%{$width}s~~(and %d more)~~", '', $hidden);
742: }
743:
744: $report[] = implode("\n", $lines);
745: }
746:
747: $groupTimers = Profile::getInstance()->getTimers($includeRunningTimers, (array) $groups);
748: foreach ($groupTimers as $group => $timers) {
749: // Sort by milliseconds elapsed, in descending order
750: uasort($timers, fn(array $a, array $b) => $b[0] <=> $a[0]);
751:
752: $maxRuns = $maxTime = $totalTime = 0;
753: $count = count($timers);
754: foreach ($timers as [$time, $runs]) {
755: $totalTime += $time;
756: $maxTime = max($maxTime, $time);
757: $maxRuns = max($maxRuns, $runs);
758: }
759:
760: if ($limit !== null && $limit < $count) {
761: $timers = array_slice($timers, 0, $limit, true);
762: }
763:
764: $lines = [];
765: $lines[] = Inflect::format(
766: $count,
767: "Metrics: **%.3fms** recorded by **{{#}}** {{#:timer}} in group '**%s**':",
768: $totalTime,
769: $group,
770: );
771:
772: $timeWidth = strlen((string) (int) $maxTime) + 4;
773: $runsWidth = strlen((string) $maxRuns) + 2;
774: $format = " %{$timeWidth}.3fms ~~{~~%{$runsWidth}s~~}~~ ***%s***";
775: foreach ($timers as $name => [$time, $runs]) {
776: $lines[] = sprintf($format, $time, sprintf('*%d*', $runs), $name);
777: }
778:
779: if ($hidden = $count - count($timers)) {
780: $width = $timeWidth + $runsWidth + 6;
781: $lines[] = sprintf("%{$width}s~~(and %d more)~~", '', $hidden);
782: }
783:
784: $report[] = implode("\n", $lines);
785: }
786:
787: if (isset($report)) {
788: Console::print(
789: "\n" . implode("\n\n", $report),
790: $level,
791: MessageType::UNFORMATTED,
792: );
793: }
794: }
795: }
796: