https://github.com/wangpengxpy/gj.spring.pf4j
A lightweight, modular plugin framework powered by PF4J and Spring, with no heavyweight Spring Boot dependency
https://github.com/wangpengxpy/gj.spring.pf4j
framework java modular pf4j-spring spring spring-boot
Last synced: 5 days ago
JSON representation
A lightweight, modular plugin framework powered by PF4J and Spring, with no heavyweight Spring Boot dependency
- Host: GitHub
- URL: https://github.com/wangpengxpy/gj.spring.pf4j
- Owner: wangpengxpy
- License: mit
- Created: 2026-06-04T17:59:03.000Z (20 days ago)
- Default Branch: main
- Last Pushed: 2026-06-13T18:04:56.000Z (11 days ago)
- Last Synced: 2026-06-13T19:23:56.496Z (11 days ago)
- Topics: framework, java, modular, pf4j-spring, spring, spring-boot
- Language: Java
- Homepage: https://github.com/wangpengxpy/gj.spring.pf4j
- Size: 283 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# gj.spring.pf4j
[](https://adoptium.net/)
[](https://github.com/wangpengxpy/gj.spring.pf4j/blob/main/LICENSE)
[](https://central.sonatype.com/artifact/io.github.wangpengxpy/gj-pf4j)
[](https://github.com/wangpengxpy/gj.spring.pf4j/stargazers)
A lightweight, modular plugin framework powered by PF4J and Spring, with no heavyweight Spring Boot dependency. Supports both Spring MVC and Spring WebFlux routing — auto-adapts to the host application's web stack.
> [中文文档](README_CN.md)
---
## Table of Contents
1. [Overview](#1-overview)
2. [Quick Start](#2-quick-start)
3. [Plugin Project Structure](#3-plugin-project-structure)
4. [Plugin Lifecycle](#4-plugin-lifecycle)
5. [REST Endpoints](#5-rest-endpoints)
6. [Data Access](#6-data-access)
7. [Database Auto-Migration](#7-database-auto-migration)
8. [Object Mapping](#8-object-mapping)
9. [Plugin Configuration Management](#9-plugin-configuration-management)
10. [Real-Time Communication](#10-real-time-communication)
11. [Internationalization (i18n)](#11-internationalization-i18n)
12. [Import/Export](#12-importexport)
13. [Scheduled Tasks](#13-scheduled-tasks)
14. [In-Process Event Bus](#14-in-process-event-bus)
15. [OpenAPI Documentation](#15-openapi-documentation)
16. [Plugin Packaging & Deployment](#16-plugin-packaging--deployment)
17. [Runtime Plugin Management API](#17-runtime-plugin-management-api)
18. [Appendix: Host Application Integration](#18-appendix-host-application-integration)
* [Version Compatibility](#181-version-compatibility)
* [Host Application Entry Point](#182-host-application-entry-point)
* [Optional: @GJModelMapperScan (Shared Models)](#183-optional-gjmodelmapperscan-shared-models)
19. [Claude Code Integration](#19-claude-code-integration)
20. [FAQ](#20-faq)
---
## 1. Overview
gj.spring.pf4j is a lightweight, modular plugin framework built on [PF4J](https://pf4j.org/) with Spring integration. It depends only on Spring core libraries (spring-context, spring-webmvc, spring-jdbc, etc.) and does not require Spring Boot as a runtime dependency. It is suitable for projects that need a modular architecture without the full Spring Boot stack.
### Core Capabilities
- **[Plugin Lifecycle Management](#4-plugin-lifecycle)** — load, start, stop, restart, unload, and delete plugins at runtime
- **[Runtime Plugin Management API](#17-runtime-plugin-management-api)** — `GJPluginService` provides lock-controlled runtime management
- **[REST Endpoints](#5-rest-endpoints)** — `@RestController` beans are auto-detected and registered into the main application's route table, supporting both MVC and WebFlux
- **[Dual Routing Mode](#52-spring-mvc-vs-webflux-dual-routing)** — supports both Spring MVC (Servlet) and Spring WebFlux (Reactive) routing; plugins require zero changes
- **[OpenAPI Documentation](#15-openapi-documentation)** — powered by SpringDoc; each plugin auto-generates an independent `GroupedOpenApi`
- **[Data Access](#6-data-access)** — powered by [MyBatis-Plus](https://baomidou.com/); each plugin gets its own `SqlSessionFactory`, `SqlSessionTemplate`, and `TransactionManager`, all sharing the main application's `DataSource`
- **[SQL Keyword Quoting](#66-sql-keyword-quoting)** — MyBatis-Plus `InnerInterceptor` automatically wraps column names with database-specific quote characters to prevent reserved-keyword conflicts; both host app and plugins can register keyword definitions via `GJTableKeywordProvider`
- **[Database Auto-Migration](#7-database-auto-migration)** — automatic `@TableName` entity schema migration (CREATE TABLE / ADD COLUMN only), supports 7 databases, production-safe
- **[Object Mapping](#8-object-mapping)** — powered by [ModelMapper](https://modelmapper.org/); plugins implement `GJPluginModelMapperConfig`, auto-discovered via Spring bean scanning
- **[Import/Export](#12-importexport)** — powered by [EasyExcel](https://easyexcel.opensource.alibaba.com/); multi-sheet read/write with automatic i18n header translation
- **[Real-Time Communication](#10-real-time-communication)** — powered by [netty-socketio](https://github.com/mrniko/netty-socketio); built-in Hub pattern (SignalR-style) with group and user-targeted messaging
- **[Scheduled Tasks](#13-scheduled-tasks)** — powered by [Quartz](https://www.quartz-scheduler.org/); supports cron, fixed-interval, and run-once execution
- **[In-Process Event Bus](#14-in-process-event-bus)** — lightweight in-process event bus with sync/async publishing and Ant-style wildcard matching
- **[Internationalization (i18n)](#11-internationalization-i18n)** — per-plugin `messages.properties` with fallback to the main application
---
## 2. Quick Start
### 2.1 Install the Archetype Locally
```bash
cd src/gj-archetypes
mvn clean install
```
### 2.2 Generate a Plugin Project
```bash
mvn archetype:generate \
-DarchetypeGroupId=io.github.wangpengxpy \
-DarchetypeArtifactId=gj-archetype \
-DarchetypeVersion=1.0.4 \
-DgroupId=com.example \
-DpluginName=user \
-DpackagePrefix=gj.module
```
**Parameters:**
| Parameter | Meaning | Example |
|---|---|---|
| `groupId` | Maven groupId for the generated project | `com.example` |
| `pluginName` | Short plugin name (used for class/package naming) | `user` |
| `packagePrefix` | Java package prefix for the plugin | `gj.module` |
The generated `plugin.id` will be `gj.module.user`, and all Java classes will reside under the `gj.module.user` package.
### 2.3 Generated Project Structure
```
user-plugin/
├── pom.xml # Plugin POM, depends on gj-pf4j
├── pom-parent.xml # Build parent POM (packaging rules)
└── src/
└── main/
├── java/
│ └── gj/module/user/
│ ├── UserPlugin.java # Plugin entry point
│ ├── UserConfig.java # Plugin configuration
│ ├── controllers/
│ │ └── UserController.java # REST controller
│ ├── dao/
│ │ └── UserMapper.java # MyBatis Mapper
│ ├── dto/
│ │ └── EgroupDTO.java # Data transfer object
│ ├── model/
│ │ └── Test.java # Database entity
│ ├── modelmapper/
│ │ └── UserModelMapperConfig.java # ModelMapper config
│ ├── request/
│ │ └── UserEventRequest.java # Request DTO
│ ├── response/
│ │ ├── UserResponse.java # List response DTO
│ │ └── UserEventResponse.java # Event response DTO
│ └── service/
│ ├── UserService.java # Service interface
│ └── impl/
│ └── UserServiceImpl.java # Service implementation
└── resources/
├── plugin.properties # PF4J plugin descriptor
└── gj.module.user.properties # Plugin business configuration
```
---
## 3. Plugin Project Structure
### 3.1 Directory Conventions
| Directory / File | Purpose | Notes |
|---|---|---|
| `{Plugin}.java` | Plugin entry class | Extends `GJPlugin`, lifecycle hooks |
| `{Plugin}Config.java` | Plugin config class | `@ConfigurationProperties` binding |
| `controllers/` | REST controllers | `@RestController`, auto-registered as routes |
| `dao/` | Data access layer | MyBatis Mapper interfaces, extending `BaseMapper` |
| `model/` | Database entities | MyBatis-Plus `@TableName` entities |
| `dto/` | Data transfer objects | Non-persistent DTOs |
| `request/` | Request objects | API input DTOs |
| `response/` | Response objects | API return DTOs |
| `service/` | Service interfaces | Business logic interfaces |
| `serviceimpl/` | Service implementations | `@Service`, `@Transactional` |
| `modelmapper/` | Mapping configuration | Implements `GJPluginModelMapperConfig` |
| `plugin.properties` | PF4J descriptor | `plugin.id`, `plugin.class`, `plugin.version` |
| `{pluginId}.properties` | Plugin business config | Business parameters bound to Config class |
### 3.2 plugin.properties
```properties
plugin.id=gj.module.user
plugin.class=gj.module.user.UserPlugin
plugin.version=1.0.0-SNAPSHOT
plugin.description=
plugin.provider=
plugin.dependencies=
```
> **Constraint:** `plugin.id` must exactly match the plugin's main package name.
### 3.3 {pluginId}.properties (Plugin Business Config)
```properties
gj.module.user.enabled=true
gj.module.user.value=iot
```
### 3.4 pom-parent.xml Build Rules
The `pom-parent.xml` is a standalone parent POM with a fully automated build pipeline. Running `mvn clean package` executes the following steps in order:
#### Step 1: Dependency Classification — Auto-Separate Shared vs. Private JARs
`maven-dependency-plugin` scans all runtime dependencies at the `prepare-package` phase and automatically classifies them based on the `excludeGroupIds` list:
| Dependency Type | Criteria | Handling |
|---|---|---|
| **Shared (provided by host)** | groupId in the exclusion list | **Skipped** — not copied, not packaged |
| **Plugin-private** | groupId NOT in the exclusion list | Copied to `target/lib/`, packaged into `lib/` |
The exclusion list covers all frameworks already integrated by the host application: the entire Spring ecosystem, MyBatis-Plus, PF4J, Jackson, Netty, SocketIO, ModelMapper, EasyExcel, Lombok, SLF4J, Hibernate Validator, Jakarta, and more — 60+ groupIds in total.
```xml
org.springframework, org.springframework.boot,
org.mybatis, com.baomidou,
org.pf4j,
com.fasterxml.jackson.core,
io.netty, com.corundumstudio.socketio,
org.modelmapper,
org.projectlombok,
org.slf4j, ch.qos.logback,
jakarta.servlet, jakarta.annotation,
com.alibaba,
...
```
> **This means:** you simply declare dependencies in `pom.xml`. The build automatically decides — shared JARs are excluded; only plugin-specific third-party libraries end up in `lib/`.
#### Step 2: Private Library Aggregation
If the plugin has **non-Maven-Central private JARs** (e.g., internal SDKs, customized libraries), place them in `src/lib/`. They are automatically merged into `target/lib/` during the build.
```
user-plugin/
src/
lib/
internal-sdk-1.0.jar ← manually placed, auto-packaged into lib/
```
#### Step 3: MANIFEST.MF Auto-Generation
`maven-antrun-plugin` scans all JARs under `target/lib/` and generates the MANIFEST.MF:
```manifest
Manifest-Version: 1.0
Class-Path: lib/internal-sdk-1.0.jar lib/some-third-party.jar
Plugin-Id: gj.module.user
Plugin-Version: 1.0.0-SNAPSHOT
```
`maven-jar-plugin` uses this MANIFEST.MF when packaging the JAR, ensuring that `GJJarPluginLoader` correctly resolves and loads `lib/` dependencies at runtime.
#### Step 4: Assemble Output Directory
All artifacts converge into `target/plugins/{artifactId}/`:
```
target/plugins/gj.module.user/
├── gj.module.user-1.0.0-SNAPSHOT.jar ← Plugin main JAR (with generated MANIFEST.MF)
├── gj.module.user.json ← Copied from src/main/resources
└── lib/ ← Plugin-private dependencies (auto-classified + src/lib/ merged)
├── internal-sdk-1.0.jar
└── some-third-party.jar
```
The entire directory can be copied directly into the host application's `plugins/` directory for deployment (see Chapter 16).
---
## 4. Plugin Lifecycle
Every plugin must create a class extending `GJPlugin`. The framework controls initialization through two hooks:
### 4.1 Plugin Entry Class
```java
package gj.module.user;
import gj.pf4j.GJPlugin;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class UserPlugin extends GJPlugin {
/**
* Called BEFORE the Spring context is refreshed.
* Use this to programmatically register beans (do NOT rely on
* @Component/@Service stereotype scanning here).
*/
@Override
protected AnnotationConfigApplicationContext beforeApplicationContextRefresh(
AnnotationConfigApplicationContext context) {
// Example: register a manually created bean
context.registerBean("customBean", CustomBean.class);
return context;
}
/**
* Called AFTER the Spring context has been fully refreshed.
* Beans can be safely retrieved and initialized here.
*/
@Override
protected void afterApplicationContextReady(
AnnotationConfigApplicationContext context) {
// Example: retrieve a bean and run initialization logic
UserService userService = context.getBean(UserService.class);
userService.initialize();
}
}
```
### 4.2 Lifecycle Flow
```
Plugin loaded (loadPlugin)
└─ GJSpringPlugin wrapper created
└─ start()
├─ preCreateApplicationContext() ← Creates AnnotationConfigApplicationContext
│ ├─ Registers GJPluginLifecycleManager
│ ├─ Sets GJPluginBeanNameGenerator (prevents bean name collisions)
│ ├─ Sets parent context = main app ApplicationContext
│ └─ Scans the plugin package
│
├─ beforeApplicationContextRefresh() ← [Hook 1] Programmatic bean registration
│
├─ registerPluginResources() ← Registers i18n / MyBatis / config files
│
├─ context.refresh() ← Spring container refresh
│
└─ afterApplicationContextReady() ← [Hook 2] Post-init logic
```
### 4.3 Key Constraints
- **Naming consistency:** `plugin.id` (e.g., `gj.module.user`) must exactly match the package name of the plugin's main class. Mismatch throws `IllegalStateException` at startup.
- **Bean registration pattern:** The framework auto-scans the plugin package via `scan()`, detecting `@Component`, `@Service`, `@Configuration`, and other Spring stereotypes. `beforeApplicationContextRefresh()` is called before the context is refreshed — use `context.registerBean()` for programmatic registration.
- **Bean name isolation:** The framework automatically prefixes all plugin bean names with `{pluginId}.` to prevent collisions (e.g., `gj.module.user.userService`).
---
## 5. REST Endpoints
### 5.1 Basic Usage
Create `@RestController` classes in the plugin. The framework auto-detects all `@RequestMapping`-annotated methods and registers them into the main application's route table at plugin startup.
```java
package gj.module.user.controllers;
import gj.module.user.response.UserResponse;
import gj.module.user.service.UserService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/user")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/list")
public List getList() {
return userService.getList();
}
@GetMapping("/{id}")
public UserResponse getById(@PathVariable Integer id) {
return userService.getById(id);
}
@PostMapping("/create")
public boolean create(@RequestBody UserCreateRequest request) {
return userService.create(request);
}
@DeleteMapping("/{id}")
public boolean delete(@PathVariable Integer id) {
return userService.delete(id);
}
}
```
### 5.2 Spring MVC vs. WebFlux Dual Routing
The framework **supports both Spring MVC (Servlet stack) and Spring WebFlux (Reactive stack)**, automatically adapting to the host application's web architecture. Plugin-side code is **identical** for both modes — you always use `@RestController` + `@RequestMapping`. The only difference is which HandlerMapping is used at runtime:
| Host App Architecture | Registered HandlerMapping | Routing Style |
|---|---|---|
| Spring MVC (Servlet) | `GJPluginRequestMappingHandlerMapping` | `@RequestMapping` annotation-based |
| Spring WebFlux (Reactive) | `GJPluginWebFluxRequestMappingHandlerMapping` | `@RequestMapping` annotation-based |
#### Annotation-Based Routing
Write `@RestController` as usual in the plugin. The framework automatically selects the correct HandlerMapping based on the host application's web type. **Plugins do not need to know whether the host uses MVC or WebFlux.**
#### WebFlux Functional Routing (WebFlux Mode Only)
When the host application uses WebFlux, the framework also supports defining routes via `RouterFunction` (functional style). Inject `GJPluginWebFluxRouterFunctionRegistry` and call `register()`:
```java
package gj.module.user.route;
import gj.pf4j.webflux.GJPluginWebFluxRouterFunctionRegistry;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.*;
import java.util.List;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
@Configuration
public class UserRouterConfig {
private final GJPluginWebFluxRouterFunctionRegistry registry;
private final UserHandler handler;
public UserRouterConfig(GJPluginWebFluxRouterFunctionRegistry registry, UserHandler handler) {
this.registry = registry;
this.handler = handler;
}
@PostConstruct
public void registerRoutes() {
RouterFunction routes = RouterFunctions
.route(GET("/api/v1/user/list"), handler::getList)
.andRoute(GET("/api/v1/user/{id}"),
request -> handler.getById(request.pathVariable("id")));
registry.register(List.of(routes));
}
}
```
> Call `unregister()` for cleanup on hot-unload. See **[Appendix: Host Application Integration](#18-appendix-host-application-integration)** for detailed MVC / WebFlux configuration steps.
---
## 6. Data Access
Powered by [MyBatis-Plus](https://baomidou.com/).
### 6.1 DAO Package Convention
Mapper interfaces must reside in the `{pluginId}.dao` package (dots replace hyphens). For example, `plugin.id = gj.module.user` → DAO package is `gj.module.user.dao`.
### 6.2 Entity Class
```java
package gj.module.user.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
@Data
@TableName("user")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField("name")
private String name;
@TableField("email")
private String email;
@TableField("description")
private String description;
}
```
### 6.3 Mapper Interface
```java
package gj.module.user.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import gj.module.user.model.User;
public interface UserMapper extends BaseMapper {
}
```
### 6.4 Service Implementation
```java
package gj.module.user.serviceimpl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import gj.module.user.dao.UserMapper;
import gj.module.user.model.User;
import gj.module.user.response.UserResponse;
import gj.module.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@Transactional
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
public UserServiceImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public List getList() {
LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery();
List users = userMapper.selectList(queryWrapper);
return users.stream().map(u -> {
UserResponse resp = new UserResponse();
resp.setId(u.getId());
resp.setName(u.getName());
resp.setEmail(u.getEmail());
return resp;
}).toList();
}
}
```
### 6.5 Data Layer Isolation
`GJPluginMybatisSqlSessionManager` creates for each plugin:
- A dedicated `SqlSessionFactory` (camelCase mapping, no cache, no lazy loading)
- A dedicated `SqlSessionTemplate` (cached for reuse)
- A dedicated `DataSourceTransactionManager`
- A `MapperScannerConfigurer` scoped to the plugin's DAO package only
All plugins share the main application's `DataSource`. Resources are cleaned up automatically when a plugin stops.
### 6.6 SQL Keyword Quoting
The framework includes a MyBatis-Plus `InnerInterceptor` that automatically detects the database type at runtime and wraps column names with the correct quote character when they conflict with reserved keywords (e.g., `order`, `comment`, `context`).
**Quote character by database:**
| Database | Quote |
|----------|-------|
| MySQL | `` ` `` (backtick) |
| DM / PostgreSQL / GaussDB / KingbaseES / SQLite / Oracle | `"` (double quote) |
**Extension point:** `GJTableKeywordProvider` — both the host application and plugins implement this interface and register as Spring Beans to declare table-column mappings that may conflict with database keywords.
**Host application example:**
```java
package com.example.config;
import gj.pf4j.mybatis.interceptor.GJTableKeywordProvider;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
@Component
public class AppKeywords implements GJTableKeywordProvider {
@Override
public Map> getTableKeywords() {
return Map.of(
"el-t1", Set.of("order"),
"el-t2", Set.of("comment")
);
}
}
```
**Plugin example:**
```java
package gj.module.user.keyword;
import gj.pf4j.mybatis.interceptor.GJTableKeywordProvider;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
@Component
public class UserKeywords implements GJTableKeywordProvider {
@Override
public Map> getTableKeywords() {
return Map.of(
"user_table", Set.of("level", "comment", "type")
);
}
}
```
Host app providers are auto-scanned at startup; plugin providers are auto-scanned after the plugin context is refreshed. Table and column names are case-insensitive.
---
## 7. Database Auto-Migration
The framework provides automatic database schema migration for plugin entities. When a plugin starts, `@TableName` entities are automatically scanned and compared against the current database schema. Missing tables and columns are created automatically — no manual SQL migration scripts required.
### 7.1 Supported Databases
The migration engine supports **7 databases** with automatic dialect detection via JDBC connection metadata:
| Database | Detection |
|----------|-----------|
| MySQL | JDBC URL or product name |
| PostgreSQL | JDBC URL or product name |
| GaussDB / openGauss | JDBC URL or product name |
| KingbaseES | JDBC URL or product name |
| DM (Dameng) | JDBC URL or product name |
| SQLite | JDBC URL or product name |
| Oracle | JDBC URL or product name |
No additional configuration is needed — the dialect (identifier quoting, type mapping, DDL rendering) is resolved automatically from the `DataSource` connection.
### 7.2 Production Safety
The migration engine follows a **strict additive-only policy**. Only two DDL operations are ever generated:
| Operation | Condition |
|-----------|-----------|
| **CREATE TABLE** | Table does not exist in the database |
| **ALTER TABLE ADD COLUMN** | Column does not exist in the target table |
No `DROP TABLE`, `DROP COLUMN`, `ALTER COLUMN`, `RENAME`, or any other destructive DDL is ever produced. Existing tables, columns, and data are never modified. This makes auto-migration safe for production use.
### 7.3 Plugin Auto-Migration
Plugins require **zero configuration** for migration. Any `@TableName` entity class placed under the plugin's package is automatically scanned during plugin startup. The framework compares the entity model against the live database schema and executes any necessary `CREATE TABLE` or `ADD COLUMN` statements.
Migration is triggered automatically when:
- A **new plugin with new entities** is deployed — tables are created
- An **existing plugin adds a new field** to an entity — the column is added
- An **existing plugin adds a new entity** — the table is created
Migration is transparently disabled (zero overhead) when no `GJPluginModelMigrator` bean exists in the main application context — i.e., when `@EnableGJMigration` is not used.
### 7.4 Share Model Migration
The host application can also migrate its own shared model entities (e.g., `User`, `Menu`, `Role` — common entities shared across all plugins). Use the `@EnableGJMigration` annotation with `basePackages` to specify the shared model package paths:
```java
@SpringBootApplication
@ComponentScan("gj")
@EnableGJMigration(basePackages = {"com.example.common.model", "com.example.platform.entity"})
public class GJApplication {
public static void main(String[] args) {
SpringApplication.run(GJApplication.class, args);
}
}
```
**Execution guarantees:**
- **Priority**: Share models are migrated **before any plugin** — the framework guarantees shared tables exist before plugins reference them
- **Once per JVM lifecycle**: Share model migration runs exactly once, regardless of how many plugins are loaded or restarted
> **Note:** Without `@EnableGJMigration`, the host application does not create a `GJPluginModelMigrator` bean, and the entire migration subsystem is inactive. Add the annotation only when you need auto-migration.
---
## 8. Object Mapping
Powered by the open-source library [ModelMapper](https://modelmapper.org/).
### 8.1 Mapping Configuration Class
Implement `GJPluginModelMapperConfig` in your plugin and annotate it with `@Component` to register type mapping rules:
```java
package gj.module.user.modelmapper;
import gj.module.user.dto.UserDTO;
import gj.module.user.model.User;
import gj.pf4j.modelmapper.GJPluginModelMapperConfig;
import gj.pf4j.modelmapper.GJPluginTypeMapConfig;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class UserModelMapperConfig implements GJPluginModelMapperConfig {
@Override
public List getTypeMapConfigs() {
return List.of(
// Simple mapping: same-name fields auto-mapped
GJPluginTypeMapConfig.of(User.class, UserDTO.class),
// Custom mapping: explicit field mapping rules
GJPluginTypeMapConfig.of(User.class, UserResponse.class, typeMap -> {
typeMap.addMapping(User::getId, UserResponse::setId);
typeMap.addMapping(User::getName, UserResponse::setUserName);
typeMap.addMapping(User::getEmail, UserResponse::setEmailAddress);
})
);
}
}
```
### 8.2 Using ModelMapper
The framework builds and registers a `ModelMapper` bean automatically. Inject it directly:
```java
@Service
public class UserServiceImpl implements UserService {
private final ModelMapper modelMapper;
private final UserMapper userMapper;
public UserServiceImpl(ModelMapper modelMapper, UserMapper userMapper) {
this.modelMapper = modelMapper;
this.userMapper = userMapper;
}
@Override
public List getList() {
return userMapper.selectList(Wrappers.lambdaQuery())
.stream()
.map(user -> modelMapper.map(user, UserDTO.class))
.toList();
}
}
```
### 8.3 Mapping Registration Mechanism
- On plugin start, `GJPluginLifecycleManager` listens for `GJPluginStartedEvent`, scans all `GJPluginModelMapperConfig` beans from the plugin context
- All `GJPluginTypeMapConfig` entries are merged: if the host application already has a `ModelMapper`, mappings are appended to the shared instance; otherwise the framework creates one
- If a `TypeMap` already exists for a source/destination pair, the `merge` strategy is used (append, not replace)
---
## 9. Plugin Configuration Management
### 9.1 Configuration Class
Use `@ConfigurationProperties` to bind plugin-specific configuration:
```java
package gj.module.user;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Setter
@Getter
@Component
@ConfigurationProperties(prefix = "gj.module.user")
public class UserConfig {
private boolean enabled = true;
private String value;
private int maxRetry = 3;
private String apiUrl;
}
```
### 9.2 Configuration File
Provide values in `src/main/resources/{pluginId}.properties`:
```properties
gj.module.user.enabled=true
gj.module.user.value=iot
gj.module.user.max-retry=5
gj.module.user.api-url=https://api.example.com
```
### 9.3 Injection and Usage
Any Spring bean in the plugin can inject the configuration class:
```java
@Service
public class UserServiceImpl implements UserService {
private final UserConfig config;
public UserServiceImpl(UserConfig config) {
this.config = config;
}
public void doSomething() {
if (config.isEnabled()) {
String apiUrl = config.getApiUrl();
// ...
}
}
}
```
### 9.4 Configuration Source Priority
The framework loads configuration with the following priority:
1. Plugin container internal environment variables
2. `{pluginId}.properties` file (loaded by `GJPluginLifecycle.registerResource()` into PropertySource)
3. Main application environment variables (inherited from parent context as fallback)
---
## 10. Real-Time Communication
Powered by [netty-socketio](https://github.com/mrniko/netty-socketio).
### 10.1 Creating a Hub
Extend `GJHub` and use `@GJHubMethod` to annotate message handler methods:
```java
package gj.module.user.socketio;
import gj.pf4j.socketio.GJHub;
import gj.pf4j.socketio.GJHubMethod;
import org.springframework.stereotype.Component;
import java.util.concurrent.CompletableFuture;
@Component
public class UserHub extends GJHub {
public UserHub() {
super("userHub"); // hubName — clients route messages by this name
}
/**
* Called when a client connects
*/
@Override
public CompletableFuture onConnectedAsync() {
return CompletableFuture.runAsync(() -> {
String connectionId = getContext().getConnectionId();
System.out.println("User connected: (" + connectionId + ")");
});
}
/**
* Called when a client disconnects
*/
@Override
public CompletableFuture onDisconnectedAsync() {
return CompletableFuture.runAsync(() -> {
System.out.println("disconnected");
});
}
/**
* Handle the "sendMessage" event from clients
*/
@GJHubMethod("sendMessage")
public void onSendMessage(MessageData data) {
// Broadcast to all clients except the sender
getClients().others().sendAsync("newMessage", data);
// Send to a specific group
getClients().group("admin").sendAsync("newMessage", data);
}
/**
* Handle the "joinGroup" event from clients
*/
@GJHubMethod("joinGroup")
public void onJoinGroup(String groupName) {
getGroups().addToGroupAsync(groupName);
}
}
```
### 10.2 Client Push API
`getClients()` returns a `GJHubCallerClients` with the following targeting methods:
```java
// All connected clients
getClients().all().sendAsync("eventName", data);
// Only the caller
getClients().caller().sendAsync("eventName", data);
// Everyone except the caller
getClients().others().sendAsync("eventName", data);
// A specific connection
getClients().client("connectionId123").sendAsync("eventName", data);
// A specific group
getClients().group("admin").sendAsync("eventName", data);
// A specific user (by userId)
getClients().user("userId123").sendAsync("eventName", data);
// A group excluding a specific user
getClients().groupExceptUser("admin", "excludedUserId").sendAsync("eventName", data);
// All except certain connections
getClients().allExcept(List.of("connId1", "connId2")).sendAsync("eventName", data);
```
### 10.3 Group Management API
`getGroups()` returns a `GJGroupManager`:
```java
// Join a group
getGroups().addToGroupAsync("groupName");
// Leave a group
getGroups().removeFromGroupAsync("groupName");
// Check group membership
getGroups().isInGroupAsync("groupName").thenAccept(inGroup -> {
System.out.println("In group: " + inGroup);
});
// Get all groups for the current connection
getGroups().getGroupsForConnectionAsync().thenAccept(groups -> {
System.out.println("My groups: " + groups);
});
// Get all connection IDs in a group
getGroups().getConnectionsInGroupAsync("groupName").thenAccept(connections -> {
System.out.println("Connections in group: " + connections);
});
```
### 10.4 Hub Context
Retrieve current connection information inside hub methods via `getContext()`:
```java
GJHubCallerContext ctx = getContext();
String connectionId = ctx.getConnectionId();
Map queryParams = ctx.getQueryParams();
```
> Frontend can pass custom parameters via the connection URL (e.g., `?hub=userHub&userId=123`). Access them in the Hub via `ctx.getQueryParam("key")`. Avoid passing plaintext sensitive information in the URL.
### 10.5 Server-Side Configuration
Configure the Socket.IO server in the host application's configuration file as needed, for example:
```properties
socketio.port=9600
socketio.maxConnectionsPerSecond=10
```
See `GJSocketIOConfig` source for all available properties and their defaults.
---
## 11. Internationalization (i18n)
### 11.1 Plugin i18n Files
Place `i18n/messages*.properties` in the plugin classpath:
```
src/main/resources/
i18n/
messages.properties # Default
messages_zh_CN.properties # Simplified Chinese
messages_en_US.properties # English
```
Example `i18n/messages_en_US.properties`:
```properties
user.list.title=User List
user.create.success=Created Successfully
user.delete.confirm=Confirm to delete this user?
```
Example `i18n/messages_zh_CN.properties`:
```properties
user.list.title=用户列表
user.create.success=创建成功
user.delete.confirm=确认删除该用户?
```
### 11.2 Injection and Usage
```java
@RestController
@RequestMapping("/api/v1/user")
public class UserController {
private final MessageSource messageSource;
public UserController(MessageSource messageSource) {
this.messageSource = messageSource;
}
@GetMapping("/title")
public String getTitle(Locale locale) {
return messageSource.getMessage("user.list.title", null, locale);
}
}
```
### 11.3 Fallback Mechanism
- The framework creates a `GJPluginReloadableMessageSource` for each plugin (bean name: `plugin_i18n_{pluginId}`)
- Key lookup: plugin's own messages first, then falls back to the main application's `messageSource`
- If no match is found, returns the key itself (`useCodeAsDefaultMessage = true`)
- 24-hour cache, UTF-8 encoding
---
## 12. Import/Export
Built on [EasyExcel](https://easyexcel.opensource.alibaba.com/), provides `IImportManager` and `IExportManager` interfaces with multi-sheet read/write and automatic i18n header translation.
### 12.1 Export Example
```java
@Service
public class UserExportService {
private final IExportManager exportManager;
private final UserMapper userMapper;
public UserExportService(IExportManager exportManager, UserMapper userMapper) {
this.exportManager = exportManager;
this.userMapper = userMapper;
}
/**
* Single-sheet export
*/
public String exportUsers() throws IOException {
List users = userMapper.selectList(null);
return exportManager.exportToXlsx(users);
}
/**
* Multi-sheet export
*/
public String exportMultiSheet() throws IOException {
Map> sheets = new LinkedHashMap<>();
sheets.put("Users", userMapper.selectList(null));
sheets.put("Roles", roleMapper.selectList(null));
return exportManager.exportMultiSheetToXlsx(sheets);
}
/**
* Export to byte stream (for HTTP download responses)
*/
public ByteArrayOutputStream exportToStream() throws IOException {
List users = userMapper.selectList(null);
return exportManager.exportToStream(users);
}
}
```
### 12.2 Import Example
```java
@Service
public class UserImportService {
private final IImportManager importManager;
public UserImportService(IImportManager importManager) {
this.importManager = importManager;
}
/**
* Multi-sheet import
*/
public void importUsers(InputStream inputStream) {
List> sheets = importManager.importFromXlsx(
"users.xlsx",
inputStream,
User.class, // Sheet 0 → User entity
Role.class // Sheet 1 → Role entity
);
List userRows = sheets.get(0); // User sheet
List roleRows = sheets.get(1); // Role sheet
// Process imported data...
}
}
```
### 12.3 Header i18n
EasyExcel `@ExcelProperty` annotation values are automatically translated via i18n during both import and export. The framework overrides `SimpleWriteHandler` and `ReadEventListener` to ensure generated and parsed Excel headers match the current locale.
```java
@Data
public class UserExcelVO {
@ExcelProperty("user.excel.name") // i18n key
private String name;
@ExcelProperty("user.excel.email")
private String email;
}
```
---
## 13. Scheduled Tasks
Powered by [Quartz](https://www.quartz-scheduler.org/). Plugins simply implement the `IPluginJob` interface and annotate it with `@PluginJob` — the framework automatically scans and registers them with the Quartz scheduler after the plugin starts.
### 13.1 Dependency Note
The framework includes built-in Quartz support (`org.quartz-scheduler:quartz`) and auto-creates a `Scheduler` bean via `GJQuartzConfig` (`@ConditionalOnMissingBean`). The host application does not need to add any Quartz dependency. If the host app already has a custom `Scheduler` bean, the framework reuses it automatically.
### 13.2 Creating a Scheduled Job
Create a bean implementing `IPluginJob` in your plugin and annotate it with `@PluginJob`:
```java
package gj.module.user.job;
import gj.pf4j.quartzjob.IPluginJob;
import gj.pf4j.quartzjob.annotation.PluginJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@PluginJob(name = "cleanExpiredTokens", intervalSeconds = 3600)
public class TokenCleanupJob implements IPluginJob {
@Override
public void execute() {
log.info("Cleaning expired tokens...");
// business logic
}
}
```
### 13.3 @PluginJob Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `name` | String | **required** | Globally unique job identifier |
| `intervalSeconds` | long | -1 | Fixed interval in seconds; mutually exclusive with `cronExpression` |
| `cronExpression` | String | "" | Cron expression; mutually exclusive with `intervalSeconds` |
| `runOnce` | boolean | false | Execute only once |
| `disallowConcurrentExecution` | boolean | true | Disallow concurrent execution of the same job |
### 13.4 Cron Expression Examples
```java
@PluginJob(name = "dailyReport", cronExpression = "0 0 8 * * ?") // Every day at 8:00
@PluginJob(name = "weeklySync", cronExpression = "0 0 2 ? * MON") // Every Monday at 2:00
@PluginJob(name = "initData", runOnce = true) // Run once on startup
```
### 13.5 Manual Trigger (Injecting Scheduler)
For scenarios requiring manual trigger in business logic, inject the Quartz `Scheduler` directly:
```java
@Service
public class ReportService {
private final Scheduler scheduler;
public ReportService(Scheduler scheduler) {
this.scheduler = scheduler;
}
public void triggerReport(String pluginId) throws SchedulerException {
scheduler.triggerJob(new JobKey(pluginId + ":dailyReport", pluginId));
}
}
```
---
## 14. In-Process Event Bus
The framework provides a lightweight in-process event bus for decoupled inter-plugin communication. Listeners implement `GJPluginLocalEventListener` to handle typed events, while event classes use `@EventName` for Ant-style wildcard pattern matching.
### 14.1 Defining Events
Annotate event classes with `@EventName`; name components are dot-separated:
```java
package gj.module.user.event;
import gj.pf4j.eventbus.EventName;
import lombok.Data;
@Data
@EventName("user.created")
public class UserCreatedEvent {
private Long userId;
private String userName;
}
```
### 14.2 Creating Listeners
Implement `GJPluginLocalEventListener` and annotate with `@Component` to register as a Spring bean:
```java
package gj.module.user.listener;
import gj.module.user.event.UserCreatedEvent;
import gj.pf4j.eventbus.GJPluginLocalEventListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class UserCreatedListener implements GJPluginLocalEventListener {
@Override
public void HandleEvent(UserCreatedEvent event) {
log.info("User created: {} ({})", event.getUserName(), event.getUserId());
// send welcome email, initialize user data, etc.
}
}
```
### 14.3 Publishing Events
Inject `GJPluginLocalEventBus` into any Spring bean:
```java
@Service
public class UserService {
private final GJPluginLocalEventBus eventBus;
public UserService(GJPluginLocalEventBus eventBus) {
this.eventBus = eventBus;
}
public void createUser(String name) {
// create user logic ...
// Synchronous — all listeners run on the current thread
eventBus.publish(new UserCreatedEvent(userId, name));
// Asynchronous — listeners execute in the thread pool
eventBus.publishAsync(new UserCreatedEvent(userId, name));
}
}
```
### 14.4 Wildcard Matching
`@EventName` supports Ant-style wildcards with `.` as the path separator:
```java
@EventName("user.*") // matches user.created, user.updated, etc.
@EventName("order.cancelled") // exact match
```
Multiple listeners can match a single event; each listener executes independently.
---
## 15. OpenAPI Documentation
### 15.1 Automatic Grouping
The framework automatically creates an independent `GroupedOpenApi` bean (SpringDoc) for each plugin that registers controllers. The group name follows the pattern `pluginGroupedOpenApi-{pluginId}`. In Swagger-UI, select the desired plugin from the top-right dropdown to view its API documentation.
### 15.2 Controller Example (with Swagger Annotations)
```java
@RestController
@RequestMapping("/api/v1/user")
@Tag(name = "User Management", description = "User CRUD operations")
public class UserController {
@GetMapping("/{id}")
@Operation(summary = "Get user by ID")
public UserResponse getById(
@Parameter(description = "User ID") @PathVariable Integer id) {
return userService.getById(id);
}
@PostMapping("/create")
@Operation(summary = "Create a new user")
public boolean create(
@Parameter(description = "Create request") @RequestBody UserCreateRequest request) {
return userService.create(request);
}
}
```
### 15.3 Access URL
Visit `http://localhost:{port}/swagger-ui/index.html` after startup.
---
## 16. Plugin Packaging & Deployment
### 16.1 Build the Plugin
```bash
cd user-plugin
mvn clean package
```
### 16.2 Output Directory Structure
After a successful build, `target/plugins/{artifactId}/` contains:
```
target/plugins/gj.module.user/
├── gj.module.user-1.0.0-SNAPSHOT.jar # Plugin main JAR
├── gj.module.user.json # Plugin descriptor file
└── lib/ # Plugin-private third-party dependency JARs
├── some-third-party.jar
└── ...
```
### 16.3 MANIFEST.MF
```manifest
Plugin-Id: gj.module.user
Plugin-Version: 1.0.0-SNAPSHOT
Class-Path: lib/some-third-party.jar lib/another-lib.jar
```
### 16.4 Deploy to the Host Application
Copy the entire `target/plugins/gj.module.user/` directory into the host application's `plugins/` directory:
```
Host application root/ ← current working directory in dev/debug mode
plugins/
gj.module.user/
gj.module.user-1.0.0-SNAPSHOT.jar
gj.module.user.json
lib/
...
gj.module.other/
...
```
In production (non-dev/debug profiles), the plugin directory is located at `plugins/` under the Spring Boot JAR's `ApplicationHome` directory.
### 16.5 Version Management
`GJJarPluginRepository` scans each plugin directory, parses version numbers from JAR filenames (format: `{pluginId}-{version}.jar`), and loads the latest version. When multiple versions exist in a directory, only the highest version is loaded, with a log entry recording the selection.
---
## 17. Runtime Plugin Management API
### 17.1 Injecting GJPluginService
```java
@RestController
@RequestMapping("/api/admin/plugins")
public class PluginAdminController {
private final GJPluginService pluginService;
public PluginAdminController(GJPluginService pluginService) {
this.pluginService = pluginService;
}
// ... management endpoints
}
```
### 17.2 Load and Start All Plugins
```java
@PostMapping("/load-all")
public void loadAndStartAll() {
pluginService.loadAndStartAllPlugins();
}
```
### 17.3 Start a Single Plugin
```java
@PostMapping("/{pluginId}/start")
public String startPlugin(@PathVariable String pluginId) {
PluginState state = pluginService.startPlugin(pluginId);
return "Plugin " + pluginId + " state: " + state;
}
```
> Dependencies are automatically resolved — dependent plugins are started first.
### 17.4 Stop a Single Plugin
```java
@PostMapping("/{pluginId}/stop")
public String stopPlugin(@PathVariable String pluginId) {
PluginState state = pluginService.stopPlugin(pluginId);
return "Plugin " + pluginId + " state: " + state;
}
```
> Reverse dependencies are stopped first before stopping the target plugin.
### 17.5 Restart a Single Plugin
```java
@PostMapping("/{pluginId}/restart")
public String restartPlugin(@PathVariable String pluginId) {
PluginState state = pluginService.restartPlugin(pluginId);
return "Plugin " + pluginId + " state: " + state;
}
```
### 17.6 Hot-Unload / Hot-Reload a Plugin
```java
// Hot-unload (remove from memory without deleting files)
@DeleteMapping("/{pluginId}/unload")
public String unloadPlugin(@PathVariable String pluginId) {
boolean success = pluginService.unloadPlugin(pluginId);
return success ? "Unloaded" : "Failed";
}
// Hot-reload (re-discover from filesystem and load)
@PostMapping("/{pluginId}/reload")
public String reloadPlugin(@PathVariable String pluginId) {
PluginState state = pluginService.reloadPlugin(pluginId);
return "Plugin " + pluginId + " state: " + state;
}
```
### 17.7 Reload All Plugins
```java
@PostMapping("/reload-all")
public void reloadAll() {
pluginService.reloadAll(); // stop all → unload all → load all → start all
}
```
### 17.8 Delete a Plugin
```java
@DeleteMapping("/{pluginId}")
public String deletePlugin(@PathVariable String pluginId) {
boolean deleted = pluginService.deletePlugin(pluginId);
return deleted ? "Deleted" : "Failed";
}
```
---
## 18. Appendix: Host Application Integration
### 18.1 Version Compatibility
gj-pf4j depends on Spring core libraries (spring-webmvc, spring-beans, spring-jdbc, etc.) but does **not lock version numbers**. The framework publishes a `gj-dependencies` BOM for unified version management. By importing both gj BOM and Spring Boot BOM with the correct priority order, Spring versions automatically follow the developer's chosen Spring Boot version, avoiding conflicts.
### Importing via BOM (Recommended)
In `dependencyManagement`, import the gj BOM followed by the Spring Boot BOM — **Spring Boot BOM goes last for higher priority**, ensuring Spring versions follow the developer's chosen Spring Boot version. The gj BOM only covers dependencies not managed by Spring Boot (e.g., PF4J, netty-socketio):
```xml
io.github.wangpengxpy
gj-dependencies
1.0.3
pom
import
org.springframework.boot
spring-boot-dependencies
${spring-boot.version}
pom
import
```
Then add gj-pf4j (version managed by gj BOM):
```xml
io.github.wangpengxpy
gj-pf4j
```
**Version Resolution:**
| Developer's Spring Boot | gj BOM Spring Version | Effective Version |
|---|---|---|
| 3.5.x | 3.5.5 | 3.5.x (SB BOM overrides) |
| 4.0.x | 3.5.5 | 4.0.x (SB BOM overrides) |
Dependencies in gj BOM that overlap with Spring Boot (spring-webmvc, spring-beans, etc.) are overridden by SB BOM. The gj BOM only governs dependencies not covered by SB BOM (pf4j, netty-socketio, modelmapper, etc.).
### Direct Dependency (Not Recommended)
You can also skip the BOM and depend on gj-pf4j directly, but you must ensure Spring version compatibility yourself:
```xml
io.github.wangpengxpy
gj-pf4j
1.0.5
```
### 18.2 Host Application Entry Point
**Required:**
```java
@SpringBootApplication
@ComponentScan("gj") // all framework beans reside under gj.pf4j — must be scanned
public class GJApplication {
public static void main(String[] args) {
SpringApplication.run(GJApplication.class, args);
}
}
```
- The framework includes `GJPluginConfig` and `GJPluginWebFluxConfig`; both are auto-activated via `@ComponentScan("gj")`.
- Plugins under the `plugins/` directory are automatically loaded and started after the main application's `ContextRefreshedEvent` fires.
> For configuring shared ModelMapper mappings in the host app (base model packages), see [18.3](#183-optional-gjmodelmapperscan-shared-models).
### Spring MVC Mode (Default)
Spring Boot defaults to **MVC (Servlet) mode** — no extra configuration is needed. Simply include `spring-boot-starter-web` and `springdoc-openapi-starter-webmvc-ui`:
```xml
org.springframework.boot
spring-boot-starter-web
org.springdoc
springdoc-openapi-starter-webmvc-ui
```
No need to set `WebApplicationType` (defaults to `SERVLET`):
```java
@SpringBootApplication
@ComponentScan("gj")
public class GJApplication {
public static void main(String[] args) {
SpringApplication.run(GJApplication.class, args);
}
}
```
gj-pf4j automatically uses `GJPluginRequestMappingHandlerMapping` (MVC) — plugin `@RestController` routes are registered through the Servlet container.
### Spring WebFlux Mode
If the host application uses a WebFlux reactive architecture, **two steps** are required:
**1. Swap Dependencies**
```xml
org.springframework.boot
spring-boot-starter-webflux
org.springdoc
springdoc-openapi-starter-webflux-ui
```
**2. Explicitly Set the Web Type**
```java
@SpringBootApplication
@ComponentScan("gj")
public class GJApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(GJApplication.class)
.web(WebApplicationType.REACTIVE)
.run(args);
}
}
```
Once gj-pf4j detects a `GJPluginWebFluxRequestMappingHandlerMapping` bean, it automatically switches to WebFlux mode for plugin controller route registration.
### Mode Comparison
| | MVC Mode (Default) | WebFlux Mode |
|---|---|---|
| Web Container | Tomcat (Servlet) | Netty (Reactive) |
| Dependency | `spring-boot-starter-web` | `spring-boot-starter-webflux` |
| SpringDoc | `springdoc-openapi-starter-webmvc-ui` | `springdoc-openapi-starter-webflux-ui` |
| WebApplicationType | Not set (defaults to SERVLET) | Explicit `.web(REACTIVE)` |
| Plugin Controller Code | `@RestController` | `@RestController` (identical) |
| Route Registration | `GJPluginRequestMappingHandlerMapping` | `GJPluginWebFluxRequestMappingHandlerMapping` |
### 18.3 Optional: `@GJModelMapperScan` (Shared Models)
When the host application has common base model packages (entities such as `User`, `Menu`, `Role`, plus their Mappers, DTOs, and ModelMapper mappings), add the `gj-modelmapper` artifact and use `@GJModelMapperScan` to register those mappings into a global `ModelMapper` bean. Business plugins inherit this shared instance via the parent context:
```
Host App
├─ DataSource
├─ SqlSessionFactory → UserMapper, MenuMapper, RoleMapper ...
├─ ModelMapper (User→UserDTO, Menu→MenuDTO) ← @GJModelMapperScan
│
└─ [parent context] ── Plugin (inherits)
├─ [inherits] ModelMapper — final ModelMapper mm → ready to use with all shared mappings
├─ [inherits] UserMapper — final UserMapper um → query shared tables directly
├─ [own] PluginMapper — query plugin-specific tables
└─ [appends] plugin-specific mappings — added to the shared ModelMapper automatically, no duplication
```
Configuration:
```xml
io.github.wangpengxpy
gj-modelmapper
```
```java
@SpringBootApplication
@ComponentScan("gj")
@GJModelMapperScan(
basePackages = "your.app.model", // base model package
markerInterface = GJModelMapperConfig.class
)
public class GJApplication { ... }
```
Model mapping config inside the base model package:
```java
package your.app.model;
import gj.modelmapper.GJModelMapperConfig;
import gj.modelmapper.GJModelMapperTypeMapConfig;
import java.util.List;
public class AppModelMapperConfig implements GJModelMapperConfig {
@Override
public List getTypeMapConfigs() {
return List.of(
GJModelMapperTypeMapConfig.of(User.class, UserDTO.class),
GJModelMapperTypeMapConfig.of(Menu.class, MenuDTO.class)
);
}
}
```
> **Key insight:** `@GJModelMapperScan` (host app) and plugin `GJPluginModelMapperConfig` are independent mechanisms. The former injects a global `ModelMapper` for the host; the latter is auto-discovered as a Spring bean by `GJPluginModelMapperRegistry` and appends mappings to the shared instance. If the host app does not configure `@GJModelMapperScan`, plugin ModelMapper still works — the framework creates one automatically.
---
## 19. Claude Code Integration
The framework ships with built-in [Claude Code](https://claude.ai/code) skills for AI-driven plugin development:
```bash
/gj-plugin-new "user management plugin with CRUD, real-time push, scheduled cleanup"
```
**New projects** (included automatically when generating from archetype):
```bash
mvn archetype:generate -DarchetypeGroupId=io.github.wangpengxpy -DarchetypeArtifactId=gj-archetype ...
```
**Existing projects**: copy from `tools/claude-skills/` into the project root:
```bash
git clone --depth 1 https://github.com/wangpengxpy/gj.spring.pf4j.git /tmp/gj-pf4j
cp -r /tmp/gj-pf4j/tools/claude-skills/* .claude/
```
> Internally uses OpenSpec for requirements analysis and task decomposition, then delegates to the `gj-plugin` skill for code generation.
---
## 20. FAQ
### Q1: Plugin startup fails with `plugin.id` mismatch error?
The `plugin.id` in `plugin.properties` must **exactly match** the plugin main class package name. For example, if `plugin.id=gj.module.user`, the plugin entry class must be in the `gj.module.user` package. Any mismatch throws an `IllegalStateException` at startup. See [Section 3.2](#32-pluginproperties).
### Q2: Plugin failed to start / how to troubleshoot startup failure?
Check the logs for `[PF4J]` entries. Startup failures are recorded in `GJPluginStartingError` with the plugin ID and exception detail. Common causes:
- **Missing JAR**: the plugin directory under `plugins/` must contain a JAR matching `{pluginId}-*.jar`
- **Dependency conflict**: plugin brings a library version incompatible with the host app
- **Bean wiring failure**: a `@Component` in the plugin fails to construct due to missing dependencies
See [Section 4](#4-plugin-lifecycle) for the full lifecycle flow.
### Q3: SQL works in MySQL but fails on DM/PostgreSQL with "invalid identifier"?
A column name likely conflicts with that database's reserved keywords (e.g., `order`, `comment`, `context`). Implement `GJTableKeywordProvider` and register it as a `@Component`:
```java
@Component
public class MyKeywords implements GJTableKeywordProvider {
@Override
public Map> getTableKeywords() {
return Map.of("table_name", Set.of("order", "comment"));
}
}
```
The framework automatically wraps these columns with the correct quote character at runtime. See [Section 6.6](#66-sql-keyword-quoting).
### Q4: Host app has Controllers but they don't appear in Swagger-UI?
The framework auto-creates `GroupedOpenApi` for **plugins only**, not for the host app. Add a `default` group in `application.yml`:
```yaml
springdoc:
group-configs:
- group: default
displayName: default
packagesToScan: com.example.controller
```
>`packagesToScan` must point to the host application's Controller package, not a plugin package. See [Section 15](#15-openapi-documentation).
### Q5: What is the minimum configuration for the host app?
Only one annotation is required:
```java
@SpringBootApplication
@ComponentScan("gj") // activates all framework beans
public class GJApplication {
public static void main(String[] args) {
SpringApplication.run(GJApplication.class, args);
}
}
```
Without `@ComponentScan("gj")`, no framework beans are discovered and the entire framework stays inactive. See [Section 18.2](#182-host-application-entry-point).
### Q6: How to share ModelMapper mappings between host app and plugins?
Add the `gj-modelmapper` dependency and use `@GJModelMapperScan` on the host app:
```java
@GJModelMapperScan(
basePackages = "com.example.model",
markerInterface = GJModelMapperConfig.class
)
```
Plugins append their own mappings to the shared `ModelMapper` instance automatically. See [Section 18.3](#183-optional-gjmodelmapperscan-shared-models).
### Q7: Does auto-migration ever drop tables or columns?
No. The migration engine follows a **strict additive-only policy** — only `CREATE TABLE` (when table is missing) and `ALTER TABLE ADD COLUMN` (when column is missing) are generated. Existing tables, columns, and data are never modified or deleted. See [Section 7.2](#72-production-safety).