diff --git a/composer.json b/composer.json index dae5f56d..02a7c2a1 100644 --- a/composer.json +++ b/composer.json @@ -22,14 +22,13 @@ "onebot/libonebot": "dev-develop", "psr/container": "^2.0", "psy/psysh": "^0.11.8", - "symfony/console": "^6.0 || ^5.0 || ^4.0", + "symfony/console": "^6.0", "symfony/polyfill-ctype": "^1.19", "symfony/polyfill-mbstring": "^1.19", - "symfony/polyfill-php80": "^1.16", "symfony/routing": "~6.0 || ~5.0 || ~4.0" }, "require-dev": { - "brainmaestro/composer-git-hooks": "^2.8", + "brainmaestro/composer-git-hooks": "^3.0", "friendsofphp/php-cs-fixer": "^3.2 != 3.7.0", "jetbrains/phpstorm-attributes": "^1.0", "phpstan/extension-installer": "^1.1", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9722eec0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,96 @@ +version: "3.9" + +services: + bot: + build: + context: docker/zhamao + dockerfile: ./Dockerfile + container_name: zhamao-bot + depends_on: + - database-postgres + - database-redis + networks: + - bot-net + ports: + - "20001-20005:20001-20005/tcp" + volumes: + - ".:/bot" + + walle-q: + build: + context: docker/walle-q + dockerfile: ./Dockerfile + container_name: zhamao-bot-walle-q + depends_on: + - bot + networks: + - bot-net + volumes: + - "./docker/volumes/walle-q-data:/bot" + + database-postgres: + container_name: zhamao-bot-db-postgres + env_file: + - docker/environment.env + image: postgres:14.4-alpine + networks: + - bot-net + ports: + - "5432:5432/tcp" + restart: always + volumes: + - "./docker/volumes/postgres-data:/var/lib/postgresql/data" + - "./docker/volumes/postgres-init:/docker-entrypoint-initdb.d" + + database-redis: + command: + - redis-server + - --requirepass + - "$${REDIS_PASSWORD}" + container_name: zhamao-bot-db-redis + env_file: + - docker/environment.env + image: redis:7.0-alpine + networks: + - bot-net + ports: + - "6379:6379/tcp" + restart: always + volumes: + - "./docker/volumes/redis-data:/data" + + db-admin-adminer: + container_name: zhamao-bot-dbadmin-adminer + depends_on: + database-postgres: + condition: service_started + database-redis: + condition: service_started + env_file: + - docker/environment.env + image: adminer:latest + networks: + - bot-net + ports: + - "15432:8080/tcp" + profiles: + - with-adminer + + db-admin-redis-insight: + container_name: zhamao-bot-dbadmin-redis-insight + depends_on: + database-postgres: + condition: service_started + database-redis: + condition: service_started + image: redislabs/redisinsight:latest + networks: + - bot-net + ports: + - "16379:8001/tcp" + profiles: + - with-adminer + +networks: + bot-net: + name: bot-net diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 00000000..0ffbf30a --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,2 @@ +/volumes +environment.env diff --git a/docker/environment.env.example b/docker/environment.env.example new file mode 100644 index 00000000..c683bfee --- /dev/null +++ b/docker/environment.env.example @@ -0,0 +1,7 @@ +REDIS_PASSWORD={{$REDIS_PASSWORD}} + +POSTGRES_USER={{$DB_ROOT_USER}} +POSTGRES_PASSWORD={{$DB_ROOT_PASSWORD}} +POSTGRES_APPLICATION_USER={{$DB_USER}} +POSTGRES_APPLICATION_USER_PASSWORD={{$DB_PASSWORD}} +POSTGRES_APPLICATION_DATABASE={{$DB_NAME}} diff --git a/docker/walle-q/Dockerfile b/docker/walle-q/Dockerfile new file mode 100644 index 00000000..2d784d2f --- /dev/null +++ b/docker/walle-q/Dockerfile @@ -0,0 +1,14 @@ +FROM alpine:latest + +MAINTAINER sunxyw + +RUN apk add --no-cache curl +RUN mkdir -p /bot && \ + curl -fsSL https://github.com/onebot-walle/walle-q/releases/latest/download/walle-q-i686-linux-musl.tar.gz -O && \ + tar -zxvf walle-q-i686-linux-musl.tar.gz -C /bot && \ + rm -rf walle-q-i686-linux-musl.tar.gz && \ + chmod +x /bot/walle-q-i686-linux-musl + +ENV TZ=Asia/Shanghai + +ENTRYPOINT [ "/bot/walle-q-i686-linux-musl" ] diff --git a/docker/zhamao/Dockerfile b/docker/zhamao/Dockerfile new file mode 100644 index 00000000..33fdefef --- /dev/null +++ b/docker/zhamao/Dockerfile @@ -0,0 +1,14 @@ +FROM phpswoole/swoole:5.0-php8.1 + +MAINTAINER sunxyw + +WORKDIR "/bot" + +ENV TZ=Asia/Shanghai + +EXPOSE 20001-20005 + +RUN docker-php-ext-install pcntl + +ENTRYPOINT [ "./zhamao" ] +CMD [ "server" ] diff --git a/src/Globals/global_defines_app.php b/src/Globals/global_defines_app.php index e2af9fa4..ed798513 100644 --- a/src/Globals/global_defines_app.php +++ b/src/Globals/global_defines_app.php @@ -31,6 +31,9 @@ const ZM_ERR_METHOD_NOT_FOUND = 1; // 找不到方法 const ZM_ERR_ROUTE_NOT_FOUND = 2; // 找不到路由 const ZM_ERR_ROUTE_METHOD_NOT_ALLOWED = 3; // 路由方法不允许 +const LOAD_MODE_VENDOR = 0; // 从 vendor 加载 +const LOAD_MODE_SRC = 1; // 从 src 加载 + /* 定义工作目录 */ define('WORKING_DIR', getcwd()); @@ -48,7 +51,7 @@ if (DIRECTORY_SEPARATOR === '\\') { } /* 定义启动模式,这里指的是框架本身的源码目录是通过 composer 加入 vendor 加载的还是直接放到 src 目录加载的,前者为 1,后者为 0 */ -define('LOAD_MODE', is_dir(zm_dir(SOURCE_ROOT_DIR . '/src/ZM')) ? 0 : 1); +define('LOAD_MODE', is_dir(zm_dir(SOURCE_ROOT_DIR . '/src/ZM')) ? LOAD_MODE_VENDOR : LOAD_MODE_SRC); /* 定义框架本身所处的根目录,此处如果 LOAD_MODE 为 1 的话,框架自身的根目录在 vendor/zhamao/framework 子目录下 */ if (Phar::running() !== '') { diff --git a/src/ZM/Command/Command.php b/src/ZM/Command/Command.php new file mode 100644 index 00000000..9ded2f91 --- /dev/null +++ b/src/ZM/Command/Command.php @@ -0,0 +1,136 @@ +input = $input; + $this->output = $output; + return $this->handle(); + } + + /** + * 命令的主体 + * + * @return int 命令执行结果 {@see self::SUCCESS} 或 {@see self::FAILURE} 或 {@see self::INVALID} + */ + abstract protected function handle(): int; + + /** + * 输出一段文本,默认样式 + * + * @param string $message 要输出的文本 + * @param bool $newline 是否在文本后换行 + * @see OutputInterface::write() + */ + protected function write(string $message, bool $newline = true): void + { + $this->output->write($message, $newline); + } + + /** + * 输出文本,一般用于提示信息 + * + * @param string $message 要输出的文本 + * @param bool $newline 是否在文本后换行 + */ + protected function info(string $message, bool $newline = true): void + { + $this->write("{$message}", $newline); + } + + /** + * 输出文本,一般用于错误信息 + * + * @param string $message 要输出的文本 + * @param bool $newline 是否在文本后换行 + */ + protected function error(string $message, bool $newline = true): void + { + $this->write("{$message}", $newline); + } + + /** + * 输出文本,一般用于警告或附注信息 + * + * @param string $message 要输出的文本 + * @param bool $newline 是否在文本后换行 + */ + protected function comment(string $message, bool $newline = true): void + { + $this->write("{$message}", $newline); + } + + /** + * 输出文本,一般用于提问信息 + * + * @param string $message 要输出的文本 + * @param bool $newline 是否在文本后换行 + */ + protected function question(string $message, bool $newline = true): void + { + $this->write("{$message}", $newline); + } + + /** + * 输出文本,一般用于详细信息 + * + * @param string $message 要输出的文本 + * @param bool $newline 是否在文本后换行 + */ + protected function detail(string $message, bool $newline = true): void + { + $this->write("{$message}", $newline); + } + + /** + * 输出一个区块,区块内内容可以覆写 + * + * 此功能需要 $output 为 {@see ConsoleOutputInterface} 类型 + * + * @param string $message 作为标题的文本 + * @param callable $callback 回调函数,接收一个参数,类型为 {@see ConsoleSectionOutput} + */ + protected function section(string $message, callable $callback): void + { + $output = $this->output; + if (!$output instanceof ConsoleOutputInterface) { + throw new \LogicException('Section 功能只能在 ConsoleOutputInterface 中使用'); + } + + $this->info($message); + $section = $output->section(); + try { + $callback($section); + } catch (ZMException $e) { + $this->error($e->getMessage()); + exit(self::FAILURE); + } + } +} diff --git a/src/ZM/Command/InitCommand.php b/src/ZM/Command/InitCommand.php index 6ec3d867..e841eea4 100644 --- a/src/ZM/Command/InitCommand.php +++ b/src/ZM/Command/InitCommand.php @@ -4,114 +4,237 @@ declare(strict_types=1); namespace ZM\Command; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\OutputInterface; +use ZM\Exception\InitException; class InitCommand extends Command { // the name of the command (the part after "bin/console") protected static $defaultName = 'init'; - private $extract_files = [ - '/zhamao', - '/config/global.php', - '/.gitignore', - '/config/file_header.json', - '/config/console_color.json', - '/config/motd.txt', - '/src/Module/Example/Hello.php', - '/src/Module/Middleware/TimerMiddleware.php', - '/src/Custom/global_function.php', - ]; + private string $base_path; - protected function configure() + private bool $force = false; + + protected function configure(): void { - $this->setDescription('Initialize framework starter | 初始化框架运行的基础文件'); + $this->setDescription('初始化框架运行的基础文件'); $this->setDefinition([ - new InputOption('force', 'F', null, '强制重制,覆盖现有文件'), + new InputOption('force', 'f', InputOption::VALUE_NONE, '覆盖现有文件'), + new InputOption('docker', null, InputOption::VALUE_NONE, '启用 Docker 支持'), ]); - $this->setHelp("此命令将会解压以下文件到项目的根目录:\n" . implode("\n", $this->getExtractFiles())); - // ... + $this->setHelp('提取框架的基础文件到当前目录,以便于快速开始开发。'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function handle(): int { - if (LOAD_MODE === 1) { // 从composer依赖而来的项目模式,最基本的需要初始化的模式 - $output->writeln('Initializing files'); - $base_path = WORKING_DIR; - $args = $input->getOption('force'); - foreach ($this->extract_files as $file) { - if (!file_exists($base_path . $file) || $args) { - $info = pathinfo($file); - @mkdir($base_path . $info['dirname'], 0777, true); - echo 'Copying ' . $file . PHP_EOL; - $package_name = json_decode(file_get_contents(__DIR__ . '/../../../composer.json'), true)['name']; - copy($base_path . '/vendor/' . $package_name . $file, $base_path . $file); - } else { - echo 'Skipping ' . $file . ' , file exists.' . PHP_EOL; + $this->setBasePath(); + $this->force = $this->input->getOption('force'); + + $this->section('提取框架基础文件', function (ConsoleSectionOutput $section) { + $this->extractFiles($this->getExtractFiles(), $section); + }); + + if (LOAD_MODE === LOAD_MODE_SRC) { + $this->section('应用自动加载配置', function (ConsoleSectionOutput $section) { + $autoload = [ + 'psr-4' => [ + 'Module\\' => 'src/Module', + 'Custom\\' => 'src/Custom', + ], + 'files' => [ + 'src/Custom/global_function.php', + ], + ]; + + $section->write('更新 composer.json ... '); + + if (!file_exists($this->base_path . '/composer.json')) { + throw new InitException('未找到 composer.json 文件', '请检查当前目录是否为项目根目录', 41); } - } - chmod($base_path . '/zhamao', 0755); - $autoload = [ - 'psr-4' => [ - 'Module\\' => 'src/Module', - 'Custom\\' => 'src/Custom', - ], - 'files' => [ - 'src/Custom/global_function.php', - ], - ]; - if (file_exists($base_path . '/composer.json')) { - $composer = json_decode(file_get_contents($base_path . '/composer.json'), true); + + try { + $composer = json_decode(file_get_contents($this->base_path . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InitException('解析 composer.json 文件失败', '请检查 composer.json 文件是否存在语法错误', 42, $e); + } + if (!isset($composer['autoload'])) { $composer['autoload'] = $autoload; } else { - foreach ($autoload['psr-4'] as $k => $v) { - if (!isset($composer['autoload']['psr-4'][$k])) { - $composer['autoload']['psr-4'][$k] = $v; - } - } - foreach ($autoload['files'] as $v) { - if (!in_array($v, $composer['autoload']['files'])) { - $composer['autoload']['files'][] = $v; - } - } + $composer['autoload'] = array_merge_recursive($composer['autoload'], $autoload); } - file_put_contents($base_path . '/composer.json', json_encode($composer, 64 | 128 | 256)); - $output->writeln('Executing composer command: `composer dump-autoload`'); + + try { + file_put_contents($this->base_path . '/composer.json', json_encode($composer, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } catch (\JsonException $e) { + throw new InitException('写入 composer.json 文件失败', '', 0, $e); + } + + $section->writeln('完成'); + + $section->write('执行 composer dump-autoload ... '); exec('composer dump-autoload'); - echo PHP_EOL; - } else { - echo zm_internal_errcode('E00041') . "Error occurred. Please check your updates.\n"; - return 1; - } - $output->writeln('Done!'); - return 0; + + $section->writeln('完成'); + }); } - if (LOAD_MODE === 2) { // 从phar启动的框架包,初始化的模式 - $phar_link = new \Phar(__DIR__); - $current_dir = pathinfo($phar_link->getPath())['dirname']; - chdir($current_dir); - $phar_link = 'phar://' . $phar_link->getPath(); - foreach ($this->extract_files as $file) { - if (!file_exists($current_dir . $file)) { - $info = pathinfo($file); - @mkdir($current_dir . $info['dirname'], 0777, true); - echo 'Copying ' . $file . PHP_EOL; - file_put_contents($current_dir . $file, file_get_contents($phar_link . $file)); + + if ($this->input->getOption('docker')) { + $this->section('应用 Docker 支持', function (ConsoleSectionOutput $section) { + $files = $this->getFilesFromPatterns([ + '/docker/*/Dockerfile', + '/docker/environment.env.example', + '/docker-compose.yml', + ]); + $this->extractFiles($files, $section); + + // 生成 env 文件 + if ($this->shouldExtractFile('/docker/environment.env')) { + $section->write('生成环境变量文件 ... '); + $env = file_get_contents($this->base_path . '/docker/environment.env.example'); + foreach ($this->getEnvVariables() as $key => $value) { + $env = $this->injectEnv($env, $key, $value); + } + file_put_contents($this->base_path . '/docker/environment.env', $env); + $section->writeln('完成'); } else { - echo 'Skipping ' . $file . ' , file exists.' . PHP_EOL; + $section->writeln('生成环境变量文件 ... 跳过(已存在)'); } - } + }); } - $output->writeln(zm_internal_errcode('E00042') . 'initialization must be started with composer-project mode!'); - return 1; + + // 将命令行入口标记为可执行 + chmod($this->base_path . '/zhamao', 0755); + return self::SUCCESS; } private function getExtractFiles(): array { - return $this->extract_files; + $patterns = [ + '/zhamao', + '/.gitignore', + '/config/*', + '/src/Globals/*.php', + ]; + + return $this->getFilesFromPatterns($patterns); + } + + private function getFilesFromPatterns(array $patterns): array + { + $files = []; + foreach ($patterns as $pattern) { + // TODO: 优化代码,避免在循环中使用 array_merge 以减少资源消耗 + $files = array_merge($files, glob($this->getVendorPath($pattern), GLOB_BRACE)); + } + return array_map(function ($file) { + return str_replace($this->getVendorPath(''), '', $file); + }, $files); + } + + /** + * 设置基准目录 + */ + private function setBasePath(): void + { + $base_path = WORKING_DIR; + if (file_exists($base_path . '/vendor/autoload.php')) { + $this->base_path = $base_path; + } else { + $phar_link = new \Phar(__DIR__); + $current_dir = pathinfo($phar_link->getPath())['dirname']; + chdir($current_dir); + $phar_link = 'phar://' . $phar_link->getPath(); + if (file_exists($phar_link . '/vendor/autoload.php')) { + $this->base_path = $current_dir; + } else { + throw new InitException('框架启动模式不是 Composer 模式,无法进行初始化', '如果您是从 Github 下载的框架,请参阅文档进行源码模式启动', 42); + } + } + } + + /** + * 提取文件 + * + * @param string $file 文件路径,相对于框架根目录 + * @throws InitException 提取失败时抛出异常 + */ + private function extractFile(string $file): void + { + $info = pathinfo($file); + // 确保目录存在 + if ( + !file_exists($this->base_path . $info['dirname']) + && !mkdir($concurrent_dir = $this->base_path . $info['dirname'], 0777, true) + && !is_dir($concurrent_dir) + ) { + throw new InitException("无法创建目录 {$concurrent_dir}", '请检查目录权限'); + } + + if (copy($this->getVendorPath($file), $this->base_path . $file) === false) { + throw new InitException("无法复制文件 {$file}", '请检查目录权限'); + } + } + + private function shouldExtractFile(string $file): bool + { + return !file_exists($this->base_path . $file) || $this->force; + } + + private function getVendorPath(string $file): string + { + try { + $package_name = json_decode(file_get_contents(__DIR__ . '/../../../composer.json'), true, 512, JSON_THROW_ON_ERROR)['name']; + } catch (\JsonException) { + throw new InitException('无法读取框架包的 composer.json', '请检查框架包完整性,或者重新安装框架包'); + } + return $this->base_path . '/vendor/' . $package_name . $file; + } + + private function extractFiles(array $files, OutputInterface $output): void + { + foreach ($files as $file) { + $output->write("提取 {$file} ... "); + if ($this->shouldExtractFile($file)) { + try { + $this->extractFile($file); + $output->write('完成'); + } catch (InitException $e) { + $output->write('失败'); + throw $e; + } finally { + $output->writeln(''); + } + } else { + $output->writeln('跳过(已存在)'); + } + } + } + + private function injectEnv(string $env, string $key, string $value): string + { + $pattern = "/^{$key}=.+$/m"; + if (preg_match($pattern, $env)) { + return preg_replace($pattern, "{$key}={$value}", $env); + } + + return $env . PHP_EOL . "{$key}={$value}"; + } + + private function getEnvVariables(): array + { + return [ + 'REDIS_PASSWORD' => bin2hex(random_bytes(8)), + + 'POSTGRES_USER' => 'root', + 'POSTGRES_PASSWORD' => bin2hex(random_bytes(8)), + + 'POSTGRES_APPLICATION_DATABASE' => 'zhamao', + 'POSTGRES_APPLICATION_USER' => 'zhamao', + 'POSTGRES_APPLICATION_USER_PASSWORD' => bin2hex(random_bytes(8)), + ]; } } diff --git a/src/ZM/ConsoleApplication.php b/src/ZM/ConsoleApplication.php index c670fee2..7a0c5120 100644 --- a/src/ZM/ConsoleApplication.php +++ b/src/ZM/ConsoleApplication.php @@ -19,7 +19,7 @@ use ZM\Command\Server\ServerReloadCommand; use ZM\Command\Server\ServerStartCommand; use ZM\Command\Server\ServerStatusCommand; use ZM\Command\Server\ServerStopCommand; -use ZM\Exception\InitException; +use ZM\Exception\SingletonViolationException; /** * 命令行启动的入口文件,用于初始化环境变量,并启动命令行应用 @@ -30,13 +30,10 @@ final class ConsoleApplication extends Application { private static $obj; - /** - * @throws InitException - */ public function __construct(string $name = 'zhamao-framework') { if (self::$obj !== null) { - throw new InitException(zm_internal_errcode('E00069') . 'Initializing another Application is not allowed!'); + throw new SingletonViolationException(self::class); } // 初始化命令 diff --git a/src/ZM/Exception/InitException.php b/src/ZM/Exception/InitException.php index 538bf743..49ba9dce 100644 --- a/src/ZM/Exception/InitException.php +++ b/src/ZM/Exception/InitException.php @@ -6,9 +6,4 @@ namespace ZM\Exception; class InitException extends ZMException { - public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null) - { - // TODO: change this to a better error message - parent::__construct($message, '', $code, $previous); - } } diff --git a/src/ZM/Exception/SingletonViolationException.php b/src/ZM/Exception/SingletonViolationException.php new file mode 100644 index 00000000..3e5ed60c --- /dev/null +++ b/src/ZM/Exception/SingletonViolationException.php @@ -0,0 +1,17 @@ + $argv 传入的参数(见 ServerStartCommand) - * @throws InitException * @throws \Exception */ public function __construct(array $argv = []) { // 单例化整个Framework类 if (self::$instance !== null) { - throw new InitException(zm_internal_errcode('E00069') . 'Initializing another Framework in one instance is not allowed!'); + throw new SingletonViolationException(self::class); } self::$instance = $this; diff --git a/src/ZM/InstantApplication.php b/src/ZM/InstantApplication.php index 95f79556..c6380078 100644 --- a/src/ZM/InstantApplication.php +++ b/src/ZM/InstantApplication.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace ZM; use ZM\Command\Server\ServerStartCommand; -use ZM\Exception\InitException; +use ZM\Exception\SingletonViolationException; use ZM\Plugin\InstantPlugin; class InstantApplication extends InstantPlugin @@ -16,14 +16,10 @@ class InstantApplication extends InstantPlugin /** @var array 存储要传入的args */ private array $args = []; - /** - * @param null|mixed $dir - * @throws InitException - */ - public function __construct($dir = null) + public function __construct(mixed $dir = null) { if (self::$obj !== null) { - throw new InitException(zm_internal_errcode('E00069') . 'Initializing another Application is not allowed!'); + throw new SingletonViolationException(self::class); } self::$obj = $this; // 用于标记已经初始化完成 parent::__construct($dir ?? (__DIR__ . '/../..'));