├── .env.example ├── .github ├── dependabot.yml ├── swoole.install.sh └── workflows │ ├── build.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .gitlab-ci.yml ├── .php-cs-fixer.php ├── .phpstorm.meta.php ├── Dockerfile ├── README.md ├── app ├── Amqp │ ├── Consumer │ │ └── SendMessageConsumer.php │ └── Producer │ │ └── SendMessageProducer.php ├── Chat │ ├── Constants.php │ ├── ConsumerSwitcher.php │ ├── Handler │ │ ├── ErrorMessageHandler.php │ │ ├── SendMessageHandler.php │ │ └── UserListHandler.php │ ├── HandlerInterface.php │ ├── InitNodeListener.php │ └── Node.php ├── Command │ └── DebugCommand.php ├── Constants │ └── ErrorCode.php ├── Controller │ ├── Controller.php │ └── IndexController.php ├── Exception │ ├── BusinessException.php │ └── Handler │ │ ├── BusinessExceptionHandler.php │ │ └── RPCExceptionHandler.php ├── Kernel │ ├── ClassMap │ │ ├── Coroutine.php │ │ └── ResolverDispatcher.php │ ├── Context │ │ └── Coroutine.php │ ├── Functions.php │ ├── Http │ │ ├── Response.php │ │ └── WorkerStartListener.php │ └── Log │ │ ├── AppendRequestIdProcessor.php │ │ └── LoggerFactory.php ├── Listener │ ├── AutoDeleteChannelListener.php │ ├── DbQueryExecutedListener.php │ └── QueueHandleListener.php ├── Model │ ├── Model.php │ └── User.php ├── Nsq │ └── Consumer │ │ └── SendMessageConsumer.php └── Service │ ├── Formatter │ └── UserFormatter.php │ ├── Redis │ └── UserCollection.php │ ├── Service.php │ ├── UserData.php │ ├── UserDataService.php │ ├── UserMockeryService.php │ └── UserServiceInterface.php ├── bin └── hyperf.php ├── composer.json ├── config ├── autoload │ ├── amqp.php │ ├── annotations.php │ ├── aspects.php │ ├── async_queue.php │ ├── cache.php │ ├── chat.php │ ├── commands.php │ ├── databases.php │ ├── dependencies.php │ ├── devtool.php │ ├── exceptions.php │ ├── listeners.php │ ├── logger.php │ ├── middlewares.php │ ├── nsq.php │ ├── processes.php │ ├── redis.php │ └── server.php ├── config.php ├── container.php └── routes.php ├── deploy.test.yml ├── phpstan.neon ├── phpunit.xml └── test ├── Cases ├── ExampleTest.php ├── UserMockeryServiceTest.php └── WebSocketTest.php ├── HttpTestCase.php └── bootstrap.php /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=chat-api 2 | 3 | # Mysql 4 | DB_DRIVER=mysql 5 | DB_HOST=127.0.0.1 6 | DB_PORT=3306 7 | DB_DATABASE=hyperf 8 | DB_USERNAME=root 9 | DB_PASSWORD= 10 | DB_CHARSET=utf8mb4 11 | DB_COLLATION=utf8mb4_unicode_ci 12 | DB_PREFIX= 13 | 14 | # Redis 15 | REDIS_HOST=127.0.0.1 16 | REDIS_AUTH=(null) 17 | REDIS_PORT=6379 18 | REDIS_DB=0 19 | 20 | # Nsq 21 | NSQ_HOST=127.0.0.1 22 | NSQ_PORT=4150 23 | 24 | # AMQP 25 | AMQP_HOST=127.0.0.1 26 | 27 | # Consumer 28 | CONSUMER_ENGINE=amqp 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/swoole.install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | sudo apt-get update 3 | sudo apt-get install libcurl4-openssl-dev 4 | wget https://github.com/swoole/swoole-src/archive/${SW_VERSION}.tar.gz -O swoole.tar.gz 5 | mkdir -p swoole 6 | tar -xf swoole.tar.gz -C swoole --strip-components=1 7 | rm swoole.tar.gz 8 | cd swoole 9 | phpize 10 | ./configure --enable-openssl --enable-http2 --enable-swoole-curl --enable-swoole-json 11 | make -j$(nproc) 12 | sudo make install 13 | sudo sh -c "echo extension=swoole > /etc/php/${PHP_VERSION}/cli/conf.d/swoole.ini" 14 | sudo sh -c "echo swoole.use_shortname='Off' >> /etc/php/${PHP_VERSION}/cli/conf.d/swoole.ini" 15 | php --ri swoole 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | - name: Build 12 | run: docker build -t biz-skeleton . 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Release 8 | 9 | jobs: 10 | release: 11 | name: Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | - name: Create Release 17 | id: create_release 18 | uses: actions/create-release@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | tag_name: ${{ github.ref }} 23 | release_name: Release ${{ github.ref }} 24 | draft: false 25 | prerelease: false 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit for Hyperf 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | ci: 7 | name: Test on PHP 8 | runs-on: 'ubuntu-latest' 9 | strategy: 10 | matrix: 11 | php-version: [ '8.0' ] 12 | swoole-version: [ 'v4.6.7', 'v4.7.1', 'v4.8.1' ] 13 | consumer-engine: [ 'nsq', 'amqp' ] 14 | max-parallel: 9 15 | env: 16 | SW_VERSION: ${{ matrix.swoole-version }} 17 | PHP_VERSION: ${{ matrix.php-version }} 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php-version }} 25 | tools: phpize 26 | ini-values: opcache.enable_cli=0 27 | coverage: none 28 | - name: Setup Services 29 | run: | 30 | docker network create net 31 | docker run --network net --name redis -p 6379:6379 -d redis 32 | docker run --name nsq -p 4150:4150 -p 4151:4151 -p 4160:4160 -p 4161:4161 -p 4170:4170 -p 4171:4171 --rm --network net --entrypoint /bin/nsqd -d nsqio/nsq:latest 33 | docker run -d --restart=always --name rabbitmq -p 4369:4369 -p 5672:5672 -p 15672:15672 -p 25672:25672 --network net rabbitmq:management-alpine 34 | - name: Setup Swoole 35 | run: ./.github/swoole.install.sh 36 | - name: Show Environment 37 | run: | 38 | php -v 39 | php -m 40 | php -i 41 | - name: Setup Packages 42 | run: composer update -o 43 | - name: Run Server 44 | run: | 45 | cp .env.example .env 46 | docker build -t chat-api . 47 | docker run --network net --name chat-api -p 9501:9501 -e CONSUMER_ENGINE=${{ matrix.consumer-engine }} -e DB_HOST=mysql -e REDIS_HOST=redis -e NSQ_HOST=nsq -e AMQP_HOST=rabbitmq -d chat-api 48 | docker run --network net --name chat-api2 -p 9502:9501 -e CONSUMER_ENGINE=${{ matrix.consumer-engine }} -e DB_HOST=mysql -e REDIS_HOST=redis -e NSQ_HOST=nsq -e AMQP_HOST=rabbitmq -d chat-api 49 | - name: Run Test Cases 50 | run: | 51 | composer analyse 52 | composer test 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .buildpath 2 | .settings/ 3 | .project 4 | *.patch 5 | .idea/ 6 | .git/ 7 | runtime/ 8 | vendor/ 9 | .phpintel/ 10 | .env 11 | .DS_Store 12 | *.cache 13 | *.lock 14 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # usermod -aG docker gitlab-runner 2 | 3 | stages: 4 | - build 5 | - deploy 6 | 7 | variables: 8 | PROJECT_NAME: hyperf 9 | REGISTRY_URL: registry-docker.org 10 | 11 | build_test_docker: 12 | stage: build 13 | before_script: 14 | # - git submodule sync --recursive 15 | # - git submodule update --init --recursive 16 | script: 17 | - docker build . -t $PROJECT_NAME 18 | - docker tag $PROJECT_NAME $REGISTRY_URL/$PROJECT_NAME:test 19 | - docker push $REGISTRY_URL/$PROJECT_NAME:test 20 | only: 21 | - test 22 | tags: 23 | - builder 24 | 25 | deploy_test_docker: 26 | stage: deploy 27 | script: 28 | - docker stack deploy -c deploy.test.yml --with-registry-auth $PROJECT_NAME 29 | only: 30 | - test 31 | tags: 32 | - test 33 | 34 | build_docker: 35 | stage: build 36 | before_script: 37 | # - git submodule sync --recursive 38 | # - git submodule update --init --recursive 39 | script: 40 | - docker build . -t $PROJECT_NAME 41 | - docker tag $PROJECT_NAME $REGISTRY_URL/$PROJECT_NAME:$CI_COMMIT_REF_NAME 42 | - docker tag $PROJECT_NAME $REGISTRY_URL/$PROJECT_NAME:latest 43 | - docker push $REGISTRY_URL/$PROJECT_NAME:$CI_COMMIT_REF_NAME 44 | - docker push $REGISTRY_URL/$PROJECT_NAME:latest 45 | only: 46 | - tags 47 | tags: 48 | - builder 49 | 50 | deploy_docker: 51 | stage: deploy 52 | script: 53 | - echo SUCCESS 54 | only: 55 | - tags 56 | tags: 57 | - builder 58 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 14 | ->setRules([ 15 | '@PSR2' => true, 16 | '@Symfony' => true, 17 | '@DoctrineAnnotation' => true, 18 | '@PhpCsFixer' => true, 19 | 'header_comment' => [ 20 | 'comment_type' => 'PHPDoc', 21 | 'header' => $header, 22 | 'separate' => 'none', 23 | 'location' => 'after_declare_strict', 24 | ], 25 | 'array_syntax' => [ 26 | 'syntax' => 'short' 27 | ], 28 | 'list_syntax' => [ 29 | 'syntax' => 'short' 30 | ], 31 | 'concat_space' => [ 32 | 'spacing' => 'one' 33 | ], 34 | 'blank_line_before_statement' => [ 35 | 'statements' => [ 36 | 'declare', 37 | ], 38 | ], 39 | 'general_phpdoc_annotation_remove' => [ 40 | 'annotations' => [ 41 | 'author' 42 | ], 43 | ], 44 | 'ordered_imports' => [ 45 | 'imports_order' => [ 46 | 'class', 'function', 'const', 47 | ], 48 | 'sort_algorithm' => 'alpha', 49 | ], 50 | 'single_line_comment_style' => [ 51 | 'comment_types' => [ 52 | ], 53 | ], 54 | 'yoda_style' => [ 55 | 'always_move_variable' => false, 56 | 'equal' => false, 57 | 'identical' => false, 58 | ], 59 | 'phpdoc_align' => [ 60 | 'align' => 'left', 61 | ], 62 | 'multiline_whitespace_before_semicolons' => [ 63 | 'strategy' => 'no_multi_line', 64 | ], 65 | 'constant_case' => [ 66 | 'case' => 'lower', 67 | ], 68 | 'class_attributes_separation' => true, 69 | 'combine_consecutive_unsets' => true, 70 | 'declare_strict_types' => true, 71 | 'linebreak_after_opening_tag' => true, 72 | 'lowercase_static_reference' => true, 73 | 'no_useless_else' => true, 74 | 'no_unused_imports' => true, 75 | 'not_operator_with_successor_space' => true, 76 | 'not_operator_with_space' => false, 77 | 'ordered_class_elements' => true, 78 | 'php_unit_strict' => false, 79 | 'phpdoc_separation' => false, 80 | 'single_quote' => true, 81 | 'standardize_not_equals' => true, 82 | 'multiline_comment_opening_closing' => true, 83 | ]) 84 | ->setFinder( 85 | PhpCsFixer\Finder::create() 86 | ->exclude('public') 87 | ->exclude('runtime') 88 | ->exclude('vendor') 89 | ->in(__DIR__) 90 | ) 91 | ->setUsingCache(false); 92 | -------------------------------------------------------------------------------- /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | " version="1.0" license="MIT" app.name="Hyperf" 10 | 11 | ## 12 | # ---------- env settings ---------- 13 | ## 14 | # --build-arg timezone=Asia/Shanghai 15 | ARG timezone 16 | 17 | ENV TIMEZONE=${timezone:-"Asia/Shanghai"} \ 18 | APP_ENV=prod \ 19 | SCAN_CACHEABLE=(true) 20 | 21 | # update 22 | RUN set -ex \ 23 | # show php version and extensions 24 | && php -v \ 25 | && php -m \ 26 | && php --ri swoole \ 27 | # ---------- some config ---------- 28 | && cd /etc/php8 \ 29 | # - config PHP 30 | && { \ 31 | echo "upload_max_filesize=128M"; \ 32 | echo "post_max_size=128M"; \ 33 | echo "memory_limit=1G"; \ 34 | echo "date.timezone=${TIMEZONE}"; \ 35 | } | tee conf.d/99_overrides.ini \ 36 | # - config timezone 37 | && ln -sf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime \ 38 | && echo "${TIMEZONE}" > /etc/timezone \ 39 | # ---------- clear works ---------- 40 | && rm -rf /var/cache/apk/* /tmp/* /usr/share/man \ 41 | && echo -e "\033[42;37m Build Completed :).\033[0m\n" 42 | 43 | WORKDIR /opt/www 44 | 45 | # Composer Cache 46 | # COPY ./composer.* /opt/www/ 47 | # RUN composer install --no-dev --no-scripts 48 | 49 | COPY . /opt/www 50 | RUN composer install --no-dev -o && php bin/hyperf.php 51 | 52 | EXPOSE 9501 53 | 54 | ENTRYPOINT ["php", "/opt/www/bin/hyperf.php", "start"] 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 分布式聊天系统 2 | 3 | 本系统支持 NSQ 和 AMQP 两种消费协议,可以通过修改 `CONSUMER_ENGINE` 环境变量进行切换,推荐使用 `AMQP`。 4 | 5 | ## 连接 6 | 7 | ``` 8 | ws://127.0.0.1:9501/?token=xxx 9 | ``` 10 | 11 | ## 协议 12 | 13 | 查看所有用户列表 14 | 15 | ``` 16 | {"protocal":"user.list","data":"Hello World"} 17 | ``` 18 | 19 | 向对方用户发送消息 20 | 21 | ``` 22 | {"protocal":"send.message","data":{"id":2,"message":"Hello World."}} 23 | ``` 24 | 25 | ## 测试 26 | 27 | 启动两个服务,分别使用端口 9501 和 9502 28 | 29 | 让客户端A使用以下连接,连接 9501 端口 30 | 31 | ``` 32 | ws://127.0.0.1:9501/?token=1 33 | ``` 34 | 35 | 让客户端B使用以下连接,连接 9502 端口 36 | 37 | ``` 38 | ws://127.0.0.1:9502/?token=2 39 | ``` 40 | 41 | 使用客户端 A 发送协议 42 | 43 | ```json 44 | {"protocal":"send.message","data":{"id":2,"message":"Hello World."}} 45 | ``` 46 | 47 | 客户端 B 即可收到消息 48 | -------------------------------------------------------------------------------- /app/Amqp/Consumer/SendMessageConsumer.php: -------------------------------------------------------------------------------- 1 | container->get(Sender::class); 31 | $obj = $this->container->get(UserCollection::class)->find($id); 32 | $node = $this->container->get(Node::class)->getId(); 33 | if ($obj && $obj->node === $node) { 34 | $sender->push($obj->fd, json_encode($data)); 35 | } 36 | 37 | return Result::ACK; 38 | } 39 | 40 | public function getQueue(): string 41 | { 42 | return 'chat.queue.' . di()->get(Node::class)->getId(); 43 | } 44 | 45 | public function getQueueBuilder(): QueueBuilder 46 | { 47 | return parent::getQueueBuilder()->setAutoDelete(true); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/Amqp/Producer/SendMessageProducer.php: -------------------------------------------------------------------------------- 1 | payload = [ 23 | 'id' => $id, 24 | 'data' => $data, 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/Chat/Constants.php: -------------------------------------------------------------------------------- 1 | engine === 'nsq'; 24 | } 25 | 26 | public function isAmqp(): bool 27 | { 28 | return $this->engine === 'amqp'; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Chat/Handler/ErrorMessageHandler.php: -------------------------------------------------------------------------------- 1 | '错误信息', 22 | * 'close' => false, // 是否前置关闭客户端 23 | * ] 24 | */ 25 | public function handle(Response $server, $data) 26 | { 27 | $server->push(json_encode($data)); 28 | 29 | if ($data['close'] ?? false) { 30 | $server->close(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Chat/Handler/SendMessageHandler.php: -------------------------------------------------------------------------------- 1 | 'send.message', 47 | * 'data' => [ 48 | * 'id' => 1, // 目标ID 49 | * 'message' => 'Hello World.' 50 | * ] 51 | * ] 52 | */ 53 | public function handle(Response $server, $data) 54 | { 55 | $id = $data['data']['id'] ?? 0; 56 | $message = $data['data']['message'] ?? null; 57 | if ($id && ! is_null($message)) { 58 | $user = $this->userService->first($id); 59 | if (empty($user)) { 60 | $this->errorHandler->handle($server, ['message' => '目标用户不存在']); 61 | return; 62 | } 63 | 64 | switch (true) { 65 | case $this->switcher->isNsq(): 66 | di()->get(Nsq::class)->publish(Constants::SEND_MESSAGE, Json::encode(['id' => $user->id, 'data' => $data])); 67 | break; 68 | case $this->switcher->isAmqp(): 69 | di()->get(Producer::class)->produce(new SendMessageProducer($user->id, $data)); 70 | break; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/Chat/Handler/UserListHandler.php: -------------------------------------------------------------------------------- 1 | 'user.list' 36 | * ] 37 | */ 38 | public function handle(Response $server, $data) 39 | { 40 | // 查询所有在线的用户 41 | $mine = $this->service->find($server->fd); 42 | [$count, $users] = $this->userService->find($mine->id, ['is_online' => true]); 43 | $result = $this->formatter->list($users); 44 | $data['count'] = $count; 45 | $data['list'] = $result; 46 | $server->push(json_encode($data)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Chat/HandlerInterface.php: -------------------------------------------------------------------------------- 1 | get(Node::class); 29 | $node->setId(uniqid()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Chat/Node.php: -------------------------------------------------------------------------------- 1 | id; 29 | } 30 | 31 | public function setId($id) 32 | { 33 | $this->id = $id; 34 | return $this; 35 | } 36 | 37 | public function getChannel(): string 38 | { 39 | return Constants::SEND_MESSAGE . '.' . $this->getId(); 40 | } 41 | 42 | public function heartbeat() 43 | { 44 | $redis = di()->get(Redis::class); 45 | $redis->set($this->getChannel(), '1', 3600); 46 | } 47 | 48 | public function clear() 49 | { 50 | $channelApi = di()->get(Channel::class); 51 | $client = di()->get(Api::class); 52 | $redis = di()->get(Redis::class); 53 | $conotents = $client->stats('json', Constants::SEND_MESSAGE)->getBody()->getContents(); 54 | $res = Json::decode($conotents); 55 | foreach ($res['topics'] ?? [] as $topic) { 56 | if (($topic['topic_name'] ?? null) !== Constants::SEND_MESSAGE) { 57 | continue; 58 | } 59 | 60 | $channels = $topic['channels'] ?? []; 61 | foreach ($channels as $channel) { 62 | if ($name = $channel['channel_name'] ?? null) { 63 | if (! $redis->exists($name)) { 64 | $channelApi->delete(Constants::SEND_MESSAGE, $name); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/Command/DebugCommand.php: -------------------------------------------------------------------------------- 1 | container = $container; 34 | parent::__construct('debug:send-message'); 35 | } 36 | 37 | public function configure() 38 | { 39 | $this->setDescription('Send Message to websocket client.'); 40 | $this->addArgument('token', InputArgument::REQUIRED, '目标 TOKEN'); 41 | } 42 | 43 | public function handle() 44 | { 45 | $token = $this->input->getArgument('token'); 46 | $data = ['protocal' => 'text', 'data' => 'Hello World']; 47 | di()->get(Nsq::class)->publish(Constants::SEND_MESSAGE, Json::encode(['token' => $token, 'data' => $data])); 48 | Timer::clearAll(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Constants/ErrorCode.php: -------------------------------------------------------------------------------- 1 | response = $container->get(Response::class); 27 | $this->request = $container->get(RequestInterface::class); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Controller/IndexController.php: -------------------------------------------------------------------------------- 1 | service->find($fd)) { 45 | $this->service->delete($obj); 46 | if ($user = $this->userService->firstByToken($obj->token)) { 47 | $this->userService->offline($user); 48 | } 49 | } 50 | } 51 | 52 | public function onMessage($response, Frame $frame): void 53 | { 54 | $data = json_decode($frame->data, true); 55 | $protocal = 'protocal.' . $data['protocal'] ?? ''; 56 | if (! $this->container->has($protocal)) { 57 | $this->errorMessageHandler->handle($response, ['message' => 'The Protocal is invalid.']); 58 | return; 59 | } 60 | /** @var HandlerInterface $handler */ 61 | $handler = $this->container->get($protocal); 62 | $handler->handle($response, $data); 63 | } 64 | 65 | public function onOpen($server, Request $request): void 66 | { 67 | $token = $this->request->input('token'); 68 | $user = $this->userService->firstByToken($token); 69 | if (empty($user)) { 70 | $this->errorMessageHandler->handle($server, $request->fd, ['message' => 'The Token is invalid.', 'close' => true]); 71 | return; 72 | } 73 | $this->userService->online($user); 74 | $node = di()->get(Node::class)->getId(); 75 | $this->service->save(new UserData($user->id, $token, $request->fd, $node)); 76 | [$count, $models] = $this->userService->find($user->id, ['is_online' => true]); 77 | $users = $this->formatter->list($models, $user); 78 | $result = ['protocal' => 'user.list', 'count' => $count, 'list' => $users]; 79 | $server->push(json_encode($result)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/Exception/BusinessException.php: -------------------------------------------------------------------------------- 1 | response = $container->get(Response::class); 35 | $this->logger = $container->get(StdoutLoggerInterface::class); 36 | } 37 | 38 | public function handle(Throwable $throwable, ResponseInterface $response) 39 | { 40 | switch (true) { 41 | case $throwable instanceof HttpException: 42 | return $this->response->handleException($throwable); 43 | case $throwable instanceof BusinessException: 44 | $this->logger->warning(format_throwable($throwable)); 45 | return $this->response->fail($throwable->getCode(), $throwable->getMessage()); 46 | case $throwable instanceof CircularDependencyException: 47 | $this->logger->error($throwable->getMessage()); 48 | return $this->response->fail(ErrorCode::SERVER_ERROR, $throwable->getMessage()); 49 | } 50 | 51 | $this->logger->error(format_throwable($throwable)); 52 | 53 | return $this->response->fail(ErrorCode::SERVER_ERROR, 'Server Error'); 54 | } 55 | 56 | public function isValid(Throwable $throwable): bool 57 | { 58 | return true; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/Exception/Handler/RPCExceptionHandler.php: -------------------------------------------------------------------------------- 1 | logger->warning($this->formatter->format($throwable)); 31 | } else { 32 | $this->logger->error($this->formatter->format($throwable)); 33 | } 34 | 35 | return $response; 36 | } 37 | 38 | public function isValid(Throwable $throwable): bool 39 | { 40 | return true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Kernel/ClassMap/Coroutine.php: -------------------------------------------------------------------------------- 1 | get(Go::class)->create($callable); 58 | } 59 | 60 | public static function inCoroutine(): bool 61 | { 62 | return Co::id() > 0; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/Kernel/ClassMap/ResolverDispatcher.php: -------------------------------------------------------------------------------- 1 | resolve($this->container); 44 | } 45 | 46 | $resolver = $this->getDefinitionResolver($definition); 47 | return $resolver->resolve($definition, $parameters); 48 | } 49 | 50 | /** 51 | * Check if a definition can be resolved. 52 | * 53 | * @param DefinitionInterface $definition object that defines how the value should be obtained 54 | * @param array $parameters optional parameters to use to build the entry 55 | */ 56 | public function isResolvable(DefinitionInterface $definition, array $parameters = []): bool 57 | { 58 | if ($definition instanceof SelfResolvingDefinitionInterface) { 59 | return $definition->isResolvable($this->container); 60 | } 61 | 62 | $resolver = $this->getDefinitionResolver($definition); 63 | return $resolver->isResolvable($definition, $parameters); 64 | } 65 | 66 | /** 67 | * Returns a resolver capable of handling the given definition. 68 | * 69 | * @throws RuntimeException no definition resolver was found for this type of definition 70 | */ 71 | private function getDefinitionResolver(DefinitionInterface $definition): ResolverInterface 72 | { 73 | switch (true) { 74 | case $definition instanceof ObjectDefinition: 75 | if (! $this->objectResolver) { 76 | $this->objectResolver = new ObjectResolver($this->container, $this); 77 | } 78 | return $this->objectResolver; 79 | case $definition instanceof FactoryDefinition: 80 | if (! $this->factoryResolver) { 81 | $this->factoryResolver = new FactoryResolver($this->container, $this); 82 | } 83 | return $this->factoryResolver; 84 | default: 85 | throw new RuntimeException('No definition resolver was configured for definition of type ' . get_class($definition)); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/Kernel/Context/Coroutine.php: -------------------------------------------------------------------------------- 1 | logger = $container->get(StdoutLoggerInterface::class); 30 | } 31 | 32 | /** 33 | * @return int Returns the coroutine ID of the coroutine just created. 34 | * Returns -1 when coroutine create failed. 35 | */ 36 | public function create(callable $callable): int 37 | { 38 | $id = Utils\Coroutine::id(); 39 | $coroutine = Co::create(function () use ($callable, $id) { 40 | try { 41 | // Shouldn't copy all contexts to avoid socket already been bound to another coroutine. 42 | Utils\Context::copy($id, [ 43 | AppendRequestIdProcessor::REQUEST_ID, 44 | ServerRequestInterface::class, 45 | ]); 46 | call($callable); 47 | } catch (Throwable $throwable) { 48 | $this->logger->warning((string) $throwable); 49 | } 50 | }); 51 | 52 | try { 53 | return $coroutine->getId(); 54 | } catch (\Throwable $throwable) { 55 | $this->logger->warning((string) $throwable); 56 | return -1; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Kernel/Functions.php: -------------------------------------------------------------------------------- 1 | get($id); 27 | } 28 | 29 | return $container; 30 | } 31 | } 32 | 33 | if (! function_exists('format_throwable')) { 34 | /** 35 | * Format a throwable to string. 36 | */ 37 | function format_throwable(Throwable $throwable): string 38 | { 39 | return di()->get(FormatterInterface::class)->format($throwable); 40 | } 41 | } 42 | 43 | if (! function_exists('queue_push')) { 44 | /** 45 | * Push a job to async queue. 46 | */ 47 | function queue_push(JobInterface $job, int $delay = 0, string $key = 'default'): bool 48 | { 49 | $driver = di()->get(DriverFactory::class)->get($key); 50 | return $driver->push($job, $delay); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/Kernel/Http/Response.php: -------------------------------------------------------------------------------- 1 | response = $container->get(ResponseInterface::class); 31 | } 32 | 33 | public function success(mixed $data = []): PsrResponseInterface 34 | { 35 | return $this->response->json([ 36 | 'code' => 0, 37 | 'data' => $data, 38 | ]); 39 | } 40 | 41 | public function fail(int $code, string $message = ''): PsrResponseInterface 42 | { 43 | return $this->response->json([ 44 | 'code' => $code, 45 | 'message' => $message, 46 | ]); 47 | } 48 | 49 | public function redirect($url, int $status = 302): PsrResponseInterface 50 | { 51 | return $this->response() 52 | ->withAddedHeader('Location', (string) $url) 53 | ->withStatus($status); 54 | } 55 | 56 | public function cookie(Cookie $cookie) 57 | { 58 | $response = $this->response()->withCookie($cookie); 59 | Context::set(PsrResponseInterface::class, $response); 60 | return $this; 61 | } 62 | 63 | public function handleException(HttpException $throwable): PsrResponseInterface 64 | { 65 | return $this->response() 66 | ->withAddedHeader('Server', 'Hyperf') 67 | ->withStatus($throwable->getStatusCode()) 68 | ->withBody(new SwooleStream($throwable->getMessage())); 69 | } 70 | 71 | public function response(): PsrResponseInterface 72 | { 73 | return Context::get(PsrResponseInterface::class); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/Kernel/Http/WorkerStartListener.php: -------------------------------------------------------------------------------- 1 | get(StdoutLogger::class)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Kernel/Log/AppendRequestIdProcessor.php: -------------------------------------------------------------------------------- 1 | get(HyperfLoggerFactory::class)->make(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/Listener/AutoDeleteChannelListener.php: -------------------------------------------------------------------------------- 1 | container = $container; 34 | } 35 | 36 | public function listen(): array 37 | { 38 | return [MainCoroutineServerStart::class]; 39 | } 40 | 41 | public function process(object $event) 42 | { 43 | go(function () { 44 | retry(INF, static function () { 45 | $node = di()->get(Node::class); 46 | $switcher = di()->get(ConsumerSwitcher::class); 47 | 48 | while (true) { 49 | $exited = CoordinatorManager::until(Constants::WORKER_EXIT)->yield(10); 50 | if ($exited) { 51 | break; 52 | } 53 | $node->heartbeat(); 54 | $node->clear(); 55 | } 56 | }); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Listener/DbQueryExecutedListener.php: -------------------------------------------------------------------------------- 1 | logger = $container->get(LoggerFactory::class)->get('sql'); 31 | } 32 | 33 | public function listen(): array 34 | { 35 | return [QueryExecuted::class]; 36 | } 37 | 38 | /** 39 | * @param QueryExecuted $event 40 | */ 41 | public function process(object $event) 42 | { 43 | if ($event instanceof QueryExecuted) { 44 | $sql = $event->sql; 45 | if (! Arr::isAssoc($event->bindings)) { 46 | foreach ($event->bindings as $key => $value) { 47 | $sql = Str::replaceFirst('?', "'{$value}'", $sql); 48 | } 49 | } 50 | $this->logger->info(sprintf('[%s:%s] %s', $event->connectionName, $event->time, $sql)); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/Listener/QueueHandleListener.php: -------------------------------------------------------------------------------- 1 | logger = $container->get(LoggerFactory::class)->get('queue'); 34 | } 35 | 36 | public function listen(): array 37 | { 38 | return [AfterHandle::class, BeforeHandle::class, FailedHandle::class, RetryHandle::class]; 39 | } 40 | 41 | public function process(object $event) 42 | { 43 | if ($event instanceof Event && $event->getMessage()->job()) { 44 | $job = $event->getMessage()->job(); 45 | $jobClass = get_class($job); 46 | if ($job instanceof AnnotationJob) { 47 | $jobClass = sprintf('Job[%s@%s]', $job->class, $job->method); 48 | } 49 | $date = date('Y-m-d H:i:s'); 50 | switch (true) { 51 | case $event instanceof BeforeHandle: 52 | $this->logger->info(sprintf('[%s] Processing %s.', $date, $jobClass)); 53 | break; 54 | case $event instanceof AfterHandle: 55 | $this->logger->info(sprintf('[%s] Processed %s.', $date, $jobClass)); 56 | break; 57 | case $event instanceof FailedHandle: 58 | $this->logger->error(sprintf('[%s] Failed %s.', $date, $jobClass)); 59 | $this->logger->error((string) $event->getThrowable()); 60 | break; 61 | case $event instanceof RetryHandle: 62 | $this->logger->warning(sprintf('[%s] Retried %s.', $date, $jobClass)); 63 | break; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/Model/Model.php: -------------------------------------------------------------------------------- 1 | 'integer', 'is_online' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime']; 48 | } 49 | -------------------------------------------------------------------------------- /app/Nsq/Consumer/SendMessageConsumer.php: -------------------------------------------------------------------------------- 1 | get(Node::class); 31 | $this->channel = $node->getChannel(); 32 | } 33 | 34 | public function consume(Message $payload): ?string 35 | { 36 | $data = Json::decode($payload->getBody()); 37 | $id = (int) $data['id']; 38 | $data = $data['data']; 39 | $sender = $this->container->get(Sender::class); 40 | $obj = $this->container->get(UserCollection::class)->find($id); 41 | $node = $this->container->get(Node::class)->getId(); 42 | if ($obj && $obj->node === $node) { 43 | $sender->push($obj->fd, json_encode($data)); 44 | } 45 | return Result::ACK; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Service/Formatter/UserFormatter.php: -------------------------------------------------------------------------------- 1 | $model->id, 26 | 'name' => $model->name, 27 | 'is_online' => $model->is_online, 28 | 'created_at' => $model->created_at->toDateTimeString(), 29 | 'updated_at' => $model->updated_at->toDateTimeString(), 30 | ]; 31 | } 32 | 33 | public function list($models, User $mine) 34 | { 35 | $result = []; 36 | foreach ($models as $model) { 37 | $item = $this->base($model); 38 | if ($model->id === $mine->id) { 39 | $item['own'] = true; 40 | } 41 | $result[] = $item; 42 | } 43 | 44 | return $result; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/Service/Redis/UserCollection.php: -------------------------------------------------------------------------------- 1 | get(\Redis::class); 27 | } 28 | 29 | public function save(UserData $obj) 30 | { 31 | $str = serialize($obj); 32 | 33 | return $this->set($obj->id, $str, null); 34 | } 35 | 36 | public function find(int $id): ?UserData 37 | { 38 | $str = $this->get($id); 39 | 40 | if ($str) { 41 | return unserialize($str); 42 | } 43 | 44 | return null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/Service/Service.php: -------------------------------------------------------------------------------- 1 | container = $container; 32 | $this->logger = $container->get(StdoutLoggerInterface::class); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/Service/UserData.php: -------------------------------------------------------------------------------- 1 | id = $id; 39 | $this->token = $token; 40 | $this->fd = $fd; 41 | $this->node = $node; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Service/UserDataService.php: -------------------------------------------------------------------------------- 1 | users[$obj->fd] = $obj; 30 | $this->col->save($obj); 31 | } 32 | 33 | public function find(int $fd): ?UserData 34 | { 35 | return $this->users[$fd] ?? null; 36 | } 37 | 38 | public function delete(UserData $obj) 39 | { 40 | unset($this->users[$obj->fd]); 41 | $this->col->delete($obj->token); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Service/UserMockeryService.php: -------------------------------------------------------------------------------- 1 | redis->get($key = $this->getKey($id)); 30 | if (! $string) { 31 | $res = $this->mock($key); 32 | $this->redis->set($key, Json::encode($res), 86400); 33 | } else { 34 | $res = Json::decode($string); 35 | } 36 | return $this->fill($res); 37 | } 38 | 39 | public function firstByToken(string $token): ?User 40 | { 41 | $id = (int) $token; 42 | return $this->first($id); 43 | } 44 | 45 | public function find(int $token, array $search = [], int $offset = 0, int $limit = 10): array 46 | { 47 | return [0, []]; 48 | } 49 | 50 | public function online(User $user): void 51 | { 52 | // TODO: Implement online() method. 53 | } 54 | 55 | public function offline(User $user): void 56 | { 57 | // TODO: Implement offline() method. 58 | } 59 | 60 | public function getKey(int $id): string 61 | { 62 | return 'user:' . $id; 63 | } 64 | 65 | public function mock(string $token): array 66 | { 67 | return ['id' => (int) $this->redis->incr('mock:user:id'), 'name' => $this->mockName(), 'token' => $token]; 68 | } 69 | 70 | public function fill(array $items): User 71 | { 72 | return (new User())->newFromBuilder($items); 73 | } 74 | 75 | public function mockName(): string 76 | { 77 | $string = '君不见黄河之水天上来奔流到海不复回君不见高堂明镜悲白发朝如青丝暮成雪人生得意须尽欢莫使金樽空对月天生我材必有用千金散尽还复来烹羊宰牛且为乐会须一饮三百杯岑夫子丹丘生将进酒杯莫停与君歌一曲请君为我倾耳听钟鼓馔玉不足贵但愿长醉不愿醒古来圣贤皆寂寞惟有饮者留其名陈王昔时宴平乐斗酒十千恣欢谑主人何为言少钱径须沽取对君酌五花马千金裘呼儿将出换美酒与尔同销万古愁'; 78 | $length = strlen($string) / 3; 79 | $i = rand(0, $length - 3); 80 | $res = $string[$i * 3] . $string[$i * 3 + 1] . $string[$i * 3 + 2]; 81 | $i = rand(0, $length - 3); 82 | $res .= $string[$i * 3] . $string[$i * 3 + 1] . $string[$i * 3 + 2]; 83 | if (rand(0, 9) > 4) { 84 | $i = rand(0, $length - 3); 85 | $res .= $string[$i * 3] . $string[$i * 3 + 1] . $string[$i * 3 + 2]; 86 | } 87 | return $res; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/Service/UserServiceInterface.php: -------------------------------------------------------------------------------- 1 | true, 32 | * ] 33 | * @return array [int, User[]] 34 | */ 35 | public function find(int $id, array $search = [], int $offset = 0, int $limit = 10): array; 36 | 37 | /** 38 | * 某用户上线 39 | */ 40 | public function online(User $user): void; 41 | 42 | /** 43 | * 某用户下线 44 | */ 45 | public function offline(User $user): void; 46 | } 47 | -------------------------------------------------------------------------------- /bin/hyperf.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | get(Hyperf\Contract\ApplicationInterface::class); 22 | $application->run(); 23 | })(); 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gemini/chat-api", 3 | "type": "project", 4 | "keywords": [ 5 | "php", 6 | "swoole", 7 | "framework", 8 | "hyperf", 9 | "microservice", 10 | "middleware" 11 | ], 12 | "description": "A coroutine framework that focuses on hyperspeed and flexible, specifically use for build microservices and middlewares.", 13 | "license": "MIT", 14 | "require": { 15 | "php": ">=8.0", 16 | "ext-json": "*", 17 | "ext-openssl": "*", 18 | "ext-pdo": "*", 19 | "ext-pdo_mysql": "*", 20 | "ext-redis": "*", 21 | "ext-swoole": ">=4.5", 22 | "hyperf/amqp": "2.2.*", 23 | "hyperf/async-queue": "2.2.*", 24 | "hyperf/cache": "2.2.*", 25 | "hyperf/command": "2.2.*", 26 | "hyperf/config": "2.2.*", 27 | "hyperf/constants": "2.2.*", 28 | "hyperf/contract": "2.2.*", 29 | "hyperf/database": "2.2.*", 30 | "hyperf/db-connection": "2.2.*", 31 | "hyperf/di": "2.2.*", 32 | "hyperf/dispatcher": "2.2.*", 33 | "hyperf/event": "2.2.*", 34 | "hyperf/exception-handler": "2.2.*", 35 | "hyperf/framework": "2.2.*", 36 | "hyperf/guzzle": "2.2.*", 37 | "hyperf/http-server": "2.2.*", 38 | "hyperf/logger": "2.2.*", 39 | "hyperf/model-cache": "2.2.*", 40 | "hyperf/nsq": "2.2.*", 41 | "hyperf/pool": "2.2.*", 42 | "hyperf/process": "2.2.*", 43 | "hyperf/redis": "2.2.*", 44 | "hyperf/server": "2.2.*", 45 | "hyperf/utils": "2.2.*", 46 | "hyperf/websocket-server": "2.2.*", 47 | "limingxinleo/hyperf-utils": "^3.1", 48 | "limingxinleo/redis-collection": "^1.2" 49 | }, 50 | "require-dev": { 51 | "friendsofphp/php-cs-fixer": "^3.0", 52 | "hyperf/devtool": "2.2.*", 53 | "hyperf/ide-helper": "2.2.*", 54 | "hyperf/testing": "2.2.*", 55 | "hyperf/websocket-client": "2.2.*", 56 | "mockery/mockery": "^1.0", 57 | "phpstan/phpstan": "^1.0", 58 | "swoole/ide-helper": "dev-master", 59 | "symfony/var-dumper": "^5.1" 60 | }, 61 | "autoload": { 62 | "psr-4": { 63 | "App\\": "app/" 64 | }, 65 | "files": [ 66 | "app/Kernel/Functions.php" 67 | ] 68 | }, 69 | "autoload-dev": { 70 | "psr-4": { 71 | "HyperfTest\\": "test/" 72 | } 73 | }, 74 | "minimum-stability": "dev", 75 | "prefer-stable": true, 76 | "config": { 77 | "optimize-autoloader": true, 78 | "sort-packages": true 79 | }, 80 | "extra": [], 81 | "scripts": { 82 | "post-root-package-install": [ 83 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 84 | ], 85 | "post-autoload-dump": [ 86 | "rm -rf runtime/container" 87 | ], 88 | "analyse": "phpstan analyse --memory-limit 512M -l 0 -c phpstan.neon ./app ./config", 89 | "cs-fix": "php-cs-fixer fix $1", 90 | "start": "php ./bin/hyperf.php start", 91 | "test": "co-phpunit --prepend test/bootstrap.php -c phpunit.xml --colors=always" 92 | }, 93 | "repositories": { 94 | "packagist": { 95 | "type": "composer", 96 | "url": "https://mirrors.aliyun.com/composer" 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /config/autoload/amqp.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'host' => env('AMQP_HOST', 'localhost'), 15 | 'port' => (int) env('AMQP_PORT', 5672), 16 | 'user' => env('AMQP_USER', 'guest'), 17 | 'password' => env('AMQP_PASSWORD', 'guest'), 18 | 'vhost' => env('AMQP_VHOST', '/'), 19 | 'concurrent' => [ 20 | 'limit' => 1, 21 | ], 22 | 'pool' => [ 23 | 'connections' => 2, 24 | ], 25 | 'params' => [ 26 | 'insist' => false, 27 | 'login_method' => 'AMQPLAIN', 28 | 'login_response' => null, 29 | 'locale' => 'en_US', 30 | 'connection_timeout' => 3, 31 | 'read_write_timeout' => 6, 32 | 'context' => null, 33 | 'keepalive' => true, 34 | 'heartbeat' => 3, 35 | 'channel_rpc_timeout' => 0.0, 36 | 'close_on_destruct' => false, 37 | 'max_idle_channels' => 10, 38 | ], 39 | ], 40 | ]; 41 | -------------------------------------------------------------------------------- /config/autoload/annotations.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'paths' => [ 15 | BASE_PATH . '/app', 16 | ], 17 | 'ignore_annotations' => [ 18 | 'mixin', 19 | ], 20 | 'class_map' => [ 21 | Hyperf\Utils\Coroutine::class => BASE_PATH . '/app/Kernel/ClassMap/Coroutine.php', 22 | Hyperf\Di\Resolver\ResolverDispatcher::class => BASE_PATH . '/app/Kernel/ClassMap/ResolverDispatcher.php', 23 | ], 24 | ], 25 | ]; 26 | -------------------------------------------------------------------------------- /config/autoload/aspects.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'driver' => Hyperf\AsyncQueue\Driver\RedisDriver::class, 15 | 'channel' => '{queue}', 16 | 'timeout' => 2, 17 | 'retry_seconds' => 5, 18 | 'handle_timeout' => 10, 19 | 'processes' => 1, 20 | 'concurrent' => [ 21 | 'limit' => 2, 22 | ], 23 | ], 24 | ]; 25 | -------------------------------------------------------------------------------- /config/autoload/cache.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'driver' => Hyperf\Cache\Driver\RedisDriver::class, 15 | 'packer' => Hyperf\Utils\Packer\PhpSerializerPacker::class, 16 | 'prefix' => 'c:', 17 | ], 18 | ]; 19 | -------------------------------------------------------------------------------- /config/autoload/chat.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'engine' => env('CONSUMER_ENGINE', 'amqp'), 15 | ], 16 | ]; 17 | -------------------------------------------------------------------------------- /config/autoload/commands.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'driver' => env('DB_DRIVER', 'mysql'), 15 | 'host' => env('DB_HOST', 'localhost'), 16 | 'port' => env('DB_PORT', 3306), 17 | 'database' => env('DB_DATABASE', 'hyperf'), 18 | 'username' => env('DB_USERNAME', 'root'), 19 | 'password' => env('DB_PASSWORD', ''), 20 | 'charset' => env('DB_CHARSET', 'utf8mb4'), 21 | 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 22 | 'prefix' => env('DB_PREFIX', ''), 23 | 'pool' => [ 24 | 'min_connections' => 1, 25 | 'max_connections' => 32, 26 | 'connect_timeout' => 10.0, 27 | 'wait_timeout' => 3.0, 28 | 'heartbeat' => -1, 29 | 'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60), 30 | ], 31 | 'cache' => [ 32 | 'handler' => Hyperf\ModelCache\Handler\RedisHandler::class, 33 | 'cache_key' => '{mc:%s:m:%s}:%s:%s', 34 | 'prefix' => 'default', 35 | 'ttl' => 3600 * 24, 36 | 'empty_model_ttl' => 600, 37 | 'load_script' => true, 38 | ], 39 | 'commands' => [ 40 | 'gen:model' => [ 41 | 'path' => 'app/Model', 42 | 'force_casts' => true, 43 | 'inheritance' => 'Model', 44 | 'uses' => '', 45 | 'refresh_fillable' => true, 46 | 'table_mapping' => [], 47 | ], 48 | ], 49 | ], 50 | ]; 51 | -------------------------------------------------------------------------------- /config/autoload/dependencies.php: -------------------------------------------------------------------------------- 1 | App\Kernel\Log\LoggerFactory::class, 17 | Hyperf\Server\Listener\AfterWorkerStartListener::class => App\Kernel\Http\WorkerStartListener::class, 18 | 'protocal.send.message' => SendMessageHandler::class, 19 | 'protocal.user.list' => UserListHandler::class, 20 | App\Service\UserServiceInterface::class => App\Service\UserMockeryService::class, 21 | ]; 22 | -------------------------------------------------------------------------------- /config/autoload/devtool.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'amqp' => [ 15 | 'consumer' => [ 16 | 'namespace' => 'App\\Amqp\\Consumer', 17 | ], 18 | 'producer' => [ 19 | 'namespace' => 'App\\Amqp\\Producer', 20 | ], 21 | ], 22 | 'aspect' => [ 23 | 'namespace' => 'App\\Aspect', 24 | ], 25 | 'command' => [ 26 | 'namespace' => 'App\\Command', 27 | ], 28 | 'controller' => [ 29 | 'namespace' => 'App\\Controller', 30 | ], 31 | 'job' => [ 32 | 'namespace' => 'App\\Job', 33 | ], 34 | 'listener' => [ 35 | 'namespace' => 'App\\Listener', 36 | ], 37 | 'middleware' => [ 38 | 'namespace' => 'App\\Middleware', 39 | ], 40 | 'Process' => [ 41 | 'namespace' => 'App\\Processes', 42 | ], 43 | ], 44 | ]; 45 | -------------------------------------------------------------------------------- /config/autoload/exceptions.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'http' => [ 15 | App\Exception\Handler\BusinessExceptionHandler::class, 16 | ], 17 | ], 18 | ]; 19 | -------------------------------------------------------------------------------- /config/autoload/listeners.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'handler' => [ 17 | 'class' => Monolog\Handler\StreamHandler::class, 18 | 'constructor' => [ 19 | 'stream' => BASE_PATH . '/runtime/logs/hyperf.log', 20 | 'level' => Monolog\Logger::INFO, 21 | ], 22 | ], 23 | 'formatter' => [ 24 | 'class' => Monolog\Formatter\LineFormatter::class, 25 | 'constructor' => [ 26 | 'format' => null, 27 | 'dateFormat' => 'Y-m-d H:i:s', 28 | 'allowInlineLineBreaks' => true, 29 | ], 30 | ], 31 | 'processors' => [ 32 | [ 33 | 'class' => Log\AppendRequestIdProcessor::class, 34 | ], 35 | ], 36 | ], 37 | ]; 38 | -------------------------------------------------------------------------------- /config/autoload/middlewares.php: -------------------------------------------------------------------------------- 1 | [ 14 | ], 15 | ]; 16 | -------------------------------------------------------------------------------- /config/autoload/nsq.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'enable' => true, 15 | 'host' => env('NSQ_HOST', '127.0.0.1'), 16 | 'port' => (int) env('NSQ_PORT', 4150), 17 | 'pool' => [ 18 | 'min_connections' => 1, 19 | 'max_connections' => 10, 20 | 'connect_timeout' => 10.0, 21 | 'wait_timeout' => 3.0, 22 | 'heartbeat' => -1, 23 | 'max_idle_time' => 60.0, 24 | ], 25 | 'nsqd' => [ 26 | 'port' => 4151, 27 | 'options' => [ 28 | ], 29 | ], 30 | ], 31 | ]; 32 | -------------------------------------------------------------------------------- /config/autoload/processes.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'host' => env('REDIS_HOST', 'localhost'), 15 | 'auth' => env('REDIS_AUTH', null), 16 | 'port' => (int) env('REDIS_PORT', 6379), 17 | 'db' => (int) env('REDIS_DB', 0), 18 | 'pool' => [ 19 | 'min_connections' => 1, 20 | 'max_connections' => 32, 21 | 'connect_timeout' => 10.0, 22 | 'wait_timeout' => 3.0, 23 | 'heartbeat' => -1, 24 | 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), 25 | ], 26 | ], 27 | ]; 28 | -------------------------------------------------------------------------------- /config/autoload/server.php: -------------------------------------------------------------------------------- 1 | SWOOLE_BASE, 17 | 'type' => Hyperf\Server\CoroutineServer::class, 18 | 'servers' => [ 19 | [ 20 | 'name' => 'http', 21 | 'type' => Server::SERVER_WEBSOCKET, 22 | 'host' => '0.0.0.0', 23 | 'port' => (int) env('HTTP_PORT', 9501), 24 | 'sock_type' => SWOOLE_SOCK_TCP, 25 | 'callbacks' => [ 26 | Event::ON_HAND_SHAKE => [Hyperf\WebSocketServer\Server::class, 'onHandShake'], 27 | Event::ON_MESSAGE => [Hyperf\WebSocketServer\Server::class, 'onMessage'], 28 | Event::ON_CLOSE => [Hyperf\WebSocketServer\Server::class, 'onClose'], 29 | ], 30 | ], 31 | ], 32 | 'settings' => [ 33 | 'enable_coroutine' => true, 34 | 'worker_num' => 4, 35 | 'pid_file' => BASE_PATH . '/runtime/hyperf.pid', 36 | 'open_tcp_nodelay' => true, 37 | 'max_coroutine' => 100000, 38 | 'open_http2_protocol' => true, 39 | 'max_request' => 0, 40 | 'socket_buffer_size' => 2 * 1024 * 1024, 41 | 'package_max_length' => 2 * 1024 * 1024, 42 | ], 43 | 'callbacks' => [ 44 | Event::ON_BEFORE_START => [Hyperf\Framework\Bootstrap\ServerStartCallback::class, 'beforeStart'], 45 | Event::ON_WORKER_START => [Hyperf\Framework\Bootstrap\WorkerStartCallback::class, 'onWorkerStart'], 46 | Event::ON_PIPE_MESSAGE => [Hyperf\Framework\Bootstrap\PipeMessageCallback::class, 'onPipeMessage'], 47 | Event::ON_WORKER_EXIT => [Hyperf\Framework\Bootstrap\WorkerExitCallback::class, 'onWorkerExit'], 48 | ], 49 | ]; 50 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | env('APP_NAME', 'skeleton'), 17 | 'app_env' => env('APP_ENV', 'dev'), 18 | 'scan_cacheable' => env('SCAN_CACHEABLE', false), 19 | StdoutLoggerInterface::class => [ 20 | 'log_level' => [ 21 | LogLevel::ALERT, 22 | LogLevel::CRITICAL, 23 | LogLevel::EMERGENCY, 24 | LogLevel::ERROR, 25 | LogLevel::INFO, 26 | LogLevel::NOTICE, 27 | LogLevel::WARNING, 28 | ], 29 | ], 30 | ]; 31 | -------------------------------------------------------------------------------- /config/container.php: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | test 14 | 15 | 16 | 17 | 18 | app 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/Cases/ExampleTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/Cases/UserMockeryServiceTest.php: -------------------------------------------------------------------------------- 1 | get(UserMockeryService::class)->mockName(); 26 | $this->assertTrue(in_array(mb_strlen($name), [2, 3])); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Cases/WebSocketTest.php: -------------------------------------------------------------------------------- 1 | getWebSocketClient(1, 9501); 26 | $client1->recv(1); 27 | $client2 = $this->getWebSocketClient(2, 9502); 28 | $client2->recv(1); 29 | 30 | $client1->push($data = Json::encode([ 31 | 'protocal' => 'send.message', 32 | 'data' => [ 33 | 'id' => 2, 34 | 'message' => 'Hello World', 35 | ], 36 | ])); 37 | 38 | $frame = $client2->recv(1); 39 | $this->assertSame($data, $frame->getData()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/HttpTestCase.php: -------------------------------------------------------------------------------- 1 | client = make(Testing\Client::class); 41 | // $this->client = make(Testing\HttpClient::class, ['baseUri' => 'http://127.0.0.1:9501']); 42 | } 43 | 44 | public function __call($name, $arguments) 45 | { 46 | return $this->client->{$name}(...$arguments); 47 | } 48 | 49 | /** 50 | * @return WebSocketClient\Client 51 | */ 52 | public function getWebSocketClient(int $id, int $port) 53 | { 54 | if (isset(static::$clientFactory[$id])) { 55 | return static::$clientFactory[$id]; 56 | } 57 | 58 | $client = di()->get(WebSocketClient\ClientFactory::class)->create( 59 | 'ws://127.0.0.1:' . $port . '/?token=' . $id, 60 | false 61 | ); 62 | 63 | return static::$clientFactory[$id] = $client; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/bootstrap.php: -------------------------------------------------------------------------------- 1 | get(Hyperf\Contract\ApplicationInterface::class); 27 | --------------------------------------------------------------------------------