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