SpringBoot 集成 Activiti 7 工作流引擎
初始库表、定义、部署、操作任务代码创建测试类// 创建 ProcessEngine 对象// /**// * 初始化数据库表:第一种方式// */// @Test// }/*** 初始化数据库表* 通过读取 activiti.cfg.xml 配置文件*/@Test/*** 流程部署*/@Test// 创建 ProcessEngine 对象// 获取 repositoryService 对象// 进
·
一. 版本信息
- IntelliJ IDEA 2023.3.6
- JDK 17
- Activiti 7
二. IDEA依赖插件安装
安装BPM流程图插件,如果IDEA的版本超过2020,则不支持actiBPM插件。我的IDEA是2023版本我装的是 Activiti BPMN visualizer 插件。
- 在Plugins 搜索 Activiti BPMN visualizer 安装
- 创建BPMN文件
- 使用视图模式打开bpmn.xml
三. 创建SpringBoot 集成 activiti7
- 使用 IDEA 创建SpringBoot项目
- 设置项目参数
- 在 pom.xml 依赖配置文件中添加(Mysql,Lombok,activiti7)依赖
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.4</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com</groupId> <artifactId>activiti-demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>activiti-demo</name> <description>activiti-demo</description> <properties> <java.version>17</java.version> </properties> <dependencies> <!-- web依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- mysql依赖 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.2.0</version> </dependency> <!-- lombok依赖 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- mybatis数据访问层 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency> <!-- activiti7 工作流引擎依赖 --> <dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter</artifactId> <version>7.1.0.M6</version> </dependency> <!-- 模块测试依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
- 创建本地连接的数据库
创建数据库 activiti
创建数据库表 userCREATE DATABASE `activiti` /*!40100 DEFAULT CHARACTER SET utf8 */;
添加一条测试数据-- activiti.`user` definition CREATE TABLE `user` ( `ID` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `NAME` varchar(100) DEFAULT NULL COMMENT '名称', `AGE` varchar(100) DEFAULT NULL COMMENT '年龄', `CREATED_BY` varchar(32) DEFAULT NULL COMMENT '创建人名称', `CREATED_TIME` datetime DEFAULT NULL COMMENT '创建时间', `CREATED_ID` varchar(32) DEFAULT NULL COMMENT '创建人ID', `UPDATED_BY` varchar(32) DEFAULT NULL COMMENT '更新人名称', `UPDATED_TIME` datetime DEFAULT NULL COMMENT '更新时间', `UPDATED_ID` varchar(32) DEFAULT NULL COMMENT '更新人ID', PRIMARY KEY (`ID`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';
INSERT INTO activiti.`user` (ID, NAME, AGE, CREATED_BY, CREATED_TIME, CREATED_ID, UPDATED_BY, UPDATED_TIME, UPDATED_ID) VALUES(1, '小王', '24', NULL, NULL, NULL, NULL, NULL, NULL);
- 添加 application.yml 配置文件
spring: application: name: activiti-demo datasource: #url切换数据库之后如果对应数据库名称和路径有变动,需要修改url url: jdbc:mysql://localhost:3306/activiti?useUnicode=true&characterEncoding=utf-8&useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver
- 添加 activiti.cfg.xml 配置文件(文件名不能随便改)
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE beans [ <!ELEMENT beans (bean)*> <!ATTLIST beans xmlns CDATA #REQUIRED xmlns:xsi CDATA #REQUIRED xsi:schemaLocation CDATA #REQUIRED> <!ELEMENT bean (property)*> <!ATTLIST bean id CDATA #REQUIRED class CDATA #REQUIRED> <!ELEMENT property (#PCDATA)> <!ATTLIST property name CDATA #REQUIRED value CDATA #REQUIRED> ]> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <!-- processEnqine Activiti 的流程引擎 --> <bean id="processEngineConfiguration" class="org.activiti.engine.impl.cfg.StandaloneProcessEngineConfiguration"> <property name="jdbcDriver" value="com.mysql.cj.jdbc.Driver"/> <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/activiti?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&nullCatalogMeansCurrent=true"/> <property name="jdbcUsername" value="root"/> <property name="jdbcPassword" value="root"/> <!-- activiti 数据库表处理策略 --> <!-- databaseSchemaUpdate 属性的值可以设置为以下几种 --> <!-- none:这是默认值,表示不对数据库模式做任何变更,应用程序启动时不会检查数据库表结构是否与实体类匹配--> <!-- true:设置为 true 时,Spring会在应用程序启动时检查数据库表结构,并在发现不匹配时自动创建或修改表结构以匹配实体类定义。这相当于执行了数据库迁移--> <!-- create:与 true 类似,但 create 会在每次启动时删除并重新创建表,这可能会导致数据丢失,因此使用时需要谨慎--> <!-- create-drop:在每次启动应用程序时创建表,在关闭应用程序时删除表。这通常用于测试环境--> <!-- validate:在启动时验证数据库表结构是否与实体类定义匹配,如果不匹配则抛出异常,但不会自动进行任何更改--> <property name="databaseSchemaUpdate" value="true"/> </bean> </beans>
- 启动SpringBoot项目成功
- 开始添加一个查询数据测试接口(Controller,Service,Mapper,Entity)
Controller类
Service 类package com.activitidemo.act.controller; import com.activitidemo.act.entity.UserEntity; import com.activitidemo.act.service.impl.UserServiceImp; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * <p> * 前端控制器 * </p> * * @author ningbeibei * @since 2024-09-26 */ @RestController @RequestMapping("/user-entity") public class UserController { @Resource private UserServiceImp userService; @PostMapping("/user") public Object getUser(@RequestBody UserEntity user){ QueryWrapper<UserEntity> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("id",user.getId()); return userService.getBaseMapper().selectList(queryWrapper); } }
package com.activitidemo.act.service; import com.activitidemo.act.entity.UserEntity; import com.baomidou.mybatisplus.extension.service.IService; /** * @author ningbeibei * @since 2024-09-26 */ public interface UserService extends IService<UserEntity> { }
Mapper 类package com.activitidemo.act.service.impl; import com.activitidemo.act.mapper.UserMapper; import com.activitidemo.act.service.UserService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import com.activitidemo.act.entity.UserEntity; /** * <p> * 服务实现类 * </p> * * @author ningbeibei * @since 2024-09-26 */ @Service public class UserServiceImp extends ServiceImpl<UserMapper, UserEntity> implements UserService { }
package com.activitidemo.act.mapper; import com.activitidemo.act.entity.UserEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; /** * <p> * Mapper 接口 * </p> * * @author ningbeibei * @since 2024-09-26 */ @Mapper public interface UserMapper extends BaseMapper<UserEntity> { }
Entity 类<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.activitidemo.act.entity.UserEntity"> </mapper>
目录结构package com.activitidemo.act.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Getter; import lombok.Setter; import java.io.Serializable; import java.time.LocalDateTime; /** * @author ningbeibei * @since 2024-09-26 */ @Getter @Setter @TableName("user") public class UserEntity implements Serializable { private static final long serialVersionUID = 1L; @TableField("ID") private Integer id; @TableField("NAME") private String name; @TableField("AGE") private int age; @TableField("CREATED_BY") private String createdBy; @TableField("CREATED_TIME") private LocalDateTime createdTime; @TableField("CREATED_ID") private String createdId; @TableField("UPDATED_BY") private String updatedBy; @TableField("UPDATED_TIME") private LocalDateTime updatedTime; @TableField("UPDATED_ID") private String updatedId; }
- 使用Postman接口测试工具,测试接口是否正常
四. Activiti 使用步骤
Activiti 主要流程操作步骤:
- 定义流程:按照BPMN的规范,使用流程定义工具,用流程符号把整个流程描述出来。
- 部署流程:把画好的流程定义文件,加载到数据库中,生成表的数据。
- 启动流程:使用 java 代码来操作数据库表中的内容。
- 处理任务:操作流程当中的各个任务。
1. 定义流程
2. 初始库表、定义、部署、操作任务代码
创建测试类
测试代码:
package com.activitidemo;
import org.activiti.engine.HistoryService;
import org.activiti.engine.ProcessEngine;
import org.activiti.engine.ProcessEngineConfiguration;
import org.activiti.engine.ProcessEngines;
import org.activiti.engine.RepositoryService;
import org.activiti.engine.RuntimeService;
import org.activiti.engine.TaskService;
import org.activiti.engine.history.HistoricActivityInstance;
import org.activiti.engine.repository.Deployment;
import org.activiti.engine.repository.ProcessDefinition;
import org.activiti.engine.runtime.ProcessInstance;
import org.activiti.engine.task.Comment;
import org.activiti.engine.task.Task;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.FileCopyUtils;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
@SpringBootTest
class ActivitiDemoApplicationTests {
// 创建 ProcessEngine 对象
// private ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// /**
// * 初始化数据库表:第一种方式
// */
// @Test
// public void testInitOne() {
// ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// System.err.println("processEngine = " + processEngine);
// }
/**
* 初始化数据库表
* 通过读取 activiti.cfg.xml 配置文件
*/
@Test
public void testInitTwo() {
ProcessEngineConfiguration configuration = ProcessEngineConfiguration.createProcessEngineConfigurationFromResource("activiti.cfg.xml");
ProcessEngine processEngine = configuration.buildProcessEngine();
System.err.println("processEngine = " + processEngine);
}
/**
* 流程部署
*/
@Test
public void testDeploy() {
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取 repositoryService 对象
RepositoryService repositoryService = processEngine.getRepositoryService();
// 进行部署
Deployment deployment = repositoryService.createDeployment()
.addClasspathResource("process/leave.bpmn20.xml")
.addClasspathResource("process/leave.bpmn20.png")
.name("请假流程")
.deploy();
// 输出部署的一些信息
System.out.println("流程部署ID:" + deployment.getId());
System.out.println("流程部署名称:" + deployment.getName());
System.out.println("流程部署成功");
}
/**
* 启动流程实例
*/
@Test
public void testStartProcess() {
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取 runtimeService 对象
RuntimeService runtimeService = processEngine.getRuntimeService();
// 1.根据流程定义的key启动流程实例,这个key是在定义bpmn的时候设置的
ProcessInstance instance = runtimeService.startProcessInstanceByKey("leave");
// 2.根据流程定义id启动流程实例
// ProcessInstance instance = runtimeService.startProcessInstanceById("leave:1:4");
// 获取流程实例的相关信息
System.out.println("流程定义的id = " + instance.getProcessDefinitionId());
System.out.println("流程实例的id = " + instance.getId());
System.out.println("启动流程成功 ");
}
/**
* 查询待办任务
*/
@Test
public void testSelectTodoTaskList() {
String assignee = "李四";
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取指定用户审核任务
TaskService taskService = processEngine.getTaskService();
// 使用面对对象方式查询数据库
List<Task> tasks = taskService.createTaskQuery()
.processDefinitionKey("leave")
.taskAssignee(assignee)
// 返回多个结果
.list();
// 只返回一个结果
// .singleResult();
// 自定义 sql 查询
// taskService.createNativeTaskQuery();
// 获取流程实例的相关信息
for (Task task : tasks) {
System.out.println("流程定义的id = " + task.getProcessDefinitionId());
System.out.println("流程实例的id = " + task.getProcessInstanceId());
System.out.println("任务id = " + task.getId());
System.out.println("任务名称 = " + task.getName());
}
}
/**
* 指定用户去完成任务待办:多人审批在这操作,改变审核人名称就行了
*/
@Test
public void testCompleteTask() {
String assignee = "李四";
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取指定用户审核任务
TaskService taskService = processEngine.getTaskService();
List<Task> tasks = taskService.createTaskQuery()
.processDefinitionKey("leave")
.taskAssignee(assignee)
.list();
if (tasks != null && !tasks.isEmpty()){
// 当前流程图所限制,只能做审核同意的动作
for (Task task : tasks) {
taskService.complete(task.getId());
}
}
}
/**
* 审批添加备注
*/
@Test
public void testAddComment() {
String assignee = "张三";
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取指定用户审核任务
TaskService taskService = processEngine.getTaskService();
List<Task> tasks = taskService.createTaskQuery()
.processDefinitionKey("leave")
.taskAssignee(assignee)
.list();
if (tasks != null && !tasks.isEmpty()) {
// 当前流程图所限制,只能做审核同意的动作
for (Task task : tasks) {
// 添加备注
taskService.addComment(task.getId(), task.getProcessInstanceId(), assignee + "表示同意");
taskService.complete(task.getId());
}
}
}
/**
* 查询审批历史
*/
@Test
public void testSelectHistoryTask() {
String processInstanceId = "2501";
String assignee = "张三";
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 查看历史信息
HistoryService historyService = processEngine.getHistoryService();
// 获取指定用户审核任务
TaskService taskService = processEngine.getTaskService();
// 获取历史审核信息
List<HistoricActivityInstance> userTask = historyService.createHistoricActivityInstanceQuery()
.activityType("userTask")
// 指定实例的id
.processInstanceId(processInstanceId)
.taskAssignee(assignee)
.finished()
.list();
for (HistoricActivityInstance instance : userTask) {
System.out.println("任务名称 = " + instance.getActivityName());
System.out.println("任务开始时间 = " + instance.getStartTime());
System.out.println("任务结束时间 = " + instance.getEndTime());
System.out.println("任务耗时 = " + instance.getDurationInMillis());
// 获取审批批注信息
List<Comment> taskComments = taskService.getTaskComments(instance.getTaskId());
if (!taskComments.isEmpty()){
System.out.println("审批批注 = " + taskComments.get(0).getFullMessage());
}
}
}
/**
* 查询流程相关信息
*/
@Test
public void testDefinitionQuery() {
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取仓库服务
RepositoryService repositoryService = processEngine.getRepositoryService();
// 获取流程定义集合
List<ProcessDefinition> processDefinitionList = repositoryService.createProcessDefinitionQuery()
.processDefinitionKey("leave")
// 最新的一个版本
.latestVersion()
.list();
// 遍历集合
for (ProcessDefinition definition : processDefinitionList) {
System.out.println("流程定义id = " + definition.getId());
System.out.println("流程定义名称 = " + definition.getName());
System.out.println("流程定义key = " + definition.getKey());
System.out.println("流程定义版本 = " + definition.getVersion());
System.out.println("流程部署id = " + definition.getDeploymentId());
System.out.println("===============");
}
}
/**
* 资源文件下载
*/
@Test
public void testDownloadResource() throws IOException {
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取仓库服务
RepositoryService repositoryService = processEngine.getRepositoryService();
// 获取流程定义集合
List<ProcessDefinition> list = repositoryService.createProcessDefinitionQuery()
.processDefinitionKey("leave")
// 按照版本降序
.orderByProcessDefinitionVersion()
// 降序
.desc()
.list();
// 获取最新那个
ProcessDefinition definition = list.get(0);
// 获取部署id
String deploymentId = definition.getDeploymentId();
// 获取bpmn的输入流
InputStream bpmnInputStream = repositoryService.getResourceAsStream(deploymentId, definition.getResourceName());
// 获取png的输入流
// InputStream pngInputStream = repositoryService.getResourceAsStream(deploymentId, definition.getDiagramResourceName());
String resourcePath = "C:/Users/ASUS/Desktop/" + File.separator + definition.getResourceName();
File file = new File(resourcePath);
if (!file.exists()) {
file.getParentFile().mkdirs();
}
String diagramResourcePath = "C:/Users/ASUS/Desktop/" + File.separator + definition.getDiagramResourceName();
file = new File(diagramResourcePath);
if (!file.exists()) {
file.getParentFile().mkdirs();
}
//复制文件
FileCopyUtils.copy(bpmnInputStream, Files.newOutputStream(Paths.get(resourcePath)));
// FileCopyUtils.copy(pngInputStream, Files.newOutputStream(Paths.get(diagramResourcePath)));
}
/**
* 删除已经部署的流程定义
*/
@Test
public void testDeleteDeploy() {
// 删除已经部署的流程定义
String deploymentId = "45001";
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取仓库服务
RepositoryService repositoryService = processEngine.getRepositoryService();
// 删除流程定义,如果改流程定义已有流程实例启动,则会报错
// repositoryService.deleteDeployment(deploymentId);
// 设置 true,级联删除流程定义,即使有启动的实例也可以删除
repositoryService.deleteDeployment(deploymentId, true);
}
/**
* 启动流程,需要进行 BusinessKey 绑定流程实例
*/
@Test
public void testStartBindBusinessKey() {
String businessKey = "1";
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
RuntimeService runtimeService = processEngine.getRuntimeService();
// 根据流程定义的key启动流程实例,这个key是在定义bpmn的时候设置的
// 在启动流程的时候将业务key加进去
ProcessInstance instance = runtimeService.startProcessInstanceByKey("leave", businessKey);
// 获取流程实例的相关信息
System.out.println("流程定义id = " + instance.getProcessDefinitionId());
System.out.println("流程实例id = " + instance.getId());
System.out.println("业务标识 = " + instance.getBusinessKey());
}
/**
* 跑到下一个节点,需要进行审批了,此时需要获取 BusinessKey 进而获取请假单信息
*/
@Test
public void testGetBusinessKey() {
// 1、获取李四的待办信息
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
TaskService taskService = processEngine.getTaskService();
List<Task> task = taskService.createTaskQuery()
.taskAssignee("李四")
.processDefinitionKey("leave")
.list();
// 2、获取 businessKey
// 获取流程实例id
String processInstanceId = task.get(1).getProcessInstanceId();
RuntimeService runtimeService = processEngine.getRuntimeService();
ProcessInstance processInstance = runtimeService.createProcessInstanceQuery()
.processInstanceId(processInstanceId)
.singleResult();
String businessKey = processInstance.getBusinessKey();
System.out.println("业务标识 = " + businessKey);
}
/**
* 流程定义挂起与激活
*/
@Test
public void testSuspendAllProcessInstance() {
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 获取仓库服务
RepositoryService repositoryService = processEngine.getRepositoryService();
// 获取流程定义对象
ProcessDefinition processDefinition = repositoryService.createProcessDefinitionQuery()
.processDefinitionKey("leave")
.singleResult();
boolean suspended = processDefinition.isSuspended();
// 输出流程定义状态
System.out.println("流程定义状态:" + (suspended ? "已挂起" : "已激活"));
String processDefinitionId = processDefinition.getId();
if (suspended) {
repositoryService.activateProcessDefinitionById(processDefinitionId, true, null);
System.out.println("流程id:" + processDefinitionId + "已激活");
} else {
repositoryService.suspendProcessDefinitionById(processDefinitionId, true, null);
System.out.println("流程id:" + processDefinitionId + "已挂起");
}
}
/**
* 流程实例挂起与激活
*/
@Test
public void testProcessInstance() {
// 创建 ProcessEngine 对象
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
RuntimeService runtimeService = processEngine.getRuntimeService();
// 获取流程定义对象
List<ProcessInstance> processInstanceList = runtimeService.createProcessInstanceQuery()
.processDefinitionKey("leave")
.list();
// 遍历集合
for (ProcessInstance processInstance : processInstanceList) {
boolean suspended = processInstance.isSuspended();
// 输出流程定义状态
System.out.println("流程实例状态:" + processInstance + "," + (suspended ? "已挂起" : "已激活"));
String processDefinitionId = processInstance.getId();
if (suspended) {
runtimeService.activateProcessInstanceById(processDefinitionId);
System.out.println("流程实例id:" + processDefinitionId + "已激活");
} else {
runtimeService.suspendProcessInstanceById(processDefinitionId);
System.out.println("流程实例id:" + processDefinitionId + "已挂起");
}
}
}
}
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
已为社区贡献10条内容
所有评论(0)