资讯动态

EJB 3.x入门:会话Bean、JPA与CMT事务的容器管理实战

发布时间:2026/10/13 11:26:25 来源:尧图企业网站定制
简介面向初学者的EJB入门实践项目基于IDEA开发环境与JBoss 7.1.1服务器帮助Java开发者快速建立企业级组件开发认知。资源涵盖会话Bean无状态/有状态、JPA实体Bean与消息驱动Bean三大核心类型演示了EJB注解、接口定义、事务控制及JMS消息处理的完整链路。rar压缩包内共46个文件含5个java源码、4个jsp页面、12个xml配置、15个class编译产物及2个jar依赖整体仅2.84MB结构清晰客户端与服务端模块分离便于对照学习。目前已有777人学习下载。通过实际部署与运行可深入理解EJB生命周期管理、JNDI绑定及容器事务机制掌握在JBoss应用服务器上开发与调试EJB组件的基本方法为后续学习更复杂的Java EE框架打下扎实基础。## 1. EJB 入门项目为什么把三行注解跑通比背二十个接口更重要 EJBEnterprise JavaBeans是 Java 后端容器管理的典型代表很多新手在接触它时会被 EJB 2.x 时代的 Home 接口、部署描述符劝退。实际上 EJB 3.x 早已把核心逻辑压缩到注解层面一份可直接部署的最小 EJB 项目只需三类会话 Bean、一套 JNDI 命名和一个能启动的容器。这套入门项目正是按这个逻辑组织的无状态会话 Bean、有状态会话 Bean、消息驱动 Bean配 JPA 持久化和 CMT 容器事务在 WildFly 上完整跑通“部署—查找—调用—回滚”的闭环。适合刚学完 Servlet、想搞清楚容器到底替你管了什么的人也适合团队内快速验证 EJB 方案的评估场景。 ## 2. 环境与目录结构先把 Bean 打包规则和注解语义对齐 ### 2.1 为什么选 WildFly 而不是轻量嵌入式容器 EJB 是容器规范不是库所以至少需要一个完整实现的应用服务器。轻量嵌入式容器能帮你拉起 HTTP 接口但它们对 EJB、JMS、JNDI 的支持要么缺失要么只保留部分接口跑真项目时很容易在“容器没管理”和“容器管理了但你看不见”之间翻车。常见的选择是 WildFly 或 GlassFish我一般建议新手优先用 WildFly原因有两个一是它的 standalone 模式开箱即用不需要配置额外管理节点二是它的 CLI 管理界面很直观查看 JNDI 绑定、数据源、消息队列都只要几条命令比在一堆 XML 里翻找高效得多。 版本方面只要选当前主流的发布版本即可不用刻意追新。WildFly 从较新版本开始全面转向 Jakarta EE 命名空间包名从 javax.ejb 换成了 jakarta.ejb。你下载这套资源时源码里用的是 jakarta.* 包名。如果你的老项目还跑在 Java EE 8 场景下手动把包名前缀换回 javax 就行注解语义和部署结构基本不受影响。环境变量方面只需要确认两件事JDK 版本满足服务器要求JAVA_HOME 已配置到位。其余依赖由 Maven 拉取。 ### 2.2 Maven 结构与 ejb-jar 打包规则 EJB 项目的目录结构比 Web 项目简单它没有 webapp只有 src/main/java 和 src/main/resources。核心点是 pom.xml 里把打包方式设为 ejb这样 Maven 才会生成容器能识别的 .jar。如果漏掉这一步构建产物只是一份普通 jarWildFly 会把它当成不可部署的资源忽略掉界面上一行错误都没有。 bash demo-ejb/ ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/demo/ejb │ │ │ ├── GreetingService.java │ │ │ ├── GreetingServiceBean.java │ │ │ ├── CounterService.java │ │ │ ├── CounterServiceBean.java │ │ │ └── OrderProcessorMdb.java │ │ └── resources │ │ └── META-INF │ │ └── persistence.xml │ └── test │ └── java │ └── com/demo/ejb │ └── ClientIT.java └── target └── demo-ejb.jar目录里的persistence.xml只服务于 JPA 那部分功能如果你只写会话 Bean它不是必需的。测试类ClientIT名义上是集成测试实际充当的是 EJB 远程客户端入口后面第 3 章会用到。pom.xml里最关键的依赖只有两个jakarta.ejb-api负责编译期的 EJB 注解jakarta.persistence-api负责 JPA 注解。测试部分按需引入 JUnit。不必引入服务器内置的完整 API 包容器在部署时会提供实现避免类路径冲突。modelVersion4.0.0/modelVersion groupIdcom.demo/groupId artifactIddemo-ejb/artifactId version1.0.0/version packagingejb/packaging dependencies dependency groupIdjakarta.ejb/groupId artifactIdjakarta.ejb-api/artifactId version2.3.0/version scopeprovided/scope /dependency dependency groupIdjakarta.persistence/groupId artifactIdjakarta.persistence-api/artifactId version3.1.0/version scopeprovided/scope /dependency /dependencies这里的scopeprovided是容易写错的地方。初学者往往顺手写成compile结果部署时发现 Web 层和 EJB 层类冲突。原因是 WildFly 自己已经带了这些规范实现你再打进包里就相当于把两个版本的类放到同一个类加载器报错信息随机、难查。provided保证只在编译时出现。2.3 三种会话 Bean 的接口与实现模板会话 Bean 分无状态、有状态和单例三种。这套资源里放了前两种外加一条消息驱动 Bean。无状态 Bean 的语义是“实例可在池中复用不保留调用者维度数据”有状态 Bean 则是“客户端独享实例状态跨方法保留”。写接口时要区分Remote和Local远程接口会把方法调用序列化传参本地接口直接按引用调性能差异很大但语义上只要 Java 类型匹配都可使用。package com.demo.ejb; import jakarta.ejb.Remote; Remote public interface GreetingService { String greet(String name); }package com.demo.ejb; import jakarta.ejb.Stateless; Stateless public class GreetingServiceBean implements GreetingService { Override public String greet(String name) { return Hello, name from EJB container; } }接口和实现类分开是 EJB 的规范写法虽然很多小型项目图省事让实现类直接实现Remote接口但那样做的代价是客户端必须拿到实现类的字节码否则lookup返回后无法强转。参数方面无状态 Bean 是无参构造的容器负责实例创建和池化管理你不需要写new GreetingServiceBean()。它的生命周期回调方法PostConstruct和PreDestroy可以打日志观察池化过程第 6 章的验证方法会用到。有状态 Bean 的模板稍微复杂一点需要显式声明Stateful并且通常不定义远程接口只做本地使用避免序列化整个会话状态。package com.demo.ejb; import jakarta.ejb.Stateful; import java.util.ArrayList; import java.util.List; Stateful public class CounterServiceBean implements CounterService { private final ListString items new ArrayList(); Override public void add(String item) { items.add(item); } Override public int size() { return items.size(); } }这段代码里items是有状态的客户端 A 调add(a)后另一个客户端 B 调size()返回 0因为容器为不同客户端创建了不同实例。你可以在Stateful注解旁加TransactionAttribute控制事务边界但要注意有状态 Bean 长时间不调用会被钝化passivate默认超时后容器会把它存到硬盘再恢复字段必须可序列化否则恢复时直接抛异常。实际项目中有状态 Bean 常常夹着HttpSession一起用但这套入门资源刻意避开了 Session 同步问题让新手先理解实例与状态的对应关系。2.4 打包与部署命令构建用 Maven 的三步先clean清掉旧产物再package打包最后把target目录下的 jar 复制到 WildFly 的standalone/deployments目录。WildFly 发现目录里多了一个带ejb-jar语义的文件就会自动部署。日志里会出现Deployed demo-ejb.jar之类的信息这一步是验证是否被正确识别的重要信号。cd demo-ejb mvn clean package cp target/demo-ejb.jar $WILDFLY_HOME/standalone/deployments/部署过程中可以加一行--server-configstandalone-full.xml参数启动 WildFly因为默认的standalone.xml不启用消息队列子系统第 4 章里的消息驱动 Bean 会因为没有 JMS 队列而失败。如果你已经用默认配置启动了需要停掉服务器重新以完整配置启动。顺便说一个血泪经验不要边跑服务器边往deployments目录扔 jar重复部署同一名字的文件容易让容器认为你在做热更新但 EJB 实例池不会全部重建旧客户端还在用旧实例状态不一致问题极其隐蔽。3. JNDI 查找与客户端调用从 InitialContext 到 EJB 注入3.1 JNDI 命名空间语义global、app 与 module 的区别JNDI 是 EJB 的寻址中枢客户端拿到 EJB 引用全靠它。名字写错了部署再成功也白搭。最常见的写法是java:global/app/module/bean!full-interface其中 app 名对应部署单元的应用名多数情况下就是.jar、.war或.ear文件名去掉扩展名module 名对应模块名bean 名默认是简单类名。这一长串名字很容易敲错我习惯先去服务器上用命令查而不是靠记忆硬拼。$WILDFLY_HOME/bin/jboss-cli.sh --connect # 进入 CLI 后执行 /subsystemejb3:list-jndi-bindingslist-jndi-bindings会打印所有 EJB 的 JNDI 绑定全名直接复制最保险。三种命名空间的规律是java:global全局可见跨部署单元可访问java:app只在本应用内部共享适合在同一个.ear里做模块间调用java:module作用域更小只在本模块内部有效。客户端调用时优先用java:global。这里有个参数值得注意如果你的接口只有一个捆包名有时可以省略写成java:global/demo-ejb/CounterServiceBean但当多个接口同时存在时省略接口全限定名会导致lookup返回类型不明确所以客户端代码里我总是带上!com.demo.ejb.CounterService。3.2 标准式远程客户端InitialContext 与 prepare 参数远程客户端的核心是InitialContext收到一整套连接属性其中最重要的三个初始上下文工厂、URL 和连接安全凭证。WildFly 的标准工厂名是org.wildfly.naming.client.WildFlyInitialContextFactory注意这是 WildFly 特有的换成其他容器就得重新查。URL 里写的是http-remoting协议不是 HTTP 也不是 IIOP很多刚从 EJB 2.x 转过来的人会在这里卡住。package com.demo.ejb.client; import com.demo.ejb.GreetingService; import javax.naming.Context; import javax.naming.InitialContext; import java.util.Hashtable; public class StandaloneClient { public static void main(String[] args) throws Exception { HashtableString, String env new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, org.wildfly.naming.client.WildFlyInitialContextFactory); env.put(Context.PROVIDER_URL, http-remoting://localhost:8080); env.put(Context.SECURITY_PRINCIPAL, admin); env.put(Context.SECURITY_CREDENTIALS, password); Context context new InitialContext(env); GreetingService service (GreetingService) context.lookup( java:global/demo-ejb/GreetingServiceBean!com.demo.ejb.GreetingService); System.out.println(service.greet(developer)); context.close(); } }这里SECURITY_PRINCIPAL和SECURITY_CREDENTIALS是服务器端添加应用用户时使用的账号密码不是操作系统账号。如果服务器没有启用安全域这两行可以去掉但生产环境强烈建议保留否则任何能连到 8080 端口的人都能调用远程 EJB。代码里用Hashtable存参数是安全做法HashMap在某些 JDK 版本下做类型推断会有坑。context.close()必须执行否则RemotingConnection会一直挂在客户端进程上轻则资源泄漏重则把服务器连接池打满。3.3 注解注入与显式 lookup 的边界在 Java EE 容器内部例如 Servlet 或另一个 EJB 中通常直接用EJB注入接口引用。这是最省事的写法连 JNDI 都省了。但入口类必须由容器托管想在普通main方法里new一个对象再注入是做不到的注解只在依赖注入阶段生效。遇到这种情况要么把这段逻辑挪进 Servlet 的doGet要么回到 3.2 节的方式用InitialContext手动查。import jakarta.ejb.EJB; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; WebServlet(/greet) public class GreetServlet extends HttpServlet { EJB private GreetingService greetingService; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.getWriter().write(greetingService.greet(servlet)); } }EJB默认按接口类型自动装配容器会找到唯一匹配的实例并注入。如果同类型接口有多个实现就必须用EJB(beanName CounterServiceBean)指名道姓。这里有一个常见的边界EJB默认是本地调用还是远程调用取决于接口定义。接口标注Remote时即便客户端同处一个服务器调用也会走远程序列化通道对象传参按副本传递标注Local时按引用传递。入门阶段的坑是混用两种注解接口标了Remote实现却期望本地引用修改效果结果一看对象没变互相猜锅。3.4 用 Dark 环境排查JNDI 找不到绑定名排查 JNDI 问题时最有效的工具不是看代码而是服务器日志和 CLI。先确认list-jndi-bindings里有目标名字再看客户端打印的异常类型。如果报NameNotFoundException说明名字拼写有出入或者部署单元与应用名不一致如果报ClassNotFoundException大概率是接口 jar 没入客户端 classpath如果报ClassCastException则多半是接口信息不一致客户端与服务器端的接口包路径或方法签名不同步。这三类错误在现场几乎是“三连”第 5 章专门展开讲。4. 持久化与事务边界JPA、CMT 和一条回滚日志4.1 EntityManager 注入与事务边界在哪里把 JPA 挂进 EJB 是入门项目里最常被问起的部分。EntityManager不能直接new必须通过PersistenceContext注入由容器为它绑定当前事务上下文。事务边界默认就是 EJB 方法调用本身方法进入时容器开启事务方法退出时提交或回滚。这个机制叫 CMT容器管理事务。package com.demo.ejb; import jakarta.ejb.Stateless; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; Stateless public class OrderServiceBean implements OrderService { PersistenceContext(unitName demoPU) private EntityManager em; Override public void createOrder(String orderNo) { Order order new Order(); order.setOrderNo(orderNo); em.persist(order); } }unitName要与persistence.xml中persistence-unit namedemoPU一一对应写错不会在编译期报错而是在首次调用时抛出PersistenceException。这块有一个值得注意的参数PersistenceContext默认的TransactionAttribute是 REQUIRED也就是调用createOrder时如果外层没有事务容器自动开启一个新事务并在方法结束后提交。新手最容易误解成“JPA 自己会提交”事实是em.persist(order)只是把实体标记为持久化真正落库发生在事务边界处。如果你在方法里手动em.flush()只是强制 SQL 下发但那一步仍属于当前事务。4.2 CMT 事务属性选型REQUIRED 与 REQUIRES_NEW 的取舍事务属性写在方法或者整个类上TransactionAttribute有六个取值入门阶段只需要吃透 REQUIRED 和 REQUIRES_NEW。REQUIRED 是默认值调用链上已有事务就直接加入没有就新建。它的好处是简单坏处是如果外层某个无关紧要的查询因为脏读被污染整个业务事务都会受影响。REQUIRES_NEW 则不管外层有没有事务都开启一个全新事务常用在审计日志或异步通知场景日志写入跟主业务隔离主业务回滚不影响日志落库。import jakarta.ejb.TransactionAttribute; import jakarta.ejb.TransactionAttributeType; Stateless public class AuditServiceBean implements AuditService { TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) Override public void writeAudit(String action) { // 独立事务单独提交或回滚 } }这里要提醒的是 REQUIRES_NEW 的开销。每调用一次这个方法容器都要开启一条新数据库连接和事务在高频写日志场景下会放大连接池压力。生产环境我会考虑把审计数据丢进消息队列由另一个 Bean 异步消费而不是让事务子切到数据库层。事务属性与异常的关系是容器只在方法抛出RuntimeException时回滚受检异常默认不会触发回滚除非显式设置rollback规则。这个特性让很多人误以为事务一定回滚第 5 章有专门条目。4.3 消息驱动 Bean 与 JMS 队列绑定MDB 是 EJB 里最不一样的存在它没有接口也没有客户端调用入口触发它的是消息队列。部署 MDB 前必须确认消息子系统已经启用。它的激活属性写在实现类的注解里其中destinationLookup指向队列的 JNDI 名destinationType固定写jakarta.jms.QueueacknowledgeMode控制签收方式。package com.demo.ejb; import jakarta.ejb.ActivationConfigProperty; import jakarta.ejb.MessageDriven; import jakarta.jms.Message; import jakarta.jms.MessageListener; import jakarta.jms.TextMessage; MessageDriven(activationConfig { ActivationConfigProperty(propertyName destinationLookup, propertyValue java:/jms/queue/demoQueue), ActivationConfigProperty(propertyName destinationType, propertyValue jakarta.jms.Queue), ActivationConfigProperty(propertyName acknowledgeMode, propertyValue Auto-acknowledge) }) public class OrderProcessorMdb implements MessageListener { Override public void onMessage(Message message) { try { TextMessage textMessage (TextMessage) message; System.out.println(Received: textMessage.getText()); } catch (Exception e) { // MDB 里吞异常要非常小心 throw new RuntimeException(e); } } }注意onMessage里如果捕获异常不重新抛出消息会变成已签收但未处理业务上就是悄悄丢掉。重新抛出RuntimeException后容器才会把消息放回队列等待重投。这个行为在不同服务器上策略不完全一致WildFly 默认会把消息重新投递但投递次数过多会进入死信队列。MessageDriven的事务属性默认是 REQUIRED意思是消息处理方法和 EJB 方法一样在事务边界内一旦方法抛异常消息读游标会回滚下次才会再读到。4.4 从服务器日志确认事务回滚节点事务问题多表现为“数据库里数据变了但业务端认为应该回滚”。我在排查时会直接看服务器的 EJB 3 事务日志找到Rolled back transaction或者Marked transaction for rollback关键字。日志里会带类名和方法名据此可以倒推出是哪个方法抛了异常。有一个细节当事务已经标记为 rollback-only 后即使后面所有代码都成功执行容器也不会提交。# 打开 WildFly 日志搜索事务回滚 tail -f $WILDFLY_HOME/standalone/log/server.log | grep -i rollback\|transaction实操中我发现不少回滚问题本质是异常类型选错了。比如在 EJB 方法里用一个自定义业务异常继承Exception结果它属于受检异常容器默认不把这种异常跟回滚关联页面报错了但数据照样写进去。解决方式就是在自定义异常上继承RuntimeException或者在方法上标注ApplicationException(rollback true)。这个注解是 EJB 规范提供的显式声明比RuntimeException更精确推荐团队规范里强制要求业务异常一律继承它。5. 避坑EJB 入门最常见的五个翻车现场5.1 部署成功但客户端 ClassNotFound 或 ClassCastException现象服务器日志里部署正常客户端lookup也找到了 JNDI 名但一强转就抛ClassCastException或者根本报ClassNotFoundException。原因接口的类型加载问题。远程调用时服务器返回的是一个代理对象客户端需要自己能加载接口类的字节码才能完成强转。当客户端把服务器端部署包整个丢进 classpath又遇到两边的类加载器对同一个接口加载了两份不同字节码时就会出现“同名不同类”的假象。解决把接口定义抽到独立的模块 jar服务器端和客户端都只依赖这个 jar不要把实现类一起塞进客户端。我一般这么处理project 分三个模块api只留接口与 DTO、ejb-jar实现、client测试调用。pom.xml里api模块的打包方式保持 jar然后两边都引它。5.2 无状态 Bean 竟然“有状态”现象无状态 Bean 某个字段比如一个整数计数器多次调用后值越变越大或者出现偶发的脏数据读。原因无状态不代表实例只有一个或每次都新建而是指“容器不从业务语义上保证状态”。WildFly 默认会为无状态 Bean 维护一个实例池实例被 A 调用完下一次可能被 B 复用。A 留在这个实例里的字段B 能看见。解决无状态 Bean 里不要写任何会话级字段所有方法局部变量用完即弃。像计数器这种需求要么用SingletonBean 加Lock(WRITE)要么把状态丢进数据库。排查时用日志打印实例哈希码看到多次调用hashCode()相同就能确认复用了。5.3 事务不回滚数据库里躺着“半成品”数据现象业务方法先改了一条记录接着抛出一个业务异常页面显示失败但数据库里这条记录已经被改过了。原因异常是受检异常容器不把受检异常视为回滚触发器等于事务边界上没人喊“停”。另外还有一种情况是方法内部自己catch住了异常吞掉后没重新抛出容器看到的是方法正常返回事务自然提交。解决业务异常统一继承RuntimeException或者用ApplicationException(rollback true)显式声明方法里尽量别写裸 catch如果要处理异常处理完必须重新抛出或者调用context.setRollbackOnly()。我给自己定的习惯是所有 EJB 方法的异常边界与调用边界重合内部捕获层只做日志和包装不改变回滚状态。5.4 EJB 注入是 null现象在工具类或普通new出来的对象里写EJB private GreetingService service;调用时发现是 null。原因EJB注解不是运行时魔法它靠容器在依赖注入阶段扫描并装配。你自己new出来的对象不归容器管注入动作根本不会执行。解决访问入口必须由容器管理比如 Servlet、JAX-RS 资源类或另一个 EJB。如果你的确有普通工具类需要调用 EJB就把这个工具类也交给容器托管或者干脆在工具类内部用InitialContext手动去 JNDI 查。这里有另一种容易踩的情形Servlet 用了EJB、但写在一个子类里子类没有被容器扫描到注入同样是 null。此时把注入字段放到容器能识别的顶层类中再通过 getter 传给子类。5.5 远程调用超时或连接被拒现象客户端InitialContext.lookup能成功但一调方法就超时或者干脆连lookup时连接被拒报Connection refused。原因WildFly 默认用 8080 端口做http-remoting但如果你启动时指定了其他端口偏移量比如-Djboss.socket.binding.port-offset100客户端 URL 就要跟着改成 8180。还有一种情况是客户端本地没有引入wildfly-ejb-client相关依赖协议握手自动降级失败。解决先确认服务器端口用 CLI 的:read-attribute(namesocket-binding-group, include-defaultstrue)看当前绑定再确认客户端依赖中包含ejb-clientjar 包最简单的做法是使用 Maven 的wildfly-ejb-client-bom管理版本然后引入所需 artifact。生产环境还要检查防火墙是否放行了对应 TCP 端口。6. 进阶验证用日志和 CLI 把 Bean 生命周期看穿6.1 给无状态 Bean 挂上生命周期日志无状态 Bean 实例池的行为很抽象但用一行PostConstruct就能把它变得具体。入口是在 Bean 里加一个实例计数器字段每次构造时打印哈希码和当前总数。客户端连续调用五次观察日志里出现多少个新实例你就知道容器帮你创建了多少对象、复用了多少。Stateless public class GreetingServiceBean implements GreetingService { private static final AtomicInteger INSTANCE_COUNT new AtomicInteger(0); private final int instanceId INSTANCE_COUNT.incrementAndGet(); PostConstruct public void init() { System.out.println(EJB instance created: instanceId System.identityHashCode(this)); } PreDestroy public void destroy() { System.out.println(EJB instance destroyed: instanceId); } Override public String greet(String name) { System.out.println(greet called on instance: instanceId); return Hello, name; } }AtomicInteger保证多线程下计数不丢identityHashCode区分不同实例。日志里如果看到五次调用全部命中同一个 instanceId说明容器一直在复用同一个实例这符合无状态 Bean 的正常池化策略。如果看到新实例频繁创建说明高并发下池不够需要调大max-pool-size。6.2 用 CLI 动态观察实例池与 JNDI 绑定服务器运行时CLI 可以读取 EJB 的运行时统计这比看日志更直接。进入 CLI 后先启用统计通道再查具体 Bean 的统计信息。$WILDFLY_HOME/bin/jboss-cli.sh --connect /subsystemejb3:write-attribute(nameenable-statistics, valuetrue) # 查看所有已部署的 EJB 概况 /subsystemejb3:list-cache-names # 查看某个无状态 Bean 的统计 /subsystemejb3/stateless-session-beanGreetingServiceBean:read-resource(include-runtimetrue)read-resource返回里会有invocation-count、pool-size和peak-invocation-count等字段。对齐数据库行数变化就能判断是不是 EJB 层把事务给吞了。这种先看池、再看调用数、再回头查日志的排查顺序我现在会强制走一遍。回到刚拆这套资源时的教训有一次我在一个无状态 Bean 里加了成员字段上线当天数据就乱套了日志滚了半天才发现实例被池化复用。从那以后我每次搭 EJB 项目都先把生命周期日志和统计开关打开不写业务先验证容器行为再往上堆代码。这套入门包里的 Bean、客户端脚本和部署说明就是这样一点一点调顺的照着跑一遍能省掉你至少两天的折腾时间希望帮到你。p a hrefhttps://download.csdn.net/download/weixin_38481963/11114645 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑