Repository: crossoverJie/cim Branch: master Commit: f1f3bae6a3d5 Files: 226 Total size: 423.0 KB Directory structure: gitextract_k26pvt4s/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── workflows/ │ ├── docker.yml │ ├── maven.yml │ └── reusable_run_tests.yml ├── .gitignore ├── CLAUDE.md ├── LICENSE ├── Makefile ├── README-zh.md ├── README.md ├── checkstyle/ │ ├── checkstyle.xml │ └── suppressions.xml ├── cim-client/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── crossoverjie/ │ │ │ └── cim/ │ │ │ └── client/ │ │ │ ├── CIMClientApplication.java │ │ │ ├── config/ │ │ │ │ ├── AppConfiguration.java │ │ │ │ ├── BeanConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── scanner/ │ │ │ │ └── Scan.java │ │ │ ├── service/ │ │ │ │ ├── InnerCommand.java │ │ │ │ ├── InnerCommandContext.java │ │ │ │ ├── MsgHandle.java │ │ │ │ ├── MsgLogger.java │ │ │ │ ├── ShutDownSign.java │ │ │ │ └── impl/ │ │ │ │ ├── AsyncMsgLogger.java │ │ │ │ ├── EchoServiceImpl.java │ │ │ │ ├── MsgCallBackListener.java │ │ │ │ ├── MsgHandler.java │ │ │ │ └── command/ │ │ │ │ ├── CloseAIModelCommand.java │ │ │ │ ├── DelayMsgCommand.java │ │ │ │ ├── EchoInfoCommand.java │ │ │ │ ├── EmojiCommand.java │ │ │ │ ├── OpenAIModelCommand.java │ │ │ │ ├── PrefixSearchCommand.java │ │ │ │ ├── PrintAllCommand.java │ │ │ │ ├── PrintOnlineUsersCommand.java │ │ │ │ ├── QueryHistoryCommand.java │ │ │ │ └── ShutDownCommand.java │ │ │ └── util/ │ │ │ └── SpringBeanFactory.java │ │ └── resources/ │ │ ├── application.yaml │ │ └── banner.txt │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── crossoverjie/ │ │ └── cim/ │ │ ├── client/ │ │ │ └── service/ │ │ │ ├── InnerCommandContextTest.java │ │ │ └── impl/ │ │ │ └── AsyncMsgLoggerTest.java │ │ └── server/ │ │ └── test/ │ │ ├── CommonTest.java │ │ └── EchoTest.java │ └── resources/ │ └── application.yaml ├── cim-client-sdk/ │ ├── README.md │ ├── pom.xml │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── com/ │ │ └── crossoverjie/ │ │ └── cim/ │ │ └── client/ │ │ └── sdk/ │ │ ├── Client.java │ │ ├── ClientBuilder.java │ │ ├── ClientState.java │ │ ├── Event.java │ │ ├── FetchOfflineMsgJob.java │ │ ├── ReConnectManager.java │ │ ├── RouteManager.java │ │ ├── impl/ │ │ │ ├── ClientBuilderImpl.java │ │ │ ├── ClientConfigurationData.java │ │ │ └── ClientImpl.java │ │ └── io/ │ │ ├── CIMClientHandle.java │ │ ├── CIMClientHandleInitializer.java │ │ ├── MessageListener.java │ │ ├── ReconnectCheck.java │ │ └── backoff/ │ │ ├── BackoffStrategy.java │ │ └── RandomBackoff.java │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── crossoverjie/ │ │ └── cim/ │ │ └── client/ │ │ └── sdk/ │ │ ├── ClientTest.java │ │ └── OfflineMsgTest.java │ └── resources/ │ ├── application-route.yaml │ └── init.sql ├── cim-common/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── crossoverjie/ │ │ │ └── cim/ │ │ │ └── common/ │ │ │ ├── constant/ │ │ │ │ └── Constants.java │ │ │ ├── core/ │ │ │ │ └── proxy/ │ │ │ │ ├── DynamicUrl.java │ │ │ │ ├── Request.java │ │ │ │ └── RpcProxyManager.java │ │ │ ├── data/ │ │ │ │ └── construct/ │ │ │ │ ├── RingBufferWheel.java │ │ │ │ ├── SortArrayMap.java │ │ │ │ └── TrieTree.java │ │ │ ├── enums/ │ │ │ │ ├── StatusEnum.java │ │ │ │ └── SystemCommandEnum.java │ │ │ ├── exception/ │ │ │ │ ├── CIMException.java │ │ │ │ └── GenericException.java │ │ │ ├── kit/ │ │ │ │ └── HeartBeatHandler.java │ │ │ ├── metastore/ │ │ │ │ ├── AbstractConfiguration.java │ │ │ │ ├── MetaStore.java │ │ │ │ ├── ZkConfiguration.java │ │ │ │ └── ZkMetaStoreImpl.java │ │ │ ├── pojo/ │ │ │ │ ├── CIMUserInfo.java │ │ │ │ └── RouteInfo.java │ │ │ ├── req/ │ │ │ │ └── BaseRequest.java │ │ │ ├── res/ │ │ │ │ ├── BaseResponse.java │ │ │ │ └── NULLBody.java │ │ │ ├── route/ │ │ │ │ └── algorithm/ │ │ │ │ ├── RouteHandle.java │ │ │ │ ├── consistenthash/ │ │ │ │ │ ├── AbstractConsistentHash.java │ │ │ │ │ ├── ConsistentHashHandle.java │ │ │ │ │ ├── SortArrayMapConsistentHash.java │ │ │ │ │ └── TreeMapConsistentHash.java │ │ │ │ ├── loop/ │ │ │ │ │ └── LoopHandle.java │ │ │ │ └── random/ │ │ │ │ └── RandomHandle.java │ │ │ └── util/ │ │ │ ├── HttpClient.java │ │ │ ├── NettyAttrUtil.java │ │ │ ├── RouteInfoParseUtil.java │ │ │ ├── SnowflakeIdWorker.java │ │ │ └── StringUtil.java │ │ ├── proto/ │ │ │ └── cim.proto │ │ └── resources/ │ │ └── log4j.properties │ └── test/ │ └── java/ │ └── com/ │ └── crossoverjie/ │ └── cim/ │ └── common/ │ ├── CommonTest.java │ ├── core/ │ │ └── proxy/ │ │ └── RpcProxyManagerTest.java │ ├── data/ │ │ └── construct/ │ │ ├── RingBufferWheelTest.java │ │ ├── ScheduledTest.java │ │ ├── SortArrayMapTest.java │ │ ├── TimerTest.java │ │ └── TrieTreeTest.java │ ├── enums/ │ │ └── SystemCommandEnumTypeTest.java │ ├── metastore/ │ │ └── MetaStoreTest.java │ ├── route/ │ │ └── algorithm/ │ │ ├── consistenthash/ │ │ │ ├── ConsistentHashHandleTest.java │ │ │ ├── RangeCheckTestUtil.java │ │ │ ├── SortArrayMapConsistentHashTest.java │ │ │ └── TreeMapConsistentHashTest.java │ │ ├── loop/ │ │ │ └── LoopHandleTest.java │ │ └── random/ │ │ └── RandomHandleTest.java │ └── util/ │ ├── HttpClientTest.java │ └── ProtocolTest.java ├── cim-forward-route/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── crossoverjie/ │ │ │ └── cim/ │ │ │ └── route/ │ │ │ ├── RouteApplication.java │ │ │ ├── config/ │ │ │ │ ├── AppConfiguration.java │ │ │ │ ├── BeanConfig.java │ │ │ │ ├── MySqlPersistenceConfig.java │ │ │ │ ├── OfflineMsgStoreConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── constant/ │ │ │ │ └── Constant.java │ │ │ ├── controller/ │ │ │ │ └── RouteController.java │ │ │ ├── exception/ │ │ │ │ └── ExceptionHandlingController.java │ │ │ ├── factory/ │ │ │ │ └── OfflineMsgFactory.java │ │ │ ├── kit/ │ │ │ │ └── NetAddressIsReachable.java │ │ │ ├── service/ │ │ │ │ ├── AccountService.java │ │ │ │ ├── CommonBizService.java │ │ │ │ ├── OfflineMsgService.java │ │ │ │ ├── UserInfoCacheService.java │ │ │ │ └── impl/ │ │ │ │ ├── AccountServiceRedisImpl.java │ │ │ │ ├── OfflineMsgServiceImpl.java │ │ │ │ └── UserInfoCacheServiceImpl.java │ │ │ └── util/ │ │ │ └── SpringBeanFactory.java │ │ └── resources/ │ │ ├── application.yaml │ │ ├── banner.txt │ │ └── lua/ │ │ └── offLine.lua │ └── test/ │ ├── java/ │ │ ├── CommonTest.java │ │ └── com/ │ │ └── crossoverjie/ │ │ └── cim/ │ │ └── route/ │ │ └── service/ │ │ └── impl/ │ │ ├── AbstractBaseTest.java │ │ ├── AccountServiceRedisImplTest.java │ │ ├── RedisTest.java │ │ └── UserInfoCacheServiceImplTest.java │ └── resources/ │ ├── application.yaml │ └── init.sql ├── cim-integration-test/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── com/ │ │ └── crossoverjie/ │ │ └── cim/ │ │ └── client/ │ │ └── sdk/ │ │ ├── route/ │ │ │ ├── AbstractRouteBaseTest.java │ │ │ └── OfflineMsgStoreRouteBaseTest.java │ │ └── server/ │ │ └── AbstractServerBaseTest.java │ └── test/ │ └── resources/ │ ├── application-client.yaml │ └── application-route.yaml ├── cim-persistence/ │ ├── cim-persistence-api/ │ │ ├── pom.xml │ │ └── src/ │ │ └── main/ │ │ └── java/ │ │ └── com/ │ │ └── crossoverjie/ │ │ └── cim/ │ │ └── persistence/ │ │ └── api/ │ │ ├── config/ │ │ │ └── BeanConfig.java │ │ ├── pojo/ │ │ │ ├── OfflineMsg.java │ │ │ └── OfflineMsgLastSendRecord.java │ │ ├── service/ │ │ │ └── OfflineMsgStore.java │ │ └── vo/ │ │ └── req/ │ │ └── SaveOfflineMsgReqVO.java │ ├── cim-persistence-mysql/ │ │ ├── pom.xml │ │ └── src/ │ │ └── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── crossoverjie/ │ │ │ └── cim/ │ │ │ └── persistence/ │ │ │ └── mysql/ │ │ │ ├── config/ │ │ │ │ └── MyBatisConfig.java │ │ │ ├── offlinemsg/ │ │ │ │ ├── OfflineMsgDb.java │ │ │ │ └── mapper/ │ │ │ │ ├── OfflineMsgLastSendRecordMapper.java │ │ │ │ └── OfflineMsgMapper.java │ │ │ └── util/ │ │ │ └── MapToJsonTypeHandler.java │ │ └── resources/ │ │ └── mapper/ │ │ ├── OfflineMsgLastSendRecordMapper.xml │ │ └── OfflineMsgMapper.xml │ ├── cim-persistence-redis/ │ │ ├── pom.xml │ │ └── src/ │ │ └── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── crossoverjie/ │ │ │ └── cim/ │ │ │ └── persistence/ │ │ │ └── redis/ │ │ │ ├── OfflineMsgBuffer.java │ │ │ ├── constant/ │ │ │ │ └── Constant.java │ │ │ └── kit/ │ │ │ └── OfflineMsgScriptExecutor.java │ │ └── resources/ │ │ └── lua/ │ │ ├── deleteOfflineMsg.lua │ │ ├── fetchOfflineMsg.lua │ │ └── saveOfflineMsg.lua │ └── pom.xml ├── cim-rout-api/ │ ├── pom.xml │ └── src/ │ └── main/ │ └── java/ │ └── com/ │ └── crossoverjie/ │ └── cim/ │ └── route/ │ └── api/ │ ├── RouteApi.java │ └── vo/ │ ├── req/ │ │ ├── ChatReqVO.java │ │ ├── LoginReqVO.java │ │ ├── OfflineMsgReqVO.java │ │ ├── P2PReqVO.java │ │ ├── RegisterInfoReqVO.java │ │ └── SendMsgReqVO.java │ └── res/ │ ├── CIMServerResVO.java │ ├── RegisterInfoResVO.java │ └── SendMsgResVO.java ├── cim-server/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── crossoverjie/ │ │ │ └── cim/ │ │ │ └── server/ │ │ │ ├── CIMServerApplication.java │ │ │ ├── config/ │ │ │ │ ├── AppConfiguration.java │ │ │ │ ├── BeanConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ ├── controller/ │ │ │ │ └── IndexController.java │ │ │ ├── handle/ │ │ │ │ └── CIMServerHandle.java │ │ │ ├── init/ │ │ │ │ └── CIMServerInitializer.java │ │ │ ├── kit/ │ │ │ │ ├── RegistryMetaStore.java │ │ │ │ ├── RouteHandler.java │ │ │ │ └── ServerHeartBeatHandlerImpl.java │ │ │ ├── server/ │ │ │ │ └── CIMServer.java │ │ │ └── util/ │ │ │ ├── SessionSocketHolder.java │ │ │ └── SpringBeanFactory.java │ │ └── resources/ │ │ ├── application.yaml │ │ └── banner.txt │ └── test/ │ └── com/ │ └── crossoverjie/ │ └── cim/ │ └── server/ │ └── util/ │ └── NettyAttrUtilTest.java ├── cim-server-api/ │ ├── pom.xml │ └── src/ │ └── main/ │ └── java/ │ └── com/ │ └── crossoverjie/ │ └── cim/ │ └── server/ │ └── api/ │ ├── ServerApi.java │ └── vo/ │ ├── req/ │ │ └── SendMsgReqVO.java │ └── res/ │ ├── OfflineMsgResVO.java │ ├── SaveOfflineMsgResVO.java │ └── SendMsgResVO.java ├── doc/ │ └── QA.md ├── docker/ │ ├── README.md │ ├── allin1-ubuntu.Dockerfile │ ├── client-ubuntu.Dockerfile │ ├── supervisord.conf │ └── wait-for-it.sh ├── pom.xml ├── script/ │ ├── build.sh │ ├── deploy.sh │ ├── route-startup.sh │ └── server-startup.sh └── sql/ ├── 01schema.sql ├── offline_msg.sql └── offline_msg_last_send_record.sql ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: 'Please check if there are similar issues before submitting' labels: '' assignees: '' --- **Bug Description** A clear and concise description of what the bug is. **Reproduce** step: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected results** A clear and concise description of what you expected to happen. **Screenshot** If applicable, add screenshots to help explain your problem. **Additional Information** Add any other context about the problem here. ================================================ FILE: .github/workflows/docker.yml ================================================ name: Multi-Platform Docker Build on: push: tags: ['image-*'] workflow_dispatch: # keep this to allow manual triggering of the workflow jobs: build-multi-arch: runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write # multipass needs this permission to authenticate with the registry steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 with: driver: docker-container platforms: linux/amd64,linux/arm64,linux/arm/v7 # set the platforms you want to build for - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Push Multi-Arch Image uses: docker/build-push-action@v5 with: context: . file: docker/allin1-ubuntu.Dockerfile platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true tags: | ghcr.io/crossoverjie/allin1-ubuntu:latest ghcr.io/crossoverjie/allin1-ubuntu:${{ github.run_id }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/maven.yml ================================================ # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Java CI with Maven on: push: branches: [ "master" ] pull_request: # Run on all pull requests regardless of target branch to support stacked PRs branches: [ "**" ] jobs: build: uses: ./.github/workflows/reusable_run_tests.yml secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/reusable_run_tests.yml ================================================ name: A reusable workflow to build and run the unit test suite on: workflow_call: secrets: codecov_token: required: true workflow_dispatch: jobs: build_and_test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' cache: maven - name: Run Checkstyle run: mvn checkstyle:check --file pom.xml continue-on-error: true - name: Build with Maven run: mvn -B package --file pom.xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} verbose: true ================================================ FILE: .gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### macOS template # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### Maven template target/ pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties # Avoid ignoring Maven wrapper jar file (.jar files are usually ignored) !/.mvn/wrapper/maven-wrapper.jar ### Java template # Compiled class file *.class # Log file *.log # BlueJ files *.ctxt # Mobile Tools for Java (J2ME) .mtj.tmp/ # Package Files # *.jar *.war *.nar *.ear *.zip *.tar.gz *.rar # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff .idea/**/tasks.xml .idea/**/dictionaries .idea/**/shelf # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml # Gradle .idea/**/gradle.xml # CMake cmake-build-debug/ cmake-build-release/ # Mongo Explorer plugin .idea/**/mongoSettings.xml # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # Editor-based Rest Client .idea/httpRequests .idea/ *.iml # Eclipse Project bin *.project *.settings/ *.classpath *.factorypath .vscode/ /.metadata/ ================================================ FILE: CLAUDE.md ================================================ # CIM Project Guide CIM (Cross-platform Instant Messaging) is a Java-based instant messaging framework. ## Requirements - **Minimum JDK Version**: JDK 17 - **Build Tool**: Maven - **Spring Boot Version**: 3.3.0 ## Environment Setup Before running compile or test commands, set the correct JDK version: ```bash export JAVA_HOME=$JAVA_17_HOME ``` `JAVA_17_HOME` is defined in `~/.zshrc`. ## Common Commands ### Compile the project ```bash mvn clean compile ``` ### Run tests ```bash mvn test ``` ### Package the project ```bash mvn clean package -DskipTests ``` ### Full build (with tests) ```bash mvn clean install ``` ## Notes - Checkstyle code style checks run automatically during Maven's `validate` phase - Ensure code passes Checkstyle checks before committing ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 crossoverJie Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ IMAGE_NAME = allin1-ubuntu REGISTRY = ghcr.io OWNER = $(shell git config --get remote.origin.url | sed -e 's/.*github.com[:/]\([^/]*\)\/.*/\1/') TAG_PREFIX = image # make tag VERSION=v1.0 tag: $(if $(VERSION),,$(error VERSION variable not set. Usage: make tag VERSION=x.y.z)) git tag -f $(TAG_PREFIX)-$(VERSION) git push origin $(TAG_PREFIX)-$(VERSION) # list all tags with the prefix list-tags: git tag -l "$(TAG_PREFIX)-*" | sort -V # get the latest tag get-latest: @git describe --abbrev=0 --tags --match="$(TAG_PREFIX)-*" 2>/dev/null | sed 's/$(TAG_PREFIX)-//' || echo "No tags found" # make version TYPE=major|minor|patch version: $(if $(TYPE),,$(error TYPE variable not set. Options: major, minor, patch)) $(eval CURRENT := $(shell make get-latest)) $(if $(CURRENT),,$(error No existing tags found. Create first tag with: make tag VERSION=1.0.0)) $(eval MAJOR := $(shell echo $(CURRENT) | cut -d. -f1)) $(eval MINOR := $(shell echo $(CURRENT) | cut -d. -f2)) $(eval PATCH := $(shell echo $(CURRENT) | cut -d. -f3)) $(if $(filter $(TYPE),major),$(eval NEW_VERSION := $(($(MAJOR)+1)).0.0)) $(if $(filter $(TYPE),minor),$(eval NEW_VERSION := $(MAJOR).$(($(MINOR)+1)).0)) $(if $(filter $(TYPE),patch),$(eval NEW_VERSION := $(MAJOR).$(MINOR).$(($(PATCH)+1)))) make tag VERSION=$(NEW_VERSION) .PHONY: tag list-tags get-latest version ================================================ FILE: README-zh.md ================================================

[![codecov](https://codecov.io/gh/crossoverJie/cim/graph/badge.svg?token=oW5Gd1oKmf)](https://codecov.io/gh/crossoverJie/cim) [![Build Status](https://img.shields.io/badge/cim-cross--im-brightgreen.svg)](https://github.com/crossoverJie/cim) [![](https://badge.juejin.im/entry/5c2c000e6fb9a049f5713e26/likes.svg?style=flat-square)](https://juejin.im/post/5c2bffdc51882509181395d7) 📘[介绍](#介绍) |📽[视频演示](#视频演示) | 🏖[TODO LIST](#todo-list) | 🌈[系统架构](#系统架构) |💡[流程图](#流程图)|🌁[快速启动](#快速启动)|👨🏻‍✈️[内置命令](#客户端内置命令)|🎤[通信](#群聊私聊)|❓[QA](https://github.com/crossoverJie/cim/blob/master/doc/QA.md)|💌[联系作者](#联系作者) [English](README.md)

# V2.0 - [x] 升级至 JDK17 & springboot3.0 - [x] Client SDK - [ ] 客户端使用 [picocli](https://picocli.info/) 替代 springboot - [x] 支持集成测试 - [ ] 集成 OpenTelemetry - [ ] 支持单节点启动(不依赖外部组件) - [ ] 第三方组件支持替换(Redis/Zookeeper 等) - [ ] 支持 Web 客户端(websocket) - [x] 支持 Docker 容器 - [ ] 支持 Kubernetes 部署 - [ ] 支持二进制客户端(使用 golang 构建) ## 介绍 `CIM(CROSS-IM)` 是面向开发者的 `IM(即时通讯)` 系统;同时提供了一些组件帮助开发者构建自己可扩展的 `IM`。 借助 `CIM` 你可以实现以下需求: - `IM` 即时通讯系统。 - `APP` 消息推送中间件。 - `IOT` 海量连接场景中的消息中间件。 > 如果在使用或开发过程中有任何问题,可以[联系作者](#联系作者)。 ## 视频演示 > 点击下方链接可以查看视频版 Demo。 | YouTube | Bilibili| | :------:| :------: | | [群聊](https://youtu.be/_9a4lIkQ5_o) [私聊](https://youtu.be/kfEfQFPLBTQ) | [群聊](https://www.bilibili.com/video/av39405501) [私聊](https://www.bilibili.com/video/av39405821) | | | ![demo.gif](pic/demo.gif) ## TODO LIST * [x] [群聊](#群聊) * [x] [私聊](#私聊) * [x] [内置命令](#客户端内置命令) * [x] [聊天记录查询](#聊天记录查询) * [x] [一键开启 AI 模式](#ai-模式) * [x] 使用 `Google Protocol Buffer` 高效编解码 * [x] 根据实际情况灵活的水平扩容、缩容 * [x] 服务端自动剔除离线客户端 * [x] 客户端自动重连 * [x] [延时消息](#延时消息) * [x] SDK 开发包 * [ ] 分组群聊 * [ ] 离线消息 * [ ] 消息加密 ## 系统架构 ![](pic/architecture.png) - `CIM` 中的各个组件均采用 `SpringBoot` 构建 - 客户端基于 [cim-client-sdk](https://github.com/crossoverJie/cim/tree/master/cim-client-sdk) 构建 - 采用 `Netty` 构建底层通信 - `MetaStore` 用于 `IM-server` 服务的注册与发现 ### cim-server IM 服务端,用于接收客户端连接、消息转发、消息推送等功能。 支持集群部署。 ### cim-route 路由服务器;用于处理消息路由、消息转发、用户登录、用户下线以及一些运维工具(获取在线用户数等)。 ### cim-client IM 客户端终端,一个命令即可启动并与其他人进行通信(群聊、私聊)。 ## 流程图 ![](https://s2.loli.net/2024/10/13/8teMn7BSa5VWuvi.png) - Server 注册到 `MetaStore` - Route 订阅 `MetaStore` - Client 登录到 Route - Route 从 `MetaStore` 获取 Server 信息 - Client 与 Server 建立连接 - Client1 发送消息到 Route - Route 选择 Server 并将消息转发给 Server - Server 将消息推送给 Client2 ## 快速启动 ### Docker `allin1` 镜像内置了 Zookeeper、Redis、cim-server、cim-forward-route 四个服务,使用 [Supervisor](http://supervisord.org/) 统一管理,开箱即用。 **支持平台:** linux/amd64, linux/arm64, linux/arm/v7 **端口说明:** | 端口 | 服务 | 说明 | |------|---------|-------------| | 2181 | Zookeeper | 服务注册与发现 | | 6379 | Redis | 数据缓存 | | 8083 | Route Server | HTTP API 路由服务 | 拉取镜像并启动: ```shell docker pull ghcr.io/crossoverjie/allin1-ubuntu:latest docker run -p 2181:2181 -p 6379:6379 -p 8083:8083 --rm --name cim-allin1 ghcr.io/crossoverjie/allin1-ubuntu:latest ``` 容器启动后,可参考下方 [注册账号](#注册账号) 和 [启动客户端](#启动客户端) 章节快速体验完整的 IM 流程。 ### 本地构建 Docker 镜像 如果需要从源码构建镜像: ```shell # 在项目根目录执行 docker build -t cim-allin1:latest -f docker/allin1-ubuntu.Dockerfile . docker run -p 2181:2181 -p 6379:6379 -p 8083:8083 --rm --name cim-allin1 cim-allin1:latest ``` ### 本地编译 首先需要安装 `Zookeeper`、`Redis` 并保证网络通畅。 ```shell docker run --rm --name zookeeper -d -p 2181:2181 zookeeper:3.9.2 docker run --rm --name redis -d -p 6379:6379 redis:7.4.0 ``` ```shell git clone https://github.com/crossoverJie/cim.git cd cim mvn clean install -DskipTests=true cd cim-server && cim-client && cim-forward-route mvn clean package spring-boot:repackage -DskipTests=true ``` ### 部署 IM-server(cim-server) ```shell cp /cim/cim-server/target/cim-server-1.0.0-SNAPSHOT.jar /xx/work/server0/ cd /xx/work/server0/ nohup java -jar /root/work/server0/cim-server-1.0.0-SNAPSHOT.jar --cim.server.port=9000 --app.zk.addr=zk地址 > /root/work/server0/log.file 2>&1 & ``` > cim-server 集群部署同理,只要保证 Zookeeper 地址相同即可。 ### 部署路由服务器(cim-forward-route) ```shell cp /cim/cim-server/cim-forward-route/target/cim-forward-route-1.0.0-SNAPSHOT.jar /xx/work/route0/ cd /xx/work/route0/ nohup java -jar /root/work/route0/cim-forward-route-1.0.0-SNAPSHOT.jar --app.zk.addr=zk地址 --spring.redis.host=redis地址 --spring.redis.port=6379 > /root/work/route/log.file 2>&1 & ``` > cim-forward-route 本身就是无状态,可以部署多台;使用 Nginx 代理即可。 ### 启动客户端 ```shell cp /cim/cim-client/target/cim-client-1.0.0-SNAPSHOT.jar /xx/work/route0/ cd /xx/work/route0/ java -jar cim-client-1.0.0-SNAPSHOT.jar --server.port=8084 --cim.user.id=唯一客户端ID --cim.user.userName=用户名 --cim.route.url=http://路由服务器:8083/ ``` ![](https://ws2.sinaimg.cn/large/006tNbRwly1fylgxjgshfj31vo04m7p9.jpg) ![](https://ws1.sinaimg.cn/large/006tNbRwly1fylgxu0x4uj31hy04q75z.jpg) 如上图,启动两个客户端可以互相通信即可。 ### 本地启动客户端 #### 注册账号 ```shell curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' -d '{ "reqNo": "1234567890", "timeStamp": 0, "userName": "zhangsan" }' 'http://路由服务器:8083/registerAccount' ``` 从返回结果中获取 `userId` ```json { "code":"9000", "message":"成功", "reqNo":null, "dataBody":{ "userId":1547028929407, "userName":"test" } } ``` #### 启动本地客户端 ```shell # 启动本地客户端 cp /cim/cim-client/target/cim-client-1.0.0-SNAPSHOT.jar /xx/work/route0/ cd /xx/work/route0/ java -jar cim-client-1.0.0-SNAPSHOT.jar --server.port=8084 --cim.user.id=上方返回的userId --cim.user.userName=用户名 --cim.route.url=http://路由服务器:8083/ ``` ## 客户端内置命令 | 命令 | 描述| | ------ | ------ | | `:q!` | 退出客户端| | `:olu` | 获取所有在线用户信息 | | `:all` | 获取所有命令 | | `:q [option]` | 【:q 关键字】查询聊天记录 | | `:ai` | 开启 AI 模式 | | `:qai` | 关闭 AI 模式 | | `:pu` | 模糊匹配用户 | | `:info` | 获取客户端信息 | | `:emoji [option]` | 查询表情包 [option:页码] | | `:delay [msg] [delayTime]` | 发送延时消息 | | `:` | 更多命令正在开发中。。 | ![](https://ws3.sinaimg.cn/large/006tNbRwly1fylh7bdlo6g30go01shdt.gif) ### 聊天记录查询 ![](https://i.loli.net/2019/05/08/5cd1c310cb796.jpg) 使用命令 `:q 关键字` 即可查询与个人相关的聊天记录。 > 客户端聊天记录默认存放在 `/opt/logs/cim/`,所以需要这个目录的写入权限。也可在启动命令中加入 `--cim.msg.logger.path = /自定义` 参数自定义目录。 ### AI 模式 ![](https://i.loli.net/2019/05/08/5cd1c30e47d95.jpg) 使用命令 `:ai` 开启 AI 模式,之后所有的消息都会由 `AI` 响应。 `:qai` 退出 AI 模式。 ### 前缀匹配用户名 ![](https://i.loli.net/2019/05/08/5cd1c32ac3397.jpg) 使用命令 `:qu prefix` 可以按照前缀的方式搜索用户信息。 > 该功能主要用于在移动端中的输入框中搜索用户。 ### 群聊/私聊 #### 群聊 ![](https://ws1.sinaimg.cn/large/006tNbRwly1fyli54e8e1j31t0056x11.jpg) ![](https://ws3.sinaimg.cn/large/006tNbRwly1fyli5yyspmj31im06atb8.jpg) ![](https://ws3.sinaimg.cn/large/006tNbRwly1fyli6sn3c8j31ss06qmzq.jpg) 群聊只需要在控制台里输入消息回车后即可发送,同时所有在线客户端都可收到消息。 #### 私聊 私聊首先需要知道对方的 `userID` 才能进行。 输入命令 `:olu` 可列出所有在线用户。 ![](https://ws4.sinaimg.cn/large/006tNbRwly1fyli98mlf3j31ta06mwhv.jpg) 接着使用 `userId;;消息内容` 的格式即可发送私聊消息。 ![](https://ws4.sinaimg.cn/large/006tNbRwly1fylib08qlnj31sk082zo6.jpg) ![](https://ws1.sinaimg.cn/large/006tNbRwly1fylibc13etj31wa0564lp.jpg) ![](https://ws3.sinaimg.cn/large/006tNbRwly1fylicmjj6cj31wg07c4qp.jpg) ![](https://ws1.sinaimg.cn/large/006tNbRwly1fylicwhe04j31ua03ejsv.jpg) 同时另一个账号收不到消息。 ![](https://ws3.sinaimg.cn/large/006tNbRwly1fylie727jaj31t20dq1ky.jpg) ### emoji 表情支持 使用命令 `:emoji 1` 查询出所有表情列表,使用表情别名即可发送表情。 ![](https://tva1.sinaimg.cn/large/006y8mN6ly1g6j910cqrzj30dn05qjw9.jpg) ![](https://tva1.sinaimg.cn/large/006y8mN6ly1g6j99hazg6j30ax03hq35.jpg) ### 延时消息 发送 10s 的延时消息: ```shell :delay delayMsg 10 ``` ![](pic/delay.gif) ## 联系作者 ## 贡献指南 欢迎贡献代码!提交 PR 前,请确保代码通过 Checkstyle 检查。 ### 代码风格 本项目使用 [Checkstyle](https://checkstyle.org/) 来规范代码风格,规则定义在 `checkstyle/checkstyle.xml` 中。 **提交前在本地运行 Checkstyle:** ```shell mvn checkstyle:check ``` **主要规则:** - `{`、`}` 和运算符前后使用空格 - 行尾不能有空格 - 文件必须以换行符结尾 - 删除未使用的 import - 常量(`static final`)必须使用 `UPPER_SNAKE_CASE` 命名 - 使用 Java 风格的数组声明:`String[] args`(而非 `String args[]`) **快速构建时跳过 Checkstyle:** ```shell mvn package -Dcheckstyle.skip=true ```
202405171520366.png
最近开通了知识星球,感谢大家对 CIM 的支持,为大家提供 100 份 10 元优惠券,也就是 69-10=59 元,具体福利大家可以扫码参考再决定是否加入。 > PS: 后续会在星球开始 V2.0 版本的重构,感兴趣的可以加入星球当面催更(当然代码依然会开源)。 - [crossoverJie@gmail.com](mailto:crossoverJie@gmail.com) - 微信公众号 ![index.jpg](https://i.loli.net/2021/10/12/ckQW9LYXSxFogJZ.jpg) ================================================ FILE: README.md ================================================

[![codecov](https://codecov.io/gh/crossoverJie/cim/graph/badge.svg?token=oW5Gd1oKmf)](https://codecov.io/gh/crossoverJie/cim) [![Build Status](https://img.shields.io/badge/cim-cross--im-brightgreen.svg)](https://github.com/crossoverJie/cim) [![](https://badge.juejin.im/entry/5c2c000e6fb9a049f5713e26/likes.svg?style=flat-square)](https://juejin.im/post/5c2bffdc51882509181395d7) 📘[Introduction](#introduction) |📽[Video Demo](#video-demo) | 🏖[TODO LIST](#todo-list) | 🌈[Architecture](#architecture) |💡[Flow Chart](#flow-chart)|🌁[Quick Start](#quick-start)|👨🏻‍✈️[Built-in Commands](#built-in-commands)|🎤[Chat](#group-chatprivate-chat)|❓[QA](https://github.com/crossoverJie/cim/blob/master/doc/QA.md)|💌[Contact](#contact) [中文文档](README-zh.md)

# V2.0 - [x] Upgrade to JDK17 & springboot3.0 - [x] Client SDK - [ ] Client use [picocli](https://picocli.info/) instead of springboot. - [x] Support integration testing. - [ ] Integrate OpenTelemetry . - [ ] Support single node startup(Contains no components). - [ ] Third-party components support replacement(Redis/Zookeeper, etc.). - [ ] Support web client(websocket). - [x] Support docker container. - [ ] Support kubernetes operation. - [ ] Supports binary client(build with golang). ## Introduction `CIM(CROSS-IM)` is an `IM (instant messaging)` system for developers; it also provides some components to help developers build their own scalable `IM`. Using `CIM`, you can achieve the following requirements: - `IM` instant messaging system. - Message push middleware for `APP`. - Message middleware for `IOT` massive connection scenarios. > If you have any questions during use or development, you can [contact the author](#contact). ## Video Demo > Click the links below to watch the video demo. | YouTube | Bilibili| | :------:| :------: | | [Group Chat](https://youtu.be/_9a4lIkQ5_o) [Private Chat](https://youtu.be/kfEfQFPLBTQ) | [Group Chat](https://www.bilibili.com/video/av39405501) [Private Chat](https://www.bilibili.com/video/av39405821) | | | ![demo.gif](pic/demo.gif) ## TODO LIST * [x] [Group Chat](#group-chat) * [x] [Private Chat](#private-chat) * [x] [Built-in Commands](#built-in-commands) * [x] [Chat History Query](#chat-history-query) * [x] [AI Mode](#ai-mode) * [x] Efficient encoding/decoding with `Google Protocol Buffer` * [x] Flexible horizontal scaling based on actual needs * [x] Server-side automatic removal of offline clients * [x] Client automatic reconnection * [x] [Delayed Messages](#delayed-messages) * [x] SDK development package * [ ] Group categorization * [ ] Offline messages * [ ] Message encryption ## Architecture ![](pic/architecture.png) - Each component in `CIM` is built using `SpringBoot` - Client build with [cim-client-sdk](https://github.com/crossoverJie/cim/tree/master/cim-client-sdk) - Use `Netty` to build the underlying communication. - `MetaStore` is used for registration and discovery of `IM-server` services. ### cim-server IM server is used to receive client connections, message forwarding, message push, etc. Support cluster deployment. ### cim-route Route server; used to process message routing, message forwarding, user login, user offline, and some operation tools (get the number of online users, etc.). ### cim-client IM client terminal, a command can be started and initiated to communicate with others (group chat, private chat). ## Flow Chart ![](https://s2.loli.net/2024/10/13/8teMn7BSa5VWuvi.png) - Server register to `MetaStore` - Route subscribe `MetaStore` - Client login to Route - Route get Server info from `MetaStore` - Client open connection to Server - Client1 send message to Route - Route select Server and forward message to Server - Server push message to Client2 ## Quick Start ### Docker The `allin1` image comes with Zookeeper, Redis, cim-server, and cim-forward-route pre-installed, all managed by [Supervisor](http://supervisord.org/) for an out-of-the-box experience. **Supported platforms:** linux/amd64, linux/arm64, linux/arm/v7 **Port mapping:** | Port | Service | Description | |------|---------|-------------| | 2181 | Zookeeper | Service registration & discovery | | 6379 | Redis | Data caching | | 8083 | Route Server | HTTP API routing service | Pull the image and start the container: ```shell docker pull ghcr.io/crossoverjie/allin1-ubuntu:latest docker run -p 2181:2181 -p 6379:6379 -p 8083:8083 --rm --name cim-allin1 ghcr.io/crossoverjie/allin1-ubuntu:latest ``` After the container starts, refer to the [Register Account](#register-account) and [Start Client](#start-client) sections below to experience the full IM workflow. ### Build Docker Image Locally To build the Docker image from source: ```shell # Run from the project root directory docker build -t cim-allin1:latest -f docker/allin1-ubuntu.Dockerfile . docker run -p 2181:2181 -p 6379:6379 -p 8083:8083 --rm --name cim-allin1 cim-allin1:latest ``` ### Build from Source First, install `Zookeeper` and `Redis` and ensure the network is accessible. ```shell docker run --rm --name zookeeper -d -p 2181:2181 zookeeper:3.9.2 docker run --rm --name redis -d -p 6379:6379 redis:7.4.0 ``` ```shell git clone https://github.com/crossoverJie/cim.git cd cim mvn clean install -DskipTests=true cd cim-server && cim-client && cim-forward-route mvn clean package spring-boot:repackage -DskipTests=true ``` ### Deploy IM-server (cim-server) ```shell cp /cim/cim-server/target/cim-server-1.0.0-SNAPSHOT.jar /xx/work/server0/ cd /xx/work/server0/ nohup java -jar /root/work/server0/cim-server-1.0.0-SNAPSHOT.jar --cim.server.port=9000 --app.zk.addr= > /root/work/server0/log.file 2>&1 & ``` > For cim-server cluster deployment, just ensure all instances point to the same Zookeeper address. ### Deploy Route Server (cim-forward-route) ```shell cp /cim/cim-server/cim-forward-route/target/cim-forward-route-1.0.0-SNAPSHOT.jar /xx/work/route0/ cd /xx/work/route0/ nohup java -jar /root/work/route0/cim-forward-route-1.0.0-SNAPSHOT.jar --app.zk.addr= --spring.redis.host= --spring.redis.port=6379 > /root/work/route/log.file 2>&1 & ``` > cim-forward-route is stateless and can be deployed on multiple nodes; use Nginx as a reverse proxy. ### Start Client ```shell cp /cim/cim-client/target/cim-client-1.0.0-SNAPSHOT.jar /xx/work/route0/ cd /xx/work/route0/ java -jar cim-client-1.0.0-SNAPSHOT.jar --server.port=8084 --cim.user.id= --cim.user.userName= --cim.route.url=http://:8083/ ``` ![](https://ws2.sinaimg.cn/large/006tNbRwly1fylgxjgshfj31vo04m7p9.jpg) ![](https://ws1.sinaimg.cn/large/006tNbRwly1fylgxu0x4uj31hy04q75z.jpg) As shown above, two clients can communicate with each other. ### Local Client Startup #### Register Account ```shell curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' -d '{ "reqNo": "1234567890", "timeStamp": 0, "userName": "zhangsan" }' 'http://:8083/registerAccount' ``` Get the `userId` from the response: ```json { "code":"9000", "message":"success", "reqNo":null, "dataBody":{ "userId":1547028929407, "userName":"test" } } ``` #### Start Local Client ```shell # Start local client cp /cim/cim-client/target/cim-client-1.0.0-SNAPSHOT.jar /xx/work/route0/ cd /xx/work/route0/ java -jar cim-client-1.0.0-SNAPSHOT.jar --server.port=8084 --cim.user.id= --cim.user.userName= --cim.route.url=http://:8083/ ``` ## Built-in Commands | Command | Description | | ------ | ------ | | `:q!` | Quit the client | | `:olu` | List all online users | | `:all` | Show all available commands | | `:q [keyword]` | Search chat history by keyword | | `:ai` | Enable AI mode | | `:qai` | Disable AI mode | | `:pu` | Fuzzy search users | | `:info` | Show client information | | `:emoji [option]` | Browse emoji list [option: page number] | | `:delay [msg] [delayTime]` | Send a delayed message | | `:` | More commands are under development... | ![](https://ws3.sinaimg.cn/large/006tNbRwly1fylh7bdlo6g30go01shdt.gif) ### Chat History Query ![](https://i.loli.net/2019/05/08/5cd1c310cb796.jpg) Use the command `:q keyword` to search chat history related to you. > Client chat history is stored in `/opt/logs/cim/` by default, so write permission is required for this directory. You can also customize the directory by adding `--cim.msg.logger.path=/custom/path` to the startup command. ### AI Mode ![](https://i.loli.net/2019/05/08/5cd1c30e47d95.jpg) Use the command `:ai` to enable AI mode. After that, all messages will be responded to by `AI`. Use `:qai` to exit AI mode. ### Prefix Match Username ![](https://i.loli.net/2019/05/08/5cd1c32ac3397.jpg) Use the command `:qu prefix` to search user information by prefix. > This feature is primarily designed for searching users in input fields on mobile clients. ### Group Chat/Private Chat #### Group Chat ![](https://ws1.sinaimg.cn/large/006tNbRwly1fyli54e8e1j31t0056x11.jpg) ![](https://ws3.sinaimg.cn/large/006tNbRwly1fyli5yyspmj31im06atb8.jpg) ![](https://ws3.sinaimg.cn/large/006tNbRwly1fyli6sn3c8j31ss06qmzq.jpg) For group chat, simply type a message in the console and press Enter to send. All online clients will receive the message. #### Private Chat To send a private message, you need to know the recipient's `userID`. Use the command `:olu` to list all online users. ![](https://ws4.sinaimg.cn/large/006tNbRwly1fyli98mlf3j31ta06mwhv.jpg) Then use the format `userId;;message content` to send a private message. ![](https://ws4.sinaimg.cn/large/006tNbRwly1fylib08qlnj31sk082zo6.jpg) ![](https://ws1.sinaimg.cn/large/006tNbRwly1fylibc13etj31wa0564lp.jpg) ![](https://ws3.sinaimg.cn/large/006tNbRwly1fylicmjj6cj31wg07c4qp.jpg) ![](https://ws1.sinaimg.cn/large/006tNbRwly1fylicwhe04j31ua03ejsv.jpg) Meanwhile, the other account will not receive the message. ![](https://ws3.sinaimg.cn/large/006tNbRwly1fylie727jaj31t20dq1ky.jpg) ### Emoji Support Use the command `:emoji 1` to list all available emojis. Use the emoji alias to send an emoji. ![](https://tva1.sinaimg.cn/large/006y8mN6ly1g6j910cqrzj30dn05qjw9.jpg) ![](https://tva1.sinaimg.cn/large/006y8mN6ly1g6j99hazg6j30ax03hq35.jpg) ### Delayed Messages Send a message with a 10-second delay: ```shell :delay delayMsg 10 ``` ![](pic/delay.gif) ## Contact ## Contributing We welcome contributions! Before submitting a PR, please ensure your code passes the Checkstyle check. ### Code Style This project uses [Checkstyle](https://checkstyle.org/) to enforce code style. The rules are defined in `checkstyle/checkstyle.xml`. **Run Checkstyle locally before committing:** ```shell mvn checkstyle:check ``` **Key rules:** - Use spaces around `{`, `}`, and operators - No trailing whitespace - Files must end with a newline - Remove unused imports - Constants (`static final`) must be `UPPER_SNAKE_CASE` - Use Java-style array declarations: `String[] args` (not `String args[]`) **Skip Checkstyle for quick builds:** ```shell mvn package -Dcheckstyle.skip=true ``` - [crossoverJie@gmail.com](mailto:crossoverJie@gmail.com) ================================================ FILE: checkstyle/checkstyle.xml ================================================ ================================================ FILE: checkstyle/suppressions.xml ================================================ ================================================ FILE: cim-client/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim 1.0.0-SNAPSHOT cim-client jar UTF-8 UTF-8 17 2.5.0 com.google.protobuf protobuf-java com.crossoverjie.netty cim-common com.crossoverjie.netty cim-client-sdk org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine test org.springframework.boot spring-boot-configuration-processor true org.springframework.boot spring-boot-starter-actuator junit junit com.alibaba fastjson com.vdurmont emoji-java 5.0.0 com.crossoverjie.netty cim-rout-api org.springframework.boot spring-boot-maven-plugin repackage ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/CIMClientApplication.java ================================================ package com.crossoverjie.cim.client; import com.crossoverjie.cim.client.scanner.Scan; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * @author crossoverJie */ @Slf4j @SpringBootApplication public class CIMClientApplication implements CommandLineRunner { public static void main(String[] args) { SpringApplication.run(CIMClientApplication.class, args); log.info("Client start success"); } @Override public void run(String... args) { Scan scan = new Scan(); Thread thread = new Thread(scan); thread.setName("scan-thread"); thread.start(); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/config/AppConfiguration.java ================================================ package com.crossoverjie.cim.client.config; import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 2018/8/24 01:43 * @since JDK 1.8 */ @Component @Data public class AppConfiguration { @Value("${cim.user.id}") private Long userId; @Value("${cim.user.userName}") private String userName; @Value("${cim.msg.logger.path}") private String msgLoggerPath; @Value("${cim.heartbeat.time}") private long heartBeatTime; @Value("${cim.reconnect.count}") private int reconnectCount; @Value("${cim.route.url}") private String routeUrl; @Value("${cim.callback.thread.queue.size}") private int queueSize; @Value("${cim.callback.thread.pool.size}") private int poolSize; } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/config/BeanConfig.java ================================================ package com.crossoverjie.cim.client.config; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.sdk.impl.ClientConfigurationData; import com.crossoverjie.cim.client.sdk.io.backoff.RandomBackoff; import com.crossoverjie.cim.client.service.MsgLogger; import com.crossoverjie.cim.client.service.ShutDownSign; import com.crossoverjie.cim.client.service.impl.MsgCallBackListener; import com.crossoverjie.cim.common.data.construct.RingBufferWheel; import com.google.common.util.concurrent.ThreadFactoryBuilder; import jakarta.annotation.Resource; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * Function:bean 配置 * * @author crossoverJie * Date: 24/05/2018 15:55 * @since JDK 1.8 */ @Configuration public class BeanConfig { @Resource private AppConfiguration appConfiguration; @Resource private ShutDownSign shutDownSign; @Resource private MsgLogger msgLogger; @Bean public Client buildClient(@Qualifier("callBackThreadPool") ThreadPoolExecutor callbackThreadPool, Event event) { OkHttpClient okHttpClient = new OkHttpClient.Builder().connectTimeout(3, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS) .writeTimeout(3, TimeUnit.SECONDS) .retryOnConnectionFailure(true).build(); return Client.builder() .auth(ClientConfigurationData.Auth.builder() .userName(appConfiguration.getUserName()) .userId(appConfiguration.getUserId()) .build()) .routeUrl(appConfiguration.getRouteUrl()) .loginRetryCount(appConfiguration.getReconnectCount()) .event(event) .reconnectCheck(client -> !shutDownSign.checkStatus()) .okHttpClient(okHttpClient) .messageListener(new MsgCallBackListener(msgLogger, event)) .callbackThreadPool(callbackThreadPool) .backoffStrategy(new RandomBackoff()) .build(); } /** * http client * * @return okHttp */ @Bean public OkHttpClient okHttpClient() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(3, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS) .writeTimeout(3, TimeUnit.SECONDS) .retryOnConnectionFailure(true); return builder.build(); } /** * Create callback thread pool * * @return */ @Bean("callBackThreadPool") public ThreadPoolExecutor buildCallerThread() { BlockingQueue queue = new LinkedBlockingQueue<>(appConfiguration.getQueueSize()); ThreadFactory executor = new ThreadFactoryBuilder() .setNameFormat("msg-callback-%d") .setDaemon(true) .build(); return new ThreadPoolExecutor(appConfiguration.getPoolSize(), appConfiguration.getPoolSize(), 1, TimeUnit.MILLISECONDS, queue, executor); } @Bean public RingBufferWheel bufferWheel() { ExecutorService executorService = Executors.newFixedThreadPool(2); return new RingBufferWheel(executorService); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/config/SwaggerConfig.java ================================================ package com.crossoverjie.cim.client.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { @Bean public OpenAPI createRestApi() { return new OpenAPI() .info(apiInfo()); } private Info apiInfo() { return new Info() .title("cim client") .description("cim client api") .termsOfService("http://crossoverJie.top") .contact(contact()) .version("1.0.0"); } private Contact contact() { Contact contact = new Contact(); contact.setName("crossoverJie"); return contact; } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/scanner/Scan.java ================================================ package com.crossoverjie.cim.client.scanner; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.MsgHandle; import com.crossoverjie.cim.client.service.MsgLogger; import com.crossoverjie.cim.client.util.SpringBeanFactory; import java.util.Scanner; import lombok.SneakyThrows; /** * Function: * * @author crossoverJie * Date: 2018/12/21 16:44 * @since JDK 1.8 */ public class Scan implements Runnable { private final MsgHandle msgHandle; private final MsgLogger msgLogger; private final Event event; public Scan() { this.msgHandle = SpringBeanFactory.getBean(MsgHandle.class); this.msgLogger = SpringBeanFactory.getBean(MsgLogger.class); this.event = SpringBeanFactory.getBean(Event.class); } @SneakyThrows @Override public void run() { Scanner sc = new Scanner(System.in); while (true) { String msg = sc.nextLine(); if (msgHandle.checkMsg(msg)) { continue; } // internal cmd if (msgHandle.innerCommand(msg)) { continue; } msgHandle.sendMsg(msg); // write to log msgLogger.log(msg); event.info(msg); } } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/InnerCommand.java ================================================ package com.crossoverjie.cim.client.service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:26 * @since JDK 1.8 */ public interface InnerCommand { /** * 执行 * @param msg */ void process(String msg) throws Exception; } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/InnerCommandContext.java ================================================ package com.crossoverjie.cim.client.service; import com.crossoverjie.cim.client.service.impl.command.PrintAllCommand; import com.crossoverjie.cim.client.util.SpringBeanFactory; import com.crossoverjie.cim.common.enums.SystemCommandEnum; import com.crossoverjie.cim.common.util.StringUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.Map; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:39 * @since JDK 1.8 */ @Slf4j @Component public class InnerCommandContext { /** * 获取执行器实例 * @param command 执行器实例 * @return */ public InnerCommand getInstance(String command) { Map allClazz = SystemCommandEnum.getAllClazz(); //兼容需要命令后接参数的数据 :q cross String[] trim = command.trim().split(" "); String clazz = allClazz.get(trim[0]); InnerCommand innerCommand = null; try { if (StringUtil.isEmpty(clazz)) { clazz = PrintAllCommand.class.getName(); } innerCommand = (InnerCommand) SpringBeanFactory.getBean(Class.forName(clazz)); } catch (Exception e) { log.error("Exception", e); } return innerCommand; } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/MsgHandle.java ================================================ package com.crossoverjie.cim.client.service; /** * Function:消息处理器 * * @author crossoverJie * Date: 2018/12/26 11:11 * @since JDK 1.8 */ public interface MsgHandle { /** * 统一的发送接口,包含了 groupChat p2pChat * * @param msg */ void sendMsg(String msg) throws Exception; /** * 校验消息 * * @param msg * @return 不能为空,后续可以加上一些敏感词 * @throws Exception */ boolean checkMsg(String msg); /** * 执行内部命令 * * @param msg * @return 是否应当跳过当前消息(包含了":" 就需要跳过) */ boolean innerCommand(String msg) throws Exception; /** * 开启 AI 模式 */ void openAIModel(); /** * 关闭 AI 模式 */ void closeAIModel(); } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/MsgLogger.java ================================================ package com.crossoverjie.cim.client.service; /** * Function: * * @author crossoverJie * Date: 2019/1/6 15:23 * @since JDK 1.8 */ public interface MsgLogger { /** * write log * @param msg */ void log(String msg); /** * 停止写入 */ void stop(); /** * 查询聊天记录 * @param key 关键字 * @return */ String query(String key); } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/ShutDownSign.java ================================================ package com.crossoverjie.cim.client.service; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 2019-02-27 16:17 * @since JDK 1.8 */ @Component public class ShutDownSign { private boolean isCommand; /** * Set user exit sign. */ public void shutdown() { isCommand = true; } public boolean checkStatus() { return isCommand; } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/AsyncMsgLogger.java ================================================ package com.crossoverjie.cim.client.service.impl; import com.crossoverjie.cim.client.config.AppConfiguration; import com.crossoverjie.cim.client.service.MsgLogger; import jakarta.annotation.Resource; import java.nio.charset.StandardCharsets; import java.util.Collections; import lombok.Cleanup; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.LocalDate; import java.util.List; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.stream.Stream; /** * Function: * * @author crossoverJie * Date: 2019/1/6 15:26 * @since JDK 1.8 */ @Slf4j @Service public class AsyncMsgLogger implements MsgLogger { /** * The default buffer size. */ private static final int DEFAULT_QUEUE_SIZE = 16; private final BlockingQueue blockingQueue = new ArrayBlockingQueue(DEFAULT_QUEUE_SIZE); private volatile boolean started = false; private final Worker worker = new Worker(); @Resource private AppConfiguration appConfiguration; @Override public void log(String msg) { // start worker startMsgLogger(); try { // TODO: 2019/1/6 消息堆满是否阻塞线程? blockingQueue.put(msg); } catch (InterruptedException e) { log.error("InterruptedException", e); } } private class Worker extends Thread { @Override public void run() { while (started) { try { String msg = blockingQueue.take(); writeLog(msg); } catch (InterruptedException e) { break; } } } } private void writeLog(String msg) { LocalDate today = LocalDate.now(); int year = today.getYear(); int month = today.getMonthValue(); int day = today.getDayOfMonth(); String dir = appConfiguration.getMsgLoggerPath() + appConfiguration.getUserName() + "/"; String fileName = dir + year + month + day + ".log"; Path file = Paths.get(fileName); boolean exists = Files.exists(Paths.get(dir), LinkOption.NOFOLLOW_LINKS); try { if (!exists) { Files.createDirectories(Paths.get(dir)); } List lines = Collections.singletonList(msg); Files.write(file, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { log.info("IOException", e); } } /** * Begin worker */ private void startMsgLogger() { if (started) { return; } worker.setDaemon(true); worker.setName("AsyncMsgLogger-Worker"); started = true; worker.start(); } @Override public void stop() { started = false; worker.interrupt(); } @Override public String query(String key) { StringBuilder sb = new StringBuilder(); Path path = Paths.get(appConfiguration.getMsgLoggerPath() + appConfiguration.getUserName() + "/"); try { @Cleanup Stream list = Files.list(path); List collect = list.toList(); for (Path file : collect) { List strings = Files.readAllLines(file); for (String msg : strings) { if (msg.trim().contains(key)) { sb.append(msg).append("\n"); } } } } catch (IOException e) { log.info("IOException", e); } return sb.toString().replace(key, "\033[31;4m" + key + "\033[0m"); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/EchoServiceImpl.java ================================================ package com.crossoverjie.cim.client.service.impl; import com.crossoverjie.cim.client.config.AppConfiguration; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.MsgLogger; import com.vdurmont.emoji.EmojiParser; import jakarta.annotation.Resource; import java.time.LocalDate; import java.time.LocalTime; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-08-27 22:37 * @since JDK 1.8 */ @Service public class EchoServiceImpl implements Event { private static final String PREFIX = "$"; @Resource private AppConfiguration appConfiguration; @Resource private MsgLogger msgLogger; @Override public void debug(String msg, Object... replace) { msgLogger.log(String.format("Debug[%s]", msg)); } @Override public void info(String msg, Object... replace) { // Make terminal can display the emoji msg = EmojiParser.parseToUnicode(msg); String date = LocalDate.now() + " " + LocalTime.now().withNano(0).toString(); msg = "[" + date + "] \033[31;4m" + appConfiguration.getUserName() + PREFIX + "\033[0m" + " " + msg; String log = print(msg, replace); System.out.println(log); } @Override public void warn(String msg, Object... replace) { info(String.format("Warn##%s##", msg), replace); } @Override public void error(String msg, Object... replace) { info(String.format("Error!!%s!!", msg), replace); } @Override public void fatal(Client client) { info("{} fatal error, shutdown client", client.getAuth()); } /** * print msg * * @param msg * @param place * @return */ private String print(String msg, Object... place) { StringBuilder sb = new StringBuilder(); int k = 0; for (int i = 0; i < place.length; i++) { int index = msg.indexOf("{}", k); if (index == -1) { return msg; } if (index != 0) { sb.append(msg, k, index); sb.append(place[i]); if (place.length == 1) { sb.append(msg, index + 2, msg.length()); } } else { sb.append(place[i]); if (place.length == 1) { sb.append(msg, index + 2, msg.length()); } } k = index + 2; } if (sb.toString().equals("")) { return msg; } else { return sb.toString(); } } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/MsgCallBackListener.java ================================================ package com.crossoverjie.cim.client.service.impl; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.sdk.io.MessageListener; import com.crossoverjie.cim.client.service.MsgLogger; import com.crossoverjie.cim.common.constant.Constants; import java.util.Map; /** * Function:自定义收到消息回调 * * @author crossoverJie * Date: 2019/1/6 17:49 * @since JDK 1.8 */ public class MsgCallBackListener implements MessageListener { private final MsgLogger msgLogger; private final Event event; public MsgCallBackListener(MsgLogger msgLogger, Event event) { this.msgLogger = msgLogger; this.event = event; } @Override public void received(Client client, Map properties, String msg) { String sendUserName = properties.getOrDefault(Constants.MetaKey.SEND_USER_NAME, "nobody"); this.msgLogger.log(sendUserName + ":" + msg); this.event.info(sendUserName + ":" + msg); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/MsgHandler.java ================================================ package com.crossoverjie.cim.client.service.impl; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.client.service.InnerCommandContext; import com.crossoverjie.cim.client.service.MsgHandle; import com.crossoverjie.cim.common.util.StringUtil; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2018/12/26 11:15 * @since JDK 1.8 */ @Slf4j @Service public class MsgHandler implements MsgHandle { @Resource private InnerCommandContext innerCommandContext; @Resource private Client client; private boolean aiModel = false; @Override public void sendMsg(String msg) throws Exception { if (aiModel) { aiChat(msg); } else { normalChat(msg); } } private void normalChat(String msg) throws Exception { String[] totalMsg = msg.split(";;"); if (totalMsg.length > 1) { P2PReqVO p2PReqVO = new P2PReqVO(); p2PReqVO.setReceiveUserId(Long.parseLong(totalMsg[0])); p2PReqVO.setMsg(totalMsg[1]); client.sendP2P(p2PReqVO); } else { client.sendGroup(msg); } } /** * AI model * * @param msg */ private void aiChat(String msg) { msg = msg.replace("吗", ""); msg = msg.replace("嘛", ""); msg = msg.replace("?", "!"); msg = msg.replace("?", "!"); msg = msg.replace("你", "我"); System.out.println("AI:\033[31;4m" + msg + "\033[0m"); } @Override public boolean checkMsg(String msg) { if (StringUtil.isEmpty(msg)) { log.warn("不能发送空消息!"); return true; } return false; } @Override public boolean innerCommand(String msg) throws Exception { if (msg.startsWith(":")) { InnerCommand instance = innerCommandContext.getInstance(msg); instance.process(msg); return true; } else { return false; } } @Override public void openAIModel() { aiModel = true; } @Override public void closeAIModel() { aiModel = false; } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/CloseAIModelCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.client.service.MsgHandle; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Service public class CloseAIModelCommand implements InnerCommand { @Resource private MsgHandle msgHandle; @Resource private Event event; @Override public void process(String msg) { msgHandle.closeAIModel(); event.info("\033[31;4m" + "。゚(゚´ω`゚)゚。 AI 下线了!" + "\033[0m"); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/DelayMsgCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.client.service.MsgHandle; import com.crossoverjie.cim.common.data.construct.RingBufferWheel; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-09-25 00:37 * @since JDK 1.8 */ @Service @Slf4j public class DelayMsgCommand implements InnerCommand { @Resource private Event event; @Resource private MsgHandle msgHandle; @Resource private RingBufferWheel ringBufferWheel; @Override public void process(String msg) { if (msg.split(" ").length <= 2) { event.info("incorrect commond, :delay [msg] [delayTime]"); return; } String message = msg.split(" ")[1]; int delayTime = Integer.parseInt(msg.split(" ")[2]); RingBufferWheel.Task task = new DelayMsgJob(message); task.setKey(delayTime); ringBufferWheel.addTask(task); event.info(msg); } private class DelayMsgJob extends RingBufferWheel.Task { private String msg; public DelayMsgJob(String msg) { this.msg = msg; } @Override public void run() { try { msgHandle.sendMsg(msg); } catch (Exception e) { log.error("Delay message send error", e); } } } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/EchoInfoCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Service public class EchoInfoCommand implements InnerCommand { @Resource private Client client; @Resource private Event event; @Override public void process(String msg) { event.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); event.info("client info={}", client.getAuth()); event.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/EmojiCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.vdurmont.emoji.Emoji; import com.vdurmont.emoji.EmojiManager; import com.vdurmont.emoji.EmojiParser; import jakarta.annotation.Resource; import java.util.List; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Service public class EmojiCommand implements InnerCommand { @Resource private Event event; @Override public void process(String msg) { if (msg.split(" ").length <= 1) { event.info("incorrect commond, :emoji [option]"); return; } String value = msg.split(" ")[1]; if (value != null) { int index = Integer.parseInt(value); List all = (List) EmojiManager.getAll(); all = all.subList(5 * index, 5 * index + 5); for (Emoji emoji : all) { event.info(EmojiParser.parseToAliases(emoji.getUnicode()) + "--->" + emoji.getUnicode()); } } } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/OpenAIModelCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.client.service.MsgHandle; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Service public class OpenAIModelCommand implements InnerCommand { @Autowired private MsgHandle msgHandle; @Override public void process(String msg) { msgHandle.openAIModel(); System.out.println("\033[31;4m" + "Hello,我是估值两亿的 AI 机器人!" + "\033[0m"); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/PrefixSearchCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.common.data.construct.TrieTree; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import jakarta.annotation.Resource; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Slf4j @Service public class PrefixSearchCommand implements InnerCommand { @Resource private Client client; @Resource private Event event; @Override public void process(String msg) { try { Set onlineUsers = client.getOnlineUser(); TrieTree trieTree = new TrieTree(); for (CIMUserInfo onlineUser : onlineUsers) { trieTree.insert(onlineUser.getUserName()); } String[] split = msg.split(" "); String key = split[1]; List list = trieTree.prefixSearch(key); for (String res : list) { res = res.replace(key, "\033[31;4m" + key + "\033[0m"); event.info(res); } } catch (Exception e) { log.error("Exception", e); } } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/PrintAllCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.common.enums.SystemCommandEnum; import jakarta.annotation.Resource; import java.util.Map; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Service public class PrintAllCommand implements InnerCommand { @Resource private Event event; @Override public void process(String msg) { Map allStatusCode = SystemCommandEnum.getAllStatusCode(); event.info("===================================="); for (Map.Entry stringStringEntry : allStatusCode.entrySet()) { String key = stringStringEntry.getKey(); String value = stringStringEntry.getValue(); event.info(key + "----->" + value); } event.info("===================================="); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/PrintOnlineUsersCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import jakarta.annotation.Resource; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Slf4j @Service public class PrintOnlineUsersCommand implements InnerCommand { @Resource private Client client; @Resource private Event event; @Override public void process(String msg) { try { Set onlineUsers = client.getOnlineUser(); event.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); for (CIMUserInfo onlineUser : onlineUsers) { event.info("userId={}=====userName={}", onlineUser.getUserId(), onlineUser.getUserName()); } event.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); } catch (Exception e) { log.error("Exception", e); } } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/QueryHistoryCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.client.service.MsgLogger; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:37 * @since JDK 1.8 */ @Slf4j @Service public class QueryHistoryCommand implements InnerCommand { @Resource private MsgLogger msgLogger; @Resource private Event event; @Override public void process(String msg) { String[] split = msg.split(" "); if (split.length < 2) { return; } String res = msgLogger.query(split[1]); event.info(res); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/service/impl/command/ShutDownCommand.java ================================================ package com.crossoverjie.cim.client.service.impl.command; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.service.InnerCommand; import com.crossoverjie.cim.client.service.MsgLogger; import com.crossoverjie.cim.client.service.ShutDownSign; import com.crossoverjie.cim.common.data.construct.RingBufferWheel; import jakarta.annotation.Resource; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-27 19:28 * @since JDK 1.8 */ @Slf4j @Service @ConditionalOnWebApplication public class ShutDownCommand implements InnerCommand { @Resource private Client cimClient; @Resource private MsgLogger msgLogger; @Resource(name = "callBackThreadPool") private ThreadPoolExecutor callBackExecutor; @Resource private Event event; @Resource private ShutDownSign shutDownSign; @Resource private RingBufferWheel ringBufferWheel; @Override public void process(String msg) throws Exception { event.info("cim client closing..."); cimClient.close(); shutDownSign.shutdown(); msgLogger.stop(); callBackExecutor.shutdown(); ringBufferWheel.stop(false); try { while (!callBackExecutor.awaitTermination(1, TimeUnit.SECONDS)) { event.info("thread pool closing"); } } catch (Exception e) { log.error("exception", e); } event.info("cim close success!"); System.exit(0); } } ================================================ FILE: cim-client/src/main/java/com/crossoverjie/cim/client/util/SpringBeanFactory.java ================================================ package com.crossoverjie.cim.client.util; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; @Component public final class SpringBeanFactory implements ApplicationContextAware { private static ApplicationContext context; public static T getBean(Class c) { return context.getBean(c); } public static T getBean(String name, Class clazz) { return context.getBean(name, clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { context = applicationContext; } } ================================================ FILE: cim-client/src/main/resources/application.yaml ================================================ spring: application: name: cim-client # web port server: port: 8082 logging: level: root: error # enable swagger springdoc: swagger-ui: enabled: true # log path cim: msg: logger: path: /opt/logs/cim/ route: url: http://localhost:8083 # route url suggested that this is Nginx address user: # cim userId and userName id: 1747309836375 userName: yuge callback: thread: queue: size: 10 pool: size: 1 heartbeat: time: 60 # cim heartbeat time (seconds) reconnect: count: 3 ================================================ FILE: cim-client/src/main/resources/banner.txt ================================================ _ ___ __ ____(_)_ _ ____/ (_)__ ___ / /_ / __/ / ' \ / __/ / / -_) _ \/ __/ \__/_/_/_/_/ \__/_/_/\__/_//_/\__/ Power by @crossoverJie ================================================ FILE: cim-client/src/test/java/com/crossoverjie/cim/client/service/InnerCommandContextTest.java ================================================ package com.crossoverjie.cim.client.service; import com.crossoverjie.cim.client.CIMClientApplication; import com.crossoverjie.cim.common.enums.SystemCommandEnum; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; @SpringBootTest(classes = CIMClientApplication.class) @RunWith(SpringRunner.class) public class InnerCommandContextTest { @Autowired private InnerCommandContext context; @Test public void execute() throws Exception { String msg = ":all"; InnerCommand execute = context.getInstance(msg); execute.process(msg); } // @Test public void execute3() throws Exception { // TODO: 2024/8/31 Integration test String msg = SystemCommandEnum.ONLINE_USER.getCommandType(); InnerCommand execute = context.getInstance(msg); execute.process(msg); } @Test public void execute4() throws Exception { String msg = ":q 天气"; InnerCommand execute = context.getInstance(msg); execute.process(msg); } @Test public void execute5() throws Exception { String msg = ":q crossoverJie"; InnerCommand execute = context.getInstance(msg); execute.process(msg); } @Test public void execute6() throws Exception { String msg = SystemCommandEnum.AI.getCommandType(); InnerCommand execute = context.getInstance(msg); execute.process(msg); } @Test public void execute7() throws Exception { String msg = SystemCommandEnum.QAI.getCommandType(); InnerCommand execute = context.getInstance(msg); execute.process(msg); } // @Test public void execute8() throws Exception { // TODO: 2024/8/31 Integration test String msg = ":pu cross"; InnerCommand execute = context.getInstance(msg); execute.process(msg); } @Test public void execute9() throws Exception { String msg = SystemCommandEnum.INFO.getCommandType(); InnerCommand execute = context.getInstance(msg); execute.process(msg); } @Test public void execute10() throws Exception { String msg = "dsds"; InnerCommand execute = context.getInstance(msg); execute.process(msg); } // @Test public void quit() throws Exception { String msg = ":q!"; InnerCommand execute = context.getInstance(msg); execute.process(msg); } } ================================================ FILE: cim-client/src/test/java/com/crossoverjie/cim/client/service/impl/AsyncMsgLoggerTest.java ================================================ package com.crossoverjie.cim.client.service.impl; import com.crossoverjie.cim.client.CIMClientApplication; import com.crossoverjie.cim.client.service.MsgLogger; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import java.util.concurrent.TimeUnit; @SpringBootTest(classes = CIMClientApplication.class) @RunWith(SpringRunner.class) public class AsyncMsgLoggerTest { @Autowired private MsgLogger msgLogger; @Test public void writeLog() throws Exception { for (int i = 0; i < 10; i++) { msgLogger.log("zhangsan:【asdsd】" + i); } TimeUnit.SECONDS.sleep(2); } @Test public void query() { String crossoverJie = msgLogger.query("crossoverJie"); System.out.println(crossoverJie); } } ================================================ FILE: cim-client/src/test/java/com/crossoverjie/cim/server/test/CommonTest.java ================================================ package com.crossoverjie.cim.server.test; import com.crossoverjie.cim.common.core.proxy.RpcProxyManager; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.route.api.RouteApi; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.vdurmont.emoji.EmojiParser; import java.io.IOException; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.LocalDate; import java.util.Arrays; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import org.junit.Test; /** * Function: * * @author crossoverJie * Date: 22/05/2018 18:44 * @since JDK 1.8 */ @Slf4j public class CommonTest { @Test public void searchMsg2() { StringBuilder sb = new StringBuilder(); String allMsg = "于是在之前的基础上我完善了一些内容,先来看看这个项目的介绍吧:\n" + "\n" + "CIM(CROSS-IM) 一款面向开发者的 IM(即时通讯)系统;同时提供了一些组件帮助开发者构建一款属于自己可水平扩展的 IM 。\n" + "\n" + "借助 CIM 你可以实现以下需求:"; String key = "CIM"; String[] split = allMsg.split("\n"); for (String msg : split) { if (msg.trim().contains(key)) { sb.append(msg).append("\n"); } } int pos = 0; String result = sb.toString(); int count = 1; int multiple = 2; while ((pos = result.indexOf(key, pos)) >= 0) { log.info("{},{}",pos, pos + key.length()); pos += key.length(); count++; } System.out.println(sb.toString()); System.out.println(sb.toString().replace(key, "\033[31;4m" + key + "\033[0m")); } @Test public void log() { String msg = "hahahdsadsd"; LocalDate today = LocalDate.now(); int year = today.getYear(); int month = today.getMonthValue(); int day = today.getDayOfMonth(); String dir = "/opt/logs/cim/zhangsan" + "/"; String fileName = dir + year + month + day + ".log"; log.info("fileName={}", fileName); Path file = Paths.get(fileName); boolean exists = Files.exists(Paths.get(dir), LinkOption.NOFOLLOW_LINKS); try { if (!exists) { Files.createDirectories(Paths.get(dir)); } List lines = Arrays.asList(msg); Files.write(file, lines, Charset.forName("UTF-8"), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { log.info("IOException", e); } } @Test public void emoji() throws Exception { String str = "An :grinning:awesome :smiley:string 😄with a few :wink:emojis!"; String result = EmojiParser.parseToUnicode(str); System.out.println(result); result = EmojiParser.parseToAliases(str); System.out.println(result); // // Collection all = EmojiManager.getAll(); // for (Emoji emoji : all) { // System.out.println(EmojiParser.parseToAliases(emoji.getUnicode()) + "--->" + emoji.getUnicode() ); // } } @Test public void emoji2() { String emostring = "😂"; String faceWithTearsOfJoy = emostring.replaceAll("\uD83D\uDE02", "face with tears of joy"); System.out.println(faceWithTearsOfJoy); System.out.println("======" + faceWithTearsOfJoy.replaceAll("face with tears of joy","\uD83D\uDE02")); } // @Test public void deSerialize() throws Exception { RouteApi routeApi = RpcProxyManager.create(RouteApi.class, "http://localhost:8083", new OkHttpClient()); BaseResponse login = routeApi.login(new LoginReqVO(1725722966520L, "cj")); System.out.println(login.getDataBody()); BaseResponse> setBaseResponse = routeApi.onlineUser(); log.info("setBaseResponse={}",setBaseResponse.getDataBody()); } @Test public void json() throws JsonProcessingException, ClassNotFoundException { String json = "{\"code\":\"9000\",\"message\":\"成功\",\"reqNo\":null,\"dataBody\":{\"ip\":\"127.0.0.1\",\"cimServerPort\":11211,\"httpPort\":8081}}"; ObjectMapper objectMapper = new ObjectMapper(); Class generic = null; for (Method declaredMethod : RouteApi.class.getDeclaredMethods()) { if (declaredMethod.getName().equals("login")) { Type returnType = declaredMethod.getGenericReturnType(); // check if the return type is a parameterized type if (returnType instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) returnType; Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); for (Type typeArgument : actualTypeArguments) { System.out.println("generic: " + typeArgument.getTypeName()); generic = Class.forName(typeArgument.getTypeName()); break; } } else { System.out.println("not a generic type"); } } } BaseResponse response = objectMapper.readValue(json, objectMapper.getTypeFactory().constructParametricType(BaseResponse.class, generic)); System.out.println(response.getDataBody().getIp()); } private static class Gen { private T t; private R r; } interface TestInterface { Gen login(); } @Test public void test1() throws JsonProcessingException { String json = "{\"code\":\"200\",\"message\":\"Success\",\"reqNo\":null,\"dataBody\":[{\"userId\":\"123\",\"userName\":\"Alice\"}, {\"userId\":\"456\",\"userName\":\"Bob\"}]}"; ObjectMapper objectMapper = new ObjectMapper(); // 获取 BaseResponse> 的泛型参数 Type setType = getGenericTypeOfBaseResponse(); // 将泛型类型传递给 ObjectMapper 进行反序列化 BaseResponse> response = objectMapper.readValue(json, objectMapper.getTypeFactory().constructParametricType(BaseResponse.class, objectMapper.getTypeFactory().constructType(setType))); System.out.println("Response Code: " + response.getCode()); System.out.println("Online Users: "); for (CIMUserInfo user : response.getDataBody()) { System.out.println("User ID: " + user.getUserId() + ", User Name: " + user.getUserName()); } } // 通过反射获取 BaseResponse> 中的泛型类型 public static Type getGenericTypeOfBaseResponse() { // 这里模拟你需要处理的 BaseResponse> ParameterizedType baseResponseType = (ParameterizedType) new TypeReference>>() {}.getType(); // 获取 BaseResponse 的泛型参数,即 Set Type[] actualTypeArguments = baseResponseType.getActualTypeArguments(); // 返回第一个泛型参数 (Set) return actualTypeArguments[0]; } } ================================================ FILE: cim-client/src/test/java/com/crossoverjie/cim/server/test/EchoTest.java ================================================ package com.crossoverjie.cim.server.test; import org.junit.Assert; import org.junit.Test; /** * Function: * * @author crossoverJie * Date: 2019-08-28 01:47 * @since JDK 1.8 */ public class EchoTest { @Test public void echo() { String msg = "{} say,you {}"; String[] place = {"zhangsan", "haha"}; String log = log(msg, place); System.out.println(log); Assert.assertEquals(log,"zhangsan say,you haha"); } @Test public void echo2() { String msg = "{} say,you {},zhangsan say {}"; String[] place = {"zhangsan", "haha", "nihao"}; String log = log(msg, place); System.out.println(log); Assert.assertEquals(log,"zhangsan say,you haha,zhangsan say nihao"); } @Test public void echo3() { String msg = "see you {},zhangsan say"; String[] place = {"zhangsan"}; String log = log(msg, place); System.out.println(log); Assert.assertEquals(log,"see you zhangsan,zhangsan say"); } @Test public void echo4() { String msg = "{}see you,zhangsan say"; String[] place = {"!!!"}; String log = log(msg, place); System.out.println(log); Assert.assertEquals(log,"!!!see you,zhangsan say"); } @Test public void echo5() { String msg = "see you,zhangsan say{}"; String[] place = {"!!!"}; String log = log(msg, place); System.out.println(log); Assert.assertEquals(log,"see you,zhangsan say!!!"); } @Test public void echo6() { String msg = "see you,zhangsan say"; String[] place = {""}; String log = log(msg, place); System.out.println(log); Assert.assertEquals(log,"see you,zhangsan say"); } private String log(String msg, String... place) { StringBuilder sb = new StringBuilder(); int k = 0; for (int i = 0; i < place.length; i++) { int index = msg.indexOf("{}", k); if (index == -1) { return msg; } if (index != 0) { sb.append(msg, k, index); sb.append(place[i]); if (place.length == 1) { sb.append(msg, index + 2, msg.length()); } } else { sb.append(place[i]); if (place.length == 1) { sb.append(msg, index + 2, msg.length()); } } k = index + 2; } return sb.toString(); } } ================================================ FILE: cim-client/src/test/resources/application.yaml ================================================ spring: application: name: cim-client main: # this will not be used to create real spring context, because don't need this context in test case. web-application-type: none # web port server: port: 8082 logging: level: root: error # enable swagger springdoc: swagger-ui: enabled: true # log path cim: msg: logger: path: /opt/logs/cim/ route: url: http://localhost:8083 # route url suggested that this is Nginx address user: # cim userId and userName id: 1722343979085 userName: zhangsan callback: thread: queue: size: 1000 pool: size: 2 heartbeat: time: 60 # cim heartbeat time (seconds) reconnect: count: 3 ================================================ FILE: cim-client-sdk/README.md ================================================ ```java var auth1 = ClientConfigurationData.Auth.builder() .userId(id) .userName(cj) .build(); Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .build(); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); // send msg String msg = "hello"; client1.sendGroup(msg); // get oline user Set onlineUser = client1.getOnlineUser(); ``` ================================================ FILE: cim-client-sdk/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim 1.0.0-SNAPSHOT cim-client-sdk 17 17 UTF-8 com.crossoverjie.netty cim-common com.crossoverjie.netty cim-rout-api org.junit.jupiter junit-jupiter test org.junit.vintage junit-vintage-engine test com.crossoverjie.netty cim-integration-test ${project.version} test ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/Client.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientBuilderImpl; import com.crossoverjie.cim.client.sdk.impl.ClientConfigurationData; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import java.io.Closeable; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; public interface Client extends Closeable { static ClientBuilder builder() { return new ClientBuilderImpl(); } default void sendP2P(P2PReqVO p2PReqVO) throws Exception { sendP2PAsync(p2PReqVO).get(); } CompletableFuture sendP2PAsync(P2PReqVO p2PReqVO); default void sendGroup(String msg) throws Exception { sendGroupAsync(msg).get(); } CompletableFuture sendGroupAsync(String msg); ClientState.State getState(); ClientConfigurationData.Auth getAuth(); Set getOnlineUser() throws Exception; Optional getServerInfo(); } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/ClientBuilder.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientConfigurationData; import com.crossoverjie.cim.client.sdk.io.MessageListener; import com.crossoverjie.cim.client.sdk.io.ReconnectCheck; import java.util.concurrent.ThreadPoolExecutor; import com.crossoverjie.cim.client.sdk.io.backoff.BackoffStrategy; import okhttp3.OkHttpClient; /** * @author crossoverJie */ public interface ClientBuilder { Client build(); ClientBuilder auth(ClientConfigurationData.Auth auth); ClientBuilder routeUrl(String routeUrl); ClientBuilder loginRetryCount(int loginRetryCount); ClientBuilder event(Event event); ClientBuilder reconnectCheck(ReconnectCheck reconnectCheck); ClientBuilder okHttpClient(OkHttpClient okHttpClient); ClientBuilder messageListener(MessageListener messageListener); ClientBuilder callbackThreadPool(ThreadPoolExecutor callbackThreadPool); ClientBuilder backoffStrategy(BackoffStrategy backoffStrategy); } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/ClientState.java ================================================ package com.crossoverjie.cim.client.sdk; import java.util.concurrent.atomic.AtomicReference; public abstract class ClientState { private static final AtomicReference STATE = new AtomicReference<>(State.Initialized); public enum State { /** * Client state */ Initialized, Reconnecting, Ready, Closed, Failed } public void setState(State s) { STATE.set(s); } public State getState() { return STATE.get(); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/Event.java ================================================ package com.crossoverjie.cim.client.sdk; public interface Event { void debug(String msg, Object... replace); void info(String msg, Object... replace); void warn(String msg, Object... replace); void error(String msg, Object... replace); void fatal(Client client); class DefaultEvent implements Event { @Override public void debug(String msg, Object... replace) { System.out.println(msg); } @Override public void info(String msg, Object... replace) { System.out.println(msg); } @Override public void warn(String msg, Object... replace) { System.out.println(msg); } @Override public void error(String msg, Object... replace) { System.err.println(msg); } @Override public void fatal(Client client) { } } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/FetchOfflineMsgJob.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientConfigurationData; import com.crossoverjie.cim.common.data.construct.RingBufferWheel; public class FetchOfflineMsgJob extends RingBufferWheel.Task { private static final int INITIAL_DELAY_SECONDS = 5; private RouteManager routeManager; private ClientConfigurationData conf; public FetchOfflineMsgJob(RouteManager routeManager, ClientConfigurationData conf) { this.routeManager = routeManager; this.conf = conf; setKey(INITIAL_DELAY_SECONDS); //It will be sent with a 5-second delay } @Override public void run() { routeManager.fetchOfflineMsgs(conf.getAuth().getUserId()); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/ReConnectManager.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientImpl; import com.crossoverjie.cim.common.kit.HeartBeatHandler; import com.google.common.util.concurrent.ThreadFactoryBuilder; import io.netty.channel.ChannelHandlerContext; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; public final class ReConnectManager { private ScheduledExecutorService scheduledExecutorService; /** * Trigger reconnect job * * @param ctx */ public void reConnect(ChannelHandlerContext ctx) { buildExecutor(); scheduledExecutorService.scheduleAtFixedRate(() -> { try { ClientImpl.getClient().getHeartBeatHandler().process(ctx); } catch (Exception e) { ClientImpl.getClient().getConf().getEvent().error("ReConnectManager reConnect error", e); } }, 0, 10, TimeUnit.SECONDS); } /** * Close reconnect job if reconnect success. */ public void reConnectSuccess() { scheduledExecutorService.shutdown(); } /*** * build a thread executor */ private void buildExecutor() { if (scheduledExecutorService == null || scheduledExecutorService.isShutdown()) { ThreadFactory factory = new ThreadFactoryBuilder() .setNameFormat("reConnect-job-%d") .setDaemon(true) .build(); scheduledExecutorService = new ScheduledThreadPoolExecutor(1, factory); } } private static class ClientHeartBeatHandle implements HeartBeatHandler { @Override public void process(ChannelHandlerContext ctx) throws Exception { ClientImpl.getClient().reconnect(); } } public static ReConnectManager createReConnectManager() { return new ReConnectManager(); } public static HeartBeatHandler createHeartBeatHandler() { return new ClientHeartBeatHandle(); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/RouteManager.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientImpl; import com.crossoverjie.cim.common.core.proxy.RpcProxyManager; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.common.res.NULLBody; import com.crossoverjie.cim.route.api.RouteApi; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.req.OfflineMsgReqVO; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import java.util.Set; import java.util.concurrent.CompletableFuture; import okhttp3.OkHttpClient; public class RouteManager { private final RouteApi routeApi; private final Event event; public RouteManager(String routeUrl, OkHttpClient okHttpClient, Event event) { routeApi = RpcProxyManager.create(RouteApi.class, routeUrl, okHttpClient); this.event = event; } public CIMServerResVO getServer(LoginReqVO loginReqVO) throws Exception { BaseResponse cimServerResVO = routeApi.login(loginReqVO); // repeat fail if (!cimServerResVO.getCode().equals(StatusEnum.SUCCESS.getCode())) { event.info(cimServerResVO.getMessage()); // when client in Reconnecting state, could exit. if (ClientImpl.getClient().getState() == ClientState.State.Reconnecting) { event.warn("###{}###", StatusEnum.RECONNECT_FAIL.getMessage()); throw new CIMException(StatusEnum.RECONNECT_FAIL); } } return cimServerResVO.getDataBody(); } public CompletableFuture sendP2P(CompletableFuture future, P2PReqVO p2PReqVO) { return CompletableFuture.runAsync(() -> { try { BaseResponse response = routeApi.p2pRoute(p2PReqVO); if (response.getCode().equals(StatusEnum.OFF_LINE.getCode())) { future.completeExceptionally(new CIMException(StatusEnum.OFF_LINE)); } future.complete(null); } catch (Exception e) { future.completeExceptionally(e); event.error("send p2p msg error", e); } }); } public CompletableFuture sendGroupMsg(ChatReqVO chatReqVO) { return CompletableFuture.runAsync(() -> { try { routeApi.groupRoute(chatReqVO); } catch (Exception e) { event.error("send group msg error", e); } }); } public void offLine(Long userId) { ChatReqVO vo = new ChatReqVO(userId, "offLine", null); routeApi.offLine(vo); } public Set onlineUser() throws Exception { BaseResponse> onlineUsersResVO = routeApi.onlineUser(); return onlineUsersResVO.getDataBody(); } public void fetchOfflineMsgs(Long userId) { OfflineMsgReqVO offlineMsgReqVO = OfflineMsgReqVO.builder().receiveUserId(userId).build(); routeApi.fetchOfflineMsgs(offlineMsgReqVO); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/impl/ClientBuilderImpl.java ================================================ package com.crossoverjie.cim.client.sdk.impl; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.ClientBuilder; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.sdk.io.MessageListener; import com.crossoverjie.cim.client.sdk.io.ReconnectCheck; import com.crossoverjie.cim.client.sdk.io.backoff.BackoffStrategy; import com.crossoverjie.cim.common.util.StringUtil; import java.util.concurrent.ThreadPoolExecutor; import okhttp3.OkHttpClient; public class ClientBuilderImpl implements ClientBuilder { private final ClientConfigurationData conf; public ClientBuilderImpl() { this(new ClientConfigurationData()); } public ClientBuilderImpl(ClientConfigurationData conf) { this.conf = conf; } @Override public Client build() { return new ClientImpl(conf); } @Override public ClientBuilder auth(ClientConfigurationData.Auth auth) { if (auth.getUserId() <= 0 || StringUtil.isEmpty(auth.getUserName())) { throw new IllegalArgumentException("userId and userName must be set"); } this.conf.setAuth(auth); return this; } @Override public ClientBuilder routeUrl(String routeUrl) { if (StringUtil.isEmpty(routeUrl)) { throw new IllegalArgumentException("routeUrl must be set"); } this.conf.setRouteUrl(routeUrl); return this; } @Override public ClientBuilder loginRetryCount(int loginRetryCount) { this.conf.setLoginRetryCount(loginRetryCount); return this; } @Override public ClientBuilder event(Event event) { this.conf.setEvent(event); return this; } @Override public ClientBuilder reconnectCheck(ReconnectCheck reconnectCheck) { this.conf.setReconnectCheck(reconnectCheck); return this; } @Override public ClientBuilder okHttpClient(OkHttpClient okHttpClient) { this.conf.setOkHttpClient(okHttpClient); return this; } @Override public ClientBuilder messageListener(MessageListener messageListener) { this.conf.setMessageListener(messageListener); return this; } @Override public ClientBuilder callbackThreadPool(ThreadPoolExecutor callbackThreadPool) { this.conf.setCallbackThreadPool(callbackThreadPool); return this; } @Override public ClientBuilder backoffStrategy(BackoffStrategy backoffStrategy) { this.conf.setBackoffStrategy(backoffStrategy); return this; } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/impl/ClientConfigurationData.java ================================================ package com.crossoverjie.cim.client.sdk.impl; import com.crossoverjie.cim.client.sdk.Event; import com.crossoverjie.cim.client.sdk.io.backoff.BackoffStrategy; import com.crossoverjie.cim.client.sdk.io.MessageListener; import com.crossoverjie.cim.client.sdk.io.backoff.RandomBackoff; import com.crossoverjie.cim.client.sdk.io.ReconnectCheck; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.concurrent.ThreadPoolExecutor; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import okhttp3.OkHttpClient; @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class ClientConfigurationData { private Auth auth; @Data @AllArgsConstructor @Builder public static class Auth { private long userId; private String userName; } private String routeUrl; private int loginRetryCount = 5; @JsonIgnore private Event event = new Event.DefaultEvent(); @JsonIgnore private MessageListener messageListener = (client, properties, msg) -> System.out.printf("id:[%s] msg:[%s]%n \n", client.getAuth(), msg); @JsonIgnore private OkHttpClient okHttpClient = new OkHttpClient(); @JsonIgnore private ThreadPoolExecutor callbackThreadPool; @JsonIgnore private ReconnectCheck reconnectCheck = (__) -> true; @JsonIgnore private BackoffStrategy backoffStrategy = new RandomBackoff(); } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/impl/ClientImpl.java ================================================ package com.crossoverjie.cim.client.sdk.impl; import static com.crossoverjie.cim.common.enums.StatusEnum.RECONNECT_FAIL; import com.crossoverjie.cim.client.sdk.Client; import com.crossoverjie.cim.client.sdk.ClientState; import com.crossoverjie.cim.client.sdk.FetchOfflineMsgJob; import com.crossoverjie.cim.client.sdk.ReConnectManager; import com.crossoverjie.cim.client.sdk.RouteManager; import com.crossoverjie.cim.client.sdk.io.CIMClientHandleInitializer; import com.crossoverjie.cim.common.data.construct.RingBufferWheel; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.kit.HeartBeatHandler; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.protocol.Request; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.google.common.util.concurrent.ThreadFactoryBuilder; import io.netty.bootstrap.Bootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.util.concurrent.DefaultThreadFactory; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @Slf4j public class ClientImpl extends ClientState implements Client { @Getter private final ClientConfigurationData conf; private static final int CALLBACK_QUEUE_SIZE = 1024; private static final int CALLBACK_POOL_SIZE = 10; // ======= private ======== private int errorCount; private SocketChannel channel; private final RouteManager routeManager; @Getter private final HeartBeatHandler heartBeatHandler = ReConnectManager.createHeartBeatHandler(); @Getter private final ReConnectManager reConnectManager = ReConnectManager.createReConnectManager(); @Getter private static ClientImpl client; @Getter private static Map clientMap = new ConcurrentHashMap<>(); @Getter private final Request heartBeatPacket; private RingBufferWheel ringBufferWheel; // Client connected server info private CIMServerResVO serverInfo; public ClientImpl(ClientConfigurationData conf) { this.conf = conf; if (this.conf.getCallbackThreadPool() == null) { BlockingQueue queue = new LinkedBlockingQueue<>(CALLBACK_QUEUE_SIZE); ThreadFactory factory = new ThreadFactoryBuilder() .setNameFormat("msg-callback-%d") .setDaemon(true) .build(); this.conf.setCallbackThreadPool( new ThreadPoolExecutor(CALLBACK_POOL_SIZE, CALLBACK_POOL_SIZE, 1, TimeUnit.SECONDS, queue, factory)); } routeManager = new RouteManager(conf.getRouteUrl(), conf.getOkHttpClient(), conf.getEvent()); heartBeatPacket = Request.newBuilder() .setRequestId(this.conf.getAuth().getUserId()) .setReqMsg("ping") .setCmd(BaseCommand.PING) .build(); client = this; clientMap.put(conf.getAuth().getUserId(), this); connectServer(v -> this.conf.getEvent().info("Login success!")); postConnectionSetup(); } /** * 1. Pull offline messages from the server */ private void postConnectionSetup() { ringBufferWheel = new RingBufferWheel(Executors.newFixedThreadPool(1)); ringBufferWheel.addTask(new FetchOfflineMsgJob(routeManager, conf)); } private void connectServer(Consumer success) { this.doConnectServer().whenComplete((r, e) -> { if (r) { success.accept(null); } if (e != null) { if (e instanceof CIMException cimException && cimException.getErrorCode() .equals(RECONNECT_FAIL.getCode())) { this.conf.getEvent().fatal(this); } else { if (errorCount++ >= this.conf.getLoginRetryCount()) { this.conf.getEvent() .error("The maximum number of reconnections has been reached[{}]times, exit cim client!", errorCount); this.conf.getEvent().fatal(this); } } } }); } /** * 1. User login and get target server * 2. Connect target server * 3. send login cmd to server */ private CompletableFuture doConnectServer() { CompletableFuture future = new CompletableFuture<>(); this.userLogin(future).ifPresentOrElse((cimServer) -> { this.doConnectServer(cimServer, future); this.loginServer(); this.serverInfo = cimServer; future.complete(true); }, () -> { this.conf.getEvent().error("Login fail!, cannot get server info!"); this.conf.getEvent().fatal(this); future.complete(false); }); return future; } /** * Login and get server info * * @return Server info */ private Optional userLogin(CompletableFuture future) { LoginReqVO loginReqVO = new LoginReqVO(conf.getAuth().getUserId(), conf.getAuth().getUserName()); CIMServerResVO cimServer = null; try { cimServer = routeManager.getServer(loginReqVO); log.info("cimServer=[{}]", cimServer); } catch (Exception e) { log.error("login fail", e); future.completeExceptionally(e); } return Optional.ofNullable(cimServer); } private final EventLoopGroup group = new NioEventLoopGroup(0, new DefaultThreadFactory("cim-work")); private void doConnectServer(CIMServerResVO cimServer, CompletableFuture future) { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(group) .channel(NioSocketChannel.class) .handler(new CIMClientHandleInitializer()); ChannelFuture sync; try { sync = bootstrap.connect(cimServer.getIp(), cimServer.getCimServerPort()).sync(); if (sync.isSuccess()) { this.conf.getEvent().info("Start cim client success!"); channel = (SocketChannel) sync.channel(); } } catch (InterruptedException e) { future.completeExceptionally(e); } } /** * Send login cmd to server */ private void loginServer() { Request login = Request.newBuilder() .setRequestId(this.conf.getAuth().getUserId()) .setReqMsg(this.conf.getAuth().getUserName()) .setCmd(BaseCommand.LOGIN_REQUEST) .build(); channel.writeAndFlush(login) .addListener((ChannelFutureListener) channelFuture -> this.conf.getEvent().info("Registry cim server success!") ); } /** * 1. clear route information. * 2. reconnect. * 3. shutdown reconnect job. * 4. reset reconnect state. * @throws Exception */ public void reconnect() throws Exception { if (channel != null && channel.isActive()) { return; } this.serverInfo = null; // clear route information. this.routeManager.offLine(this.getConf().getAuth().getUserId()); this.conf.getEvent().info("cim trigger reconnecting...."); this.conf.getBackoffStrategy().runBackoff(); // don't set State ready, because when connect success, the State will be set to ready automate. connectServer(v -> { this.reConnectManager.reConnectSuccess(); this.conf.getEvent().info("Great! reConnect success!!!"); }); } @Override public void close() { if (channel != null) { channel.close(); channel = null; } super.setState(ClientState.State.Closed); this.routeManager.offLine(this.getAuth().getUserId()); this.clientMap.remove(this.getAuth().getUserId()); ringBufferWheel.stop(true); } @Override public void sendP2P(P2PReqVO p2PReqVO) throws Exception { recordSendLog(sendP2PAsync(p2PReqVO), "P2P"); } @Override public void sendGroup(String msg) throws Exception { recordSendLog(sendGroupAsync(msg), "GROUP"); } private void recordSendLog(CompletableFuture future, String msgWay) { future.orTimeout(10, TimeUnit.SECONDS) .whenComplete((result, throwable) -> { if (throwable == null) { log.info("{} message task completed successfully", msgWay); } else if (throwable instanceof TimeoutException) { log.error("{} message processing timeout", msgWay, throwable); } else { log.warn("{} message task completed with exception", msgWay, throwable); } }); } @Override public CompletableFuture sendP2PAsync(P2PReqVO p2PReqVO) { CompletableFuture future = new CompletableFuture<>(); p2PReqVO.setUserId(this.conf.getAuth().getUserId()); return routeManager.sendP2P(future, p2PReqVO); } @Override public CompletableFuture sendGroupAsync(String msg) { // TODO: 2024/9/12 return messageId return this.routeManager.sendGroupMsg(new ChatReqVO(this.conf.getAuth().getUserId(), msg, null)); } @Override public ClientConfigurationData.Auth getAuth() { return this.conf.getAuth(); } @Override public ClientState.State getState() { return super.getState(); } @Override public Set getOnlineUser() throws Exception { return routeManager.onlineUser(); } @Override public Optional getServerInfo() { return Optional.ofNullable(this.serverInfo); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/io/CIMClientHandle.java ================================================ package com.crossoverjie.cim.client.sdk.io; import com.crossoverjie.cim.client.sdk.ClientState; import com.crossoverjie.cim.client.sdk.impl.ClientImpl; import com.crossoverjie.cim.common.constant.Constants; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.protocol.Response; import com.crossoverjie.cim.common.util.NettyAttrUtil; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import lombok.extern.slf4j.Slf4j; @ChannelHandler.Sharable @Slf4j public class CIMClientHandle extends SimpleChannelInboundHandler { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent idleStateEvent) { if (idleStateEvent.state() == IdleState.WRITER_IDLE) { ctx.writeAndFlush(ClientImpl.getClient().getHeartBeatPacket()).addListeners((ChannelFutureListener) future -> { if (!future.isSuccess()) { log.error("heart beat error,close Channel"); ClientImpl.getClient().getConf().getEvent().warn("heart beat error,close Channel"); future.channel().close(); } }); } } super.userEventTriggered(ctx, evt); } @Override public void channelActive(ChannelHandlerContext ctx) { ClientImpl.getClient().getConf().getEvent().debug("ChannelActive"); ClientImpl.getClient().setState(ClientState.State.Ready); } @Override public void channelInactive(ChannelHandlerContext ctx) { if (!ClientImpl.getClient().getConf().getReconnectCheck().isNeedReconnect(ClientImpl.getClient())) { return; } ClientImpl.getClient().setState(ClientState.State.Closed); ClientImpl.getClient().getConf().getEvent().warn("Client inactive, let's reconnect"); ClientImpl.getClient().getReConnectManager().reConnect(ctx); } @Override protected void channelRead0(ChannelHandlerContext ctx, Response msg) { if (msg.getCmd() == BaseCommand.PING) { ClientImpl.getClient().getConf().getEvent().debug("received ping from server"); NettyAttrUtil.updateReaderTime(ctx.channel(), System.currentTimeMillis()); } if (msg.getCmd() != BaseCommand.PING) { String receiveUserId = msg.getPropertiesMap().get(Constants.MetaKey.RECEIVE_USER_ID); ClientImpl client = ClientImpl.getClientMap().get(Long.valueOf(receiveUserId)); if (client == null) { log.error("client not found for userId: {}", receiveUserId); return; } // callback client.getConf().getCallbackThreadPool().execute(() -> { log.info("client address: {} :{}", ctx.channel().remoteAddress(), client); MessageListener messageListener = client.getConf().getMessageListener(); if (msg.getBatchResMsgCount() > 0) { for (int i = 0; i < msg.getBatchResMsgCount(); i++) { messageListener.received(client, msg.getPropertiesMap(), msg.getBatchResMsg(i)); } } else { messageListener.received(client, msg.getPropertiesMap(), msg.getResMsg()); } }); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { ClientImpl.getClient().getConf().getEvent().error(cause.getCause().toString()); ctx.close(); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/io/CIMClientHandleInitializer.java ================================================ package com.crossoverjie.cim.client.sdk.io; import com.crossoverjie.cim.common.protocol.Response; import io.netty.channel.Channel; import io.netty.channel.ChannelInitializer; import io.netty.handler.codec.protobuf.ProtobufDecoder; import io.netty.handler.codec.protobuf.ProtobufEncoder; import io.netty.handler.codec.protobuf.ProtobufVarint32FrameDecoder; import io.netty.handler.codec.protobuf.ProtobufVarint32LengthFieldPrepender; import io.netty.handler.timeout.IdleStateHandler; public class CIMClientHandleInitializer extends ChannelInitializer { private final CIMClientHandle cimClientHandle = new CIMClientHandle(); @Override protected void initChannel(Channel ch) { ch.pipeline() .addLast(new IdleStateHandler(0, 10, 0)) // google Protobuf .addLast(new ProtobufVarint32FrameDecoder()) .addLast(new ProtobufDecoder(Response.getDefaultInstance())) .addLast(new ProtobufVarint32LengthFieldPrepender()) .addLast(new ProtobufEncoder()) .addLast(cimClientHandle); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/io/MessageListener.java ================================================ package com.crossoverjie.cim.client.sdk.io; import com.crossoverjie.cim.client.sdk.Client; import java.util.Map; public interface MessageListener { /** * @param client client * @param properties meta data * @param msg msgs */ void received(Client client, Map properties, String msg); } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/io/ReconnectCheck.java ================================================ package com.crossoverjie.cim.client.sdk.io; import com.crossoverjie.cim.client.sdk.Client; public interface ReconnectCheck { /** * By the default, the client will reconnect to the server when the connection is close(inactive). * @return false if the client should not reconnect to the server. */ boolean isNeedReconnect(Client client); } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/io/backoff/BackoffStrategy.java ================================================ package com.crossoverjie.cim.client.sdk.io.backoff; import java.util.concurrent.TimeUnit; /** * @author:qjj * @create: 2024-09-21 12:16 * @Description: backoff strategy interface */ public interface BackoffStrategy { /** * @return the backoff time in milliseconds */ long nextBackoff(); /** * Run the backoff strategy * @throws InterruptedException */ default void runBackoff() throws InterruptedException { TimeUnit.SECONDS.sleep(nextBackoff()); } } ================================================ FILE: cim-client-sdk/src/main/java/com/crossoverjie/cim/client/sdk/io/backoff/RandomBackoff.java ================================================ package com.crossoverjie.cim.client.sdk.io.backoff; /** * @author:qjj * @create: 2024-09-21 12:22 * @Description: random backoff strategy */ public class RandomBackoff implements BackoffStrategy { @Override public long nextBackoff() { return (int) (Math.random() * 7 + 3); } } ================================================ FILE: cim-client-sdk/src/test/java/com/crossoverjie/cim/client/sdk/ClientTest.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientConfigurationData; import com.crossoverjie.cim.client.sdk.impl.ClientImpl; import com.crossoverjie.cim.client.sdk.io.backoff.RandomBackoff; import com.crossoverjie.cim.client.sdk.route.AbstractRouteBaseTest; import com.crossoverjie.cim.common.constant.Constants; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import com.crossoverjie.cim.route.constant.Constant; import lombok.Cleanup; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @Slf4j public class ClientTest extends AbstractRouteBaseTest { @AfterEach public void tearDown() { super.close(); } @Test public void groupChat() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "crossoverJie"; String zs = "zs"; Long id = super.registerAccount(cj); Long zsId = super.registerAccount(zs); var auth1 = ClientConfigurationData.Auth.builder() .userId(id) .userName(cj) .build(); var auth2 = ClientConfigurationData.Auth.builder() .userId(zsId) .userName(zs) .build(); @Cleanup Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 serverInfo = " + serverInfo.get()); AtomicReference client2Receive = new AtomicReference<>(); @Cleanup Client client2 = Client.builder() .auth(auth2) .routeUrl(routeUrl) .messageListener((client, properties, message) -> { client2Receive.set(message); Assertions.assertEquals(properties.get(Constants.MetaKey.SEND_USER_ID), String.valueOf(auth1.getUserId())); Assertions.assertEquals(properties.get(Constants.MetaKey.SEND_USER_NAME), auth1.getUserName()); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state2 = client2.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state2)); Optional serverInfo2 = client2.getServerInfo(); Assertions.assertTrue(serverInfo2.isPresent()); System.out.println("client2 serverInfo = " + serverInfo2.get()); // send msg String msg = "hello"; client1.sendGroup(msg); Set onlineUser = client1.getOnlineUser(); Assertions.assertEquals(onlineUser.size(), 2); onlineUser.forEach(userInfo -> { log.info("online user = {}", userInfo); Long userId = userInfo.getUserId(); if (userId.equals(id)) { Assertions.assertEquals(cj, userInfo.getUserName()); } else if (userId.equals(zsId)) { Assertions.assertEquals(zs, userInfo.getUserName()); } }); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(msg, client2Receive.get())); super.stopSingle(); client1.close(); client2.close(); } @Test public void testP2PChat() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "cj"; String zs = "zs"; String ls = "ls"; Long cjId = super.registerAccount(cj); Long zsId = super.registerAccount(zs); Long lsId = super.registerAccount(ls); var auth1 = ClientConfigurationData.Auth.builder() .userName(cj) .userId(cjId) .build(); var auth2 = ClientConfigurationData.Auth.builder() .userName(zs) .userId(zsId) .build(); var auth3 = ClientConfigurationData.Auth.builder() .userName(ls) .userId(lsId) .build(); var client1Receive = new ArrayList<>(); @Cleanup Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .messageListener((__, properties, message) -> { log.info("client1 receive message = {}", message); client1Receive.add(message); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 serverInfo = " + serverInfo.get()); // client2 AtomicReference client2Receive = new AtomicReference<>(); @Cleanup Client client2 = Client.builder() .auth(auth2) .routeUrl(routeUrl) .messageListener((client, properties, message) -> client2Receive.set(message)) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state2 = client2.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state2)); Optional serverInfo2 = client2.getServerInfo(); Assertions.assertTrue(serverInfo2.isPresent()); System.out.println("client2 serverInfo = " + serverInfo2.get()); // client3 AtomicReference client3Receive = new AtomicReference<>(); @Cleanup Client client3 = Client.builder() .auth(auth3) .routeUrl(routeUrl) .messageListener((client, properties, message) -> { log.info("client3 receive message = {}", message); client3Receive.set(message); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state3 = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state3)); Optional serverInfo3 = client3.getServerInfo(); Assertions.assertTrue(serverInfo3.isPresent()); System.out.println("client3 serverInfo = " + serverInfo3.get()); // client1 send msg to client3 String msg = "hello"; client1.sendP2P(P2PReqVO.builder() .receiveUserId(lsId) .msg(msg) .build()); Set onlineUser = client1.getOnlineUser(); Assertions.assertEquals(onlineUser.size(), 3); onlineUser.forEach(userInfo -> { log.info("online user = {}", userInfo); Long userId = userInfo.getUserId(); if (userId.equals(cjId)) { Assertions.assertEquals(cj, userInfo.getUserName()); } else if (userId.equals(zsId)) { Assertions.assertEquals(zs, userInfo.getUserName()); } else if (userId.equals(lsId)) { Assertions.assertEquals(ls, userInfo.getUserName()); } }); // client2 send batch msg to client1 var batchMsg = List.of("a","b","c"); client2.sendP2P(P2PReqVO.builder() .receiveUserId(cjId) .batchMsg(batchMsg) .build()); Assertions.assertEquals(ClientImpl.getClientMap().size(), 3); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(msg, client3Receive.get())); Awaitility.await().untilAsserted( () -> Assertions.assertNull(client2Receive.get())); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(batchMsg, client1Receive)); super.stopSingle(); client1.close(); client2.close(); client3.close(); Assertions.assertEquals(ClientImpl.getClientMap().size(), 0); } /** * 1. Start two servers. * 2. Start two client, and send message. * 3. Stop one server which is connected by client1. * 4. Wait for client1 reconnect. * 5. Send message again. * * @throws Exception */ @Test public void testReconnect() throws Exception { super.startTwoServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "cj"; String zs = "zs"; Long cjId = super.registerAccount(cj); Long zsId = super.registerAccount(zs); var auth1 = ClientConfigurationData.Auth.builder() .userName(cj) .userId(cjId) .build(); var auth2 = ClientConfigurationData.Auth.builder() .userName(zs) .userId(zsId) .build(); var backoffStrategy = new RandomBackoff(); @Cleanup Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .backoffStrategy(backoffStrategy) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); AtomicReference client2Receive = new AtomicReference<>(); @Cleanup Client client2 = Client.builder() .auth(auth2) .routeUrl(routeUrl) .messageListener((client, properties, message) -> client2Receive.set(message)) .backoffStrategy(backoffStrategy) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state2 = client2.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state2)); Optional serverInfo2 = client2.getServerInfo(); Assertions.assertTrue(serverInfo2.isPresent()); System.out.println("client2 serverInfo = " + serverInfo2.get()); // send msg String msg = "hello"; client1.sendGroup(msg); Awaitility.await() .untilAsserted(() -> Assertions.assertEquals(msg, client2Receive.get())); client2Receive.set(""); System.out.println("ready to restart server"); TimeUnit.SECONDS.sleep(3); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("server info = " + serverInfo.get()); super.stopServer(serverInfo.get().getCimServerPort()); System.out.println("stop server success! " + serverInfo.get()); // Waiting server stopped, and client reconnect. TimeUnit.SECONDS.sleep(30); System.out.println("reconnect state: " + client1.getState()); Awaitility.await().atMost(15, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 reconnect server info = " + serverInfo.get()); // Send message again. log.info("send message again, client2Receive = {}", client2Receive.get()); client1.sendGroup(msg); Awaitility.await() .untilAsserted(() -> Assertions.assertEquals(msg, client2Receive.get())); super.stopTwoServer(); client1.close(); client2.close(); } @Test public void offLineAndOnline() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "crossoverJie"; String zs = "zs"; Long id = super.registerAccount(cj); Long zsId = super.registerAccount(zs); var auth1 = ClientConfigurationData.Auth.builder() .userId(id) .userName(cj) .build(); var auth2 = ClientConfigurationData.Auth.builder() .userId(zsId) .userName(zs) .build(); @Cleanup Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 serverInfo = " + serverInfo.get()); AtomicReference client2Receive = new AtomicReference<>(); Client client2 = Client.builder() .auth(auth2) .routeUrl(routeUrl) .messageListener((client, properties, message) -> client2Receive.set(message)) // Avoid auto reconnect, this test will manually close client. .reconnectCheck((client) -> false) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state2 = client2.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state2)); Optional serverInfo2 = client2.getServerInfo(); Assertions.assertTrue(serverInfo2.isPresent()); System.out.println("client2 serverInfo = " + serverInfo2.get()); // send msg String msg = "hello"; client1.sendGroup(msg); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(msg, client2Receive.get())); client2Receive.set(""); // Manually offline client2.close(); TimeUnit.SECONDS.sleep(10); client2 = Client.builder() .auth(auth2) .routeUrl(routeUrl) .messageListener((client, properties, message) -> client2Receive.set(message)) // Avoid to auto reconnect, this test will manually close client. .reconnectCheck((client) -> false) .build(); ClientState.State state3 = client2.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state3)); // send msg again client1.sendGroup(msg); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(msg, client2Receive.get())); super.stopSingle(); client1.close(); client2.close(); } @Test public void testClose() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "crossoverJie"; Long id = super.registerAccount(cj); var auth1 = ClientConfigurationData.Auth.builder() .userId(id) .userName(cj) .build(); Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 serverInfo = " + serverInfo.get()); client1.close(); ClientState.State state1 = client1.getState(); Assertions.assertEquals(ClientState.State.Closed, state1); super.stopSingle(); } @Test public void testIncorrectUser() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "xx"; long id = 100L; var auth1 = ClientConfigurationData.Auth.builder() .userId(id) .userName(cj) .build(); Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .build(); TimeUnit.SECONDS.sleep(3); Assertions.assertDoesNotThrow(client1::close); super.stopSingle(); } } ================================================ FILE: cim-client-sdk/src/test/java/com/crossoverjie/cim/client/sdk/OfflineMsgTest.java ================================================ package com.crossoverjie.cim.client.sdk; import com.crossoverjie.cim.client.sdk.impl.ClientConfigurationData; import com.crossoverjie.cim.client.sdk.impl.ClientImpl; import com.crossoverjie.cim.client.sdk.route.OfflineMsgStoreRouteBaseTest; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.constant.Constant; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.jupiter.api.*; import java.util.ArrayList; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @Slf4j public class OfflineMsgTest extends OfflineMsgStoreRouteBaseTest { @Test public void testP2POfflineChatRedis() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.REDIS); String routeUrl = "http://localhost:8083"; String cj = "cj"; String ls = "ls"; Long cjId = super.registerAccount(cj); Long lsId = super.registerAccount(ls); var auth1 = ClientConfigurationData.Auth.builder() .userName(cj) .userId(cjId) .build(); var auth3 = ClientConfigurationData.Auth.builder() .userName(ls) .userId(lsId) .build(); var client1Receive = new ArrayList<>(); Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .messageListener((__, properties, message) -> { log.info("client1 receive message = {}", message); client1Receive.add(message); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 serverInfo = " + serverInfo.get()); // client3 AtomicReference client3Receive = new AtomicReference<>(); Client client3 = Client.builder() .auth(auth3) .routeUrl(routeUrl) .messageListener((client, properties, message) -> { log.info("client3 receive message = {}", message); client3Receive.set(message); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state3 = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state3)); Optional serverInfo3 = client3.getServerInfo(); Assertions.assertTrue(serverInfo3.isPresent()); System.out.println("client3 serverInfo = " + serverInfo3.get()); // client1 send msg to client3 String msg = "hello"; client1.sendP2P(P2PReqVO.builder() .receiveUserId(lsId) .msg(msg) .build()); Set onlineUser = client1.getOnlineUser(); Assertions.assertEquals(onlineUser.size(), 2); onlineUser.forEach(userInfo -> { log.info("online user = {}", userInfo); Long userId = userInfo.getUserId(); if (userId.equals(cjId)) { Assertions.assertEquals(cj, userInfo.getUserName()); } else if (userId.equals(lsId)) { Assertions.assertEquals(ls, userInfo.getUserName()); } }); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(msg, client3Receive.get())); // Manually offline client3 client3.close(); client3Receive.set(null); ClientState.State closeState = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Closed, closeState)); // client1 send client3 an offline message String offlineMsg = "offline message"; client1.sendP2P(P2PReqVO.builder() .receiveUserId(lsId) .msg(offlineMsg) .build()); // online again client3 = Client.builder() .auth(auth3) .routeUrl(routeUrl) .messageListener((client, properties, message) -> { log.info("client3 online again receive message = {}", message); client3Receive.set(message); }) .build(); ClientState.State client3AgainState = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, client3AgainState)); // check offline message Awaitility.await().untilAsserted( () -> Assertions.assertEquals(offlineMsg, client3Receive.get())); // close client1.close(); client3.close(); super.close(); super.stopSingle(); Assertions.assertEquals(ClientImpl.getClientMap().size(), 0); } @Test public void testP2POfflineChatMysql() throws Exception { super.starSingleServer(); super.startRoute(Constant.OfflineStoreMode.MYSQL); String routeUrl = "http://localhost:8083"; String cj = "cj"; String ls = "ls"; Long cjId = super.registerAccount(cj); Long lsId = super.registerAccount(ls); var auth1 = ClientConfigurationData.Auth.builder() .userName(cj) .userId(cjId) .build(); var auth3 = ClientConfigurationData.Auth.builder() .userName(ls) .userId(lsId) .build(); var client1Receive = new ArrayList<>(); Client client1 = Client.builder() .auth(auth1) .routeUrl(routeUrl) .messageListener((__, properties, message) -> { log.info("client1 receive message = {}", message); client1Receive.add(message); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state = client1.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state)); Optional serverInfo = client1.getServerInfo(); Assertions.assertTrue(serverInfo.isPresent()); System.out.println("client1 serverInfo = " + serverInfo.get()); // client3 AtomicReference client3Receive = new AtomicReference<>(); Client client3 = Client.builder() .auth(auth3) .routeUrl(routeUrl) .messageListener((client, properties, message) -> { log.info("client3 receive message = {}", message); client3Receive.set(message); }) .build(); TimeUnit.SECONDS.sleep(3); ClientState.State state3 = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, state3)); Optional serverInfo3 = client3.getServerInfo(); Assertions.assertTrue(serverInfo3.isPresent()); System.out.println("client3 serverInfo = " + serverInfo3.get()); // client1 send msg to client3 String msg = "hello"; client1.sendP2P(P2PReqVO.builder() .receiveUserId(lsId) .msg(msg) .build()); Set onlineUser = client1.getOnlineUser(); Assertions.assertEquals(onlineUser.size(), 2); onlineUser.forEach(userInfo -> { log.info("online user = {}", userInfo); Long userId = userInfo.getUserId(); if (userId.equals(cjId)) { Assertions.assertEquals(cj, userInfo.getUserName()); } else if (userId.equals(lsId)) { Assertions.assertEquals(ls, userInfo.getUserName()); } }); Awaitility.await().untilAsserted( () -> Assertions.assertEquals(msg, client3Receive.get())); // Manually offline client3 client3.close(); client3Receive.set(null); ClientState.State closeState = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Closed, closeState)); // client1 send client3 an offline message String offlineMsg = "offline message"; client1.sendP2P(P2PReqVO.builder() .receiveUserId(lsId) .msg(offlineMsg) .build()); // online again client3 = Client.builder() .auth(auth3) .routeUrl(routeUrl) .messageListener((client, properties, message) -> { log.info("client3 online again receive message = {}", message); client3Receive.set(message); }) .build(); ClientState.State client3AgainState = client3.getState(); Awaitility.await().atMost(10, TimeUnit.SECONDS) .untilAsserted(() -> Assertions.assertEquals(ClientState.State.Ready, client3AgainState)); // check offline message Awaitility.await().untilAsserted( () -> Assertions.assertEquals(offlineMsg, client3Receive.get())); // close client1.close(); client3.close(); super.close(); super.stopSingle(); Assertions.assertEquals(ClientImpl.getClientMap().size(), 0); } } ================================================ FILE: cim-client-sdk/src/test/resources/application-route.yaml ================================================ spring: application: name: cim-forward-route data: redis: host: 127.0.0.1 port: 6379 jedis: pool: max-active: 100 max-idle: 100 max-wait: 1000 min-idle: 10 # web port server: port: 8083 logging: level: root: info # enable swagger springdoc: swagger-ui: enabled: true app: zk: connect: timeout: 30000 root: /route # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.loop.LoopHandle # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.random.RandomHandle # route strategy route: way: handler: com.crossoverjie.cim.common.route.algorithm.loop.LoopHandle #app.route.way.consitenthash=com.crossoverjie.cim.common.route.algorithm.consistenthash.SortArrayMapConsistentHash consitenthash: com.crossoverjie.cim.common.route.algorithm.consistenthash.TreeMapConsistentHash offline: store: mode: redis redis: expire: message-ttl-days: 3 ================================================ FILE: cim-client-sdk/src/test/resources/init.sql ================================================ -- 创建表 CREATE TABLE IF NOT EXISTS `offline_msg` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `message_id` BIGINT NOT NULL, `receive_user_id` BIGINT NOT NULL, `content` VARCHAR(2000), `message_type` INT, `status` TINYINT COMMENT '0: Pending, 1: Acked', `created_at` DATETIME, `properties` VARCHAR(2000), INDEX `idx_receive_user_id` ( `receive_user_id` ) ); CREATE TABLE offline_msg_last_send_record ( receive_user_id BIGINT NOT NULL PRIMARY KEY, last_message_id BIGINT, updated_at DATETIME ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ================================================ FILE: cim-common/pom.xml ================================================ cim com.crossoverjie.netty 1.0.0-SNAPSHOT 4.0.0 1.0.0-SNAPSHOT cim-common org.projectlombok lombok org.slf4j slf4j-api com.google.protobuf protobuf-java com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter com.squareup.okhttp3 okhttp com.github.ben-manes.caffeine caffeine com.101tec zkclient org.apache.curator curator-recipes org.apache.zookeeper zookeeper junit junit org.junit.vintage junit-vintage-engine test org.junit.jupiter junit-jupiter test io.netty netty-all ${netty.version} com.alibaba fastjson kr.motd.maven os-maven-plugin 1.5.0.Final org.xolstice.maven.plugins protobuf-maven-plugin 0.5.1 com.google.protobuf:protoc:${protobuf-java.version}:exe:${os.detected.classifier} grpc-java io.grpc:protoc-gen-grpc-java:1.19.0:exe:${os.detected.classifier} compile compile-custom ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/constant/Constants.java ================================================ package com.crossoverjie.cim.common.constant; /** * Function:常量 * * @author crossoverJie * Date: 28/03/2018 17:41 * @since JDK 1.8 */ public class Constants { /** * 服务端手动 push 次数 */ public static final String COUNTER_SERVER_PUSH_COUNT = "counter.server.push.count"; /** * 客户端手动 push 次数 */ public static final String COUNTER_CLIENT_PUSH_COUNT = "counter.client.push.count"; public static class MetaKey { public static final String SEND_USER_ID = "sendUserId"; public static final String SEND_USER_NAME = "sendUserName"; public static final String RECEIVE_USER_ID = "receiveUserId"; public static final String RECEIVE_USER_NAME = "receiveUserName"; } //从数据库读取离线消息的每次获取量 public static final Integer FETCH_OFFLINE_MSG_LIMIT = 100; public static final Integer OFFLINE_MSG_PENDING = 0; public static final Integer OFFLINE_MSG_DELIVERED = 1; public static final Integer MSG_TYPE_TEXT = 0; public static final Integer MSG_TYPE_IMAGE = 1; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/core/proxy/DynamicUrl.java ================================================ package com.crossoverjie.cim.common.core.proxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @author crossoverJie */ @Target({ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface DynamicUrl { boolean useMethodEndpoint() default true; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/core/proxy/Request.java ================================================ package com.crossoverjie.cim.common.core.proxy; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * @author crossoverJie */ @Retention(RetentionPolicy.RUNTIME) public @interface Request { String method() default POST; String url() default ""; String GET = "GET"; String POST = "POST"; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/core/proxy/RpcProxyManager.java ================================================ package com.crossoverjie.cim.common.core.proxy; import static com.crossoverjie.cim.common.enums.StatusEnum.VALIDATION_FAIL; import com.alibaba.fastjson.JSONObject; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.util.HttpClient; import com.crossoverjie.cim.common.util.StringUtil; import com.fasterxml.jackson.databind.ObjectMapper; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Proxy; import java.lang.reflect.Type; import java.net.URI; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import okhttp3.Response; /** * RpcProxyManager is a proxy manager for creating dynamic proxy instances of interfaces. * It handles HTTP requests and responses using OkHttpClient. * * @param the type of the proxied interface */ @Slf4j public final class RpcProxyManager { private Class clazz; private String url; private OkHttpClient okHttpClient; private final ObjectMapper objectMapper = new ObjectMapper(); /** * Private constructor to initialize RpcProxyManager. * * @param clazz Proxied interface * @param url Server provider URL * @param okHttpClient HTTP client */ private RpcProxyManager(Class clazz, String url, OkHttpClient okHttpClient) { this.clazz = clazz; this.url = url; this.okHttpClient = okHttpClient; } private RpcProxyManager(Class clazz, OkHttpClient okHttpClient) { this(clazz, "", okHttpClient); } /** * Default private constructor. */ private RpcProxyManager() { } /** * Creates a proxy instance of the specified interface. * * @param clazz Proxied interface * @param url Server provider URL * @param okHttpClient HTTP client * @param Type of the proxied interface * @return Proxy instance of the specified interface */ public static T create(Class clazz, String url, OkHttpClient okHttpClient) { return new RpcProxyManager<>(clazz, url, okHttpClient).getInstance(); } public static T create(Class clazz, OkHttpClient okHttpClient) { return new RpcProxyManager<>(clazz, okHttpClient).getInstance(); } /** * Gets the proxy instance of the API. * * @return Proxy instance of the API */ @SuppressWarnings("unchecked") public T getInstance() { return (T) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{clazz}, new ProxyInvocation()); } /** * ProxyInvocation is an invocation handler for handling method calls on proxy instances. */ private class ProxyInvocation implements InvocationHandler { /** * Handles method calls on proxy instances. * * @param proxy The proxy instance * @param method The method being called * @param args The arguments of the method call * @return The result of the method call * @throws Throwable if an error occurs during method invocation */ @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Response result = null; String serverUrl = url + "/" + method.getName(); Request annotation = method.getAnnotation(Request.class); if (annotation != null && StringUtil.isNotEmpty(annotation.url())) { serverUrl = url + "/" + annotation.url(); } URI serverUri = new URI(serverUrl); serverUrl = serverUri.normalize().toString(); Object para = null; Class parameterType = null; for (int i = 0; i < method.getParameterAnnotations().length; i++) { Annotation[] annotations = method.getParameterAnnotations()[i]; if (annotations.length == 0) { para = args[i]; parameterType = method.getParameterTypes()[i]; } for (Annotation ann : annotations) { if (ann instanceof DynamicUrl) { if (args[i] instanceof String) { serverUrl = (String) args[i]; if (((DynamicUrl) ann).useMethodEndpoint()) { serverUrl = serverUrl + "/" + method.getName(); } break; } else { throw new CIMException("DynamicUrl must be String type"); } } } } try { if (annotation != null && annotation.method().equals(Request.GET)) { result = HttpClient.get(okHttpClient, serverUrl); } else { if (args == null || args.length > 2 || para == null || parameterType == null) { throw new IllegalArgumentException(VALIDATION_FAIL.message()); } JSONObject jsonObject = new JSONObject(); for (Field field : parameterType.getDeclaredFields()) { field.setAccessible(true); jsonObject.put(field.getName(), field.get(para)); } result = HttpClient.post(okHttpClient, jsonObject.toString(), serverUrl); } if (method.getReturnType() == void.class) { return null; } String json = result.body().string(); Type genericTypeOfBaseResponse = getGenericTypeOfBaseResponse(method); if (genericTypeOfBaseResponse == null) { return objectMapper.readValue(json, method.getReturnType()); } else { return objectMapper.readValue(json, objectMapper.getTypeFactory() .constructParametricType(method.getReturnType(), objectMapper.getTypeFactory().constructType(genericTypeOfBaseResponse))); } } finally { if (result != null) { result.body().close(); } } } } private Type getGenericTypeOfBaseResponse(Method declaredMethod) { Type returnType = declaredMethod.getGenericReturnType(); // check if the return type is a parameterized type if (returnType instanceof ParameterizedType parameterizedType) { Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); for (Type typeArgument : actualTypeArguments) { return typeArgument; } } return null; } /** * Gets the generic type of the BaseResponse. * * @param declaredMethod The method whose return type is being checked * @return The generic type of the BaseResponse, or null if not found * @throws ClassNotFoundException if the class of the generic type is not found private Class getBaseResponseGeneric(Method declaredMethod) throws ClassNotFoundException { Type returnType = declaredMethod.getGenericReturnType(); // check if the return type is a parameterized type if (returnType instanceof ParameterizedType parameterizedType) { Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); for (Type typeArgument : actualTypeArguments) { // BaseResponse only has one generic type return getClass(typeArgument); } } return null; } public static Class getClass(Type type) throws ClassNotFoundException { if (type instanceof Class) { // 普通类型,直接返回 return (Class) type; } else if (type instanceof ParameterizedType) { // 参数化类型,返回原始类型 return getClass(((ParameterizedType) type).getRawType()); } else if (type instanceof TypeVariable) { // 类型变量,无法在运行时获取具体类型 return Object.class; } else { throw new ClassNotFoundException("无法处理的类型: " + type.toString()); } }*/ } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/data/construct/RingBufferWheel.java ================================================ package com.crossoverjie.cim.common.data.construct; import lombok.extern.slf4j.Slf4j; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * Function:Ring Queue, it can be used to delay task. * * @author crossoverJie * Date: 2019-09-20 14:46 * @since JDK 1.8 */ @Slf4j public final class RingBufferWheel { /** * default ring buffer size */ private static final int STATIC_RING_SIZE = 64; private Object[] ringBuffer; private int bufferSize; /** * business thread pool */ private ExecutorService executorService; private volatile int size = 0; /*** * task stop sign */ private volatile boolean stop = false; /** * task start sign */ private volatile AtomicBoolean start = new AtomicBoolean(false); /** * total tick times */ private AtomicInteger tick = new AtomicInteger(); private Lock lock = new ReentrantLock(); private Condition condition = lock.newCondition(); private AtomicInteger taskId = new AtomicInteger(); private Map taskMap = new ConcurrentHashMap<>(16); /** * Create a new delay task ring buffer by default size * * @param executorService the business thread pool */ public RingBufferWheel(ExecutorService executorService) { this.executorService = executorService; this.bufferSize = STATIC_RING_SIZE; this.ringBuffer = new Object[bufferSize]; } /** * Create a new delay task ring buffer by custom buffer size * * @param executorService the business thread pool * @param bufferSize custom buffer size */ public RingBufferWheel(ExecutorService executorService, int bufferSize) { this(executorService); if (!powerOf2(bufferSize)) { throw new RuntimeException("bufferSize=[" + bufferSize + "] must be a power of 2"); } this.bufferSize = bufferSize; this.ringBuffer = new Object[bufferSize]; } /** * Add a task into the ring buffer(thread safe) * * @param task business task extends {@link Task} */ public int addTask(Task task) { int key = task.getKey(); int id; try { lock.lock(); int index = mod(key, bufferSize); task.setIndex(index); Set tasks = get(index); int cycleNum = cycleNum(key, bufferSize); if (tasks != null) { task.setCycleNum(cycleNum); tasks.add(task); } else { task.setIndex(index); task.setCycleNum(cycleNum); Set sets = new HashSet<>(); sets.add(task); put(key, sets); } id = taskId.incrementAndGet(); task.setTaskId(id); taskMap.put(id, task); size++; } finally { lock.unlock(); } start(); return id; } /** * Cancel task by taskId * @param id unique id through {@link #addTask(Task)} * @return */ public boolean cancel(int id) { boolean flag = false; Set tempTask = new HashSet<>(); try { lock.lock(); Task task = taskMap.get(id); if (task == null) { return false; } Set tasks = get(task.getIndex()); for (Task tk : tasks) { if (tk.getKey() == task.getKey() && tk.getCycleNum() == task.getCycleNum()) { size--; flag = true; taskMap.remove(id); } else { tempTask.add(tk); } } //update origin data ringBuffer[task.getIndex()] = tempTask; } finally { lock.unlock(); } return flag; } /** * Thread safe * * @return the size of ring buffer */ public int taskSize() { return size; } /** * Same with method {@link #taskSize} * @return */ public int taskMapSize() { return taskMap.size(); } /** * Start background thread to consumer wheel timer, it will always run until you call method {@link #stop} */ public void start() { if (!start.get()) { if (start.compareAndSet(start.get(), true)) { log.info("Delay task is starting"); Thread job = new Thread(new TriggerJob()); job.setName("consumer RingBuffer thread"); job.start(); start.set(true); } } } /** * Stop consumer ring buffer thread * * @param force True will force close consumer thread and discard all pending tasks * otherwise the consumer thread waits for all tasks to completes before closing. */ public void stop(boolean force) { if (force) { log.info("Delay task is forced stop"); stop = true; executorService.shutdownNow(); } else { log.info("Delay task is stopping"); if (taskSize() > 0) { try { lock.lock(); condition.await(); stop = true; } catch (InterruptedException e) { log.error("InterruptedException", e); } finally { lock.unlock(); } } executorService.shutdown(); } } private Set get(int index) { return (Set) ringBuffer[index]; } private void put(int key, Set tasks) { int index = mod(key, bufferSize); ringBuffer[index] = tasks; } /** * Remove and get task list. * @param key * @return task list */ private Set remove(int key) { Set tempTask = new HashSet<>(); Set result = new HashSet<>(); Set tasks = (Set) ringBuffer[key]; if (tasks == null) { return result; } for (Task task : tasks) { if (task.getCycleNum() == 0) { result.add(task); size2Notify(); } else { // decrement 1 cycle number and update origin data task.setCycleNum(task.getCycleNum() - 1); tempTask.add(task); } // remove task, and free the memory. taskMap.remove(task.getTaskId()); } //update origin data ringBuffer[key] = tempTask; return result; } private void size2Notify() { try { lock.lock(); size--; if (size == 0) { condition.signal(); } } finally { lock.unlock(); } } private boolean powerOf2(int target) { if (target < 0) { return false; } int value = target & (target - 1); if (value != 0) { return false; } return true; } private int mod(int target, int mod) { // equals target % mod target = target + tick.get(); return target & (mod - 1); } private int cycleNum(int target, int mod) { //equals target/mod return target >> Integer.bitCount(mod - 1); } /** * An abstract class used to implement business. */ public abstract static class Task extends Thread { private int index; private int cycleNum; private int key; /** * The unique ID of the task */ private int taskId; @Override public void run() { } public int getKey() { return key; } /** * * @param key Delay time(seconds) */ public void setKey(int key) { this.key = key; } public int getCycleNum() { return cycleNum; } private void setCycleNum(int cycleNum) { this.cycleNum = cycleNum; } public int getIndex() { return index; } private void setIndex(int index) { this.index = index; } public int getTaskId() { return taskId; } public void setTaskId(int taskId) { this.taskId = taskId; } } private class TriggerJob implements Runnable { @Override public void run() { int index = 0; while (!stop) { try { Set tasks = remove(index); for (Task task : tasks) { executorService.submit(task); } if (++index > bufferSize - 1) { index = 0; } //Total tick number of records tick.incrementAndGet(); TimeUnit.SECONDS.sleep(1); } catch (Exception e) { log.error("Exception", e); } } log.info("Delay task has stopped"); } } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/data/construct/SortArrayMap.java ================================================ package com.crossoverjie.cim.common.data.construct; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; import org.apache.curator.shaded.com.google.common.collect.Sets; /** * Function:根据 key 排序的 Map * * @author crossoverJie * Date: 2019-02-25 18:17 * @since JDK 1.8 */ public class SortArrayMap extends AbstractMap { /** * 核心数组 */ private Node[] buckets; private static final int DEFAULT_SIZE = 10; /** * 数组大小 */ private int size = 0; public SortArrayMap() { buckets = new Node[DEFAULT_SIZE]; } /** * 写入数据 * @param key * @param value */ public void add(Long key, String value) { checkSize(size + 1); Node node = new Node(key, value); buckets[size++] = node; } public SortArrayMap remove(String value) { List list = new ArrayList<>(Arrays.asList(buckets)); list.removeIf(next -> next != null && next.value.equals(value)); buckets = list.toArray(new Node[0]); return this; } /** * 校验是否需要扩容 * @param size */ private void checkSize(int size) { if (size >= buckets.length) { //扩容自身的 3/2 int oldLen = buckets.length; int newLen = oldLen + (oldLen >> 1); buckets = Arrays.copyOf(buckets, newLen); } } /** * 顺时针取出数据 * @param key * @return */ public String firstNodeValue(long key) { if (size == 0) { return null; } for (Node bucket : buckets) { if (bucket == null) { break; } if (bucket.key >= key) { return bucket.value; } } return buckets[0].value; } /** * 排序 */ public void sort() { Arrays.sort(buckets, 0, size, (o1, o2) -> { if (o1.key > o2.key) { return 1; } else { return 0; } }); } public void print() { for (Node bucket : buckets) { if (bucket == null) { continue; } System.out.println(bucket.toString()); } } @Override public int size() { return size; } @Override public void clear() { buckets = new Node[DEFAULT_SIZE]; size = 0; } @Override public Set> entrySet() { Set> set = Sets.newHashSet(); for (Node bucket : buckets) { set.add(new SimpleEntry<>(String.valueOf(bucket.key), bucket.value)); } return set; } @Override public Set keySet() { Set set = Sets.newHashSet(); for (Node bucket : buckets) { if (bucket == null) { continue; } set.add(bucket.value); } return set; } /** * 数据节点 */ private class Node { public Long key; public String value; public Node(Long key, String value) { this.key = key; this.value = value; } @Override public String toString() { return "Node{" + "key=" + key + ", value='" + value + '\'' + '}'; } } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/data/construct/TrieTree.java ================================================ package com.crossoverjie.cim.common.data.construct; import com.crossoverjie.cim.common.util.StringUtil; import java.util.ArrayList; import java.util.List; /** * Function:字典树字符前缀模糊匹配 * * @author crossoverJie * Date: 2019/1/7 18:58 * @since JDK 1.8 */ public class TrieTree { /** * 大小写都可保存 */ private static final int CHILDREN_LENGTH = 26 * 2; /** * 存放的最大字符串长度 */ private static final int MAX_CHAR_LENGTH = 16; private static final char UPPERCASE_STAR = 'A'; /** * 小写就要 -71 */ private static final char LOWERCASE_STAR = 'G'; private Node root; public TrieTree() { root = new Node(); } /** * 写入 * * @param data */ public void insert(String data) { this.insert(this.root, data); } private void insert(Node root, String data) { char[] chars = data.toCharArray(); for (int i = 0; i < chars.length; i++) { char aChar = chars[i]; int index; if (Character.isUpperCase(aChar)) { index = aChar - UPPERCASE_STAR; } else { //小写就要 -71 index = aChar - LOWERCASE_STAR; } if (index >= 0 && index < CHILDREN_LENGTH) { if (root.children[index] == null) { Node node = new Node(); root.children[index] = node; root.children[index].data = chars[i]; } //最后一个字符设置标志 if (i + 1 == chars.length) { root.children[index].isEnd = true; } //指向下一节点 root = root.children[index]; } } } /** * 递归深度遍历 * * @param key * @return */ public List prefixSearch(String key) { List value = new ArrayList(); if (StringUtil.isEmpty(key)) { return value; } char k = key.charAt(0); int index; if (Character.isUpperCase(k)) { index = k - UPPERCASE_STAR; } else { index = k - LOWERCASE_STAR; } if (root.children != null && root.children[index] != null) { return query(root.children[index], value, key.substring(1), String.valueOf(k)); } return value; } private List query(Node child, List value, String key, String result) { if (child.isEnd && key == null) { value.add(result); } if (StringUtil.isNotEmpty(key)) { char ca = key.charAt(0); int index; if (Character.isUpperCase(ca)) { index = ca - UPPERCASE_STAR; } else { index = ca - LOWERCASE_STAR; } if (child.children[index] != null) { query(child.children[index], value, key.substring(1).equals("") ? null : key.substring(1), result + ca); } } else { for (int i = 0; i < CHILDREN_LENGTH; i++) { if (child.children[i] == null) { continue; } int j; if (Character.isUpperCase(child.children[i].data)) { j = UPPERCASE_STAR + i; } else { j = LOWERCASE_STAR + i; } char temp = (char) j; query(child.children[i], value, null, result + temp); } } return value; } /** * 查询所有 * * @return */ public List all() { char[] chars = new char[MAX_CHAR_LENGTH]; List value = depth(this.root, new ArrayList(), chars, 0); return value; } public List depth(Node node, List list, char[] chars, int index) { if (node.children == null || node.children.length == 0) { return list; } Node[] children = node.children; for (int i = 0; i < children.length; i++) { Node child = children[i]; if (child == null) { continue; } if (child.isEnd) { chars[index] = child.data; char[] temp = new char[index + 1]; for (int j = 0; j < chars.length; j++) { if (chars[j] == 0) { continue; } temp[j] = chars[j]; } list.add(String.valueOf(temp)); return list; } else { chars[index] = child.data; index++; depth(child, list, chars, index); index = 0; } } return list; } /** * 字典树节点 */ private class Node { /** * 是否为最后一个字符 */ public boolean isEnd = false; /** * 如果只是查询,则不需要存储数据 */ public char data; public Node[] children = new Node[CHILDREN_LENGTH]; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/enums/StatusEnum.java ================================================ package com.crossoverjie.cim.common.enums; import java.util.ArrayList; import java.util.List; /** * @author crossoverJie */ public enum StatusEnum { /** 成功 */ SUCCESS("9000", "Success"), /** 成功 */ FALLBACK("8000", "FALL_BACK"), /** 参数校验失败**/ VALIDATION_FAIL("3000", "invalid argument"), /** 失败 */ FAIL("4000", "Failure"), /** 重复登录 */ REPEAT_LOGIN("5000", "Repeat login, log out an account please!"), /** 请求限流 */ REQUEST_LIMIT("6000", "请求限流"), /** 账号不在线 */ OFF_LINE("7000", "You selected user is offline!, please try again later!"), SERVER_NOT_AVAILABLE("7100", "cim server is not available, please try again later!"), RECONNECT_FAIL("7200", "Reconnect fail, continue to retry!"), /** 登录信息不匹配 */ ACCOUNT_NOT_MATCH("9100", "The User information you have used is incorrect!"), OFFLINE_MESSAGE_STORAGE_ERROR("9200", "Offline message storage error!"), OFFLINE_MESSAGE_FETCH_ERROR("9201", "Offline message fetch error!"), OFFLINE_MESSAGE_DELETE_ERROR("9202", "Offline message delete error!"); /** 枚举值码 */ private final String code; /** 枚举描述 */ private final String message; /** * 构建一个 StatusEnum 。 * @param code 枚举值码。 * @param message 枚举描述。 */ private StatusEnum(String code, String message) { this.code = code; this.message = message; } /** * 得到枚举值码。 * @return 枚举值码。 */ public String getCode() { return code; } /** * 得到枚举描述。 * @return 枚举描述。 */ public String getMessage() { return message; } /** * 得到枚举值码。 * @return 枚举值码。 */ public String code() { return code; } /** * 得到枚举描述。 * @return 枚举描述。 */ public String message() { return message; } /** * 通过枚举值码查找枚举值。 * @param code 查找枚举值的枚举值码。 * @return 枚举值码对应的枚举值。 * @throws IllegalArgumentException 如果 code 没有对应的 StatusEnum 。 */ public static StatusEnum findStatus(String code) { for (StatusEnum status : values()) { if (status.getCode().equals(code)) { return status; } } throw new IllegalArgumentException("ResultInfo StatusEnum not legal:" + code); } /** * 获取全部枚举值。 * * @return 全部枚举值。 */ public static List getAllStatus() { List list = new ArrayList(); for (StatusEnum status : values()) { list.add(status); } return list; } /** * 获取全部枚举值码。 * * @return 全部枚举值码。 */ public static List getAllStatusCode() { List list = new ArrayList(); for (StatusEnum status : values()) { list.add(status.code()); } return list; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/enums/SystemCommandEnum.java ================================================ package com.crossoverjie.cim.common.enums; import java.util.HashMap; import java.util.Map; /** * Function: * * @author crossoverJie * Date: 2018/12/26 18:38 * @since JDK 1.8 */ public enum SystemCommandEnum { ALL(":all ", "获取所有命令", "PrintAllCommand"), ONLINE_USER(":olu ", "获取所有在线用户", "PrintOnlineUsersCommand"), QUIT(":q! ", "退出程序", "ShutDownCommand"), QUERY(":q ", "【:q 关键字】查询聊天记录", "QueryHistoryCommand"), AI(":ai ", "开启 AI 模式", "OpenAIModelCommand"), QAI(":qai ", "关闭 AI 模式", "CloseAIModelCommand"), PREFIX(":pu ", "模糊匹配用户", "PrefixSearchCommand"), EMOJI(":emoji ", "emoji 表情列表", "EmojiCommand"), INFO(":info ", "获取客户端信息", "EchoInfoCommand"), DELAY_MSG(":delay ", "delay message, :delay [msg] [delayTime]", "DelayMsgCommand"); /** 枚举值码 */ private final String commandType; /** 枚举描述 */ private final String desc; /** * 实现类 */ private final String clazz; /** * 构建一个 。 * @param commandType 枚举值码。 * @param desc 枚举描述。 */ private SystemCommandEnum(String commandType, String desc, String clazz) { this.commandType = commandType; this.desc = desc; this.clazz = clazz; } /** * 得到枚举值码。 * @return 枚举值码。 */ public String getCommandType() { return commandType; } /** * 获取 class。 * @return class。 */ public String getClazz() { return clazz; } /** * 得到枚举描述。 * @return 枚举描述。 */ public String getDesc() { return desc; } /** * 得到枚举值码。 * @return 枚举值码。 */ public String code() { return commandType; } /** * 得到枚举描述。 * @return 枚举描述。 */ public String message() { return desc; } /** * 获取全部枚举值码。 * * @return 全部枚举值码。 */ public static Map getAllStatusCode() { Map map = new HashMap(16); for (SystemCommandEnum status : values()) { map.put(status.getCommandType(), status.getDesc()); } return map; } public static Map getAllClazz() { Map map = new HashMap(16); for (SystemCommandEnum status : values()) { map.put(status.getCommandType().trim(), "com.crossoverjie.cim.client.service.impl.command." + status.getClazz()); } return map; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/exception/CIMException.java ================================================ package com.crossoverjie.cim.common.exception; import com.crossoverjie.cim.common.enums.StatusEnum; /** * Function: * * @author crossoverJie * Date: 2018/8/25 15:26 * @since JDK 1.8 */ public class CIMException extends GenericException { public CIMException(String errorCode, String errorMessage) { super(errorMessage); this.errorCode = errorCode; this.errorMessage = errorMessage; } public CIMException(Exception e, String errorCode, String errorMessage) { super(e, errorMessage); this.errorCode = errorCode; this.errorMessage = errorMessage; } public CIMException(String message) { super(message); this.errorMessage = message; } public CIMException(StatusEnum statusEnum) { super(statusEnum.getMessage()); this.errorMessage = statusEnum.message(); this.errorCode = statusEnum.getCode(); } public CIMException(StatusEnum statusEnum, String message) { super(message); this.errorMessage = message; this.errorCode = statusEnum.getCode(); } public CIMException(Exception oriEx) { super(oriEx); } public CIMException(Throwable oriEx) { super(oriEx); } public CIMException(String message, Exception oriEx) { super(message, oriEx); this.errorMessage = message; } public CIMException(String message, Throwable oriEx) { super(message, oriEx); this.errorMessage = message; } public static boolean isResetByPeer(String msg) { if ("Connection reset by peer".equals(msg)) { return true; } return false; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/exception/GenericException.java ================================================ package com.crossoverjie.cim.common.exception; import java.io.Serializable; /** * Function: * * @author crossoverJie * Date: 2018/8/25 15:27 * @since JDK 1.8 */ public class GenericException extends RuntimeException implements Serializable { private static final long serialVersionUID = 1L; String errorCode; String errorMessage; public GenericException() { } public GenericException(String message) { super(message); } public GenericException(Exception oriEx) { super(oriEx); } public GenericException(Exception oriEx, String message) { super(message, oriEx); } public GenericException(Throwable oriEx) { super(oriEx); } public GenericException(String message, Exception oriEx) { super(message, oriEx); } public GenericException(String message, Throwable oriEx) { super(message, oriEx); } public String getErrorCode() { return this.errorCode; } public void setErrorCode(String errorCode) { this.errorCode = errorCode; } public String getErrorMessage() { return this.errorMessage; } public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/kit/HeartBeatHandler.java ================================================ package com.crossoverjie.cim.common.kit; import io.netty.channel.ChannelHandlerContext; /** * Function: * * @author crossoverJie * Date: 2019-01-20 17:15 * @since JDK 1.8 */ public interface HeartBeatHandler { /** * 处理心跳 * @param ctx * @throws Exception */ void process(ChannelHandlerContext ctx) throws Exception; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/metastore/AbstractConfiguration.java ================================================ package com.crossoverjie.cim.common.metastore; import lombok.Builder; import lombok.Data; /** * @author crossverJie */ @Data @Builder public class AbstractConfiguration { private String metaServiceUri; private int timeoutMs; private RETRY retryPolicy; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/metastore/MetaStore.java ================================================ package com.crossoverjie.cim.common.metastore; import java.util.List; import java.util.Set; /** * @author crossoverJie */ public interface MetaStore { void initialize(AbstractConfiguration configuration) throws Exception; /** * Get available server list * @return available server list * @throws Exception exception */ Set getAvailableServerList() throws Exception; /** * Add server to meta store * @throws Exception exception */ void addServer(String ip, int cimServerPort, int httpPort) throws Exception; /** * Subscribe server list * @param childListener child listener * @throws Exception exception */ void listenServerList(ChildListener childListener) throws Exception; /** * @throws Exception */ void rebuildCache() throws Exception; interface ChildListener { /** * Child changed * @param parentPath parent path(eg. for zookeeper: [/cim]) * @param currentChildren current children * @throws Exception exception */ void childChanged(String parentPath, List currentChildren) throws Exception; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/metastore/ZkConfiguration.java ================================================ package com.crossoverjie.cim.common.metastore; import org.apache.curator.RetryPolicy; /** * @author crossoverJie */ public class ZkConfiguration extends AbstractConfiguration { ZkConfiguration(String metaServiceUri, int timeout, RetryPolicy retryPolicy) { super(metaServiceUri, timeout, retryPolicy); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/metastore/ZkMetaStoreImpl.java ================================================ package com.crossoverjie.cim.common.metastore; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.I0Itec.zkclient.ZkClient; import org.apache.curator.framework.CuratorFramework; import org.apache.zookeeper.CreateMode; import org.apache.zookeeper.Watcher; /** * @author crossovreJie */ @Slf4j public class ZkMetaStoreImpl implements MetaStore { public static final String ROOT = "/cim"; private ZkClient client; Cache cache; @Override public void initialize(AbstractConfiguration configuration) throws Exception { cache = Caffeine.newBuilder().build(); client = new ZkClient(configuration.getMetaServiceUri(), configuration.getTimeoutMs()); } @Override public Set getAvailableServerList() throws Exception { if (cache.asMap().size() > 0) { return cache.asMap().keySet(); } List coll = client.getChildren(ROOT); Map voidMap = coll.stream().collect(Collectors.toMap( Function.identity(), Function.identity() )); cache.putAll(voidMap); return voidMap.keySet(); } @Override public void addServer(String ip, int cimServerPort, int httpPort) throws Exception { boolean exists = client.exists(ROOT); if (!exists) { client.createPersistent(ROOT); } String zkParse = RouteInfoParseUtil.parse(RouteInfo.builder() .ip(ip) .cimServerPort(cimServerPort) .httpPort(httpPort) .build()); String serverPath = String.format("%s/%s", ROOT, zkParse); client.createEphemeral(serverPath); log.info("Add server to zk [{}]", serverPath); } @Override public void listenServerList(ChildListener childListener) throws Exception { client.subscribeChildChanges(ROOT, (parentPath, currentChildren) -> { log.info("Clear and update local cache parentPath=[{}],current server list=[{}]", parentPath, currentChildren.toString()); childListener.childChanged(parentPath, currentChildren); // TODO: 2024/8/19 maybe can reuse currentChildren. // Because rebuildCache() will re-fetch the server list from zk. rebuildCache(); }); } @Override public synchronized void rebuildCache() throws Exception { cache.invalidateAll(); this.getAvailableServerList(); } private List watchedGetChildren(CuratorFramework client, String path) throws Exception { /** * Get children and set a watcher on the node. The watcher notification will come through the * CuratorListener (see setDataAsync() above). */ return client.getChildren().watched().forPath(path); } private void createEphemeral(CuratorFramework client, String path, byte[] payload) throws Exception { // this will create the given EPHEMERAL ZNode with the given data client.create().withMode(CreateMode.EPHEMERAL).forPath(path, payload); } private void create(CuratorFramework client, String path, byte[] payload) throws Exception { // this will create the given ZNode with the given data client.create().forPath(path, payload); } private void watchedGetChildren(CuratorFramework client, String path, Watcher watcher) throws Exception { // Get children and set the given watcher on the node. client.getChildren().usingWatcher(watcher).forPath(path); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/pojo/CIMUserInfo.java ================================================ package com.crossoverjie.cim.common.pojo; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * Function: 用户信息 * * @author crossoverJie * Date: 2018/12/24 02:33 * @since JDK 1.8 */ @Data @AllArgsConstructor @NoArgsConstructor public class CIMUserInfo { private Long userId; private String userName; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/pojo/RouteInfo.java ================================================ package com.crossoverjie.cim.common.pojo; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; /** * Function: * * @author crossoverJie * Date: 2020-04-12 20:48 * @since JDK 1.8 */ @Data @AllArgsConstructor @Builder public final class RouteInfo { private String ip; private Integer cimServerPort; private Integer httpPort; } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/req/BaseRequest.java ================================================ package com.crossoverjie.cim.common.req; import io.swagger.v3.oas.annotations.media.Schema; /** * Function: * @author crossoverJie * Date: 2017/6/7 下午11:28 * @since JDK 1.8 */ public class BaseRequest { @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "reqNo", example = "1234567890") private String reqNo; @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "timestamp", example = "0") private int timeStamp; public BaseRequest() { this.setTimeStamp((int)(System.currentTimeMillis() / 1000)); } public String getReqNo() { return reqNo; } public void setReqNo(String reqNo) { this.reqNo = reqNo; } public int getTimeStamp() { return timeStamp; } public void setTimeStamp(int timeStamp) { this.timeStamp = timeStamp; } @Override public String toString() { return "BaseRequest{" + "reqNo='" + reqNo + '\'' + ", timeStamp=" + timeStamp + '}'; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/res/BaseResponse.java ================================================ package com.crossoverjie.cim.common.res; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.util.StringUtil; import java.io.Serializable; public class BaseResponse implements Serializable { private String code; private String message; /** * 请求号 */ private String reqNo; private T dataBody; public BaseResponse() {} public BaseResponse(T dataBody) { this.dataBody = dataBody; } public BaseResponse(String code, String message) { this.code = code; this.message = message; } public BaseResponse(String code, String message, T dataBody) { this.code = code; this.message = message; this.dataBody = dataBody; } public BaseResponse(String code, String message, String reqNo, T dataBody) { this.code = code; this.message = message; this.reqNo = reqNo; this.dataBody = dataBody; } public static BaseResponse create(T t) { return new BaseResponse(t); } public static BaseResponse create(T t, StatusEnum statusEnum) { return new BaseResponse(statusEnum.getCode(), statusEnum.getMessage(), t); } public static BaseResponse createSuccess(T t, String message) { String msg = StringUtil.isNullOrEmpty(message) ? StatusEnum.SUCCESS.getMessage() : message; return new BaseResponse(StatusEnum.SUCCESS.getCode(), msg, t); } public static BaseResponse createFail(T t, String message) { return new BaseResponse(StatusEnum.FAIL.getCode(), StringUtil.isNullOrEmpty(message) ? StatusEnum.FAIL.getMessage() : message, t); } public static BaseResponse create(T t, StatusEnum statusEnum, String message) { return new BaseResponse(statusEnum.getCode(), message, t); } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public T getDataBody() { return dataBody; } public void setDataBody(T dataBody) { this.dataBody = dataBody; } public String getReqNo() { return reqNo; } public void setReqNo(String reqNo) { this.reqNo = reqNo; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/res/NULLBody.java ================================================ package com.crossoverjie.cim.common.res; /** * Function:空对象,用在泛型中,表示没有额外的请求参数或者返回参数 * * @author crossoverJie * Date: 2017/6/7 下午11:57 * @since JDK 1.8 */ public class NULLBody { public NULLBody() {} public static NULLBody create() { return new NULLBody(); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/RouteHandle.java ================================================ package com.crossoverjie.cim.common.route.algorithm; import com.crossoverjie.cim.common.pojo.RouteInfo; import java.util.List; /** * Function: * * @author crossoverJie * Date: 2019-02-27 00:31 * @since JDK 1.8 */ public interface RouteHandle { /** * 再一批服务器里进行路由 * @param values * @param key * @return */ // TODO: 2024/9/13 Use List instead of List to make the code more type-safe String routeServer(List values, String key); List removeExpireServer(RouteInfo routeInfo); } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/AbstractConsistentHash.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Map; /** * Function:一致性 hash 算法抽象类 * * @author crossoverJie * Date: 2019-02-27 00:35 * @since JDK 1.8 */ public abstract class AbstractConsistentHash { /** * 新增节点 * @param key * @param value */ protected abstract void add(long key, String value); /** * remove node * @param value node * @return current data */ protected abstract Map remove(String value); /** * Clear old data in the structure */ protected abstract void clear(); /** * 排序节点,数据结构自身支持排序可以不用重写 */ protected void sort() { } /** * 根据当前的 key 通过一致性 hash 算法的规则取出一个节点 * @param value * @return */ protected abstract String getFirstNodeValue(String value); /** * 传入节点列表以及客户端信息获取一个服务节点 * @param values * @param key * @return */ public String process(List values, String key) { // fix https://github.com/crossoverJie/cim/issues/79 clear(); for (String value : values) { add(hash(value), value); } sort(); return getFirstNodeValue(key); } /** * hash 运算 * @param value * @return */ public Long hash(String value) { MessageDigest md5; try { md5 = MessageDigest.getInstance("MD5"); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5 not supported", e); } md5.reset(); byte[] keyBytes = null; try { keyBytes = value.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { throw new RuntimeException("Unknown string :" + value, e); } md5.update(keyBytes); byte[] digest = md5.digest(); // hash code, Truncate to 32-bits long hashCode = ((long) (digest[3] & 0xFF) << 24) | ((long) (digest[2] & 0xFF) << 16) | ((long) (digest[1] & 0xFF) << 8) | (digest[0] & 0xFF); long truncateHashCode = hashCode & 0xffffffffL; return truncateHashCode; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/ConsistentHashHandle.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * Function: * * @author crossoverJie * Date: 2019-02-27 00:33 * @since JDK 1.8 */ public class ConsistentHashHandle implements RouteHandle { private AbstractConsistentHash hash; public void setHash(AbstractConsistentHash hash) { this.hash = hash; } @Override public String routeServer(List values, String key) { return hash.process(values, key); } @Override public List removeExpireServer(RouteInfo routeInfo) { Map remove = hash.remove(RouteInfoParseUtil.parse(routeInfo)); return new ArrayList<>(remove.keySet()); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/SortArrayMapConsistentHash.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import com.crossoverjie.cim.common.data.construct.SortArrayMap; import com.google.common.annotations.VisibleForTesting; import java.util.Map; /** * Function:自定义排序 Map 实现 * * @author crossoverJie * Date: 2019-02-27 00:38 * @since JDK 1.8 */ public class SortArrayMapConsistentHash extends AbstractConsistentHash { private SortArrayMap sortArrayMap = new SortArrayMap(); /** * 虚拟节点数量 */ private static final int VIRTUAL_NODE_SIZE = 2; @Override public void add(long key, String value) { for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) { Long hash = super.hash("vir" + key + i); sortArrayMap.add(hash, value); } sortArrayMap.add(key, value); } @Override protected Map remove(String value) { sortArrayMap = sortArrayMap.remove(value); return sortArrayMap; } @Override public void sort() { sortArrayMap.sort(); } /** * Used only in test. * @return Return the data structure of the current algorithm. */ @VisibleForTesting public SortArrayMap getSortArrayMap() { return sortArrayMap; } @Override protected void clear() { sortArrayMap.clear(); } @Override public String getFirstNodeValue(String value) { long hash = super.hash(value); return sortArrayMap.firstNodeValue(hash); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/TreeMapConsistentHash.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.exception.CIMException; import com.google.common.annotations.VisibleForTesting; import java.util.HashMap; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; /** * Function:TreeMap 实现 * * @author crossoverJie * Date: 2019-02-27 01:16 * @since JDK 1.8 */ public class TreeMapConsistentHash extends AbstractConsistentHash { private final TreeMap treeMap = new TreeMap(); /** * 虚拟节点数量 */ private static final int VIRTUAL_NODE_SIZE = 2; @Override public void add(long key, String value) { for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) { Long hash = super.hash("vir" + key + i); treeMap.put(hash, value); } treeMap.put(key, value); } @Override protected Map remove(String value) { treeMap.entrySet().removeIf(next -> next.getValue().equals(value)); Map result = new HashMap<>(treeMap.entrySet().size()); for (Map.Entry longStringEntry : treeMap.entrySet()) { result.put(longStringEntry.getValue(), ""); } return result; } @Override protected void clear() { treeMap.clear(); } /** * Used only in test. * @return Return the data structure of the current algorithm. */ @VisibleForTesting public TreeMap getTreeMap() { return treeMap; } @Override public String getFirstNodeValue(String value) { long hash = super.hash(value); SortedMap last = treeMap.tailMap(hash); if (!last.isEmpty()) { return last.get(last.firstKey()); } if (treeMap.size() == 0) { throw new CIMException(StatusEnum.SERVER_NOT_AVAILABLE); } return treeMap.firstEntry().getValue(); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/loop/LoopHandle.java ================================================ package com.crossoverjie.cim.common.route.algorithm.loop; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import java.util.List; import java.util.concurrent.atomic.AtomicLong; /** * Function: * * @author crossoverJie * Date: 2019-02-27 15:13 * @since JDK 1.8 */ public class LoopHandle implements RouteHandle { private final AtomicLong index = new AtomicLong(); private List values; @Override public String routeServer(List values, String key) { if (values.size() == 0) { throw new CIMException(StatusEnum.SERVER_NOT_AVAILABLE); } this.values = values; Long position = index.incrementAndGet() % values.size(); if (position < 0) { position = 0L; } return values.get(position.intValue()); } @Override public List removeExpireServer(RouteInfo routeInfo) { String parse = RouteInfoParseUtil.parse(routeInfo); values.removeIf(next -> next.equals(parse)); return values; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/route/algorithm/random/RandomHandle.java ================================================ package com.crossoverjie.cim.common.route.algorithm.random; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import java.util.List; import java.util.concurrent.ThreadLocalRandom; /** * Function: 路由策略, 随机 * * @Author: jiangyunxiong * @Date: 2019/3/7 11:56 * @since JDK 1.8 */ public class RandomHandle implements RouteHandle { private List values; @Override public String routeServer(List values, String key) { int size = values.size(); if (size == 0) { throw new CIMException(StatusEnum.SERVER_NOT_AVAILABLE); } this.values = values; int offset = ThreadLocalRandom.current().nextInt(size); return values.get(offset); } @Override public List removeExpireServer(RouteInfo routeInfo) { String parse = RouteInfoParseUtil.parse(routeInfo); values.removeIf(next -> next.equals(parse)); return values; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/util/HttpClient.java ================================================ package com.crossoverjie.cim.common.util; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import java.io.IOException; /** * Function: * * @author crossoverJie * Date: 2020-04-25 00:39 * @since JDK 1.8 */ public final class HttpClient { private static final MediaType MEDIA_TYPE = MediaType.parse("application/json"); public static Response post(OkHttpClient okHttpClient, String params, String url) throws IOException { RequestBody requestBody = RequestBody.create(MEDIA_TYPE, params); Request request = new Request.Builder() .url(url) .post(requestBody) .build(); Response response = okHttpClient.newCall(request).execute(); if (!response.isSuccessful()) { throw new IOException("request url [" + url + "], params [" + params + "] failed, response code: " + response.code() + ", message: " + response.message()); } return response; } public static Response get(OkHttpClient okHttpClient, String url) throws IOException { Request request = new Request.Builder() .url(url) .get() .build(); Response response = okHttpClient.newCall(request).execute(); if (!response.isSuccessful()) { throw new IOException("Unexpected code " + response); } return response; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/util/NettyAttrUtil.java ================================================ package com.crossoverjie.cim.common.util; import io.netty.channel.Channel; import io.netty.util.Attribute; import io.netty.util.AttributeKey; /** * Function: * * @author crossoverJie * Date: 2019/1/9 00:57 * @since JDK 1.8 */ public class NettyAttrUtil { private static final AttributeKey ATTR_KEY_READER_TIME = AttributeKey.valueOf("readerTime"); public static void updateReaderTime(Channel channel, Long time) { channel.attr(ATTR_KEY_READER_TIME).set(time.toString()); } public static Long getReaderTime(Channel channel) { String value = getAttribute(channel, ATTR_KEY_READER_TIME); if (value != null) { return Long.valueOf(value); } return null; } private static String getAttribute(Channel channel, AttributeKey key) { Attribute attr = channel.attr(key); return attr.get(); } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/util/RouteInfoParseUtil.java ================================================ package com.crossoverjie.cim.common.util; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.pojo.RouteInfo; import static com.crossoverjie.cim.common.enums.StatusEnum.VALIDATION_FAIL; /** * Function: * * @author crossoverJie * Date: 2020-04-12 20:42 * @since JDK 1.8 */ public class RouteInfoParseUtil { public static RouteInfo parse(String info) { try { String[] serverInfo = info.split(":"); return new RouteInfo(serverInfo[0], Integer.parseInt(serverInfo[1]), Integer.parseInt(serverInfo[2])); } catch (Exception e) { throw new CIMException(VALIDATION_FAIL); } } public static String parse(RouteInfo routeInfo) { return routeInfo.getIp() + ":" + routeInfo.getCimServerPort() + ":" + routeInfo.getHttpPort(); } private RouteInfoParseUtil() { } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/util/SnowflakeIdWorker.java ================================================ package com.crossoverjie.cim.common.util; /** * @author zhongcanyu * @date 2025/5/18 * @description */ public class SnowflakeIdWorker { private final long workerId = 1L; private static final long EPOCH = 1622505600000L; private long sequence = 0L; private long lastTimestamp = -1L; private static final long WORKER_ID_BITS = 10L; private static final long SEQUENCE_BITS = 12L; private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; private long tilNextMillis(long lastTs) { long ts = System.currentTimeMillis(); while (ts <= lastTs) { ts = System.currentTimeMillis(); } return ts; } public synchronized long nextId() { long ts = System.currentTimeMillis(); if (ts < lastTimestamp) { throw new IllegalStateException("Clock moved backwards."); } if (ts == lastTimestamp) { sequence = (sequence + 1) & MAX_SEQUENCE; if (sequence == 0) { ts = tilNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = ts; return ((ts - EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; } } ================================================ FILE: cim-common/src/main/java/com/crossoverjie/cim/common/util/StringUtil.java ================================================ package com.crossoverjie.cim.common.util; /** * Function: * * @author crossoverJie * Date: 22/05/2018 15:16 * @since JDK 1.8 */ public class StringUtil { public StringUtil() { } public static boolean isNullOrEmpty(String str) { return null == str || 0 == str.trim().length(); } public static boolean isEmpty(String str) { return str == null || "".equals(str.trim()); } public static boolean isNotEmpty(String str) { return str != null && !"".equals(str.trim()); } public static String formatLike(String str) { return isNotEmpty(str) ? "%" + str + "%" : null; } } ================================================ FILE: cim-common/src/main/proto/cim.proto ================================================ syntax = "proto3"; package com.crossoverjie.cim.common.protocol; option java_package = "com.crossoverjie.cim.common.protocol"; option java_multiple_files = true; message Request{ int64 requestId = 2; string reqMsg = 1; BaseCommand cmd = 3; map properties = 4; repeated string batchReqMsg = 5; } message Response{ int64 responseId = 2; string resMsg = 1; BaseCommand cmd = 3; map properties = 4; repeated string batchResMsg = 5; } enum BaseCommand{ LOGIN_REQUEST = 0; MESSAGE = 1; PING = 2; OFFLINE = 3; } ================================================ FILE: cim-common/src/main/resources/log4j.properties ================================================ # Global logging configuration log4j.rootLogger=DEBUG,CONSOLE,LOGFILE log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender log4j.appender.CONSOLE.Target = System.out log4j.appender.CONSOLE.Threshold = INFO log4j.appender.CONSOLE.Encoding = UTF-8 log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout log4j.appender.CONSOLE.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %5p (%c:%L) - %m%n log4j.appender.LOGFILE=org.apache.log4j.FileAppender log4j.appender.LOGFILE.File =./logs/error.log log4j.appender.LOGFILE.Threshold = ERROR log4j.appender.LOGFILE.Encoding = UTF-8 log4j.appender.LOGFILE.layout = org.apache.log4j.PatternLayout log4j.appender.LOGFILE.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss} [ %t:%r ] - [ %p ] %m%n ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/CommonTest.java ================================================ package com.crossoverjie.cim.common; import java.time.LocalDate; import java.time.LocalTime; import java.util.concurrent.TimeUnit; import org.junit.Test; /** * Function: * * @author crossoverJie * Date: 2019-09-23 14:21 * @since JDK 1.8 */ public class CommonTest { @Test public void test2() { System.out.println(LocalDate.now().toString()); System.out.println(LocalTime.now().withNano(0).toString()); } @Test public void test() throws InterruptedException { System.out.println(is2(9)); System.out.println(Integer.bitCount(64 - 1)); int target = 1569312600; int mod = 64; System.out.println(target % mod); System.out.println(mod(target, mod)); System.out.println("============"); System.out.println(cycleNum(256, 64)); } private int mod(int target, int mod) { // equals target % mod return target & (mod - 1); } private int cycleNum(int target, int mod) { //equals target/mod return target >> Integer.bitCount(mod - 1); } private boolean is2(int target) { if (target < 0) { return false; } int value = target & (target - 1); if (value != 0) { return false; } return true; } private void cycle() throws InterruptedException { int index = 0; while (true) { System.out.println("=======" + index); if (++index > 63) { index = 0; } TimeUnit.MILLISECONDS.sleep(200); } } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/core/proxy/RpcProxyManagerTest.java ================================================ package com.crossoverjie.cim.common.core.proxy; import com.crossoverjie.cim.common.exception.CIMException; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.io.Serializable; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import okhttp3.OkHttpClient; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class RpcProxyManagerTest { @Test public void testGet() { OkHttpClient client = new OkHttpClient(); String url = "https://api.github.com/users"; Github github = RpcProxyManager.create(Github.class, url, client); GithubResponse githubResponse = github.crossoverjie(); Assertions.assertEquals(githubResponse.getName(), "crossoverJie"); github.torvalds(); } @Test public void testPost() { OkHttpClient client = new OkHttpClient(); String url = "http://echo.free.beeceptor.com"; Echo echo = RpcProxyManager.create(Echo.class, url, client); EchoRequest request = new EchoRequest(); request.setName("crossoverJie"); request.setAge(18); request.setCity("shenzhen"); EchoResponse response = echo.echo(request); Assertions.assertEquals(response.getParsedBody().getName(), "crossoverJie"); Assertions.assertEquals(response.getParsedBody().getAge(), 18); Assertions.assertEquals(response.getParsedBody().getCity(), "shenzhen"); } @Test public void testUrl() { OkHttpClient client = new OkHttpClient(); String url = "http://echo.free.beeceptor.com/sample-request?author=beeceptor"; /** * { * "method": "POST", * "protocol": "http", * "host": "echo.free.beeceptor.com", * "path": "/sample-request?author=beeceptor", * "ip": "111.249.70.48:41382", * "headers": { * "Host": "echo.free.beeceptor.com", * "User-Agent": "okhttp/3.3.1", * "Content-Length": "50", * "Accept-Encoding": "gzip", * "Content-Type": "application/json; charset=utf-8", * "Via": "1.1 Caddy" * }, * "parsedQueryParams": { * "author": "beeceptor" * }, * "parsedBody": { * "city": "shenzhen", * "name": "crossoverJie", * "age": 18 * } * } */ Echo echo = RpcProxyManager.create(Echo.class, client); EchoRequest request = new EchoRequest(); request.setName("crossoverJie"); request.setAge(18); request.setCity("shenzhen"); EchoResponse response = echo.echoTarget(url, request); Assertions.assertEquals(response.getParsedBody().getName(), "crossoverJie"); Assertions.assertEquals(response.getParsedBody().getAge(), 18); Assertions.assertEquals(response.getParsedBody().getCity(), "shenzhen"); response = echo.echoTarget(request, url); Assertions.assertEquals(response.getParsedBody().getName(), "crossoverJie"); String req = "/request"; response = echo.request("http://echo.free.beeceptor.com", request); Assertions.assertEquals(response.getPath(), req); Assertions.assertEquals(response.getParsedBody().getAge(), 18); Assertions.assertThrows(CIMException.class, () -> echo.echoTarget(request)); } @Test public void testFail() { OkHttpClient client = new OkHttpClient(); String url = "http://echo.free.beeceptor.com"; Echo echo = RpcProxyManager.create(Echo.class, url, client); EchoRequest request = new EchoRequest(); request.setName("crossoverJie"); request.setAge(18); request.setCity("shenzhen"); Assertions.assertThrows(IllegalArgumentException.class, () -> echo.fail(request, "test", "")); } @Test public void testGeneric() { OkHttpClient client = new OkHttpClient(); String url = "http://echo.free.beeceptor.com"; Echo echo = RpcProxyManager.create(Echo.class, url, client); EchoRequest request = new EchoRequest(); request.setName("crossoverJie"); request.setAge(18); request.setCity("shenzhen"); EchoGeneric response = echo.echoGeneric(request); Assertions.assertEquals(response.getHeaders().getHost(), "echo.free.beeceptor.com"); } interface Echo { @Request(url = "sample-request?author=beeceptor") EchoResponse echo(EchoRequest message); @Request(url = "sample-request?author=beeceptor") EchoResponse echoTarget(@DynamicUrl(useMethodEndpoint = false) String url, EchoRequest message); EchoResponse echoTarget(EchoRequest message, @DynamicUrl(useMethodEndpoint = false) String url); @Request(url = "sample-request?author=beeceptor") EchoResponse echoTarget(@DynamicUrl EchoRequest message); EchoResponse request(@DynamicUrl() String url, EchoRequest message); @Request(url = "sample-request?author=beeceptor") EchoResponse fail(EchoRequest message, String s, String s1); @Request(url = "sample-request?author=beeceptor") EchoGeneric echoGeneric(EchoRequest message); } @Data public static class EchoRequest { private String name; private int age; private String city; } @Data @AllArgsConstructor @NoArgsConstructor public static class CIMServerResVO implements Serializable { private String ip; private Integer cimServerPort; private Integer httpPort; } @Data @JsonIgnoreProperties(ignoreUnknown = true) @AllArgsConstructor @NoArgsConstructor public static class EchoGeneric { private String method; private String protocol; private String host; private T headers; } @NoArgsConstructor @Data public static class EchoResponse { @JsonProperty("method") private String method; @JsonProperty("protocol") private String protocol; @JsonProperty("host") private String host; @JsonProperty("path") private String path; @JsonProperty("ip") private String ip; @JsonProperty("headers") private HeadersDTO headers; @JsonProperty("parsedQueryParams") private ParsedQueryParamsDTO parsedQueryParams; @JsonProperty("parsedBody") private ParsedBodyDTO parsedBody; @NoArgsConstructor @Data public static class HeadersDTO { @JsonProperty("Host") private String host; @JsonProperty("User-Agent") private String userAgent; @JsonProperty("Content-Length") private String contentLength; @JsonProperty("Accept") private String accept; @JsonProperty("Content-Type") private String contentType; @JsonProperty("Accept-Encoding") private String acceptEncoding; @JsonProperty("Via") private String via; } @NoArgsConstructor @Data public static class ParsedQueryParamsDTO { @JsonProperty("author") private String author; } @NoArgsConstructor @Data public static class ParsedBodyDTO { @JsonProperty("name") private String name; @JsonProperty("age") private Integer age; @JsonProperty("city") private String city; } } interface Github { @Request(method = Request.GET) GithubResponse crossoverjie(); @Request(method = Request.GET) void torvalds(); } @Data @JsonIgnoreProperties(ignoreUnknown = true) public static class GithubResponse { @JsonProperty("name") private String name; } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/data/construct/RingBufferWheelTest.java ================================================ package com.crossoverjie.cim.common.data.construct; import com.google.common.util.concurrent.ThreadFactoryBuilder; import io.netty.util.HashedWheelTimer; import io.netty.util.Timeout; import io.netty.util.TimerTask; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; @Slf4j public class RingBufferWheelTest { public static void main(String[] args) throws Exception { test8(); } private static void test8() throws Exception { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel wheel = new RingBufferWheel(executorService); while (true) { log.info("task size={}, task map size={}", wheel.taskSize(), wheel.taskMapSize()); TimeUnit.SECONDS.sleep(1); for (int i = 0; i < 1000; i++) { RingBufferWheel.Task task = new ByteTask(1024 * 1024); task.setKey(1); wheel.addTask(task); } } } private static class ByteTask extends RingBufferWheel.Task { private byte[] b; public ByteTask(int size) { this.b = new byte[size]; } @Override public void run() { // empty task } } private static void test1() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel.Task task = new Task(); task.setKey(10); RingBufferWheel wheel = new RingBufferWheel(executorService); wheel.addTask(task); task = new Task(); task.setKey(74); wheel.addTask(task); while (true) { log.info("task size={}", wheel.taskSize()); TimeUnit.SECONDS.sleep(1); } } private static void test2() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel.Task task = new Task(); task.setKey(10); RingBufferWheel wheel = new RingBufferWheel(executorService); wheel.addTask(task); task = new Task(); task.setKey(74); wheel.addTask(task); wheel.start(); // new Thread(() -> { // while (true){ // logger.info("task size={}" , wheel.taskSize()); // try { // TimeUnit.SECONDS.sleep(1); // } catch (InterruptedException e) { // e.printStackTrace(); // } // } // }).start(); TimeUnit.SECONDS.sleep(12); wheel.stop(true); } private static void test3() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel.Task task = new Task(); task.setKey(10); RingBufferWheel wheel = new RingBufferWheel(executorService); wheel.addTask(task); task = new Task(); task.setKey(60); wheel.addTask(task); TimeUnit.SECONDS.sleep(2); wheel.stop(false); } private static void test4() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel wheel = new RingBufferWheel(executorService); for (int i = 0; i < 65; i++) { RingBufferWheel.Task task = new Job(i); task.setKey(i); wheel.addTask(task); } wheel.start(); log.info("task size={}", wheel.taskSize()); wheel.stop(false); } private static void test5() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel wheel = new RingBufferWheel(executorService, 512); for (int i = 0; i < 65; i++) { RingBufferWheel.Task task = new Job(i); task.setKey(i); wheel.addTask(task); } log.info("task size={}", wheel.taskSize()); wheel.stop(false); } private static void test6() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel wheel = new RingBufferWheel(executorService, 512); for (int i = 0; i < 10; i++) { RingBufferWheel.Task task = new Job(i); task.setKey(i); wheel.addTask(task); } TimeUnit.SECONDS.sleep(5); RingBufferWheel.Task task = new Job(15); task.setKey(15); wheel.addTask(task); log.info("task size={}", wheel.taskSize()); wheel.stop(false); } private static void test7() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(2); RingBufferWheel wheel = new RingBufferWheel(executorService, 512); for (int i = 0; i < 10; i++) { RingBufferWheel.Task task = new Job(i); task.setKey(i); wheel.addTask(task); } RingBufferWheel.Task task = new Job(15); task.setKey(15); int cancel = wheel.addTask(task); new Thread(() -> { boolean flag = wheel.cancel(cancel); log.info("cancel id={},key={} result={}", cancel, task.getKey(), flag); }).start(); RingBufferWheel.Task task1 = new Job(20); task1.setKey(20); wheel.addTask(task1); log.info("task size={}", wheel.taskSize()); wheel.stop(false); } private static void concurrentTest() throws Exception { BlockingQueue queue = new LinkedBlockingQueue(10); ThreadFactory product = new ThreadFactoryBuilder() .setNameFormat("msg-callback-%d") .setDaemon(true) .build(); ThreadPoolExecutor business = new ThreadPoolExecutor(4, 4, 1, TimeUnit.MILLISECONDS, queue, product); ExecutorService executorService = Executors.newFixedThreadPool(10); RingBufferWheel wheel = new RingBufferWheel(executorService); for (int i = 0; i < 10; i++) { business.execute(new Runnable() { @Override public void run() { for (int i1 = 0; i1 < 30; i1++) { RingBufferWheel.Task task = new Job(i1); task.setKey(i1); wheel.addTask(task); } } }); } log.info("task size={}", wheel.taskSize()); wheel.stop(false); } private static class Job extends RingBufferWheel.Task { private int num; public Job(int num) { this.num = num; } @Override public void run() { log.info("number={}", num); } } private static class Task extends RingBufferWheel.Task { @Override public void run() { log.info("================"); } } public static void hashTimerTest() { BlockingQueue queue = new LinkedBlockingQueue(10); ThreadFactory product = new ThreadFactoryBuilder() .setNameFormat("msg-callback-%d") .setDaemon(true) .build(); ThreadPoolExecutor business = new ThreadPoolExecutor(4, 4, 1, TimeUnit.MILLISECONDS, queue, product); HashedWheelTimer hashedWheelTimer = new HashedWheelTimer(); for (int i = 0; i < 10; i++) { int finalI = i; business.execute(new Runnable() { @Override public void run() { for (int i1 = 0; i1 < 10; i1++) { hashedWheelTimer.newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { log.info("====" + finalI); } }, finalI, TimeUnit.SECONDS); } } }); } } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/data/construct/ScheduledTest.java ================================================ package com.crossoverjie.cim.common.data.construct; import com.google.common.util.concurrent.ThreadFactoryBuilder; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; /** * Function: * * @author crossoverJie * Date: 2019-10-11 10:41 * @since JDK 1.8 */ @Slf4j public class ScheduledTest { public static void main(String[] args) { log.info("start....."); ThreadFactory scheduled = new ThreadFactoryBuilder() .setNameFormat("scheduled-%d") .build(); ScheduledThreadPoolExecutor scheduledExecutorService = new ScheduledThreadPoolExecutor(2, scheduled); scheduledExecutorService.schedule(() -> log.info("scheduled........."), 3, TimeUnit.SECONDS); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/data/construct/SortArrayMapTest.java ================================================ package com.crossoverjie.cim.common.data.construct; import java.util.SortedMap; import java.util.TreeMap; import org.junit.Test; public class SortArrayMapTest { int count = 1000000; @Test public void ad() { SortArrayMap map = new SortArrayMap(); for (int i = 0; i < 9; i++) { map.add(Long.valueOf(i), "127.0.0." + i); } map.print(); System.out.println(map.size()); } @Test public void add() { SortArrayMap map = new SortArrayMap(); for (int i = 0; i < 10; i++) { map.add(Long.valueOf(i), "127.0.0." + i); } map.print(); System.out.println(map.size()); } @Test public void add2() { SortArrayMap map = new SortArrayMap(); for (int i = 0; i < 20; i++) { map.add(Long.valueOf(i), "127.0.0." + i); } map.sort(); map.print(); System.out.println(map.size()); } @Test public void add3() { SortArrayMap map = new SortArrayMap(); map.add(100L, "127.0.0.100"); map.add(10L, "127.0.0.10"); map.add(8L, "127.0.0.8"); map.add(1000L, "127.0.0.1000"); map.print(); System.out.println(map.size()); } @Test public void firstNode() { SortArrayMap map = new SortArrayMap(); map.add(100L, "127.0.0.100"); map.add(10L, "127.0.0.10"); map.add(8L, "127.0.0.8"); map.add(1000L, "127.0.0.1000"); map.sort(); map.print(); String value = map.firstNodeValue(101); System.out.println(value); } @Test public void firstNode2() { SortArrayMap map = new SortArrayMap(); map.add(100L, "127.0.0.100"); map.add(10L, "127.0.0.10"); map.add(8L, "127.0.0.8"); map.add(1000L, "127.0.0.1000"); map.sort(); map.print(); String value = map.firstNodeValue(1); System.out.println(value); } @Test public void firstNode3() { SortArrayMap map = new SortArrayMap(); map.add(100L, "127.0.0.100"); map.add(10L, "127.0.0.10"); map.add(8L, "127.0.0.8"); map.add(1000L, "127.0.0.1000"); map.sort(); map.print(); String value = map.firstNodeValue(1001); System.out.println(value); } @Test public void firstNode4() { SortArrayMap map = new SortArrayMap(); map.add(100L, "127.0.0.100"); map.add(10L, "127.0.0.10"); map.add(8L, "127.0.0.8"); map.add(1000L, "127.0.0.1000"); map.sort(); map.print(); String value = map.firstNodeValue(9); System.out.println(value); } @Test public void add4() { SortArrayMap map = new SortArrayMap(); map.add(100L, "127.0.0.100"); map.add(10L, "127.0.0.10"); map.add(8L, "127.0.0.8"); map.add(1000L, "127.0.0.1000"); map.sort(); map.print(); System.out.println(map.size()); } @Test public void add5() { SortArrayMap map = new SortArrayMap(); long star = System.currentTimeMillis(); for (int i = 0; i < count; i++) { double d = Math.random(); int ran = (int) (d * 100); map.add(Long.valueOf(i + ran), "127.0.0." + i); } map.sort(); long end = System.currentTimeMillis(); System.out.println("排序耗时 " + (end - star)); System.out.println(map.size()); } @Test public void add6() { SortArrayMap map = new SortArrayMap(); long star = System.currentTimeMillis(); for (int i = 0; i < count; i++) { double d = Math.random(); int ran = (int) (d * 100); map.add(Long.valueOf(i + ran), "127.0.0." + i); } long end = System.currentTimeMillis(); System.out.println("不排耗时 " + (end - star)); System.out.println(map.size()); } @Test public void add7() { TreeMap treeMap = new TreeMap(); long star = System.currentTimeMillis(); for (int i = 0; i < count; i++) { double d = Math.random(); int ran = (int) (d * 100); treeMap.put(Long.valueOf(i + ran), "127.0.0." + i); } long end = System.currentTimeMillis(); System.out.println("耗时 " + (end - star)); System.out.println(treeMap.size()); } @Test public void add8() { TreeMap map = new TreeMap(); map.put(100L, "127.0.0.100"); map.put(10L, "127.0.0.10"); map.put(8L, "127.0.0.8"); map.put(1000L, "127.0.0.1000"); SortedMap last = map.tailMap(101L); if (!last.isEmpty()) { System.out.println(last.get(last.firstKey())); } else { System.out.println(map.firstEntry().getValue()); } } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/data/construct/TimerTest.java ================================================ package com.crossoverjie.cim.common.data.construct; import java.util.Timer; import java.util.TimerTask; import lombok.extern.slf4j.Slf4j; /** * Function: * * @author crossoverJie * Date: 2019-10-09 22:48 * @since JDK 1.8 */ @Slf4j public class TimerTest { public static void main(String[] args) { log.info("start"); Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { log.info("test"); } }, 50000); timer.schedule(new TimerTask() { @Override public void run() { log.info("test"); } }, 30000); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/data/construct/TrieTreeTest.java ================================================ package com.crossoverjie.cim.common.data.construct; import java.util.List; import org.junit.Assert; import org.junit.Test; public class TrieTreeTest { @Test public void insert() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("abc"); trieTree.insert("abcd"); } @Test public void all() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("ABC"); trieTree.insert("abC"); List all = trieTree.all(); String result = ""; for (String s : all) { result += s + ","; System.out.println(s); } Assert.assertTrue("ABC,abC,".equals(result)); } @Test public void all2() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("abc"); trieTree.insert("abC"); List all = trieTree.all(); String result = ""; for (String s : all) { result += s + ","; System.out.println(s); } //Assert.assertTrue("ABC,abC,".equals(result)); } @Test public void prefixSea() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("java"); trieTree.insert("jsf"); trieTree.insert("jsp"); trieTree.insert("javascript"); trieTree.insert("php"); String result = ""; List ab = trieTree.prefixSearch("jav"); for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("java,javascript,")); } @Test public void prefixSea2() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("java"); trieTree.insert("jsf"); trieTree.insert("jsp"); trieTree.insert("javascript"); trieTree.insert("php"); String result = ""; List ab = trieTree.prefixSearch("j"); for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("java,javascript,jsf,jsp,")); } @Test public void prefixSea3() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("java"); trieTree.insert("jsf"); trieTree.insert("jsp"); trieTree.insert("javascript"); trieTree.insert("php"); String result = ""; List ab = trieTree.prefixSearch("js"); for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("jsf,jsp,")); } @Test public void prefixSea4() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("java"); trieTree.insert("jsf"); trieTree.insert("jsp"); trieTree.insert("javascript"); trieTree.insert("php"); String result = ""; List ab = trieTree.prefixSearch("jav"); for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("java,javascript,")); } @Test public void prefixSea5() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("java"); trieTree.insert("jsf"); trieTree.insert("jsp"); trieTree.insert("javascript"); trieTree.insert("php"); String result = ""; List ab = trieTree.prefixSearch("js"); for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("jsf,jsp,")); } @Test public void prefixSearch() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("abc"); trieTree.insert("abd"); trieTree.insert("ABe"); List ab = trieTree.prefixSearch("AB"); for (String s : ab) { System.out.println(s); } System.out.println("========"); //char[] chars = new char[3] ; //for (int i = 0; i < 3; i++) { // int a = 97 + i ; // chars[i] = (char) a ; //} // //String s = String.valueOf(chars); //System.out.println(s); } @Test public void prefixSearch2() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); List ab = trieTree.prefixSearch("AC"); for (String s : ab) { System.out.println(s); } Assert.assertTrue(ab.size() == 0); } @Test public void prefixSearch3() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); List ab = trieTree.prefixSearch("CD"); for (String s : ab) { System.out.println(s); } Assert.assertTrue(ab.size() == 1); } @Test public void prefixSearch4() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); List ab = trieTree.prefixSearch("Cd"); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("Cde,")); } @Test public void prefixSearch44() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("a"); trieTree.insert("b"); trieTree.insert("c"); trieTree.insert("d"); trieTree.insert("e"); trieTree.insert("f"); trieTree.insert("g"); trieTree.insert("h"); } @Test public void prefixSearch5() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); trieTree.insert("CDfff"); trieTree.insert("Cdfff"); List ab = trieTree.prefixSearch("Cd"); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("Cde,Cdfff,")); } @Test public void prefixSearch6() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); trieTree.insert("CDfff"); trieTree.insert("Cdfff"); List ab = trieTree.prefixSearch("CD"); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("CDa,CDfff,")); } @Test public void prefixSearch7() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); trieTree.insert("CDfff"); trieTree.insert("Cdfff"); List ab = trieTree.prefixSearch(""); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("")); } @Test public void prefixSearch8() throws Exception { TrieTree trieTree = new TrieTree(); List ab = trieTree.prefixSearch(""); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("")); } @Test public void prefixSearch9() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("Cde"); trieTree.insert("CDa"); trieTree.insert("ABe"); trieTree.insert("CDfff"); trieTree.insert("Cdfff"); List ab = trieTree.prefixSearch("CDFD"); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("")); } @Test public void prefixSearch10() throws Exception { TrieTree trieTree = new TrieTree(); trieTree.insert("crossoverJie"); trieTree.insert("zhangsan"); List ab = trieTree.prefixSearch("c"); String result = ""; for (String s : ab) { result += s + ","; System.out.println(s); } Assert.assertTrue(result.equals("crossoverJie,")); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/enums/SystemCommandEnumTypeTest.java ================================================ package com.crossoverjie.cim.common.enums; import java.util.Map; import org.junit.Test; public class SystemCommandEnumTypeTest { @Test public void getAllStatusCode() throws Exception { Map allStatusCode = SystemCommandEnum.getAllStatusCode(); for (Map.Entry stringStringEntry : allStatusCode.entrySet()) { String key = stringStringEntry.getKey(); String value = stringStringEntry.getValue(); System.out.println(key + "----->" + value); } } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/metastore/MetaStoreTest.java ================================================ package com.crossoverjie.cim.common.metastore; import java.util.List; import java.util.concurrent.TimeUnit; import lombok.SneakyThrows; import org.I0Itec.zkclient.ZkClient; import org.apache.curator.framework.CuratorFramework; import org.apache.curator.framework.CuratorFrameworkFactory; import org.apache.curator.framework.api.CuratorWatcher; import org.apache.curator.retry.ExponentialBackoffRetry; import org.apache.zookeeper.CreateMode; import org.apache.zookeeper.Watcher; import org.apache.zookeeper.data.Stat; public class MetaStoreTest { private static final String CONNECTION_STRING = "127.0.0.1:2181"; // TODO: 2024/8/30 integration test @SneakyThrows // @Test public void testZk() { ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3); CuratorFramework client = CuratorFrameworkFactory.builder() .connectString(CONNECTION_STRING) .retryPolicy(retryPolicy) .connectionTimeoutMs(5000) .sessionTimeoutMs(5000) .build(); client.start(); Stat stat = client.checkExists().forPath("/cim"); if (stat == null) { create(client, "/cim", null); } List list = null; list = curatorWatcherGetChildren(client, "/cim", watchedEvent -> { String name = Thread.currentThread().getName(); System.out.println("watchedEvent = " + watchedEvent + " name = " + name); // try { // List children = watchedGetChildren(client, "/cim"); // System.out.println("children = " + children); // } catch (Exception e) { // throw new RuntimeException(e); // } }); System.out.println(list); // createEphemeral(client, "/cim/route1", null); // createEphemeral(client, "/cim/route2", null); TimeUnit.SECONDS.sleep(1000); } public static void createEphemeral(CuratorFramework client, String path, byte[] payload) throws Exception { // this will create the given EPHEMERAL ZNode with the given data client.create().withMode(CreateMode.EPHEMERAL).forPath(path, payload); } public static void create(CuratorFramework client, String path, byte[] payload) throws Exception { // this will create the given ZNode with the given data client.create().forPath(path, payload); } public static List watchedGetChildren(CuratorFramework client, String path) throws Exception { /** * Get children and set a watcher on the node. The watcher notification will come through the * CuratorListener (see setDataAsync() above). */ return client.getChildren().watched().forPath(path); } public static List watchedGetChildren(CuratorFramework client, String path, Watcher watcher) throws Exception { /** * Get children and set the given watcher on the node. */ return client.getChildren().usingWatcher(watcher).forPath(path); } public static List curatorWatcherGetChildren(CuratorFramework client, String path, CuratorWatcher watcher) throws Exception { /** * Get children and set the given watcher on the node. */ return client.getChildren().usingWatcher(watcher).forPath(path); } @SneakyThrows // @Test public void zkClientTest() { ZkClient zkClient = new ZkClient(CONNECTION_STRING, 5000); zkClient.subscribeChildChanges("/cim", (parentPath, currentChildren) -> { System.out.println("parentPath = " + parentPath); System.out.println("currentChildren = " + currentChildren); }); TimeUnit.SECONDS.sleep(1000); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/ConsistentHashHandleTest.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; class ConsistentHashHandleTest { @Test void removeSortMapExpireServer() { ConsistentHashHandle routeHandle = new ConsistentHashHandle(); routeHandle.setHash(new SortArrayMapConsistentHash()); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { var routeInfo = new RouteInfo("127.0.0." + i, 1000, 2000); strings.add(RouteInfoParseUtil.parse(routeInfo)); } RouteInfo routeInfo = new RouteInfo("127.0.0.9", 1000, 2000); String parse = RouteInfoParseUtil.parse(routeInfo); String r1 = routeHandle.routeServer(strings, parse); String r2 = routeHandle.routeServer(strings, parse); assertEquals(r1, r2); List list = routeHandle.removeExpireServer(routeInfo); boolean contains = list.contains(parse); assertFalse(contains); } @Test void removeTreeMapExpireServer() { ConsistentHashHandle routeHandle = new ConsistentHashHandle(); routeHandle.setHash(new TreeMapConsistentHash()); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { var routeInfo = new RouteInfo("127.0.0." + i, 1000, 2000); strings.add(RouteInfoParseUtil.parse(routeInfo)); } RouteInfo routeInfo = new RouteInfo("127.0.0.9", 1000, 2000); String parse = RouteInfoParseUtil.parse(routeInfo); String r1 = routeHandle.routeServer(strings, parse); String r2 = routeHandle.routeServer(strings, parse); assertEquals(r1, r2); List list = routeHandle.removeExpireServer(routeInfo); boolean contains = list.contains(parse); assertFalse(contains); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/RangeCheckTestUtil.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import org.junit.Assert; /** * @description: TODO * @author: zhangguoa * @date: 2024/9/12 9:58 * @project: cim */ public class RangeCheckTestUtil { public static void assertInRange(int value, int l, int r) { Assert.assertTrue(value >= l && value <= r); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/SortArrayMapConsistentHashTest.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import com.crossoverjie.cim.common.data.construct.SortArrayMap; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import org.junit.Assert; import org.junit.Test; public class SortArrayMapConsistentHashTest { @Test public void getFirstNodeValue() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process1 = map.process(strings, "zhangsan"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "zhangsan"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue2() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process1 = map.process(strings, "zhangsan2"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "zhangsan2"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue3() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process1 = map.process(strings, "1551253899106"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "1551253899106"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue4() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); strings.add("45.78.28.220:9000:8081"); strings.add("45.78.28.220:9100:9081"); String process1 = map.process(strings, "1551253899106"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "1551253899106"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue5() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); strings.add("45.78.28.220:9000:8081"); strings.add("45.78.28.220:9100:9081"); strings.add("45.78.28.220:9100:10081"); String process1 = map.process(strings, "1551253899106"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "1551253899106"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue6() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); strings.add("45.78.28.220:9000:8081"); strings.add("45.78.28.220:9100:9081"); strings.add("45.78.28.220:9100:10081"); String process1 = map.process(strings, "1551253899106"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "1551253899106"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue7() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); strings.add("45.78.28.220:9000:8081"); strings.add("45.78.28.220:9100:9081"); strings.add("45.78.28.220:9100:10081"); strings.add("45.78.28.220:9100:00081"); String process1 = map.process(strings, "1551253899106"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "1551253899106"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue8() { AbstractConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } Set processes = new HashSet<>(); for (int i = 0; i < 10; i++) { String process = map.process(strings, "zhangsan" + i); processes.add(process); } RangeCheckTestUtil.assertInRange(processes.size(), 2, 10); } @Test public void testVirtualNode() throws NoSuchFieldException, IllegalAccessException { SortArrayMapConsistentHash map = new SortArrayMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process = map.process(strings, "zhangsan"); SortArrayMap sortArrayMap = map.getSortArrayMap(); int virtualNodeSize = 2; System.out.println("sortArrayMapSize = " + sortArrayMap.size() + "\n" + "virtualNodeSize = " + virtualNodeSize); Assert.assertEquals(sortArrayMap.size(), (virtualNodeSize + 1) * 10); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/route/algorithm/consistenthash/TreeMapConsistentHashTest.java ================================================ package com.crossoverjie.cim.common.route.algorithm.consistenthash; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeMap; import org.junit.Assert; import org.junit.Test; public class TreeMapConsistentHashTest { @Test public void getFirstNodeValue() { AbstractConsistentHash map = new TreeMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process1 = map.process(strings, "zhangsan"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "zhangsan"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue2() { AbstractConsistentHash map = new TreeMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process1 = map.process(strings, "zhangsan2"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "zhangsan2"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue3() { AbstractConsistentHash map = new TreeMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process1 = map.process(strings, "1551253899106"); for (int i = 0; i < 100; i++) { String process = map.process(strings, "1551253899106"); Assert.assertEquals(process1, process); } } @Test public void getFirstNodeValue4() { AbstractConsistentHash map = new TreeMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } Set processes = new HashSet<>(); for (int i = 0; i < 10; i++) { String process = map.process(strings, "zhangsan" + i); processes.add(process); } RangeCheckTestUtil.assertInRange(processes.size(), 2, 10); } @Test public void testVirtualNode() throws NoSuchFieldException, IllegalAccessException { TreeMapConsistentHash map = new TreeMapConsistentHash(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { strings.add("127.0.0." + i); } String process = map.process(strings, "zhangsan"); TreeMap treeMap = map.getTreeMap(); int virtualNodeSize = 2; System.out.println("treeMapSize = " + treeMap.size() + "\n" + "virtualNodeSize = " + virtualNodeSize); Assert.assertEquals(treeMap.size(), (virtualNodeSize + 1) * 10); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/route/algorithm/loop/LoopHandleTest.java ================================================ package com.crossoverjie.cim.common.route.algorithm.loop; import static org.junit.jupiter.api.Assertions.*; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; class LoopHandleTest { @Test void removeExpireServer() { RouteHandle routeHandle = new LoopHandle(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { var routeInfo = new RouteInfo("127.0.0." + i, 1000, 2000); strings.add(RouteInfoParseUtil.parse(routeInfo)); } String zs = routeHandle.routeServer(strings, "zs"); String zs2 = routeHandle.routeServer(strings, "zs"); assertNotEquals(zs, zs2); RouteInfo remove = new RouteInfo("127.0.0.0", 1000, 2000); List list = routeHandle.removeExpireServer(remove); boolean contains = list.contains(RouteInfoParseUtil.parse(remove)); assertFalse(contains); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/route/algorithm/random/RandomHandleTest.java ================================================ package com.crossoverjie.cim.common.route.algorithm.random; import static org.junit.jupiter.api.Assertions.*; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; class RandomHandleTest { @Test void removeExpireServer() { RouteHandle routeHandle = new RandomHandle(); List strings = new ArrayList<>(); for (int i = 0; i < 10; i++) { var routeInfo = new RouteInfo("127.0.0." + i, 1000, 2000); strings.add(RouteInfoParseUtil.parse(routeInfo)); } routeHandle.routeServer(strings, "zs"); RouteInfo remove = new RouteInfo("127.0.0.0", 1000, 2000); List list = routeHandle.removeExpireServer(remove); boolean contains = list.contains(RouteInfoParseUtil.parse(remove)); assertFalse(contains); } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/util/HttpClientTest.java ================================================ package com.crossoverjie.cim.common.util; import com.alibaba.fastjson.JSONObject; import java.io.IOException; import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; import org.junit.Before; import org.junit.Test; public class HttpClientTest { private OkHttpClient okHttpClient; @Before public void before() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(30, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(true); okHttpClient = builder.build(); } @Test public void call() throws IOException { JSONObject jsonObject = new JSONObject(); jsonObject.put("msg", "hello"); jsonObject.put("userId", 1586617710861L); // TODO: 2024/8/30 Integration test // HttpClient.call(okHttpClient,jsonObject.toString(),"http://127.0.0.1:8081/sendMsg") ; } } ================================================ FILE: cim-common/src/test/java/com/crossoverjie/cim/common/util/ProtocolTest.java ================================================ package com.crossoverjie.cim.common.util; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.protocol.Request; import com.google.protobuf.InvalidProtocolBufferException; import org.junit.Test; public class ProtocolTest { @Test public void testProtocol() throws InvalidProtocolBufferException { Request protocol = Request.newBuilder() .setRequestId(123L) .setReqMsg("你好啊") .setCmd(BaseCommand.LOGIN_REQUEST) .build(); byte[] encode = encode(protocol); Request parseFrom = decode(encode); System.out.println(protocol); System.out.println(protocol.toString().equals(parseFrom.toString())); } /** * 编码 * @param protocol protocol * @return byte array */ public static byte[] encode(Request protocol) { return protocol.toByteArray(); } /** * 解码 * @param bytes bytes * @return Request * @throws InvalidProtocolBufferException exception */ public static Request decode(byte[] bytes) throws InvalidProtocolBufferException { return Request.parseFrom(bytes); } } ================================================ FILE: cim-forward-route/pom.xml ================================================ cim com.crossoverjie.netty 1.0.0-SNAPSHOT 4.0.0 cim-forward-route jar UTF-8 UTF-8 17 2.5.0 com.crossoverjie.netty cim-persistence-api ${project.version} com.crossoverjie.netty cim-persistence-mysql ${project.version} com.crossoverjie.netty cim-persistence-redis ${project.version} com.crossoverjie.netty cim-common com.crossoverjie.netty cim-rout-api com.crossoverjie.netty cim-server-api org.springframework.boot spring-boot-starter-data-redis org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine test org.junit.jupiter junit-jupiter test com.clever-cloud testcontainers-zookeeper com.redis testcontainers-redis test org.testcontainers mysql test org.testcontainers testcontainers org.testcontainers junit-jupiter org.springframework.boot spring-boot-configuration-processor true org.springframework.boot spring-boot-starter-actuator io.netty netty-all junit junit com.alibaba fastjson ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/RouteApplication.java ================================================ package com.crossoverjie.cim.route; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.context.annotation.ComponentScan; /** * @author crossoverJie */ @Slf4j @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) @ComponentScan(basePackages = { "com.crossoverjie.cim.route", "com.crossoverjie.cim.persistence" }) public class RouteApplication implements CommandLineRunner { public static void main(String[] args) { SpringApplication.run(RouteApplication.class, args); log.info("Start cim route success!!!"); } @Override public void run(String... args) throws Exception { } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/config/AppConfiguration.java ================================================ package com.crossoverjie.cim.route.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 2018/8/24 01:43 * @since JDK 1.8 */ @Component public class AppConfiguration { @Value("${app.zk.root}") private String zkRoot; @Value("${app.zk.addr}") private String zkAddr; @Value("${server.port}") private int port; @Value("${app.zk.connect.timeout}") private int zkConnectTimeout; @Value("${app.route.way.handler}") private String routeWay; @Value("${app.route.way.consitenthash}") private String consistentHashWay; public int getZkConnectTimeout() { return zkConnectTimeout; } public int getPort() { return port; } public void setPort(int port) { this.port = port; } public String getZkRoot() { return zkRoot; } public void setZkRoot(String zkRoot) { this.zkRoot = zkRoot; } public String getZkAddr() { return zkAddr; } public void setZkAddr(String zkAddr) { this.zkAddr = zkAddr; } public String getRouteWay() { return routeWay; } public void setRouteWay(String routeWay) { this.routeWay = routeWay; } public String getConsistentHashWay() { return consistentHashWay; } public void setConsistentHashWay(String consistentHashWay) { this.consistentHashWay = consistentHashWay; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/config/BeanConfig.java ================================================ package com.crossoverjie.cim.route.config; import com.crossoverjie.cim.common.core.proxy.RpcProxyManager; import com.crossoverjie.cim.common.metastore.MetaStore; import com.crossoverjie.cim.common.metastore.ZkConfiguration; import com.crossoverjie.cim.common.metastore.ZkMetaStoreImpl; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.route.algorithm.consistenthash.AbstractConsistentHash; import com.crossoverjie.cim.common.util.SnowflakeIdWorker; import com.crossoverjie.cim.server.api.ServerApi; import com.github.benmanes.caffeine.cache.CacheLoader; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.LoadingCache; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import org.apache.curator.retry.ExponentialBackoffRetry; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; import java.lang.reflect.Method; import java.util.Optional; import java.util.concurrent.TimeUnit; import static com.crossoverjie.cim.route.constant.Constant.ACCOUNT_PREFIX; /** * Function: * * @author crossoverJie * Date: 2018/12/23 00:25 * @since JDK 1.8 */ @Configuration @Slf4j public class BeanConfig { @Resource private AppConfiguration appConfiguration; @Bean public MetaStore metaStore() throws Exception { MetaStore metaStore = new ZkMetaStoreImpl(); ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3); metaStore.initialize(ZkConfiguration.builder() .metaServiceUri(appConfiguration.getZkAddr()) .timeoutMs(appConfiguration.getZkConnectTimeout()) .retryPolicy(retryPolicy) .build()); metaStore.listenServerList((root, currentChildren) -> { log.info("Server list change, root=[{}], current server list=[{}]", root, currentChildren); }); return metaStore; } /** * Redis bean * * @param factory * @return */ @Bean public RedisTemplate redisTemplate(RedisConnectionFactory factory) { StringRedisTemplate redisTemplate = new StringRedisTemplate(factory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new StringRedisSerializer()); redisTemplate.afterPropertiesSet(); return redisTemplate; } /** * http client * * @return okHttp */ @Bean public OkHttpClient okHttpClient() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(30, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(true); return builder.build(); } @Bean public RouteHandle buildRouteHandle() throws Exception { String routeWay = appConfiguration.getRouteWay(); RouteHandle routeHandle = (RouteHandle) Class.forName(routeWay).newInstance(); log.info("Current route algorithm is [{}]", routeHandle.getClass().getSimpleName()); if (routeWay.contains("ConsistentHash")) { //一致性 hash 算法 Method method = Class.forName(routeWay).getMethod("setHash", AbstractConsistentHash.class); AbstractConsistentHash consistentHash = (AbstractConsistentHash) Class.forName(appConfiguration.getConsistentHashWay()).newInstance(); method.invoke(routeHandle, consistentHash); return routeHandle; } else { return routeHandle; } } @Bean("userInfoCache") public LoadingCache> userInfoCache(RedisTemplate redisTemplate) { return Caffeine.newBuilder() .initialCapacity(64) .maximumSize(1024) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<>() { @Override public Optional load(Long userId) throws Exception { String sendUserName = redisTemplate.opsForValue().get(ACCOUNT_PREFIX + userId); if (sendUserName == null) { return Optional.empty(); } CIMUserInfo cimUserInfo = new CIMUserInfo(userId, sendUserName); return Optional.of(cimUserInfo); } }); } @Bean public ServerApi serverApi(OkHttpClient okHttpClient) { return RpcProxyManager.create(ServerApi.class, okHttpClient); } @Bean public SnowflakeIdWorker snowflakeIdWorker() { return new SnowflakeIdWorker(); } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/config/MySqlPersistenceConfig.java ================================================ package com.crossoverjie.cim.route.config; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; /** * @author zhongcanyu * @date 2025/5/31 * @description */ @Configuration @ConditionalOnProperty(prefix = "offline.store", name = "mode", havingValue = "mysql") @MapperScan(basePackages = "com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper") @Import(DataSourceAutoConfiguration.class) public class MySqlPersistenceConfig { } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/config/OfflineMsgStoreConfig.java ================================================ package com.crossoverjie.cim.route.config; import com.crossoverjie.cim.persistence.mysql.offlinemsg.OfflineMsgDb; import com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper.OfflineMsgLastSendRecordMapper; import com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper.OfflineMsgMapper; import com.crossoverjie.cim.persistence.redis.OfflineMsgBuffer; import com.crossoverjie.cim.persistence.redis.kit.OfflineMsgScriptExecutor; import com.crossoverjie.cim.route.constant.Constant; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author zhongcanyu * @date 2025/5/18 * @description */ @Configuration public class OfflineMsgStoreConfig { @Bean @ConditionalOnProperty(name = "offline.store.mode", havingValue = Constant.OfflineStoreMode.MYSQL) public OfflineMsgDb offlineMsgDbStore(OfflineMsgMapper offlineMsgMapper, OfflineMsgLastSendRecordMapper offlineMsgLastSendRecordMapper) { return new OfflineMsgDb(offlineMsgMapper, offlineMsgLastSendRecordMapper); } @Bean @ConditionalOnProperty(name = "offline.store.mode", havingValue = Constant.OfflineStoreMode.REDIS) public OfflineMsgBuffer offlineMsgBufferStore(OfflineMsgScriptExecutor scriptExecutor, @Value("${offline.store.redis.expire.message-ttl-days}") Integer configuredDays, ObjectMapper objectMapper) { return new OfflineMsgBuffer(scriptExecutor, configuredDays, objectMapper); } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/config/SwaggerConfig.java ================================================ package com.crossoverjie.cim.route.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { @Bean public OpenAPI createRestApi() { return new OpenAPI() .info(apiInfo()); } private Info apiInfo() { return new Info() .title("cim-forward-route") .description("cim-forward-route api") .termsOfService("http://crossoverJie.top") .contact(contact()) .version("1.0.0"); } private Contact contact() { Contact contact = new Contact(); contact.setName("crossoverJie"); return contact; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/constant/Constant.java ================================================ package com.crossoverjie.cim.route.constant; /** * Function: * * @author crossoverJie * Date: 2018/9/10 14:07 * @since JDK 1.8 */ public final class Constant { /** * 账号前缀 */ public static final String ACCOUNT_PREFIX = "cim-account:"; /** * 路由信息前缀 */ public static final String ROUTE_PREFIX = "cim-route:"; /** * 登录状态前缀 */ public static final String LOGIN_STATUS_PREFIX = "login-status"; public static final class OfflineStoreMode { /** * redis */ public static final String REDIS = "redis"; /** * mysql */ public static final String MYSQL = "mysql"; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/controller/RouteController.java ================================================ package com.crossoverjie.cim.route.controller; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.metastore.MetaStore; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.common.res.NULLBody; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import com.crossoverjie.cim.route.api.RouteApi; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.req.OfflineMsgReqVO; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.req.RegisterInfoReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.api.vo.res.RegisterInfoResVO; import com.crossoverjie.cim.route.service.AccountService; import com.crossoverjie.cim.route.service.CommonBizService; import com.crossoverjie.cim.route.service.OfflineMsgService; import com.crossoverjie.cim.route.service.UserInfoCacheService; import com.crossoverjie.cim.server.api.ServerApi; import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import static com.crossoverjie.cim.common.enums.StatusEnum.OFF_LINE; /** * Function: * * @author crossoverJie * Date: 22/05/2018 14:46 * @since JDK 1.8 */ @Slf4j @Controller @RequestMapping("/") public class RouteController implements RouteApi { @Resource private MetaStore metaStore; @Resource private AccountService accountService; @Resource private UserInfoCacheService userInfoCacheService; @Resource private CommonBizService commonBizService; @Resource private RouteHandle routeHandle; @Resource private ServerApi serverApi; @Resource private OfflineMsgService offlineMsgService; @Operation(summary = "群聊 API") @RequestMapping(value = "groupRoute", method = RequestMethod.POST) @ResponseBody() @Override public BaseResponse groupRoute(@RequestBody ChatReqVO groupReqVO) { BaseResponse res = new BaseResponse(); log.info("msg=[{}]", groupReqVO.toString()); Map serverResVoMap = accountService.loadRouteRelated(); for (Map.Entry cimServerResVoEntry : serverResVoMap.entrySet()) { Long userId = cimServerResVoEntry.getKey(); CIMServerResVO cimServerResVO = cimServerResVoEntry.getValue(); if (userId.equals(groupReqVO.getUserId())) { // Skip the sender Optional cimUserInfo = userInfoCacheService.loadUserInfoByUserId(groupReqVO.getUserId()); cimUserInfo.ifPresent(userInfo -> log.warn("skip send user userId={}", userInfo)); continue; } // Push message ChatReqVO chatVO = new ChatReqVO(userId, groupReqVO.getMsg(), null); accountService.pushMsg(cimServerResVO, groupReqVO.getUserId(), chatVO); } res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); return res; } /** * 私聊路由 * * @param p2pRequest * @return */ @Operation(summary = "私聊 API") @RequestMapping(value = "p2pRoute", method = RequestMethod.POST) @ResponseBody() @Override public BaseResponse p2pRoute(@RequestBody P2PReqVO p2pRequest) { BaseResponse res = new BaseResponse(); try { //获取接收消息用户的路由信息 Optional cimServerResVO = accountService.loadRouteRelatedByUserId(p2pRequest.getReceiveUserId()); if (cimServerResVO.isEmpty()) { log.warn("userId={} not online, save offline msg", p2pRequest.getReceiveUserId()); offlineMsgService.saveOfflineMsg(p2pRequest); throw new CIMException(OFF_LINE); } //p2pRequest.getReceiveUserId()==>消息接收者的 userID ChatReqVO chatVO = new ChatReqVO(p2pRequest.getReceiveUserId(), p2pRequest.getMsg(), p2pRequest.getBatchMsg()); accountService.pushMsg(cimServerResVO.get(), p2pRequest.getUserId(), chatVO); res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); } catch (CIMException e) { res.setCode(e.getErrorCode()); res.setMessage(e.getErrorMessage()); } return res; } @Operation(summary = "客户端下线") @RequestMapping(value = "offLine", method = RequestMethod.POST) @ResponseBody() @Override public BaseResponse offLine(@RequestBody ChatReqVO chatReqVO) { BaseResponse res = new BaseResponse(); Optional cimUserInfo = userInfoCacheService.loadUserInfoByUserId(chatReqVO.getUserId()); cimUserInfo.ifPresent(userInfo -> { log.info("user [{}] offline!", userInfo); accountService.offLine(chatReqVO.getUserId()); }); res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); return res; } /** * 获取一台 CIM server * * @return */ @Operation(summary = "登录并获取服务器") @RequestMapping(value = "login", method = RequestMethod.POST) @ResponseBody() @Override public BaseResponse login(@RequestBody LoginReqVO loginReqVO) throws Exception { BaseResponse res = new BaseResponse(); //登录校验 StatusEnum status = accountService.login(loginReqVO); res.setCode(status.getCode()); res.setMessage(status.getMessage()); if (status != StatusEnum.SUCCESS) { return res; } // check server available Set availableServerList = metaStore.getAvailableServerList(); String key = String.valueOf(loginReqVO.getUserId()); String server = routeHandle.routeServer(List.copyOf(availableServerList), key); log.info("userInfo=[{}] route server info=[{}]", loginReqVO, server); RouteInfo routeInfo = RouteInfoParseUtil.parse(server); routeInfo = commonBizService.checkServerAvailable(routeInfo, key); //保存路由信息 accountService.saveRouteInfo(loginReqVO, server); CIMServerResVO vo = new CIMServerResVO(routeInfo.getIp(), routeInfo.getCimServerPort(), routeInfo.getHttpPort()); res.setDataBody(vo); return res; } /** * 注册账号 * * @return */ @Operation(summary = "注册账号") @RequestMapping(value = "registerAccount", method = RequestMethod.POST) @ResponseBody() @Override public BaseResponse registerAccount(@RequestBody RegisterInfoReqVO registerInfoReqVO) throws Exception { BaseResponse res = new BaseResponse(); long userId = System.currentTimeMillis(); RegisterInfoResVO info = new RegisterInfoResVO(userId, registerInfoReqVO.getUserName()); info = accountService.register(info); res.setDataBody(info); res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); return res; } /** * 获取所有在线用户 * * @return */ @Operation(summary = "获取所有在线用户") @RequestMapping(value = "onlineUser", method = RequestMethod.GET) @ResponseBody() @Override public BaseResponse> onlineUser() throws Exception { BaseResponse> res = new BaseResponse(); Set cimUserInfos = userInfoCacheService.onlineUser(); res.setDataBody(cimUserInfos); res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); return res; } @Operation(summary = "Client fetch offline messages") @RequestMapping(value = "fetchOfflineMsgs", method = RequestMethod.POST) @ResponseBody() @Override public BaseResponse fetchOfflineMsgs(@RequestBody OfflineMsgReqVO offlineMsgReqVO) { BaseResponse res = new BaseResponse(); try { // Get the routing information of the user receiving the message Optional cimServerResVO = accountService.loadRouteRelatedByUserId(offlineMsgReqVO.getReceiveUserId()); cimServerResVO.ifPresent(cimServerRes -> { offlineMsgService.fetchOfflineMsgs(cimServerRes, offlineMsgReqVO.getReceiveUserId()); }); res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); } catch (CIMException e) { res.setCode(e.getErrorCode()); res.setMessage(e.getErrorMessage()); } return res; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/exception/ExceptionHandlingController.java ================================================ package com.crossoverjie.cim.route.exception; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.res.BaseResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; /** * Function: * * @author crossoverJie * Date: 2020-04-12 22:13 * @since JDK 1.8 */ @Slf4j @ControllerAdvice public class ExceptionHandlingController { @ExceptionHandler(CIMException.class) @ResponseBody() public BaseResponse handleAllExceptions(CIMException ex) { log.error("exception", ex); BaseResponse baseResponse = new BaseResponse(); baseResponse.setCode(ex.getErrorCode()); baseResponse.setMessage(ex.getMessage()); return baseResponse; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/factory/OfflineMsgFactory.java ================================================ package com.crossoverjie.cim.route.factory; import com.crossoverjie.cim.common.constant.Constants; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import com.crossoverjie.cim.common.util.SnowflakeIdWorker; import com.crossoverjie.cim.persistence.api.vo.req.SaveOfflineMsgReqVO; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import static com.crossoverjie.cim.common.constant.Constants.MSG_TYPE_TEXT; import static com.crossoverjie.cim.common.constant.Constants.OFFLINE_MSG_PENDING; @Service public class OfflineMsgFactory { private final SnowflakeIdWorker idWorker; public OfflineMsgFactory(SnowflakeIdWorker idWorker) { this.idWorker = idWorker; } public OfflineMsg createFromVo(SaveOfflineMsgReqVO vo) { try { Long msgId = idWorker.nextId(); return OfflineMsg.builder() .messageId(msgId) .receiveUserId(vo.getReceiveUserId()) .content(vo.getMsg()) .messageType(MSG_TYPE_TEXT) .status(OFFLINE_MSG_PENDING) .createdAt(LocalDateTime.now()) .properties(createPropertiesMap(vo)) .build(); } catch (Exception e) { throw new CIMException("Failed to create OfflineMsg from SaveOfflineMsgReqVO", e); } } private Map createPropertiesMap(SaveOfflineMsgReqVO vo) { Map sourceProps = vo.getProperties(); if (sourceProps == null) { return Map.of(); } Map properties = new HashMap<>(); properties.put(Constants.MetaKey.SEND_USER_ID, sourceProps.get(Constants.MetaKey.SEND_USER_ID)); properties.put(Constants.MetaKey.SEND_USER_NAME, sourceProps.get(Constants.MetaKey.SEND_USER_NAME)); properties.put(Constants.MetaKey.RECEIVE_USER_ID, sourceProps.get(Constants.MetaKey.RECEIVE_USER_ID)); return properties; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/kit/NetAddressIsReachable.java ================================================ package com.crossoverjie.cim.route.kit; import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; import lombok.extern.slf4j.Slf4j; /** * Function: * * @author crossoverJie * Date: 2020-04-12 20:32 * @since JDK 1.8 */ @Slf4j public class NetAddressIsReachable { /** * check ip and port * * @param address * @param port * @param timeout * @return True if connection successful */ public static boolean checkAddressReachable(String address, int port, int timeout) { Socket socket = new Socket(); try { socket.connect(new InetSocketAddress(address, port), timeout); return true; } catch (IOException exception) { return false; } finally { try { socket.close(); } catch (IOException e) { log.warn("close socket error", e); } } } private NetAddressIsReachable() { } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/AccountService.java ================================================ package com.crossoverjie.cim.route.service; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.api.vo.res.RegisterInfoResVO; import java.util.Map; import java.util.Optional; /** * Function: 账户服务 * * @author crossoverJie * Date: 2018/12/23 21:57 * @since JDK 1.8 */ public interface AccountService { /** * 注册用户 * @param info 用户信息 * @return * @throws Exception */ RegisterInfoResVO register(RegisterInfoResVO info) throws Exception; /** * 登录服务 * @param loginReqVO 登录信息 * @return true 成功 false 失败 * @throws Exception */ StatusEnum login(LoginReqVO loginReqVO) throws Exception; /** * 保存路由信息 * @param msg 服务器信息 * @param loginReqVO 用户信息 * @throws Exception */ void saveRouteInfo(LoginReqVO loginReqVO, String msg) throws Exception; /** * 加载所有用户的路有关系 * @return 所有的路由关系 */ Map loadRouteRelated(); /** * Get user route info * @param userId * @return route info */ Optional loadRouteRelatedByUserId(Long userId); /** * 推送消息 * @param cimServerResVO * @param groupReqVO 消息 * @param sendUserId 发送者的ID * @throws Exception */ void pushMsg(CIMServerResVO cimServerResVO, long sendUserId, ChatReqVO groupReqVO); /** * 用户下线 * @param userId 下线用户ID * @throws Exception */ void offLine(Long userId); } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/CommonBizService.java ================================================ package com.crossoverjie.cim.route.service; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.route.algorithm.RouteHandle; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import com.crossoverjie.cim.route.kit.NetAddressIsReachable; import jakarta.annotation.Resource; import java.util.List; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 2020-04-12 21:40 * @since JDK 1.8 */ @Component @Slf4j public class CommonBizService { @Resource private RouteHandle routeHandle; /** * check ip and port, and return a new server if the current server is not available * @param routeInfo */ @SneakyThrows public RouteInfo checkServerAvailable(RouteInfo routeInfo, String userId) { boolean reachable = NetAddressIsReachable.checkAddressReachable(routeInfo.getIp(), routeInfo.getCimServerPort(), 1000); if (!reachable) { log.error("ip={}, port={} are not available, remove it.", routeInfo.getIp(), routeInfo.getCimServerPort()); List list = routeHandle.removeExpireServer(routeInfo); String routeServer = routeHandle.routeServer(list, userId); log.info("Reselect new server:[{}]", routeServer); return RouteInfoParseUtil.parse(routeServer); } return routeInfo; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/OfflineMsgService.java ================================================ package com.crossoverjie.cim.route.service; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; /** * Offline message push service */ public interface OfflineMsgService { /** * fetch offline messages * @param cimServerResVO * @param receiveUserId */ void fetchOfflineMsgs(CIMServerResVO cimServerResVO, Long receiveUserId); /** * save offline message * @param p2pRequest */ void saveOfflineMsg(P2PReqVO p2pRequest); } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/UserInfoCacheService.java ================================================ package com.crossoverjie.cim.route.service; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import java.util.Optional; import java.util.Set; /** * Function: * * @author crossoverJie * Date: 2018/12/24 11:06 * @since JDK 1.8 */ public interface UserInfoCacheService { /** * 通过 userID 获取用户信息 * @param userId 用户唯一 ID * @return * @throws Exception */ Optional loadUserInfoByUserId(Long userId); /** * 保存和检查用户登录情况 * @param userId userId 用户唯一 ID * @return true 为可以登录 false 为已经登录 * @throws Exception */ boolean saveAndCheckUserLoginStatus(Long userId) throws Exception; /** * query all online user * @return online user */ Set onlineUser(); } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/impl/AccountServiceRedisImpl.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.crossoverjie.cim.common.constant.Constants; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.pojo.RouteInfo; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.util.RouteInfoParseUtil; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.api.vo.res.RegisterInfoResVO; import com.crossoverjie.cim.route.service.AccountService; import com.crossoverjie.cim.route.service.UserInfoCacheService; import com.crossoverjie.cim.server.api.ServerApi; import com.crossoverjie.cim.server.api.vo.req.SendMsgReqVO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Optional; import static com.crossoverjie.cim.route.constant.Constant.ACCOUNT_PREFIX; import static com.crossoverjie.cim.route.constant.Constant.LOGIN_STATUS_PREFIX; import static com.crossoverjie.cim.route.constant.Constant.ROUTE_PREFIX; /** * Function: * * @author crossoverJie * Date: 2018/12/23 21:58 * @since JDK 1.8 */ @Slf4j @Service public class AccountServiceRedisImpl implements AccountService { @Resource private RedisTemplate redisTemplate; @Resource private UserInfoCacheService userInfoCacheService; @Resource private ServerApi serverApi; @Override public RegisterInfoResVO register(RegisterInfoResVO info) { String key = ACCOUNT_PREFIX + info.getUserId(); String name = redisTemplate.opsForValue().get(info.getUserName()); if (null == name) { //为了方便查询,冗余一份 redisTemplate.opsForValue().set(key, info.getUserName()); redisTemplate.opsForValue().set(info.getUserName(), key); } else { long userId = Long.parseLong(name.split(":")[1]); info.setUserId(userId); info.setUserName(info.getUserName()); } return info; } @Override public StatusEnum login(LoginReqVO loginReqVO) throws Exception { //再去Redis里查询 String key = ACCOUNT_PREFIX + loginReqVO.getUserId(); String userName = redisTemplate.opsForValue().get(key); if (null == userName) { return StatusEnum.ACCOUNT_NOT_MATCH; } if (!userName.equals(loginReqVO.getUserName())) { return StatusEnum.ACCOUNT_NOT_MATCH; } //登录成功,保存登录状态 boolean status = userInfoCacheService.saveAndCheckUserLoginStatus(loginReqVO.getUserId()); if (!status) { //重复登录 return StatusEnum.REPEAT_LOGIN; } return StatusEnum.SUCCESS; } @Override public void saveRouteInfo(LoginReqVO loginReqVO, String msg) throws Exception { String key = ROUTE_PREFIX + loginReqVO.getUserId(); redisTemplate.opsForValue().set(key, msg); } @Override public Map loadRouteRelated() { Map routes = new HashMap<>(64); RedisConnection connection = redisTemplate.getConnectionFactory().getConnection(); ScanOptions options = ScanOptions.scanOptions() .match(ROUTE_PREFIX + "*") .build(); Cursor scan = connection.scan(options); while (scan.hasNext()) { byte[] next = scan.next(); String key = new String(next, StandardCharsets.UTF_8); log.info("key={}", key); parseServerInfo(routes, key); } scan.close(); return routes; } @Override public Optional loadRouteRelatedByUserId(Long userId) { String value = redisTemplate.opsForValue().get(ROUTE_PREFIX + userId); if (value == null) { return Optional.empty(); } RouteInfo parse = RouteInfoParseUtil.parse(value); CIMServerResVO cimServerResVO = new CIMServerResVO(parse.getIp(), parse.getCimServerPort(), parse.getHttpPort()); return Optional.of(cimServerResVO); } private void parseServerInfo(Map routes, String key) { long userId = Long.valueOf(key.split(":")[1]); String value = redisTemplate.opsForValue().get(key); RouteInfo parse = RouteInfoParseUtil.parse(value); CIMServerResVO cimServerResVO = new CIMServerResVO(parse.getIp(), parse.getCimServerPort(), parse.getHttpPort()); routes.put(userId, cimServerResVO); } @Override public void pushMsg(CIMServerResVO cimServerResVO, long sendUserId, ChatReqVO chatReqVO) { Optional cimUserInfo = userInfoCacheService.loadUserInfoByUserId(sendUserId); cimUserInfo.ifPresent(sendUserInfo -> { String url = "http://" + cimServerResVO.getIp() + ":" + cimServerResVO.getHttpPort(); SendMsgReqVO vo = new SendMsgReqVO(chatReqVO.getMsg(), chatReqVO.getUserId(), chatReqVO.getBatchMsg(), BaseCommand.MESSAGE); vo.setProperties(Map.of( Constants.MetaKey.SEND_USER_ID, String.valueOf(sendUserId), Constants.MetaKey.SEND_USER_NAME, sendUserInfo.getUserName(), Constants.MetaKey.RECEIVE_USER_ID, String.valueOf(chatReqVO.getUserId())) ); serverApi.sendMsg(vo, url); }); } @Override public void offLine(Long userId) { DefaultRedisScript redisScript = new DefaultRedisScript(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/offLine.lua"))); redisTemplate.execute(redisScript, Collections.singletonList(ROUTE_PREFIX + userId), LOGIN_STATUS_PREFIX, userId.toString()); } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/impl/OfflineMsgServiceImpl.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.crossoverjie.cim.common.constant.Constants; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import com.crossoverjie.cim.persistence.api.service.OfflineMsgStore; import com.crossoverjie.cim.persistence.api.vo.req.SaveOfflineMsgReqVO; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.factory.OfflineMsgFactory; import com.crossoverjie.cim.route.service.OfflineMsgService; import com.crossoverjie.cim.route.service.UserInfoCacheService; import com.crossoverjie.cim.server.api.ServerApi; import com.crossoverjie.cim.server.api.vo.req.SendMsgReqVO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @Slf4j @Service public class OfflineMsgServiceImpl implements OfflineMsgService { @Resource private OfflineMsgStore offlineMsgStore; @Resource private OfflineMsgFactory offlineMsgFactory; @Resource private UserInfoCacheService userInfoCacheService; @Resource private ServerApi serverApi; @Override public void fetchOfflineMsgs(CIMServerResVO cimServerResVO, Long receiveUserId) { String url = "http://" + cimServerResVO.getIp() + ":" + cimServerResVO.getHttpPort(); while (true) { List offlineMsgs = offlineMsgStore.fetch(receiveUserId); if (offlineMsgs == null || offlineMsgs.isEmpty()) { break; } offlineMsgs.sort(Comparator.comparing(OfflineMsg::getCreatedAt)); SendMsgReqVO msgReqVO = SendMsgReqVO .builder() .userId(receiveUserId) .cmd(BaseCommand.OFFLINE).batchMsg(offlineMsgs.stream().map(OfflineMsg::getContent).toList()) .properties(offlineMsgs.get(0).getProperties()) .build(); serverApi.sendMsg(msgReqVO, url); offlineMsgStore.markDelivered(receiveUserId, offlineMsgs.stream().map(OfflineMsg::getMessageId).toList()); } } @Override public void saveOfflineMsg(P2PReqVO p2pRequest) { Optional cimUserInfo = userInfoCacheService.loadUserInfoByUserId(p2pRequest.getUserId()); cimUserInfo.ifPresent(userInfo -> { SaveOfflineMsgReqVO saveOfflineMsgReqVO = SaveOfflineMsgReqVO.builder() .msg(p2pRequest.getMsg()) .receiveUserId(p2pRequest.getReceiveUserId()) .properties(Map.of( Constants.MetaKey.SEND_USER_ID, userInfo.getUserId().toString(), Constants.MetaKey.SEND_USER_NAME, userInfo.getUserName(), Constants.MetaKey.RECEIVE_USER_ID, p2pRequest.getReceiveUserId().toString() )).build(); OfflineMsg offlineMsg = offlineMsgFactory.createFromVo(saveOfflineMsgReqVO); offlineMsgStore.save(offlineMsg); }); } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/service/impl/UserInfoCacheServiceImpl.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.route.service.UserInfoCacheService; import com.github.benmanes.caffeine.cache.LoadingCache; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.HashSet; import java.util.Optional; import java.util.Set; import static com.crossoverjie.cim.route.constant.Constant.LOGIN_STATUS_PREFIX; /** * Function: * * @author crossoverJie * Date: 2018/12/24 11:06 * @since JDK 1.8 */ @Slf4j @Service public class UserInfoCacheServiceImpl implements UserInfoCacheService { @Autowired private RedisTemplate redisTemplate; @Resource(name = "userInfoCache") private LoadingCache> userInfoMap; @Override public Optional loadUserInfoByUserId(Long userId) { //Retrieve user information using a second-level cache. return userInfoMap.get(userId); } @Override public boolean saveAndCheckUserLoginStatus(Long userId) throws Exception { Long add = redisTemplate.opsForSet().add(LOGIN_STATUS_PREFIX, userId.toString()); return add != 0; } @Override public Set onlineUser() { Set set = null; Set members = redisTemplate.opsForSet().members(LOGIN_STATUS_PREFIX); for (String member : members) { if (set == null) { set = new HashSet<>(64); } try { Optional cimUserInfo = loadUserInfoByUserId(Long.valueOf(member)); cimUserInfo.ifPresent(set::add); } catch (NumberFormatException e) { log.warn("Skipping invalid user ID format in Redis set: {}", member); } } return set; } } ================================================ FILE: cim-forward-route/src/main/java/com/crossoverjie/cim/route/util/SpringBeanFactory.java ================================================ package com.crossoverjie.cim.route.util; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; @Component public final class SpringBeanFactory implements ApplicationContextAware { private static ApplicationContext context; public static T getBean(Class c) { return context.getBean(c); } public static T getBean(String name, Class clazz) { return context.getBean(name, clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { context = applicationContext; } } ================================================ FILE: cim-forward-route/src/main/resources/application.yaml ================================================ spring: application: name: cim-forward-route data: redis: host: 127.0.0.1 port: 6379 jedis: pool: max-active: 100 max-idle: 100 max-wait: 1000 min-idle: 10 # datasource: # driver-class-name: com.mysql.cj.jdbc.Driver # url: jdbc:mysql://localhost:3306/cim-test?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 # username: root # password: zcyzcy159162 # web port server: port: 8083 logging: level: root: info # enable swagger springdoc: swagger-ui: enabled: true app: zk: addr: 127.0.0.1:2181 connect: timeout: 30000 root: /route # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.loop.LoopHandle # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.random.RandomHandle # route strategy route: way: handler: com.crossoverjie.cim.common.route.algorithm.consistenthash.ConsistentHashHandle #app.route.way.consitenthash=com.crossoverjie.cim.common.route.algorithm.consistenthash.SortArrayMapConsistentHash consitenthash: com.crossoverjie.cim.common.route.algorithm.consistenthash.TreeMapConsistentHash mybatis: configuration: map-underscore-to-camel-case: true # 自动驼峰转换 type-aliases-package: com.crossoverjie.cim.persistence.api.pojo # 实体类包路径 mapper-locations: classpath*:mapper/**/*.xml global-config: db-config: id-type: auto offline: store: mode: redis redis: expire: message-ttl-days: 3 ================================================ FILE: cim-forward-route/src/main/resources/banner.txt ================================================ _ __ ____(_)_ _ _______ __ __/ /____ / __/ / ' \ / __/ _ \/ // / __/ -_) \__/_/_/_/_/ /_/ \___/\_,_/\__/\__/ Power by @crossoverJie ================================================ FILE: cim-forward-route/src/main/resources/lua/offLine.lua ================================================ redis.call('DEL', KEYS[1]) redis.call('SREM', ARGV[1], ARGV[2]) ================================================ FILE: cim-forward-route/src/test/java/CommonTest.java ================================================ import com.crossoverjie.cim.route.kit.NetAddressIsReachable; import org.junit.Test; /** * Function: * * @author crossoverJie * Date: 2020-04-12 18:38 * @since JDK 1.8 */ public class CommonTest { @Test public void test() { boolean reachable = NetAddressIsReachable.checkAddressReachable("127.0.0.1", 11211, 1000); System.out.println(reachable); } } ================================================ FILE: cim-forward-route/src/test/java/com/crossoverjie/cim/route/service/impl/AbstractBaseTest.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.clevercloud.testcontainers.zookeeper.ZooKeeperContainer; import com.redis.testcontainers.RedisContainer; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.MountableFile; public class AbstractBaseTest { @Container static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7.4.0")); private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName .parse("zookeeper") .withTag("3.9.2"); private static final Duration DEFAULT_STARTUP_TIMEOUT = Duration.ofSeconds(60); @Container static final ZooKeeperContainer ZOOKEEPER_CONTAINER = new ZooKeeperContainer(DEFAULT_IMAGE_NAME, DEFAULT_STARTUP_TIMEOUT); @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0.33") .withDatabaseName("cim-test") .withUsername("cimUserName") .withPassword("cimPassWord") .withCopyFileToContainer( MountableFile.forClasspathResource("init.sql"), "/docker-entrypoint-initdb.d/init.sql" ) .withExposedPorts(3306) .withReuse(true); @BeforeAll public static void before() { redis.setExposedPorts(List.of(6379)); redis.setPortBindings(List.of("6379:6379")); redis.start(); ZOOKEEPER_CONTAINER.setExposedPorts(List.of(2181)); ZOOKEEPER_CONTAINER.setPortBindings(List.of("2181:2181")); ZOOKEEPER_CONTAINER.start(); // 启动 MySQL MYSQL.start(); // 动态设置 Spring 数据源配置(如果使用 Spring Boot) System.setProperty("spring.datasource.url", MYSQL.getJdbcUrl()); System.setProperty("spring.datasource.username", MYSQL.getUsername()); System.setProperty("spring.datasource.password", MYSQL.getPassword()); } @AfterAll public static void after() { redis.stop(); ZOOKEEPER_CONTAINER.stop(); MYSQL.stop(); } } ================================================ FILE: cim-forward-route/src/test/java/com/crossoverjie/cim/route/service/impl/AccountServiceRedisImplTest.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.alibaba.fastjson.JSON; import com.crossoverjie.cim.route.RouteApplication; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.service.AccountService; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @Slf4j @SpringBootTest(classes = RouteApplication.class) public class AccountServiceRedisImplTest extends AbstractBaseTest { @Autowired private AccountService accountService; @Test public void loadRouteRelated() throws Exception { for (int i = 0; i < 100; i++) { Map longCIMServerResVOMap = accountService.loadRouteRelated(); log.info("longCIMServerResVOMap={},cun={}", JSON.toJSONString(longCIMServerResVOMap),i); } } } ================================================ FILE: cim-forward-route/src/test/java/com/crossoverjie/cim/route/service/impl/RedisTest.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.crossoverjie.cim.route.RouteApplication; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; @SpringBootTest(classes = RouteApplication.class) public class RedisTest extends AbstractBaseTest { @Autowired private RedisTemplate redisTemplate; @Test public void test() { redisTemplate.opsForValue().set("test","test"); String test = redisTemplate.opsForValue().get("test"); Assertions.assertEquals("test",test); } } ================================================ FILE: cim-forward-route/src/test/java/com/crossoverjie/cim/route/service/impl/UserInfoCacheServiceImplTest.java ================================================ package com.crossoverjie.cim.route.service.impl; import com.alibaba.fastjson.JSON; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.route.RouteApplication; import com.crossoverjie.cim.route.service.UserInfoCacheService; import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @Slf4j @SpringBootTest(classes = RouteApplication.class) public class UserInfoCacheServiceImplTest extends AbstractBaseTest { @Autowired private UserInfoCacheService userInfoCacheService; @Test public void checkUserLoginStatus() throws Exception { boolean status = userInfoCacheService.saveAndCheckUserLoginStatus(2000L); log.info("status={}", status); } @Test public void onlineUser() { Set cimUserInfos = userInfoCacheService.onlineUser(); log.info("cimUserInfos={}", JSON.toJSONString(cimUserInfos)); } } ================================================ FILE: cim-forward-route/src/test/resources/application.yaml ================================================ spring: application: name: cim-forward-route data: redis: host: 127.0.0.1 port: 6379 jedis: pool: max-active: 100 max-idle: 100 max-wait: 1000 min-idle: 10 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cim-test?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 username: root password: ${DB_PASSWORD} # web port server: port: 8083 logging: level: root: info # enable swagger springdoc: swagger-ui: enabled: true app: zk: addr: 127.0.0.1:2181 connect: timeout: 30000 root: /route # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.loop.LoopHandle # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.random.RandomHandle # route strategy route: way: handler: com.crossoverjie.cim.common.route.algorithm.consistenthash.ConsistentHashHandle #app.route.way.consitenthash=com.crossoverjie.cim.common.route.algorithm.consistenthash.SortArrayMapConsistentHash consitenthash: com.crossoverjie.cim.common.route.algorithm.consistenthash.TreeMapConsistentHash mybatis: configuration: map-underscore-to-camel-case: true # 自动驼峰转换 type-aliases-package: com.crossoverjie.cim.persistence.api.pojo # 实体类包路径 mapper-locations: classpath*:mapper/**/*.xml global-config: db-config: id-type: auto offline: store: mode: mysql ================================================ FILE: cim-forward-route/src/test/resources/init.sql ================================================ -- 创建表 CREATE TABLE IF NOT EXISTS `offline_msg` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `message_id` BIGINT NOT NULL, `receive_user_id` BIGINT NOT NULL, `content` VARCHAR(2000), `message_type` INT, `status` TINYINT COMMENT '0: Pending, 1: Acked', `created_at` DATETIME, `properties` VARCHAR(2000), INDEX `idx_receive_user_id` ( `receive_user_id` ) ); CREATE TABLE offline_msg_last_send_record ( receive_user_id BIGINT NOT NULL PRIMARY KEY, last_message_id BIGINT, updated_at DATETIME ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; ================================================ FILE: cim-integration-test/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim 1.0.0-SNAPSHOT cim-integration-test 17 17 UTF-8 com.crossoverjie.netty cim-server ${project.version} compile com.crossoverjie.netty cim-forward-route ${project.version} compile org.junit.vintage junit-vintage-engine test org.junit.jupiter junit-jupiter test org.testcontainers testcontainers compile org.testcontainers junit-jupiter org.springframework.boot spring-boot-starter-test compile com.clever-cloud testcontainers-zookeeper compile com.redis testcontainers-redis compile org.testcontainers mysql compile ================================================ FILE: cim-integration-test/src/main/java/com/crossoverjie/cim/client/sdk/route/AbstractRouteBaseTest.java ================================================ package com.crossoverjie.cim.client.sdk.route; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.client.sdk.server.AbstractServerBaseTest; import com.crossoverjie.cim.route.RouteApplication; import com.crossoverjie.cim.route.api.RouteApi; import com.crossoverjie.cim.route.api.vo.req.RegisterInfoReqVO; import com.crossoverjie.cim.route.api.vo.res.RegisterInfoResVO; import com.redis.testcontainers.RedisContainer; import org.springframework.boot.SpringApplication; import org.springframework.context.ConfigurableApplicationContext; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.utility.DockerImageName; public abstract class AbstractRouteBaseTest extends AbstractServerBaseTest { @Container RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7.4.0")); protected ConfigurableApplicationContext run; public void startRoute(String offlineModel) { redis.start(); SpringApplication route = new SpringApplication(RouteApplication.class); String[] args = new String[]{ "--spring.data.redis.host=" + redis.getHost(), "--spring.data.redis.port=" + redis.getMappedPort(6379), "--app.zk.addr=" + super.getZookeeperAddr(), "--offline.store.model=" + offlineModel, }; route.setAdditionalProfiles("route"); run = route.run(args); } public void close() { super.close(); redis.close(); run.close(); } public Long registerAccount(String userName) throws Exception { // register user RouteApi routeApi = com.crossoverjie.cim.route.util.SpringBeanFactory.getBean(RouteApi.class); RegisterInfoReqVO reqVO = new RegisterInfoReqVO(); reqVO.setUserName(userName); BaseResponse account = routeApi.registerAccount(reqVO); return account.getDataBody().getUserId(); } } ================================================ FILE: cim-integration-test/src/main/java/com/crossoverjie/cim/client/sdk/route/OfflineMsgStoreRouteBaseTest.java ================================================ package com.crossoverjie.cim.client.sdk.route; import com.crossoverjie.cim.route.RouteApplication; import com.crossoverjie.cim.route.constant.Constant; import org.springframework.boot.SpringApplication; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.MountableFile; /** * @author zhongcanyu * @date 2025/6/5 * @description */ public class OfflineMsgStoreRouteBaseTest extends AbstractRouteBaseTest { private MySQLContainer mysql; @Override public void startRoute(String offlineModel) { redis.start(); SpringApplication route = new SpringApplication(RouteApplication.class); String[] args; if (Constant.OfflineStoreMode.MYSQL.equals(offlineModel)) { mysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.33")) .withDatabaseName("cim-test") .withUsername("cimUserName") .withPassword("cimPassWord") .withCopyFileToContainer( MountableFile.forClasspathResource("init.sql"), "/docker-entrypoint-initdb.d/init.sql" ) .withExposedPorts(3306) .withReuse(true); mysql.start(); args = new String[]{ "--spring.data.redis.host=" + redis.getHost(), "--spring.data.redis.port=" + redis.getMappedPort(6379), "--app.zk.addr=" + super.getZookeeperAddr(), "--offline.store.model=" + offlineModel, "--spring.datasource.url=" + mysql.getJdbcUrl(), "--spring.datasource.username=" + mysql.getUsername(), "--spring.datasource.password=" + mysql.getPassword() }; } else { args = new String[]{ "--spring.data.redis.host=" + redis.getHost(), "--spring.data.redis.port=" + redis.getMappedPort(6379), "--app.zk.addr=" + super.getZookeeperAddr(), "--offline.store.model=" + offlineModel, }; } route.setAdditionalProfiles("route"); run = route.run(args); } @Override public void close() { if (mysql != null) { mysql.stop(); } super.close(); } } ================================================ FILE: cim-integration-test/src/main/java/com/crossoverjie/cim/client/sdk/server/AbstractServerBaseTest.java ================================================ package com.crossoverjie.cim.client.sdk.server; import com.clevercloud.testcontainers.zookeeper.ZooKeeperContainer; import com.crossoverjie.cim.server.CIMServerApplication; import java.time.Duration; import java.util.HashMap; import java.util.Map; import lombok.Getter; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.context.ConfigurableApplicationContext; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.utility.DockerImageName; public abstract class AbstractServerBaseTest { private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName .parse("zookeeper") .withTag("3.9.2"); private static final Duration DEFAULT_STARTUP_TIMEOUT = Duration.ofSeconds(60); @Container public final ZooKeeperContainer zooKeeperContainer = new ZooKeeperContainer(DEFAULT_IMAGE_NAME, DEFAULT_STARTUP_TIMEOUT); @Getter private String zookeeperAddr; private ConfigurableApplicationContext singleRun; public void starSingleServer() { zooKeeperContainer.start(); zookeeperAddr = String.format("%s:%d", zooKeeperContainer.getHost(), zooKeeperContainer.getMappedPort(ZooKeeperContainer.DEFAULT_CLIENT_PORT)); SpringApplication server = new SpringApplication(CIMServerApplication.class); String[] args = new String[]{ "--app.zk.addr=" + zookeeperAddr, "--spring.autoconfigure.exclude=" + DataSourceAutoConfiguration.class.getName() }; singleRun = server.run(args); } public void stopSingle() { singleRun.close(); } private final Map runMap = new HashMap<>(2); public void startTwoServer() { if (!zooKeeperContainer.isRunning()) { zooKeeperContainer.start(); } zookeeperAddr = String.format("%s:%d", zooKeeperContainer.getHost(), zooKeeperContainer.getMappedPort(ZooKeeperContainer.DEFAULT_CLIENT_PORT)); SpringApplication server = new SpringApplication(CIMServerApplication.class); String[] args1 = new String[]{ "--cim.server.port=11211", "--server.port=8081", "--app.zk.addr=" + zookeeperAddr, "--spring.autoconfigure.exclude=" + DataSourceAutoConfiguration.class.getName() }; ConfigurableApplicationContext run1 = server.run(args1); runMap.put(Integer.parseInt("11211"), run1); SpringApplication server2 = new SpringApplication(CIMServerApplication.class); String[] args2 = new String[]{ "--cim.server.port=11212", "--server.port=8082", "--app.zk.addr=" + zookeeperAddr, "--spring.autoconfigure.exclude=" + DataSourceAutoConfiguration.class.getName() }; ConfigurableApplicationContext run2 = server2.run(args2); runMap.put(Integer.parseInt("11212"), run2); } public void stopServer(Integer port) { runMap.get(port).close(); runMap.remove(port); } public void stopTwoServer() { runMap.forEach((k, v) -> v.close()); } public void close() { zooKeeperContainer.close(); } } ================================================ FILE: cim-integration-test/src/test/resources/application-client.yaml ================================================ spring: application: name: cim-client # web port server: port: 8082 logging: level: root: error # enable swagger springdoc: swagger-ui: enabled: true # log path cim: msg: logger: path: /opt/logs/cim/ route: url: http://localhost:8083 # route url suggested that this is Nginx address user: # cim userId and userName id: 1722343979085 userName: zhangsan callback: thread: queue: size: 2 pool: size: 2 heartbeat: time: 60 # cim heartbeat time (seconds) reconnect: count: 3 ================================================ FILE: cim-integration-test/src/test/resources/application-route.yaml ================================================ spring: application: name: cim-forward-route data: redis: host: 127.0.0.1 port: 6379 jedis: pool: max-active: 100 max-idle: 100 max-wait: 1000 min-idle: 10 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cim-test?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 username: root password: ${DB_PASSWORD} # web port server: port: 8083 logging: level: root: info # enable swagger springdoc: swagger-ui: enabled: true app: zk: connect: timeout: 30000 root: /route # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.loop.LoopHandle # route strategy #app.route.way=com.crossoverjie.cim.common.route.algorithm.random.RandomHandle # route strategy route: way: handler: com.crossoverjie.cim.common.route.algorithm.consistenthash.ConsistentHashHandle #app.route.way.consitenthash=com.crossoverjie.cim.common.route.algorithm.consistenthash.SortArrayMapConsistentHash consitenthash: com.crossoverjie.cim.common.route.algorithm.consistenthash.TreeMapConsistentHash ================================================ FILE: cim-persistence/cim-persistence-api/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim-persistence 1.0.0-SNAPSHOT com.crossoverjie.netty cim-persistence-api 17 org.springframework.boot spring-boot-starter-data-redis org.springframework.boot spring-boot-maven-plugin true ================================================ FILE: cim-persistence/cim-persistence-api/src/main/java/com/crossoverjie/cim/persistence/api/config/BeanConfig.java ================================================ package com.crossoverjie.cim.persistence.api.config; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.hash.Jackson2HashMapper; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; /** * @author zhongcanyu * @date 2025/5/19 * @description */ @Configuration("persistenceBeanConfig") @Slf4j public class BeanConfig { /** * Redis bean * * @param factory * @return */ @Bean public RedisTemplate stringObjectRedisTemplate(RedisConnectionFactory factory) { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(factory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.afterPropertiesSet(); return redisTemplate; } @Bean public Jackson2HashMapper hashMapper(ObjectMapper objectMapper) { return new Jackson2HashMapper(objectMapper, false); } } ================================================ FILE: cim-persistence/cim-persistence-api/src/main/java/com/crossoverjie/cim/persistence/api/pojo/OfflineMsg.java ================================================ package com.crossoverjie.cim.persistence.api.pojo; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.Map; /** * @author zhongcanyu * @date 2025/5/18 * @description */ @Data @AllArgsConstructor @NoArgsConstructor @Builder public class OfflineMsg { private Long messageId; private Long receiveUserId; private String content; private Integer messageType; // 0: Text, 1: Image private Integer status; // 0: Pending, 1: Acked private LocalDateTime createdAt; /** * 消息来源存储在这里 */ private Map properties; } ================================================ FILE: cim-persistence/cim-persistence-api/src/main/java/com/crossoverjie/cim/persistence/api/pojo/OfflineMsgLastSendRecord.java ================================================ package com.crossoverjie.cim.persistence.api.pojo; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** * @author zhongcanyu * @date 2025/5/18 * @description */ @Data @AllArgsConstructor @NoArgsConstructor public class OfflineMsgLastSendRecord { private Long userId; private String lastMessageId; private LocalDateTime updatedAt; } ================================================ FILE: cim-persistence/cim-persistence-api/src/main/java/com/crossoverjie/cim/persistence/api/service/OfflineMsgStore.java ================================================ package com.crossoverjie.cim.persistence.api.service; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import java.util.List; /** * @author zhongcanyu * @date 2025/5/18 * @description */ public interface OfflineMsgStore { /** * Save offline message * * @param offlineMsg */ void save(OfflineMsg offlineMsg); /** * Fetch offline messages for a user * * @param userId * @return */ List fetch(Long userId); /** * Mark messages as delivered * * @param userId * @param messageIds */ void markDelivered(Long userId, List messageIds); } ================================================ FILE: cim-persistence/cim-persistence-api/src/main/java/com/crossoverjie/cim/persistence/api/vo/req/SaveOfflineMsgReqVO.java ================================================ package com.crossoverjie.cim.persistence.api.vo.req; import com.crossoverjie.cim.common.req.BaseRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import java.util.Map; @Data @EqualsAndHashCode(callSuper = true) @Builder @AllArgsConstructor public class SaveOfflineMsgReqVO extends BaseRequest { @NotNull(message = "msg 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "msg", example = "hello") private String msg; @NotNull(message = "userId 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "userId", example = "11") private Long receiveUserId; @Setter @Getter private Map properties; } ================================================ FILE: cim-persistence/cim-persistence-mysql/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim-persistence 1.0.0-SNAPSHOT com.crossoverjie.netty cim-persistence-mysql 17 org.mybatis.spring.boot mybatis-spring-boot-starter 3.0.3 com.mysql mysql-connector-j 8.2.0 com.crossoverjie.netty cim-persistence-api ${project.version} org.springframework.boot spring-boot-maven-plugin true ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/java/com/crossoverjie/cim/persistence/mysql/config/MyBatisConfig.java ================================================ package com.crossoverjie.cim.persistence.mysql.config; import com.crossoverjie.cim.persistence.mysql.util.MapToJsonTypeHandler; import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author zhongcanyu * @date 2025/5/18 * @description */ @Configuration public class MyBatisConfig { @Bean public ConfigurationCustomizer configurationCustomizer() { return configuration -> { configuration.getTypeHandlerRegistry().register(MapToJsonTypeHandler.class); }; } } ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/java/com/crossoverjie/cim/persistence/mysql/offlinemsg/OfflineMsgDb.java ================================================ package com.crossoverjie.cim.persistence.mysql.offlinemsg; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import com.crossoverjie.cim.persistence.api.service.OfflineMsgStore; import com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper.OfflineMsgLastSendRecordMapper; import com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper.OfflineMsgMapper; import java.util.List; import static com.crossoverjie.cim.common.constant.Constants.FETCH_OFFLINE_MSG_LIMIT; /** * @author zhongcanyu * @date 2025/5/18 * @description */ public class OfflineMsgDb implements OfflineMsgStore { private final OfflineMsgMapper offlineMsgMapper; private final OfflineMsgLastSendRecordMapper offlineMsgLastSendRecordMapper; public OfflineMsgDb(OfflineMsgMapper offlineMsgMapper, OfflineMsgLastSendRecordMapper offlineMsgLastSendRecordMapper) { this.offlineMsgMapper = offlineMsgMapper; this.offlineMsgLastSendRecordMapper = offlineMsgLastSendRecordMapper; } @Override public void save(OfflineMsg offlineMsg) { offlineMsgMapper.insert(offlineMsg); } @Override public List fetch(Long receiveUserId) { return offlineMsgMapper.fetchOfflineMsgsWithCursor(receiveUserId, FETCH_OFFLINE_MSG_LIMIT); } @Override public void markDelivered(Long receiveUserId, List messageIds) { offlineMsgMapper.updateStatus(receiveUserId, messageIds); offlineMsgLastSendRecordMapper.saveLatestMessageId(receiveUserId, messageIds.get(messageIds.size() - 1)); } } ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/java/com/crossoverjie/cim/persistence/mysql/offlinemsg/mapper/OfflineMsgLastSendRecordMapper.java ================================================ package com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; /** * @author zhongcanyu * @date 2025/5/10 * @description */ @Mapper public interface OfflineMsgLastSendRecordMapper { void saveLatestMessageId(@Param("receiveUserId") Long receiveUserId, @Param("lastMessageId") Long lastMessageId); } ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/java/com/crossoverjie/cim/persistence/mysql/offlinemsg/mapper/OfflineMsgMapper.java ================================================ package com.crossoverjie.cim.persistence.mysql.offlinemsg.mapper; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; /** * @author zhongcanyu * @date 2025/5/9 * @description */ @Mapper public interface OfflineMsgMapper { int insert(OfflineMsg msg); int insertBatch(@Param("offlineMsgs") List offlineMsgs); List fetchOfflineMsgsWithCursor(@Param("receiveUserId") Long receiveUserId, @Param("limit") Integer limit); int updateStatus( @Param("receiveUserId") Long receiveUserId, @Param("messageIds") List messageIds); List fetchOfflineMsgIdsWithCursor(@Param("receiveUserId") Long receiveUserId); } ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/java/com/crossoverjie/cim/persistence/mysql/util/MapToJsonTypeHandler.java ================================================ package com.crossoverjie.cim.persistence.mysql.util; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Map; /** * @author zhongcanyu * @date 2025/5/18 * @description */ public class MapToJsonTypeHandler extends BaseTypeHandler> { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, Map parameter, JdbcType jdbcType) throws SQLException { try { String json = OBJECT_MAPPER.writeValueAsString(parameter); ps.setString(i, json); } catch (Exception e) { throw new SQLException("Failed to convert Map to JSON", e); } } @Override public Map getNullableResult(ResultSet rs, String columnName) throws SQLException { return parseJson(rs.getString(columnName)); } @Override public Map getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parseJson(rs.getString(columnIndex)); } @Override public Map getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parseJson(cs.getString(columnIndex)); } private Map parseJson(String json) { try { return OBJECT_MAPPER.readValue(json, new TypeReference>() { }); } catch (Exception e) { return null; } } } ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/resources/mapper/OfflineMsgLastSendRecordMapper.xml ================================================ INSERT INTO offline_msg_last_send_record (receive_user_id, last_message_id, updated_at) VALUES (#{receiveUserId}, #{lastMessageId}, NOW()) ON DUPLICATE KEY UPDATE last_message_id = #{lastMessageId}, updated_at = NOW() ================================================ FILE: cim-persistence/cim-persistence-mysql/src/main/resources/mapper/OfflineMsgMapper.xml ================================================ INSERT INTO offline_msg (message_id, receive_user_id, content, message_type, status, created_at, properties) VALUES (#{messageId}, #{receiveUserId}, #{content}, #{messageType}, #{status}, #{createdAt}, #{properties, typeHandler=com.crossoverjie.cim.persistence.mysql.util.MapToJsonTypeHandler}) INSERT INTO offline_msg ( message_id, receive_user_id, content, message_type, status, created_at, properties ) VALUES ( #{item.messageId}, #{item.receiveUserId}, #{item.content}, #{item.messageType}, #{item.status}, #{item.createdAt}, #{item.properties, typeHandler=com.crossoverjie.cim.persistence.mysql.util.MapToJsonTypeHandler} ) UPDATE offline_msg SET status = 1 WHERE receive_user_id = #{receiveUserId} AND message_id IN #{id} ================================================ FILE: cim-persistence/cim-persistence-redis/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim-persistence 1.0.0-SNAPSHOT com.crossoverjie.netty cim-persistence-redis 17 com.crossoverjie.netty cim-persistence-api ${project.version} org.springframework.boot spring-boot-maven-plugin true ================================================ FILE: cim-persistence/cim-persistence-redis/src/main/java/com/crossoverjie/cim/persistence/redis/OfflineMsgBuffer.java ================================================ package com.crossoverjie.cim.persistence.redis; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import com.crossoverjie.cim.persistence.api.service.OfflineMsgStore; import com.crossoverjie.cim.persistence.redis.kit.OfflineMsgScriptExecutor; import com.fasterxml.jackson.databind.ObjectMapper; import io.lettuce.core.RedisException; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.util.CollectionUtils; import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; import java.util.stream.Collectors; import static com.crossoverjie.cim.persistence.redis.constant.Constant.FETCH_OFFLINE_MSG_SIZE; import static com.crossoverjie.cim.persistence.redis.constant.Constant.OFFLINE_MSG_TTL_DAYS; /** * @author zhongcanyu * @date 2025/5/18 * @description */ @Slf4j public class OfflineMsgBuffer implements OfflineMsgStore { private final int messageTtlDays; private final OfflineMsgScriptExecutor scriptExecutor; private final ObjectMapper objectMapper; public OfflineMsgBuffer(OfflineMsgScriptExecutor scriptExecutor, Integer configuredDays, ObjectMapper objectMapper) { this.messageTtlDays = ensureValidTtlOrDefault(configuredDays); this.scriptExecutor = scriptExecutor; this.objectMapper = objectMapper; } private int ensureValidTtlOrDefault(Integer configuredDays) { return (configuredDays != null && configuredDays > 0) ? configuredDays : OFFLINE_MSG_TTL_DAYS; } @Override public void save(OfflineMsg msg) { try { scriptExecutor.saveOfflineMsg(msg, messageTtlDays); } catch (SerializationException | RedisException e) { log.error("Failed to save offline message", e); throw new CIMException(StatusEnum.OFFLINE_MESSAGE_STORAGE_ERROR); } } @Override public List fetch(Long userId) { try { List jsonResult = scriptExecutor.fetchOfflineMsgs(userId, FETCH_OFFLINE_MSG_SIZE); List offlineMsgs = jsonResult.stream() .map(json -> { try { return objectMapper.readValue(json, OfflineMsg.class); } catch (IOException e) { throw new UncheckedIOException(e); } }) .collect(Collectors.toList()); return offlineMsgs; } catch (UncheckedIOException | SerializationException | RedisException e) { log.error("Failed to fetch offline messages for userId: {}", userId, e); throw new CIMException(StatusEnum.OFFLINE_MESSAGE_FETCH_ERROR); } } @Override public void markDelivered(Long userId, List messageIds) { if (CollectionUtils.isEmpty(messageIds)) { return; } try { scriptExecutor.deleteOfflineMsg(userId, messageIds); } catch (RedisException e) { log.error("Failed to delete offline messages for userId: {}", userId, e); throw new CIMException(StatusEnum.OFFLINE_MESSAGE_DELETE_ERROR); } } } ================================================ FILE: cim-persistence/cim-persistence-redis/src/main/java/com/crossoverjie/cim/persistence/redis/constant/Constant.java ================================================ package com.crossoverjie.cim.persistence.redis.constant; /** * @author zhongcanyu * @date 2025/6/14 * @description */ public final class Constant { /** * The number of messages captured from redis each time */ public static final Integer FETCH_OFFLINE_MSG_SIZE = 100; /** * Default expire time for offline message */ public static final Integer OFFLINE_MSG_TTL_DAYS = 7; /** * Redis key prefix for offline message */ public static final String MSG_KEY = "offline:msg:"; /** * Redis key prefix for offline message index */ public static final String USER_IDX = "offline:msg:user:"; } ================================================ FILE: cim-persistence/cim-persistence-redis/src/main/java/com/crossoverjie/cim/persistence/redis/kit/OfflineMsgScriptExecutor.java ================================================ package com.crossoverjie.cim.persistence.redis.kit; import com.crossoverjie.cim.persistence.api.pojo.OfflineMsg; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.stereotype.Component; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static com.crossoverjie.cim.persistence.redis.constant.Constant.MSG_KEY; import static com.crossoverjie.cim.persistence.redis.constant.Constant.USER_IDX; /** * @author zhongcanyu * @date 2025/6/13 * @description */ @Component public class OfflineMsgScriptExecutor { private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; public OfflineMsgScriptExecutor(RedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; } private static final DefaultRedisScript SAVE_OFFLINE_MSG_SCRIPT; private static final DefaultRedisScript FETCH_OFFLINE_MSG_SCRIPT; private static final DefaultRedisScript DELETE_OFFLINE_MSG_SCRIPT; static { SAVE_OFFLINE_MSG_SCRIPT = new DefaultRedisScript<>(); SAVE_OFFLINE_MSG_SCRIPT.setLocation(new ClassPathResource("lua/saveOfflineMsg.lua")); SAVE_OFFLINE_MSG_SCRIPT.setResultType(Long.class); FETCH_OFFLINE_MSG_SCRIPT = new DefaultRedisScript<>(); FETCH_OFFLINE_MSG_SCRIPT.setLocation(new ClassPathResource("lua/fetchOfflineMsg.lua")); FETCH_OFFLINE_MSG_SCRIPT.setResultType(List.class); DELETE_OFFLINE_MSG_SCRIPT = new DefaultRedisScript<>(); DELETE_OFFLINE_MSG_SCRIPT.setLocation(new ClassPathResource("lua/deleteOfflineMsg.lua")); DELETE_OFFLINE_MSG_SCRIPT.setResultType(Long.class); } public Long saveOfflineMsg(OfflineMsg msg, Integer messageTtlDays) { List keys = Arrays.asList(MSG_KEY, USER_IDX); List allArgs = new ArrayList<>(); allArgs.add(msg.getMessageId()); allArgs.add(msg.getReceiveUserId()); allArgs.add(serialize(msg)); allArgs.add(Duration.ofDays(messageTtlDays).getSeconds()); return redisTemplate.execute(SAVE_OFFLINE_MSG_SCRIPT, keys, allArgs.toArray()); } private String serialize(OfflineMsg msg) { try { return objectMapper.writeValueAsString(msg); } catch (JsonProcessingException e) { throw new SerializationException("OfflineMsg serialization failed", e); } } public List fetchOfflineMsgs(Long userId, Integer size) { List keys = Arrays.asList(MSG_KEY, USER_IDX); List allArgs = new ArrayList<>(); allArgs.add(userId); allArgs.add(size); return (List) redisTemplate.execute(FETCH_OFFLINE_MSG_SCRIPT, keys, allArgs.toArray()); } public Long deleteOfflineMsg(Long userId, List msgIds) { List keys = Arrays.asList(MSG_KEY, USER_IDX); List allArgs = new ArrayList<>(); allArgs.add(userId); allArgs.addAll(msgIds); return redisTemplate.execute(DELETE_OFFLINE_MSG_SCRIPT, keys, allArgs.toArray()); } } ================================================ FILE: cim-persistence/cim-persistence-redis/src/main/resources/lua/deleteOfflineMsg.lua ================================================ local msgPrefix = KEYS[1] local userIdxPrefix = KEYS[2] local userId = ARGV[1] local userListKey = userIdxPrefix .. userId for i = 2, #ARGV do local msgKey = msgPrefix .. ARGV[i] redis.call("DEL", msgKey) redis.call("LREM", userListKey, 0, ARGV[i]) end ================================================ FILE: cim-persistence/cim-persistence-redis/src/main/resources/lua/fetchOfflineMsg.lua ================================================ local userId = ARGV[1] local rangeSize = tonumber(ARGV[2]) local msgPrefix = KEYS[1] local userIdxPrefix = KEYS[2] local userListKey = userIdxPrefix .. userId local ids = redis.call('LRANGE', userListKey, 0, rangeSize - 1) local result = {} for i, id in ipairs(ids) do local msgKey = msgPrefix .. id local serializedMsg = redis.call('GET', msgKey) if serializedMsg then table.insert(result, serializedMsg) end end return result ================================================ FILE: cim-persistence/cim-persistence-redis/src/main/resources/lua/saveOfflineMsg.lua ================================================ local msgPrefix = KEYS[1] local userIdxPrefix = KEYS[2] local msgId = ARGV[1] local receiveUserId = ARGV[2] local msgValue = ARGV[3] local ttlSeconds = tonumber(ARGV[4]) local msgKey = msgPrefix .. msgId local userListKey = userIdxPrefix .. receiveUserId redis.call("SET", msgKey, msgValue) if ttlSeconds and ttlSeconds > 0 then redis.call("EXPIRE", msgKey, ttlSeconds) end redis.call("RPUSH", userListKey, msgId) if ttlSeconds and ttlSeconds > 0 then redis.call("EXPIRE", userListKey, ttlSeconds) end return 1 ================================================ FILE: cim-persistence/pom.xml ================================================ 4.0.0 cim com.crossoverjie.netty 1.0.0-SNAPSHOT com.crossoverjie.netty cim-persistence pom cim-persistence-api cim-persistence-mysql cim-persistence-redis 17 com.crossoverjie.netty cim-common org.springframework.boot spring-boot-starter org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin ================================================ FILE: cim-rout-api/pom.xml ================================================ cim com.crossoverjie.netty 1.0.0-SNAPSHOT 4.0.0 cim-rout-api 1.0.0-SNAPSHOT com.crossoverjie.netty cim-common log4j log4j org.springframework.boot spring-boot-starter-web jakarta.validation jakarta.validation-api ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/RouteApi.java ================================================ package com.crossoverjie.cim.route.api; import com.crossoverjie.cim.common.core.proxy.Request; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.common.res.NULLBody; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.route.api.vo.req.LoginReqVO; import com.crossoverjie.cim.route.api.vo.req.OfflineMsgReqVO; import com.crossoverjie.cim.route.api.vo.req.P2PReqVO; import com.crossoverjie.cim.route.api.vo.req.RegisterInfoReqVO; import com.crossoverjie.cim.route.api.vo.res.CIMServerResVO; import com.crossoverjie.cim.route.api.vo.res.RegisterInfoResVO; import java.util.Set; /** * Function: Route Api * * @author crossoverJie * Date: 2020-04-24 23:43 * @since JDK 1.8 */ public interface RouteApi { /** * group chat * * @param groupReqVO * @return * @throws Exception */ BaseResponse groupRoute(ChatReqVO groupReqVO); /** * Point to point chat * @param p2pRequest * @return * @throws Exception */ BaseResponse p2pRoute(P2PReqVO p2pRequest); /** * Offline account * * @param groupReqVO * @return * @throws Exception */ BaseResponse offLine(ChatReqVO groupReqVO); /** * Login account * @param loginReqVO * @return * @throws Exception */ BaseResponse login(LoginReqVO loginReqVO) throws Exception; /** * Register account * * @param registerInfoReqVO * @return * @throws Exception */ BaseResponse registerAccount(RegisterInfoReqVO registerInfoReqVO) throws Exception; /** * Get all online users * * @return * @throws Exception */ @Request(method = Request.GET) BaseResponse> onlineUser() throws Exception; BaseResponse fetchOfflineMsgs(OfflineMsgReqVO offlineMsgReqVO); // TODO: 2024/8/19 Get cache server & metastore server } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/req/ChatReqVO.java ================================================ package com.crossoverjie.cim.route.api.vo.req; import com.crossoverjie.cim.common.req.BaseRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import java.util.List; /** * Function: Google Protocol 编解码发送 * * @author crossoverJie * Date: 2018/05/21 15:56 * @since JDK 1.8 */ @EqualsAndHashCode(callSuper = true) @Data @AllArgsConstructor @NoArgsConstructor public class ChatReqVO extends BaseRequest { @NotNull(message = "userId 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "userId", example = "1545574049323") private Long userId; @NotNull(message = "msg 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "msg", example = "hello") private String msg; private List batchMsg; @Override public String toString() { return "GroupReqVO{" + "userId=" + userId + ", msg='" + msg + '\'' + "} " + super.toString(); } } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/req/LoginReqVO.java ================================================ package com.crossoverjie.cim.route.api.vo.req; import com.crossoverjie.cim.common.req.BaseRequest; import lombok.AllArgsConstructor; /** * Function: * * @author crossoverJie * Date: 2018/12/23 22:30 * @since JDK 1.8 */ @AllArgsConstructor public class LoginReqVO extends BaseRequest { private Long userId; private String userName; public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } @Override public String toString() { return "LoginReqVO{" + "userId=" + userId + ", userName='" + userName + '\'' + "} " + super.toString(); } } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/req/OfflineMsgReqVO.java ================================================ package com.crossoverjie.cim.route.api.vo.req; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * @author zhongcanyu * @date 2025/5/11 * @description */ @Builder @Data @AllArgsConstructor @NoArgsConstructor public class OfflineMsgReqVO { @NotNull(message = "userId can't be null") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "message received userId", example = "1545574049323") private Long receiveUserId; } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/req/P2PReqVO.java ================================================ package com.crossoverjie.cim.route.api.vo.req; import com.crossoverjie.cim.common.req.BaseRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Getter; import lombok.Setter; import java.util.List; /** * Function: P2P request * * @author crossoverJie * Date: 2018/05/21 15:56 * @since JDK 1.8 */ @Builder public class P2PReqVO extends BaseRequest { @NotNull(message = "userId can't be null") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "current send userId", example = "1545574049323") private Long userId; @NotNull(message = "userId can't be null") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "message received userId", example = "1545574049323") private Long receiveUserId; @NotNull(message = "msg can't be null") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "msg", example = "hello") private String msg; @Getter @Setter private List batchMsg; public P2PReqVO() { } public P2PReqVO(Long userId, Long receiveUserId, String msg) { this.userId = userId; this.receiveUserId = receiveUserId; this.msg = msg; } public P2PReqVO(Long userId, Long receiveUserId, String msg, List batchMsg) { this.userId = userId; this.receiveUserId = receiveUserId; this.msg = msg; this.batchMsg = batchMsg; } public Long getReceiveUserId() { return receiveUserId; } public void setReceiveUserId(Long receiveUserId) { this.receiveUserId = receiveUserId; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } @Override public String toString() { return "GroupReqVO{" + "userId=" + userId + ", msg='" + msg + '\'' + "} " + super.toString(); } } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/req/RegisterInfoReqVO.java ================================================ package com.crossoverjie.cim.route.api.vo.req; import com.crossoverjie.cim.common.req.BaseRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; /** * Function: * * @author crossoverJie * Date: 2018/12/23 22:04 * @since JDK 1.8 */ public class RegisterInfoReqVO extends BaseRequest { @NotNull(message = "用户名不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "userName", example = "zhangsan") private String userName; public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } @Override public String toString() { return "RegisterInfoReqVO{" + "userName='" + userName + '\'' + "} " + super.toString(); } } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/req/SendMsgReqVO.java ================================================ package com.crossoverjie.cim.route.api.vo.req; import com.crossoverjie.cim.common.req.BaseRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; /** * Function: * * @author crossoverJie * Date: 2018/05/21 15:56 * @since JDK 1.8 */ public class SendMsgReqVO extends BaseRequest { @NotNull(message = "msg 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "msg", example = "hello") private String msg; @NotNull(message = "id 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "id", example = "11") private long id; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public long getId() { return id; } public void setId(long id) { this.id = id; } } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/res/CIMServerResVO.java ================================================ package com.crossoverjie.cim.route.api.vo.res; import java.io.Serializable; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * Function: * * @author crossoverJie * Date: 2018/12/23 00:43 * @since JDK 1.8 */ @Data @AllArgsConstructor @NoArgsConstructor public class CIMServerResVO implements Serializable { private String ip; private Integer cimServerPort; private Integer httpPort; } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/res/RegisterInfoResVO.java ================================================ package com.crossoverjie.cim.route.api.vo.res; import java.io.Serializable; /** * Function: * * @author crossoverJie * Date: 2018/12/23 21:54 * @since JDK 1.8 */ public class RegisterInfoResVO implements Serializable { private Long userId; private String userName; public RegisterInfoResVO(Long userId, String userName) { this.userId = userId; this.userName = userName; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } @Override public String toString() { return "RegisterInfo{" + "userId=" + userId + ", userName='" + userName + '\'' + '}'; } } ================================================ FILE: cim-rout-api/src/main/java/com/crossoverjie/cim/route/api/vo/res/SendMsgResVO.java ================================================ package com.crossoverjie.cim.route.api.vo.res; /** * Function: * * @author crossoverJie * Date: 2017/6/26 15:43 * @since JDK 1.8 */ public class SendMsgResVO { private String msg; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } } ================================================ FILE: cim-server/pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim 1.0.0-SNAPSHOT cim-server jar UTF-8 UTF-8 17 com.google.protobuf protobuf-java com.crossoverjie.netty cim-common com.crossoverjie.netty cim-rout-api com.crossoverjie.netty cim-server-api javax.servlet javax.servlet-api 4.0.1 provided org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-configuration-processor true org.springframework.boot spring-boot-starter-actuator junit junit com.alibaba fastjson jakarta.validation jakarta.validation-api ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/CIMServerApplication.java ================================================ package com.crossoverjie.cim.server; import com.crossoverjie.cim.common.metastore.MetaStore; import com.crossoverjie.cim.server.config.AppConfiguration; import com.crossoverjie.cim.server.kit.RegistryMetaStore; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import java.net.InetAddress; /** * @author crossoverJie */ @SpringBootApplication @Slf4j public class CIMServerApplication implements CommandLineRunner { @Resource private AppConfiguration appConfiguration; @Resource private MetaStore metaStore; @Value("${server.port}") private int httpPort; public static void main(String[] args) { SpringApplication.run(CIMServerApplication.class, args); log.info("Start cim server success!!!"); } @Override public void run(String... args) throws Exception { String addr = InetAddress.getLocalHost().getHostAddress(); Thread thread = new Thread(new RegistryMetaStore(metaStore, addr, appConfiguration.getCimServerPort(), httpPort)); thread.setName("registry-zk"); thread.start(); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/config/AppConfiguration.java ================================================ package com.crossoverjie.cim.server.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 2018/8/24 01:43 * @since JDK 1.8 */ @Component public class AppConfiguration { @Value("${app.zk.root}") private String zkRoot; @Value("${app.zk.addr}") private String zkAddr; @Value("${app.zk.switch}") private boolean zkSwitch; @Value("${cim.server.port}") private int cimServerPort; @Value("${cim.route.url}") private String routeUrl; public String getRouteUrl() { return routeUrl; } public void setRouteUrl(String routeUrl) { this.routeUrl = routeUrl; } @Value("${cim.heartbeat.time}") private long heartBeatTime; @Value("${app.zk.connect.timeout}") private int zkConnectTimeout; public int getZkConnectTimeout() { return zkConnectTimeout; } public String getZkRoot() { return zkRoot; } public void setZkRoot(String zkRoot) { this.zkRoot = zkRoot; } public String getZkAddr() { return zkAddr; } public void setZkAddr(String zkAddr) { this.zkAddr = zkAddr; } public boolean isZkSwitch() { return zkSwitch; } public void setZkSwitch(boolean zkSwitch) { this.zkSwitch = zkSwitch; } public int getCimServerPort() { return cimServerPort; } public void setCimServerPort(int cimServerPort) { this.cimServerPort = cimServerPort; } public long getHeartBeatTime() { return heartBeatTime; } public void setHeartBeatTime(long heartBeatTime) { this.heartBeatTime = heartBeatTime; } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/config/BeanConfig.java ================================================ package com.crossoverjie.cim.server.config; import com.crossoverjie.cim.common.core.proxy.RpcProxyManager; import com.crossoverjie.cim.common.metastore.MetaStore; import com.crossoverjie.cim.common.metastore.ZkMetaStoreImpl; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.protocol.Request; import com.crossoverjie.cim.route.api.RouteApi; import jakarta.annotation.Resource; import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * Function: * * @author crossoverJie * Date: 2018/12/23 00:25 * @since JDK 1.8 */ @Configuration public class BeanConfig { @Resource private AppConfiguration appConfiguration; /** * http client * @return okHttp */ @Bean public OkHttpClient okHttpClient() { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(30, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(true); return builder.build(); } @Bean public MetaStore metaStore() { return new ZkMetaStoreImpl(); } /** * 创建心跳单例 * @return */ @Bean(value = "heartBeat") public Request heartBeat() { return Request.newBuilder() .setRequestId(0L) .setReqMsg("pong") .setCmd(BaseCommand.PING) .build(); } @Bean public RouteApi routeApi(OkHttpClient okHttpClient) { return RpcProxyManager.create(RouteApi.class, appConfiguration.getRouteUrl(), okHttpClient); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/config/SwaggerConfig.java ================================================ package com.crossoverjie.cim.server.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { @Bean public OpenAPI createRestApi() { return new OpenAPI() .info(apiInfo()); } private Info apiInfo() { return new Info() .title("cim server") .description("cim server api") .termsOfService("http://crossoverJie.top") .contact(contact()) .version("1.0.0"); } private Contact contact() { Contact contact = new Contact(); contact.setName("crossoverJie"); return contact; } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/controller/IndexController.java ================================================ package com.crossoverjie.cim.server.controller; import com.crossoverjie.cim.common.core.proxy.DynamicUrl; import com.crossoverjie.cim.common.enums.StatusEnum; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.server.api.ServerApi; import com.crossoverjie.cim.server.api.vo.req.SendMsgReqVO; import com.crossoverjie.cim.server.api.vo.res.SendMsgResVO; import com.crossoverjie.cim.server.server.CIMServer; import io.swagger.v3.oas.annotations.Operation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; /** * Function: * * @author crossoverJie * Date: 22/05/2018 14:46 * @since JDK 1.8 */ @Controller @RequestMapping("/") public class IndexController implements ServerApi { @Autowired private CIMServer cimServer; /** * * @param sendMsgReqVO * @return */ @Override @Operation(summary = "Push msg to client") @RequestMapping(value = "sendMsg", method = RequestMethod.POST) @ResponseBody public BaseResponse sendMsg(@RequestBody SendMsgReqVO sendMsgReqVO, @DynamicUrl String url) { BaseResponse res = new BaseResponse(); cimServer.sendMsg(sendMsgReqVO); // TODO: 2024/5/30 metrics SendMsgResVO sendMsgResVO = new SendMsgResVO(); sendMsgResVO.setMsg("OK"); res.setCode(StatusEnum.SUCCESS.getCode()); res.setMessage(StatusEnum.SUCCESS.getMessage()); res.setDataBody(sendMsgResVO); return res; } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/handle/CIMServerHandle.java ================================================ package com.crossoverjie.cim.server.handle; import com.crossoverjie.cim.common.exception.CIMException; import com.crossoverjie.cim.common.kit.HeartBeatHandler; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.protocol.Request; import com.crossoverjie.cim.common.util.NettyAttrUtil; import com.crossoverjie.cim.server.kit.RouteHandler; import com.crossoverjie.cim.server.kit.ServerHeartBeatHandlerImpl; import com.crossoverjie.cim.server.util.SessionSocketHolder; import com.crossoverjie.cim.server.util.SpringBeanFactory; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import lombok.extern.slf4j.Slf4j; /** * Function: * * @author crossoverJie * Date: 17/05/2018 18:52 * @since JDK 1.8 */ @ChannelHandler.Sharable @Slf4j public class CIMServerHandle extends SimpleChannelInboundHandler { /** * 取消绑定 * * @param ctx * @throws Exception */ @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { //可能出现业务判断离线后再次触发 channelInactive CIMUserInfo userInfo = SessionSocketHolder.getUserId((NioSocketChannel) ctx.channel()); if (userInfo != null) { log.warn("[{}] trigger channelInactive offline!", userInfo.getUserName()); //Clear route info and offline. RouteHandler routeHandler = SpringBeanFactory.getBean(RouteHandler.class); routeHandler.userOffLine(userInfo, (NioSocketChannel) ctx.channel()); ctx.channel().close(); } } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent idleStateEvent = (IdleStateEvent) evt; if (idleStateEvent.state() == IdleState.READER_IDLE) { log.info("!!READER_IDLE!!"); HeartBeatHandler heartBeatHandler = SpringBeanFactory.getBean(ServerHeartBeatHandlerImpl.class); heartBeatHandler.process(ctx); } } super.userEventTriggered(ctx, evt); } @Override protected void channelRead0(ChannelHandlerContext ctx, Request msg) throws Exception { log.info("received msg=[{}]", msg.toString()); if (msg.getCmd() == BaseCommand.LOGIN_REQUEST) { //保存客户端与 Channel 之间的关系 SessionSocketHolder.put(msg.getRequestId(), (NioSocketChannel) ctx.channel()); SessionSocketHolder.saveSession(msg.getRequestId(), msg.getReqMsg()); log.info("client [{}] online success!!", msg.getReqMsg()); } //心跳更新时间 if (msg.getCmd() == BaseCommand.PING) { NettyAttrUtil.updateReaderTime(ctx.channel(), System.currentTimeMillis()); //向客户端响应 pong 消息 Request heartBeat = SpringBeanFactory.getBean("heartBeat", Request.class); ctx.writeAndFlush(heartBeat).addListeners((ChannelFutureListener) future -> { if (!future.isSuccess()) { log.error("IO error,close Channel"); future.channel().close(); } }); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { if (CIMException.isResetByPeer(cause.getMessage())) { return; } log.error(cause.getMessage(), cause); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/init/CIMServerInitializer.java ================================================ package com.crossoverjie.cim.server.init; import com.crossoverjie.cim.common.protocol.Request; import com.crossoverjie.cim.server.handle.CIMServerHandle; import io.netty.channel.Channel; import io.netty.channel.ChannelInitializer; import io.netty.handler.codec.protobuf.ProtobufDecoder; import io.netty.handler.codec.protobuf.ProtobufEncoder; import io.netty.handler.codec.protobuf.ProtobufVarint32FrameDecoder; import io.netty.handler.codec.protobuf.ProtobufVarint32LengthFieldPrepender; import io.netty.handler.timeout.IdleStateHandler; /** * Function: * * @author crossoverJie * Date: 17/05/2018 18:51 * @since JDK 1.8 */ public class CIMServerInitializer extends ChannelInitializer { private final CIMServerHandle cimServerHandle = new CIMServerHandle(); @Override protected void initChannel(Channel ch) throws Exception { ch.pipeline() //11 秒没有向客户端发送消息就发生心跳 .addLast(new IdleStateHandler(11, 0, 0)) // google Protobuf 编解码 .addLast(new ProtobufVarint32FrameDecoder()) .addLast(new ProtobufDecoder(Request.getDefaultInstance())) .addLast(new ProtobufVarint32LengthFieldPrepender()) .addLast(new ProtobufEncoder()) .addLast(cimServerHandle); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/kit/RegistryMetaStore.java ================================================ package com.crossoverjie.cim.server.kit; import com.crossoverjie.cim.common.metastore.MetaStore; import com.crossoverjie.cim.common.metastore.ZkConfiguration; import com.crossoverjie.cim.server.config.AppConfiguration; import com.crossoverjie.cim.server.util.SpringBeanFactory; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.curator.retry.ExponentialBackoffRetry; /** * Function: * * @author crossoverJie * Date: 2018/8/24 01:37 * @since JDK 1.8 */ @Slf4j public class RegistryMetaStore implements Runnable { private MetaStore metaStore; private AppConfiguration appConfiguration; private String ip; private int cimServerPort; private int httpPort; public RegistryMetaStore(MetaStore metaStore, String ip, int cimServerPort, int httpPort) { this.ip = ip; this.cimServerPort = cimServerPort; this.httpPort = httpPort; this.metaStore = metaStore; appConfiguration = SpringBeanFactory.getBean(AppConfiguration.class); } @SneakyThrows @Override public void run() { if (!appConfiguration.isZkSwitch()) { log.info("Skip registry to metaStore"); return; } ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3); metaStore.initialize(ZkConfiguration.builder() .metaServiceUri(appConfiguration.getZkAddr()) .timeoutMs(appConfiguration.getZkConnectTimeout()) .retryPolicy(retryPolicy) .build()); metaStore.addServer(ip, cimServerPort, httpPort); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/kit/RouteHandler.java ================================================ package com.crossoverjie.cim.server.kit; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.route.api.RouteApi; import com.crossoverjie.cim.route.api.vo.req.ChatReqVO; import com.crossoverjie.cim.server.util.SessionSocketHolder; import io.netty.channel.socket.nio.NioSocketChannel; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 2019-01-20 17:20 * @since JDK 1.8 */ @Component @Slf4j public class RouteHandler { @Resource private RouteApi routeApi; /** * 用户下线 * * @param userInfo * @param channel */ public void userOffLine(CIMUserInfo userInfo, NioSocketChannel channel) { if (userInfo != null) { log.info("Account [{}] offline", userInfo.getUserName()); SessionSocketHolder.removeSession(userInfo.getUserId()); //清除路由关系 clearRouteInfo(userInfo); } SessionSocketHolder.remove(channel); } /** * 清除路由关系 * * @param userInfo * @throws IOException */ public void clearRouteInfo(CIMUserInfo userInfo) { ChatReqVO vo = new ChatReqVO(userInfo.getUserId(), userInfo.getUserName(), null); routeApi.offLine(vo); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/kit/ServerHeartBeatHandlerImpl.java ================================================ package com.crossoverjie.cim.server.kit; import com.crossoverjie.cim.common.kit.HeartBeatHandler; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import com.crossoverjie.cim.common.util.NettyAttrUtil; import com.crossoverjie.cim.server.config.AppConfiguration; import com.crossoverjie.cim.server.util.SessionSocketHolder; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.socket.nio.NioSocketChannel; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * Function: * * @author crossoverJie * Date: 2019-01-20 17:16 * @since JDK 1.8 */ @Service @Slf4j public class ServerHeartBeatHandlerImpl implements HeartBeatHandler { @Autowired private RouteHandler routeHandler; @Autowired private AppConfiguration appConfiguration; @Override public void process(ChannelHandlerContext ctx) throws Exception { long heartBeatTime = appConfiguration.getHeartBeatTime() * 1000; Long lastReadTime = NettyAttrUtil.getReaderTime(ctx.channel()); long now = System.currentTimeMillis(); if (lastReadTime != null && now - lastReadTime > heartBeatTime) { CIMUserInfo userInfo = SessionSocketHolder.getUserId((NioSocketChannel) ctx.channel()); if (userInfo != null) { log.warn("客户端[{}]心跳超时[{}]ms,需要关闭连接!", userInfo.getUserName(), now - lastReadTime); } routeHandler.userOffLine(userInfo, (NioSocketChannel) ctx.channel()); ctx.channel().close(); } } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/server/CIMServer.java ================================================ package com.crossoverjie.cim.server.server; import com.crossoverjie.cim.common.protocol.Request; import com.crossoverjie.cim.server.api.vo.req.SendMsgReqVO; import com.crossoverjie.cim.server.init.CIMServerInitializer; import com.crossoverjie.cim.server.util.SessionSocketHolder; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import java.net.InetSocketAddress; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * Function: * * @author crossoverJie * Date: 21/05/2018 00:30 * @since JDK 1.8 */ @Component @Slf4j public class CIMServer { private EventLoopGroup boss = new NioEventLoopGroup(); private EventLoopGroup work = new NioEventLoopGroup(); @Value("${cim.server.port}") private int nettyPort; /** * 启动 cim server * * @return * @throws InterruptedException */ @PostConstruct public void start() throws InterruptedException { ServerBootstrap bootstrap = new ServerBootstrap() .group(boss, work) .channel(NioServerSocketChannel.class) .localAddress(new InetSocketAddress(nettyPort)) //保持长连接 .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new CIMServerInitializer()); ChannelFuture future = bootstrap.bind().sync(); if (future.isSuccess()) { log.info("Start cim server success!!!"); } } /** * 销毁 */ @PreDestroy public void destroy() { boss.shutdownGracefully().syncUninterruptibly(); work.shutdownGracefully().syncUninterruptibly(); log.info("Close cim server success!!!"); } /** * Push msg to client. * @param sendMsgReqVO message body */ public void sendMsg(SendMsgReqVO sendMsgReqVO) { NioSocketChannel socketChannel = SessionSocketHolder.get(sendMsgReqVO.getUserId()); if (null == socketChannel) { log.error("client {} offline!", sendMsgReqVO.getUserId()); return; } Request.Builder requestBuilder = Request.newBuilder() .setRequestId(sendMsgReqVO.getUserId()) .putAllProperties(sendMsgReqVO.getProperties()) .setCmd(sendMsgReqVO.getCmd()); boolean isBatch = sendMsgReqVO.getBatchMsg() != null && sendMsgReqVO.getBatchMsg().size() > 0; if (isBatch) { requestBuilder.addAllBatchReqMsg(sendMsgReqVO.getBatchMsg()); } else { requestBuilder.setReqMsg(sendMsgReqVO.getMsg()); } Request protocol = requestBuilder.build(); ChannelFuture future = socketChannel.writeAndFlush(protocol); future.addListener((ChannelFutureListener) channelFuture -> log.info("server push {} msg:[{}], socketChannel:{}", isBatch ? "batch" : "single", sendMsgReqVO, socketChannel)); } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/util/SessionSocketHolder.java ================================================ package com.crossoverjie.cim.server.util; import com.crossoverjie.cim.common.pojo.CIMUserInfo; import io.netty.channel.socket.nio.NioSocketChannel; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Function: * * @author crossoverJie * Date: 22/05/2018 18:33 * @since JDK 1.8 */ public class SessionSocketHolder { private static final Map CHANNEL_MAP = new ConcurrentHashMap<>(16); private static final Map SESSION_MAP = new ConcurrentHashMap<>(16); public static void saveSession(Long userId, String userName) { SESSION_MAP.put(userId, userName); } public static void removeSession(Long userId) { SESSION_MAP.remove(userId); } /** * Save the relationship between the userId and the channel. * @param id * @param socketChannel */ public static void put(Long id, NioSocketChannel socketChannel) { CHANNEL_MAP.put(id, socketChannel); } public static NioSocketChannel get(Long id) { return CHANNEL_MAP.get(id); } public static Map getRelationShip() { return CHANNEL_MAP; } public static void remove(NioSocketChannel nioSocketChannel) { CHANNEL_MAP.entrySet().stream().filter(entry -> entry.getValue() == nioSocketChannel).forEach(entry -> CHANNEL_MAP.remove(entry.getKey())); } /** * 获取注册用户信息 * @param nioSocketChannel * @return */ public static CIMUserInfo getUserId(NioSocketChannel nioSocketChannel) { for (Map.Entry entry : CHANNEL_MAP.entrySet()) { NioSocketChannel value = entry.getValue(); if (nioSocketChannel == value) { Long key = entry.getKey(); String userName = SESSION_MAP.get(key); CIMUserInfo info = new CIMUserInfo(key, userName); return info; } } return null; } } ================================================ FILE: cim-server/src/main/java/com/crossoverjie/cim/server/util/SpringBeanFactory.java ================================================ package com.crossoverjie.cim.server.util; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; @Component public final class SpringBeanFactory implements ApplicationContextAware { private static ApplicationContext context; public static T getBean(Class c) { return context.getBean(c); } public static T getBean(String name, Class clazz) { return context.getBean(name, clazz); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { context = applicationContext; } } ================================================ FILE: cim-server/src/main/resources/application.yaml ================================================ spring: application: name: cim-server # web port server: port: 8081 # enable swagger springdoc: swagger-ui: enabled: true logging: level: root: info # enable zk app: zk: switch: true addr: 127.0.0.1:2181 connect: timeout: 30000 root: /route # zk root path # cim server config cim: server: port: 11211 route: url: http://localhost:8083/ # route url suggested that this is Nginx address heartbeat: time: 30 # cim heartbeat time(seconds) ================================================ FILE: cim-server/src/main/resources/banner.txt ================================================ _ ____(_)_ _ ___ ___ _____ _____ ____ / __/ / ' \ (_- heartbeat){ System.out.println("超时"); }else { System.out.println("没有超时"); } } } ================================================ FILE: cim-server-api/pom.xml ================================================ cim com.crossoverjie.netty 1.0.0-SNAPSHOT 4.0.0 cim-server-api 1.0.0-SNAPSHOT com.crossoverjie.netty cim-common log4j log4j org.springframework.boot spring-boot-starter-web jakarta.validation jakarta.validation-api ================================================ FILE: cim-server-api/src/main/java/com/crossoverjie/cim/server/api/ServerApi.java ================================================ package com.crossoverjie.cim.server.api; import com.crossoverjie.cim.common.core.proxy.DynamicUrl; import com.crossoverjie.cim.common.res.BaseResponse; import com.crossoverjie.cim.server.api.vo.req.SendMsgReqVO; import com.crossoverjie.cim.server.api.vo.res.SendMsgResVO; /** * Function: * * @author crossoverJie * Date: 2020-04-25 14:23 * @since JDK 1.8 */ public interface ServerApi { /** * Push msg to client * @param sendMsgReqVO * @return * @throws Exception */ BaseResponse sendMsg(SendMsgReqVO sendMsgReqVO, @DynamicUrl String url); } ================================================ FILE: cim-server-api/src/main/java/com/crossoverjie/cim/server/api/vo/req/SendMsgReqVO.java ================================================ package com.crossoverjie.cim.server.api.vo.req; import com.crossoverjie.cim.common.protocol.BaseCommand; import com.crossoverjie.cim.common.req.BaseRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.Setter; /** * Function: * * @author crossoverJie * Date: 2018/05/21 15:56 * @since JDK 1.8 */ @Builder @AllArgsConstructor public class SendMsgReqVO extends BaseRequest { @NotNull(message = "msg 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "msg", example = "hello") private String msg; @Getter @Setter private List batchMsg; @NotNull(message = "userId 不能为空") @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "userId", example = "11") @Getter private Long userId; @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "cmd", example = "message") @Getter private BaseCommand cmd; @Setter @Getter private Map properties; public SendMsgReqVO() { } public SendMsgReqVO(String msg, Long userId, List batchMsg, BaseCommand cmd) { this.msg = msg; this.batchMsg = batchMsg; this.userId = userId; this.cmd = cmd; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } @Override public String toString() { return "SendMsgReqVO{" + "msg='" + msg + '\'' + ", batchMsg=" + batchMsg + ", userId=" + userId + ", cmd=" + cmd + ", properties=" + properties + '}'; } } ================================================ FILE: cim-server-api/src/main/java/com/crossoverjie/cim/server/api/vo/res/OfflineMsgResVO.java ================================================ package com.crossoverjie.cim.server.api.vo.res; /** * @author zhongcanyu * @date 2025/5/11 * @description */ public class OfflineMsgResVO { private String msg; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } } ================================================ FILE: cim-server-api/src/main/java/com/crossoverjie/cim/server/api/vo/res/SaveOfflineMsgResVO.java ================================================ package com.crossoverjie.cim.server.api.vo.res; public class SaveOfflineMsgResVO { private String msg; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } } ================================================ FILE: cim-server-api/src/main/java/com/crossoverjie/cim/server/api/vo/res/SendMsgResVO.java ================================================ package com.crossoverjie.cim.server.api.vo.res; /** * Function: * * @author crossoverJie * Date: 2017/6/26 15:43 * @since JDK 1.8 */ public class SendMsgResVO { private String msg; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } } ================================================ FILE: doc/QA.md ================================================ # 以下问题由网友问答整理而来 ## 部署 server 要不要加端口号? ![](https://ws2.sinaimg.cn/large/006tNbRwly1fymb41bob6j31g90c9dk6.jpg) `server` 端口号通过 `cim-server.port` 设置,同一台服务器启动多个 `server` 只要保证端口号唯一即可。 ## 部署路由服务器 `zk` 和 `redis` 地址加不加端口? ![](https://ws2.sinaimg.cn/large/006tNbRwly1fymb9wgo5hj31g909jjv6.jpg) ``` spring.redis.host=xx 
spring.redis.port=6379 ``` 其实所有的配置都是通过 `SpringBoot` 来加载的,看这个配置就知道了。 如果不加会默认使用 jar 包里的配置。 ## 本地启动 路由服务器写 `127.0.0.1` 吗? ![](https://ws4.sinaimg.cn/large/006tNbRwly1fymbc9lzidj31g908g0xb.jpg) 本地的路由服务器是多少就是多少,本机肯定就是 `127.0.0.1`. ## 本地启动如何注册账号? `cim-forward-route` 服务启动之后有一个 `registerAccount` 接口可以注册账号。 ![](https://ws2.sinaimg.cn/large/006tNbRwly1fymbjn98f6j31bn0u0aff.jpg) 账号信息会存放在 `Redis`。 ## 本地如何模拟调试? 至少需要启动以下服务: 1. 服务端 2. 路由 3. 至少两个客户端 4. `redis`、`zk` 基础组件 ================================================ FILE: docker/README.md ================================================ # Build in local ```shell docker build -t cim-allin1:latest -f allin1-ubuntu.Dockerfile . docker run -p 2181:2181 -p 6379:6379 -p 8083:8083 --rm --name cim-allin1 cim-allin1 ``` # Client ```shell java -jar target/cim-client-1.0.0-SNAPSHOT.jar --server.port=8084 --cim.user.id={userId} --cim.user.userName={userName} --cim.route.url=http://127.0.0.1:8083 ``` ================================================ FILE: docker/allin1-ubuntu.Dockerfile ================================================ FROM ubuntu:22.04 # install basic dependencies RUN apt-get update && apt-get install -y \ openjdk-17-jdk \ redis-server \ wget \ supervisor \ netcat-openbsd \ && rm -rf /var/lib/apt/lists/* # install zookeeper RUN wget https://dlcdn.apache.org/zookeeper/zookeeper-3.9.3/apache-zookeeper-3.9.3-bin.tar.gz \ && tar -xzf apache-zookeeper-3.9.3-bin.tar.gz -C /opt \ && mv /opt/apache-zookeeper-3.9.3-bin /opt/zookeeper \ && rm apache-zookeeper-3.9.3-bin.tar.gz # configure zookeeper RUN mkdir -p /var/lib/zookeeper && \ echo "tickTime=2000\n\ dataDir=/var/lib/zookeeper\n\ clientPort=2181\n\ initLimit=5\n\ syncLimit=2\n" > /opt/zookeeper/conf/zoo.cfg # wait-for-it.sh ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /wait-for-it.sh RUN chmod +x /wait-for-it.sh # copy java app ADD https://github.com/crossoverJie/cim/releases/download/v2.1.0/cim-server-1.0.0-SNAPSHOT.jar /cim-server.jar ADD https://github.com/crossoverJie/cim/releases/download/v2.1.0/cim-forward-route-1.0.0-SNAPSHOT.jar /cim-route.jar RUN mkdir -p /var/log/supervisor ADD https://raw.githubusercontent.com/crossoverJie/cim/refs/heads/master/docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf CMD ["supervisord", "-n"] ================================================ FILE: docker/client-ubuntu.Dockerfile ================================================ # This Dockerfile is reserved for configuring the Ubuntu-based client environment. # Implementation will be added in the future as part of the client setup process. ================================================ FILE: docker/supervisord.conf ================================================ [supervisord] nodaemon=true logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid [program:redis] command=redis-server --bind 0.0.0.0 autostart=true autorestart=unexpected stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:zookeeper] command=/opt/zookeeper/bin/zkServer.sh start-foreground autostart=true autorestart=unexpected stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:server] command=bash -c "/wait-for-it.sh -t 60 localhost:6379 -- \ /wait-for-it.sh -t 60 localhost:2181 -- \ java -jar /cim-server.jar" autostart=true autorestart=false stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:route] command=bash -c "\ /wait-for-it.sh -t 60 localhost:11211 -- \ java -jar /cim-route.jar" autorestart=false stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ================================================ FILE: docker/wait-for-it.sh ================================================ #!/usr/bin/env bash # Use this script to test if a given TCP host/port are available WAITFORIT_cmdname=${0##*/} echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } usage() { cat << USAGE >&2 Usage: $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] -h HOST | --host=HOST Host or IP under test -p PORT | --port=PORT TCP port under test Alternatively, you specify the host and port as host:port -s | --strict Only execute subcommand if the test succeeds -q | --quiet Don't output any status messages -t TIMEOUT | --timeout=TIMEOUT Timeout in seconds, zero for no timeout -- COMMAND ARGS Execute command with args after the test finishes USAGE exit 1 } wait_for() { if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" else echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" fi WAITFORIT_start_ts=$(date +%s) while : do if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then nc -z $WAITFORIT_HOST $WAITFORIT_PORT WAITFORIT_result=$? else (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 WAITFORIT_result=$? fi if [[ $WAITFORIT_result -eq 0 ]]; then WAITFORIT_end_ts=$(date +%s) echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" break fi sleep 1 done return $WAITFORIT_result } wait_for_wrapper() { # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 if [[ $WAITFORIT_QUIET -eq 1 ]]; then timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & else timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & fi WAITFORIT_PID=$! trap "kill -INT -$WAITFORIT_PID" INT wait $WAITFORIT_PID WAITFORIT_RESULT=$? if [[ $WAITFORIT_RESULT -ne 0 ]]; then echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" fi return $WAITFORIT_RESULT } # process arguments while [[ $# -gt 0 ]] do case "$1" in *:* ) WAITFORIT_hostport=(${1//:/ }) WAITFORIT_HOST=${WAITFORIT_hostport[0]} WAITFORIT_PORT=${WAITFORIT_hostport[1]} shift 1 ;; --child) WAITFORIT_CHILD=1 shift 1 ;; -q | --quiet) WAITFORIT_QUIET=1 shift 1 ;; -s | --strict) WAITFORIT_STRICT=1 shift 1 ;; -h) WAITFORIT_HOST="$2" if [[ $WAITFORIT_HOST == "" ]]; then break; fi shift 2 ;; --host=*) WAITFORIT_HOST="${1#*=}" shift 1 ;; -p) WAITFORIT_PORT="$2" if [[ $WAITFORIT_PORT == "" ]]; then break; fi shift 2 ;; --port=*) WAITFORIT_PORT="${1#*=}" shift 1 ;; -t) WAITFORIT_TIMEOUT="$2" if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi shift 2 ;; --timeout=*) WAITFORIT_TIMEOUT="${1#*=}" shift 1 ;; --) shift WAITFORIT_CLI=("$@") break ;; --help) usage ;; *) echoerr "Unknown argument: $1" usage ;; esac done if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then echoerr "Error: you need to provide a host and port to test." usage fi WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} # Check to see if timeout is from busybox? WAITFORIT_TIMEOUT_PATH=$(type -p timeout) WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) WAITFORIT_BUSYTIMEFLAG="" if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then WAITFORIT_ISBUSY=1 # Check if busybox timeout uses -t flag # (recent Alpine versions don't support -t anymore) if timeout &>/dev/stdout | grep -q -e '-t '; then WAITFORIT_BUSYTIMEFLAG="-t" fi else WAITFORIT_ISBUSY=0 fi if [[ $WAITFORIT_CHILD -gt 0 ]]; then wait_for WAITFORIT_RESULT=$? exit $WAITFORIT_RESULT else if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then wait_for_wrapper WAITFORIT_RESULT=$? else wait_for WAITFORIT_RESULT=$? fi fi if [[ $WAITFORIT_CLI != "" ]]; then if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" exit $WAITFORIT_RESULT fi exec "${WAITFORIT_CLI[@]}" else exit $WAITFORIT_RESULT fi ================================================ FILE: pom.xml ================================================ 4.0.0 com.crossoverjie.netty cim 1.0.0-SNAPSHOT pom cim Spring Boot 4.12 4.1.68.Final UTF-8 UTF-8 2.5.0 5.1.0 3.8.6 0.8.11 4.28.3 1.19.0 10.14.2 3.3.1 org.springframework.boot spring-boot-starter-parent 3.3.0 cim-server cim-client cim-common cim-forward-route cim-rout-api cim-server-api cim-integration-test cim-client-sdk cim-persistence com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.4.0 com.squareup.okhttp3 okhttp 4.9.2 com.101tec zkclient 0.11 org.apache.curator curator-recipes ${curator.version} org.apache.curator curator-x-discovery ${curator.version} org.apache.zookeeper zookeeper ${zookeeper.version} net.java.dev.javacc javacc ch.qos.logback * io.netty * org.apache.zookeeper zookeeper ${zookeeper.version} test-jar org.slf4j slf4j-api ch.qos.logback * io.netty * com.crossoverjie.netty cim-common ${project.version} com.crossoverjie.netty cim-client-sdk ${project.version} com.crossoverjie.netty cim-rout-api ${project.version} com.crossoverjie.netty cim-server-api ${project.version} com.google.protobuf protobuf-java ${protobuf-java.version} io.netty netty-all ${netty.version} junit junit ${junit.version} com.alibaba fastjson 1.2.83 com.github.ben-manes.caffeine caffeine 3.2.3 de.codecentric spring-boot-admin-starter-client 1.5.7 jakarta.validation jakarta.validation-api 3.0.0 com.clever-cloud testcontainers-zookeeper 0.1.3 test com.redis testcontainers-redis 2.2.2 test org.testcontainers mysql 1.19.7 test x86 true aarch aarch64 osx-x86_64 org.apache.maven.plugins maven-compiler-plugin 17 17 org.jacoco jacoco-maven-plugin ${jacoco-maven-plugin.version} prepare-agent report test report org.apache.maven.plugins maven-checkstyle-plugin ${maven-checkstyle-plugin.version} com.puppycrawl.tools checkstyle ${checkstyle.version} checkstyle/checkstyle.xml checkstyle/suppressions.xml true true true true validate validate check ================================================ FILE: script/build.sh ================================================ # todo build ================================================ FILE: script/deploy.sh ================================================ #!/usr/bin/env bash git pull cd .. mvn -Dmaven.test.skip=true clean package # 分发路由 cp /root/work/netty-action/cim-forward-route/target/cim-forward-route-1.0.0-SNAPSHOT.jar /root/work/route/ appname="route" ; PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}') # 遍历杀掉 pid for var in ${PID[@]}; do echo "loop pid= $var" kill -9 $var done echo "开始部署路由。。。。" sh /root/work/route/route-startup.sh echo "部署路由成功!" #======================================= # 部署server cp /root/work/netty-action/cim-server/target/cim-server-1.0.0-SNAPSHOT.jar /root/work/server/ appname="cim-server" ; PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}') # 遍历杀掉 pid for var in ${PID[@]}; do echo "loop pid= $var" kill -9 $var done echo "开始部署服务1。。。。" sh /root/work/server/server-startup.sh echo "部署服务1成功!" echo "开始部署服务2。。。。" cp /root/work/netty-action/cim-server/target/cim-server-1.0.0-SNAPSHOT.jar /root/work/server2/ sh /root/work/server2/server-startup.sh echo "部署服务2成功!" ================================================ FILE: script/route-startup.sh ================================================ #!/usr/bin/env bash nohup java -jar /root/work/route/cim-forward-route-1.0.0-SNAPSHOT.jar > /root/work/route/log.file 2>&1 & ================================================ FILE: script/server-startup.sh ================================================ #!/usr/bin/env bash nohup java -jar /root/work/server/cim-server-1.0.0-SNAPSHOT.jar --cim.server.port=9000 > /root/work/server/log.file 2>&1 & ================================================ FILE: sql/01schema.sql ================================================ create database `cim-test` default character set utf8mb4 collate utf8mb4_general_ci; ================================================ FILE: sql/offline_msg.sql ================================================ CREATE TABLE offline_msg ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id BIGINT NOT NULL, receive_user_id BIGINT NOT NULL, content VARCHAR(2000), message_type INT, status TINYINT, -- 0: Pending, 1: Acked created_at DATETIME, properties VARCHAR(2000), INDEX idx_receive_user_id (receive_user_id) ); ================================================ FILE: sql/offline_msg_last_send_record.sql ================================================ CREATE TABLE offline_msg_last_send_record ( receive_user_id BIGINT NOT NULL PRIMARY KEY, last_message_id BIGINT, updated_at DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;