Mybatis 源码(三)-一级缓存与二级缓存
2021/9/10 17:07:13
本文主要是介绍Mybatis 源码(三)-一级缓存与二级缓存,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
mybatis 默认是开启一级缓存的。
1. 缓存介绍
mybatis提供查询缓存,用于减轻数据压力,提高数据库性能。mybaits提供一级缓存,和二级缓存。 其结构图可以用下面表示:
一级缓存是SqlSession级别的缓存。在操作数据库时需要构造 sqlSession对象,每个SqlSession 都有一个Executor 执行器,执行器内部有一个缓存对象 PerpetualCache (PerpetualCache 对象内部有一个HashMap 用于缓存数据)。 不同的SqlSession 持有的Executor以及Executor 内部的 PerpetualCache 是相互隔离的,所以可以理解为是session 级别的缓存。
二级缓存是mapper级别的缓存,一个Mapper 对应一个NameSpace, 每个 Mapper 可以指定自己的 Cache, 每个Namespace 下面的MapperStatement 对象持有该 Cache 的引用。在开启二级缓存的情况下, 会使用 CachingExecutor, 该Executor query方法使用MappedStatement 内部维护的对象Cache, update 方法会清空Cache 对象。
2. 源码跟踪
(1) org.apache.ibatis.builder.xml.XMLConfigBuilder#parseConfiguration -》org.apache.ibatis.builder.xml.XMLConfigBuilder#settingsElement 内部会解析是否开启全局的二级缓存,默认是开启
configuration.setCacheEnabled(booleanValueOf(props.getProperty("cacheEnabled"), true));
(2) 创建Executor 的时候会根据上面是否开启二级缓存,然后来判断是否需要对Executor 使用 CachingExecutor 进行装饰
org.apache.ibatis.session.Configuration#newExecutor(org.apache.ibatis.transaction.Transaction, org.apache.ibatis.session.ExecutorType)
public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }
默认会采用 CachingExecutor 来包装执行器。
(3) CachingExecutor 源码如下: CachingExecutor 可以理解为装饰器模式,目的是为了二级缓存的使用。只要调用update 方法会清空MappedStatement.cache 的缓存信息。调用query 会使用二级缓存。
package org.apache.ibatis.executor; import java.sql.SQLException; import java.util.List; import org.apache.ibatis.cache.Cache; import org.apache.ibatis.cache.CacheKey; import org.apache.ibatis.cache.TransactionalCacheManager; import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.mapping.ParameterMapping; import org.apache.ibatis.mapping.ParameterMode; import org.apache.ibatis.mapping.StatementType; import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; import org.apache.ibatis.transaction.Transaction; /** * @author Clinton Begin * @author Eduardo Macarron */ public class CachingExecutor implements Executor { private final Executor delegate; private final TransactionalCacheManager tcm = new TransactionalCacheManager(); public CachingExecutor(Executor delegate) { this.delegate = delegate; delegate.setExecutorWrapper(this); } @Override public Transaction getTransaction() { return delegate.getTransaction(); } @Override public void close(boolean forceRollback) { try { // issues #499, #524 and #573 if (forceRollback) { tcm.rollback(); } else { tcm.commit(); } } finally { delegate.close(forceRollback); } } @Override public boolean isClosed() { return delegate.isClosed(); } @Override public int update(MappedStatement ms, Object parameterObject) throws SQLException { flushCacheIfRequired(ms); return delegate.update(ms, parameterObject); } @Override public <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException { flushCacheIfRequired(ms); return delegate.queryCursor(ms, parameter, rowBounds); } @Override public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql = ms.getBoundSql(parameterObject); CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql); return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); } @Override public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { Cache cache = ms.getCache(); if (cache != null) { flushCacheIfRequired(ms); if (ms.isUseCache() && resultHandler == null) { ensureNoOutParams(ms, boundSql); @SuppressWarnings("unchecked") List<E> list = (List<E>) tcm.getObject(cache, key); if (list == null) { list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); tcm.putObject(cache, key, list); // issue #578 and #116 } return list; } } return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql); } @Override public List<BatchResult> flushStatements() throws SQLException { return delegate.flushStatements(); } @Override public void commit(boolean required) throws SQLException { delegate.commit(required); tcm.commit(); } @Override public void rollback(boolean required) throws SQLException { try { delegate.rollback(required); } finally { if (required) { tcm.rollback(); } } } private void ensureNoOutParams(MappedStatement ms, BoundSql boundSql) { if (ms.getStatementType() == StatementType.CALLABLE) { for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) { if (parameterMapping.getMode() != ParameterMode.IN) { throw new ExecutorException("Caching stored procedures with OUT params is not supported. Please configure useCache=false in " + ms.getId() + " statement."); } } } } @Override public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) { return delegate.createCacheKey(ms, parameterObject, rowBounds, boundSql); } @Override public boolean isCached(MappedStatement ms, CacheKey key) { return delegate.isCached(ms, key); } @Override public void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType) { delegate.deferLoad(ms, resultObject, property, key, targetType); } @Override public void clearLocalCache() { delegate.clearLocalCache(); } private void flushCacheIfRequired(MappedStatement ms) { Cache cache = ms.getCache(); if (cache != null && ms.isFlushCacheRequired()) { tcm.clear(cache); } } @Override public void setExecutorWrapper(Executor executor) { throw new UnsupportedOperationException("This method should not be called"); } }View Code
1》构造缓存key的org.apache.ibatis.executor.BaseExecutor#createCacheKey 方法如下:
@Override public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) { if (closed) { throw new ExecutorException("Executor was closed."); } CacheKey cacheKey = new CacheKey(); cacheKey.update(ms.getId()); cacheKey.update(rowBounds.getOffset()); cacheKey.update(rowBounds.getLimit()); cacheKey.update(boundSql.getSql()); List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry(); // mimic DefaultParameterHandler logic for (ParameterMapping parameterMapping : parameterMappings) { if (parameterMapping.getMode() != ParameterMode.OUT) { Object value; String propertyName = parameterMapping.getProperty(); if (boundSql.hasAdditionalParameter(propertyName)) { value = boundSql.getAdditionalParameter(propertyName); } else if (parameterObject == null) { value = null; } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) { value = parameterObject; } else { MetaObject metaObject = configuration.newMetaObject(parameterObject); value = metaObject.getValue(propertyName); } cacheKey.update(value); } } if (configuration.getEnvironment() != null) { // issue #176 cacheKey.update(configuration.getEnvironment().getId()); } return cacheKey; }
可以看到是通过 MappedStatement的id和sql、偏移量、参数、配置信息环境等一起构成的一个唯一的key
2》获取MappedStatement 的Cache 对象,这个对象是开启二级缓存的情况下,相同Namespace 的共享一个Cache。如果Cache 不为空证明开启二级缓存,在从缓存拿数据,拿不到数据就走 delegate.query 进行实际的查询。
(4) 接下来查询会走 org.apache.ibatis.executor.BaseExecutor#query 方法 - 一级缓存
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } if (queryStack == 0 && ms.isFlushCacheRequired()) { clearLocalCache(); } List<E> list; try { queryStack++; list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } finally { queryStack--; } if (queryStack == 0) { for (DeferredLoad deferredLoad : deferredLoads) { deferredLoad.load(); } // issue #601 deferredLoads.clear(); if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // issue #482 clearLocalCache(); } } return list; }
localCache 实际就是一个包装了Map 的 org.apache.ibatis.cache.impl.PerpetualCache 结构。localCache 这里可以理解为就是一级缓存,一级缓存属于Executor, 所以是SqlSession 级别的缓存。
可以看到逻辑也是先从缓存拿,拿到之后就返回,拿不到就走 queryFromDatabase 调用doQuery 交给具体的Executor 查询数据之后放到缓存中。
3. 测试
1. 一级缓存
测试代码:
@BeforeAll static void setUp() throws Exception { // create a SqlSessionFactory try (Reader reader = Resources.getResourceAsReader("org/apache/ibatis/autoconstructor/mybatis-config.xml")) { sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader); } } @Test public void sqlSessionTest() { try (SqlSession sqlSession = sqlSessionFactory.openSession()) { // 第一个参数是 MappedStatementId, 第二个是参数是参数 Object o = sqlSession.selectOne("org.apache.ibatis.autoconstructor.AutoConstructorMapper.getSubject", 1); System.out.println(o); // 第二次查询走的是缓存,不会查db // sqlSession.clearCache(); Object o1 = sqlSession.selectOne("org.apache.ibatis.autoconstructor.AutoConstructorMapper.getSubject", 1); System.out.println(o1); } }
结果:
DEBUG [main] - ==> Preparing: SELECT * FROM subject WHERE id = ? DEBUG [main] - ==> Parameters: 1(Integer) DEBUG [main] - <== Total: 1 PrimitiveSubject{id=1, name='a', age=10, height=100, weight=45, active=true, dt=Mon Aug 30 09:32:12 CST 2021} PrimitiveSubject{id=1, name='a', age=10, height=100, weight=45, active=true, dt=Mon Aug 30 09:32:12 CST 2021}
可以第二次没有发出SQL,也可以在org.apache.ibatis.executor.BaseExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql) 打断点查看执行过程
2. 二级缓存
1. 二级缓存开启
这里需要注意,二级缓存默认是关闭的,需要手动开启。二级缓存有个全局开关和每个Mapper 单独有一个开关。
(1) 全局开关默认是开启的,如下:
org.apache.ibatis.builder.xml.XMLConfigBuilder#settingsElement:
configuration.setCacheEnabled(booleanValueOf(props.getProperty("cacheEnabled"), true));
如果想要关闭需要修改XML配置:
<settings> <setting name="cacheEnabled" value="false"/> </settings>
这个开关决定了是否使用 CachingExecutor。如果关闭了那么直接使用的是三个Executor(SIMPLE, REUSE, BATCH), 不走增强者模式,也就是不会使用二级缓存。
(2) 每个Mapper 单独开启二级缓存
0》这里使用自定义的Cache
package org.apache.ibatis.cache.decorators; import org.apache.ibatis.cache.Cache; import java.util.HashMap; import java.util.Map; public class MyLruCache implements Cache { private String id; private Node head; private Node tail; private int maxSize; private Map<Object, Node> keyMap = new HashMap<>(); public MyLruCache(String id) { this.id = id; init(); } public void init() { head = new Node(null, null); tail = new Node(null, null); head.next = tail; tail.prev = head; } public void setSize(int size) { this.maxSize = size; } @Override public String getId() { return id; } @Override public void putObject(Object key, Object value) { // 包含进行更新并移动到头部 if (keyMap.containsKey(key)) { Node node = keyMap.get(key); node.value = value; moveToFirst(node); return; } // 否则判断大小后插入尾部 if (maxSize >= getSize()) { removeLast(); } Node node = new Node(key, value); Node prev = tail.prev; prev.next = node; node.prev = prev; node.next = tail; tail.prev = node; keyMap.put(key, node); } private void removeLast() { Node lastNode = tail.prev; if (lastNode.value == null) { return; } Object key = lastNode.key; keyMap.remove(key); Node prev = lastNode.prev; prev.next = tail; tail.prev = prev; } private void moveToFirst(Node node) { Node prev = node.prev; Node next = node.next; prev.next = next; next.prev = prev; Node headNext = head.next; head.next = node; node.prev = head; headNext.prev = node; node.next = headNext; } @Override public Object getObject(Object key) { if (keyMap.containsKey(key)) { return keyMap.get(key).value; } return null; } @Override public Object removeObject(Object key) { if (!keyMap.containsKey(key)) { return null; } Node remove = keyMap.remove(key); remove.prev.next = remove.next; remove.next.prev = remove.prev; return remove.value; } @Override public void clear() { keyMap.clear(); init(); } @Override public int getSize() { return keyMap.size(); } @Override public String toString() { return "MyLruCache{" + "id='" + id + '\'' + ", head=" + head + ", tail=" + tail + ", maxSize=" + maxSize + ", keyMap=" + keyMap + '}'; } private static class Node { private Object key; private Object value; private Node prev; private Node next; public Node(Object key, Object value) { this.key = key; this.value = value; } @Override public String toString() { return "Node{" + "key=" + key + ", value=" + value + '}'; } } }
1》XML方式开启
在Mapper.xml 内部声明如下:
<cache type="org.apache.ibatis.cache.decorators.MyLruCache"/>
源码跟踪:
-1》org.apache.ibatis.builder.xml.XMLMapperBuilder#configurationElement 解析Cache 标签,然后解析相关的SQL语句
private void configurationElement(XNode context) { try { String namespace = context.getStringAttribute("namespace"); if (namespace == null || namespace.isEmpty()) { throw new BuilderException("Mapper's namespace cannot be empty"); } builderAssistant.setCurrentNamespace(namespace); cacheRefElement(context.evalNode("cache-ref")); cacheElement(context.evalNode("cache")); parameterMapElement(context.evalNodes("/mapper/parameterMap")); resultMapElements(context.evalNodes("/mapper/resultMap")); sqlElement(context.evalNodes("/mapper/sql")); buildStatementFromContext(context.evalNodes("select|insert|update|delete")); } catch (Exception e) { throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e); } }
-2》org.apache.ibatis.builder.xml.XMLMapperBuilder#cacheElement 给builderAssistant 记录Cache
private void cacheElement(XNode context) { if (context != null) { String type = context.getStringAttribute("type", "PERPETUAL"); Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type); String eviction = context.getStringAttribute("eviction", "LRU"); Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction); Long flushInterval = context.getLongAttribute("flushInterval"); Integer size = context.getIntAttribute("size"); boolean readWrite = !context.getBooleanAttribute("readOnly", false); boolean blocking = context.getBooleanAttribute("blocking", false); Properties props = context.getChildrenAsProperties(); builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props); } }
-3》org.apache.ibatis.builder.xml.XMLMapperBuilder#buildStatementFromContext 解析相关的sql标签,内部会用到cache
第一步解析标签:
private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) { for (XNode context : list) { final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId); try { statementParser.parseStatementNode(); } catch (IncompleteElementException e) { configuration.addIncompleteStatement(statementParser); } } }
第一步调用: org.apache.ibatis.builder.MapperBuilderAssistant#addMappedStatement 使用cache
public MappedStatement addMappedStatement( String id, SqlSource sqlSource, StatementType statementType, SqlCommandType sqlCommandType, Integer fetchSize, Integer timeout, String parameterMap, Class<?> parameterType, String resultMap, Class<?> resultType, ResultSetType resultSetType, boolean flushCache, boolean useCache, boolean resultOrdered, KeyGenerator keyGenerator, String keyProperty, String keyColumn, String databaseId, LanguageDriver lang, String resultSets) { if (unresolvedCacheRef) { throw new IncompleteElementException("Cache-ref not yet resolved"); } id = applyCurrentNamespace(id, false); boolean isSelect = sqlCommandType == SqlCommandType.SELECT; MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType) .resource(resource) .fetchSize(fetchSize) .timeout(timeout) .statementType(statementType) .keyGenerator(keyGenerator) .keyProperty(keyProperty) .keyColumn(keyColumn) .databaseId(databaseId) .lang(lang) .resultOrdered(resultOrdered) .resultSets(resultSets) .resultMaps(getStatementResultMaps(resultMap, resultType, id)) .resultSetType(resultSetType) .flushCacheRequired(valueOrDefault(flushCache, !isSelect)) .useCache(valueOrDefault(useCache, isSelect)) .cache(currentCache); ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id); if (statementParameterMap != null) { statementBuilder.parameterMap(statementParameterMap); } MappedStatement statement = statementBuilder.build(); configuration.addMappedStatement(statement); return statement; }
这样建立的MappedStatement 会使用相关的cache 对象。这种方式只针对XML 方式的sql 解析有效。对于注解SQL方式无效。
(2) 注解方式开启
@CacheNamespace public interface AutoConstructorMapper {
源码跟踪:
-1》org.apache.ibatis.builder.annotation.MapperAnnotationBuilder#parse
public void parse() { String resource = type.toString(); if (!configuration.isResourceLoaded(resource)) { loadXmlResource(); configuration.addLoadedResource(resource); assistant.setCurrentNamespace(type.getName()); parseCache(); parseCacheRef(); for (Method method : type.getMethods()) { if (!canHaveStatement(method)) { continue; } if (getAnnotationWrapper(method, false, Select.class, SelectProvider.class).isPresent() && method.getAnnotation(ResultMap.class) == null) { parseResultMap(method); } try { parseStatement(method); } catch (IncompleteElementException e) { configuration.addIncompleteMethod(new MethodResolver(this, method)); } } } parsePendingMethods(); } private void parseCache() { CacheNamespace cacheDomain = type.getAnnotation(CacheNamespace.class); if (cacheDomain != null) { Integer size = cacheDomain.size() == 0 ? null : cacheDomain.size(); Long flushInterval = cacheDomain.flushInterval() == 0 ? null : cacheDomain.flushInterval(); Properties props = convertToProperties(cacheDomain.properties()); assistant.useNewCache(cacheDomain.implementation(), cacheDomain.eviction(), flushInterval, size, cacheDomain.readWrite(), cacheDomain.blocking(), props); } } private void parseCacheRef() { CacheNamespaceRef cacheDomainRef = type.getAnnotation(CacheNamespaceRef.class); if (cacheDomainRef != null) { Class<?> refType = cacheDomainRef.value(); String refName = cacheDomainRef.name(); if (refType == void.class && refName.isEmpty()) { throw new BuilderException("Should be specified either value() or name() attribute in the @CacheNamespaceRef"); } if (refType != void.class && !refName.isEmpty()) { throw new BuilderException("Cannot use both value() and name() attribute in the @CacheNamespaceRef"); } String namespace = (refType != void.class) ? refType.getName() : refName; try { assistant.useCacheRef(namespace); } catch (IncompleteElementException e) { configuration.addIncompleteCacheRef(new CacheRefResolver(assistant, namespace)); } } }
parseCache 解析cache, parseCacheRef 解析cacheRef 标签。其实也就是将assistant 使用Cache 属性。
-2》接下来调用下面方法链
org.apache.ibatis.builder.annotation.MapperAnnotationBuilder#parseStatement 解析方法 -》 org.apache.ibatis.builder.MapperBuilderAssistant#addMappedStatement 构造MappedStatement 并且使用缓存。
public MappedStatement addMappedStatement( String id, SqlSource sqlSource, StatementType statementType, SqlCommandType sqlCommandType, Integer fetchSize, Integer timeout, String parameterMap, Class<?> parameterType, String resultMap, Class<?> resultType, ResultSetType resultSetType, boolean flushCache, boolean useCache, boolean resultOrdered, KeyGenerator keyGenerator, String keyProperty, String keyColumn, String databaseId, LanguageDriver lang, String resultSets) { if (unresolvedCacheRef) { throw new IncompleteElementException("Cache-ref not yet resolved"); } id = applyCurrentNamespace(id, false); boolean isSelect = sqlCommandType == SqlCommandType.SELECT; MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType) .resource(resource) .fetchSize(fetchSize) .timeout(timeout) .statementType(statementType) .keyGenerator(keyGenerator) .keyProperty(keyProperty) .keyColumn(keyColumn) .databaseId(databaseId) .lang(lang) .resultOrdered(resultOrdered) .resultSets(resultSets) .resultMaps(getStatementResultMaps(resultMap, resultType, id)) .resultSetType(resultSetType) .flushCacheRequired(valueOrDefault(flushCache, !isSelect)) .useCache(valueOrDefault(useCache, isSelect)) .cache(currentCache); ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id); if (statementParameterMap != null) { statementBuilder.parameterMap(statementParameterMap); } MappedStatement statement = statementBuilder.build(); configuration.addMappedStatement(statement); return statement; }View Code
这里需要注意:因为解析XML和注解中的sql 走的是两套机制,所以XML和注解开启的缓存只对XML中的SQL或者注解sql 有效。并且两者不能同时都开启,同时开启会报错 cache 已经存在。所以需要一个使用Cache 开启, 另一个使用 CacheRef 指向缓存,这样对于xml和注解sql 都有效。
这篇关于Mybatis 源码(三)-一级缓存与二级缓存的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南