- 浏览: 5811 次
- 性别:
- 来自: 武汉
文章分类
最新评论
Mybatis 代码流程及实现原理解析(三)
接上篇, 这篇继续分析XMLMapperBuilder.parse()里的configurationElement() 这个方法。
private void configurationElement(XNode context) { try { //mapper可以加namespace来避免重复情况 String namespace = context.getStringAttribute("namespace"); builderAssistant.setCurrentNamespace(namespace); //该节点表示从其他名目空间引用缓存配置 cacheRefElement(context.evalNode("cache-ref")); cacheElement(context.evalNode("cache"));//解析cache子节点. //解析parameterMap 子节点,因为有多个并列的parameterMap节点,所以要加上路径,解析返回的列表 parameterMapElement(context.evalNodes("/mapper/parameterMap")); //解析resultMap子节点,因为有多个并列的resultMap节点,所以要加上路径,解析返回的列表 resultMapElements(context.evalNodes("/mapper/resultMap")); //解析sql子节点,因为有多个并列的sql节点,所以要加上路径,解析返回的列表 sqlElement(context.evalNodes("/mapper/sql")); //解析select,insert,update,delete子节点 buildStatementFromContext(context.evalNodes("select|insert|update|delete")); } catch (Exception e) { throw new RuntimeException("Error parsing Mapper XML. Cause: " + e, e); } }此方法逐步解析<mapper>的子节点。
子节点<cache-ref>
xml配置片段:
<cache-ref namespace="com.Book"/>
java代码解析:
private void cacheRefElement(XNode context) { if (context != null) { //首先用该节点的namespace替换cacherefmap中mapper文件的。 configuration.addCacheRef(builderAssistant.getCurrentNamespace(), context.getStringAttribute("namespace")); CacheRefResolver cacheRefResolver = new CacheRefResolver(builderAssistant, context.getStringAttribute("namespace")); try { //创建resolver,判断该namespace是否可用。 cacheRefResolver.resolveCacheRef(); } catch (IncompleteElementException e) { //如果不可用,加入到为完成列表,在所有节点解析完成后,再统一做处理 configuration.addIncompleteCacheRef(cacheRefResolver); } } }CacheRefResolver.java的resolve方法
public Cache resolveCacheRef() { return assistant.useCacheRef(cacheRefNamespace); }MapperBuilderAssistant.java的useCacheRef方法:
public Cache useCacheRef(String namespace) { if (namespace == null) { throw new BuilderException("cache-ref element requires a namespace attribute."); } try { unresolvedCacheRef = true; Cache cache = configuration.getCache(namespace); //如果当前configuration对象没有持有该namespace的Cache, 则抛出异常。 if (cache == null) { throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found."); } currentCache = cache; unresolvedCacheRef = false; return cache; } catch (IllegalArgumentException e) { throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.", e); } }
子节点<cache>
xml配置如下:
<mapper namespace="com.skoyou.domain.UserDAO"> <cache type="com.domain.something.MyCustomCache" eviction="FIFO" flushInterval="60000" size="512" readOnly="tr"> <property name="cacheFile" value="/tmp/my-custom-cache.tmp"/> </cache>type:配置自定义缓存或为其他第三方缓存方案 创建适配器来完全覆盖缓存行为。如果未指定的话, 使用“PERPETFUL”类型(PerpetualCache.java)。
eviction:缓存回收策略,默认值是“LRU”, 有如下几种策略可以指定:
LRU:最近最少使用的:移除最长时间不被使用的对象。
FIFO:先进先出:按对象进入缓存的顺序来移除它们。
SOFT:软引用:移除基于垃圾回收器状态和软引用规则的对象。
WEAK:弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象.
flushInterval:刷新间隔)可以被设置为任意的正整数,而且它们代表一个合理的毫秒 形式的时间段。默认情况是不设置,也就是没有刷新间隔,缓存仅仅调用语句时刷新。
size:(引用数目)可以被设置为任意正整数,要记住你缓存的对象数目和你运行环境的 可用内存资源数目。默认值是 1024。
readyOnly:(只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓 存对象的相同实例。因此这些对象不能被修改。这提供了很重要的性能优势。可读写的缓存 会返回缓存对象的拷贝(通过序列化) 。这会慢一些,但是安全,因此默认是 false。
cache可以有个子节点<property> 来加载.
java方法解析:
private void cacheElement(XNode context) throws Exception { if (context != null) { //获取cache type的值,没有的话,使用默认PERPETUAL String type = context.getStringAttribute("type", "PERPETUAL"); // 得到type类, 默认是PERPETUAL 对应的PerpetualCache Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type); //获取eviction的值,没有的话,使用默认LRU得到eviction类, 即LruCache.class 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); Properties props = context.getChildrenAsProperties(); //根据提供的参数, 建一个cache对象, 并将这个对象放入到Configuration 对象中. builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, props); } }
子节点<parameterMap>
在Mybatis中,这个节点已经被废弃了!老式风格的参数映射。内联参数是首选,这个元素可能在将来被移除。其实解析还是比较简单的。取这个节点的所有属性,build出一个ParameterMapping对象,方法parametermappings列表里, 然后将这个列表更新到configuration对象中的ParameterMap中去。
for (XNode parameterMapNode : list) { String id = parameterMapNode.getStringAttribute("id"); String type = parameterMapNode.getStringAttribute("type"); Class<?> parameterClass = resolveClass(type); List<XNode> parameterNodes = parameterMapNode.evalNodes("parameter"); List<ParameterMapping> parameterMappings = new ArrayList<ParameterMapping>(); for (XNode parameterNode : parameterNodes) { String property = parameterNode.getStringAttribute("property"); String javaType = parameterNode.getStringAttribute("javaType"); String jdbcType = parameterNode.getStringAttribute("jdbcType"); String resultMap = parameterNode.getStringAttribute("resultMap"); String mode = parameterNode.getStringAttribute("mode"); String typeHandler = parameterNode.getStringAttribute("typeHandler"); Integer numericScale = parameterNode.getIntAttribute("numericScale"); ParameterMode modeEnum = resolveParameterMode(mode); Class<?> javaTypeClass = resolveClass(javaType); JdbcType jdbcTypeEnum = resolveJdbcType(jdbcType); @SuppressWarnings("unchecked") Class<? extends TypeHandler<?>> typeHandlerClass = (Class<? extends TypeHandler<?>>) resolveClass(typeHandler); ParameterMapping parameterMapping = builderAssistant.buildParameterMapping(parameterClass, property, javaTypeClass, jdbcTypeEnum, resultMap, modeEnum, typeHandlerClass, numericScale); parameterMappings.add(parameterMapping); } builderAssistant.addParameterMap(id, parameterClass, parameterMappings); } }
子节点<resultMap>
这个节点的解析较复杂,会在下一遍中单独讲解。
子节点<sql>
<sql id="userColumns"> id,username,password </sql>这个元素可以被用来定义可重用的 SQL 代码段,可以包含在其他语句中。
Java 代码
private void sqlElement(List<XNode> list) throws Exception { if (configuration.getDatabaseId() != null) { //传人configuration现有的database id。 sqlElement(list, configuration.getDatabaseId()); } sqlElement(list, null); }sqlElement方法:
private void sqlElement(List<XNode> list, String requiredDatabaseId) throws Exception { for (XNode context : list) { //由于有多个并列的sql节点,需求逐个解析。 //sql 也可以有自己的database id,但实际上这个id要与全局配置的一致 String databaseId = context.getStringAttribute("databaseId"); String id = context.getStringAttribute("id"); //判断id是否包含有当前的namespace,包含的话直接返回当前id。 没有的话,则以当前mapper的namespace.id值返回 id = builderAssistant.applyCurrentNamespace(id, false); if (databaseIdMatchesCurrent(id, databaseId, requiredDatabaseId)) sqlFragments.put(id, context); } }databaseIdMatchesCurrent 方法:
sql节点自己指定的database id要与configuration中的一致。
private boolean databaseIdMatchesCurrent(String id, String databaseId, String requiredDatabaseId) { if (requiredDatabaseId != null) { if (!requiredDatabaseId.equals(databaseId)) { return false; } } else { if (databaseId != null) { return false; } // skip this fragment if there is a previous one with a not null databaseId if (this.sqlFragments.containsKey(id)) { XNode context = this.sqlFragments.get(id); if (context.getStringAttribute("databaseId") != null) { return false; } } } return true; }实际上这个sqlFragments map是在XMLMappBuilder初始化的时候,有configuration传入的, 所以configuration能得到最新的对这个map的修改。这个map会在build select等节点的时候被用到。
子节点<select>,<insert>,<update>,<delete>
节点配置,以select为例:
<select id="selectPerson" databaseId="test" fetchSize="256" timeout="10000" parameterMap="deprecated" parameterType="int" resultMap="personResultMap" resultType="hashmap" lang="XML" resultSetType="FORWARD_ONLY" statementType="PREPARED" flushCache="false" useCache="true" resultOrdered="false" >
节点属性描述:
id | 在命名空间中唯一的标识符,可以被用来引用这条语句。 |
databaseId | 如果配置了databaseProvider,databaseId要与配置的一致。如果不匹配的话,后续代码不会继续解析 |
fetchSize | 这是暗示驱动程序每次批量返回的结果行数。默认不设置(驱动 自行处理)。 |
timeout | 这个设置驱动程序等待数据库返回请求结果,并抛出异常时间的 最大等待值。默认不设置(驱动自行处理) |
parameterMap | 这是引用外部 parameterMap 的已经被废弃的方法。使用内联参数 映射和 parameterType 属性。 |
parameterType | 将会传入这条语句的参数类的完全限定名或别名。 |
resultMap | 命名引用外部的 resultMap。 返回 map 是 MyBatis 最具力量的特性, 对其有一个很好的理解的话, 许多复杂映射的情形就 能被解决了。 使用 resultMap 或 resultType,但不能同时使用。 |
resultType | 从这条语句中返回的期望类型的类的完全限定名或别名。注意集 合情形,那应该是集合可以包含的类型,而不能是集合本身。使 用 resultType 或 resultMap,但不能同时使用 |
lang | 提供”XML“或”RAW“,不同的lang,可能会影响最终的sql语句的生成,默认是”XML“ |
resultSetType | FORWARD_ONLY|SCROLL_SENSITIVE|SCROLL_INSENSITIVE 中的一种。默认是不设置(驱动自行处理)。 |
statementType | STA TEMENT,PREPARED 或 CALLABLE 的一种。 这会让 MyBatis 使用选择使用 Statement,PreparedStatement 或 CallableStatement。 默认值:PREPARED |
fhushCache | 将其设置为 true,不论语句什么时候被调用,都会导致缓存被 清空。默认值:false |
userCache | 将其设置为 true, 将会导致本条语句的结果被缓存。 默认值: true |
resultOrdered | 这个属性值适用于有嵌套结果的select 语句。如果是true, 那么就认为包含了嵌套的结果, 或者嵌套的结果被分在一组。这样的话, 当一个新的主结果返回时, 就不会出现上一个结果行。 这能够更加内存友好地填充嵌套结果。默认是false。 |
另外还有3个是只对insert有效的属性:
useGeneratedKeys | 这会告诉 MyBatis使用JDBC的getGeneratedKeys 方法来取出由数据(比如:像 MySQL 和 SQL Server 这样的数据库管理系统的自动递增字段)内部生成的主键。 默认值:false |
keyProperty | 标记一个属性, MyBatis 会通过 getGeneratedKeys 或者通过 insert 语句的 selectKey 子元素设置它的值。 默认: 不设置 |
keyColumn | 标记一个属性, MyBatis 会通过 getGeneratedKeys 或者通过 insert 语句的 selectKey 子元素设置它的值。 默认: 不设置 |
sql语句的解析实际上是在 XMLStatementBuider的parseStatementNode方法中解析的。
private void buildStatementFromContext(List<XNode> list) { if (configuration.getDatabaseId() != null) { buildStatementFromContext(list, configuration.getDatabaseId()); } buildStatementFromContext(list, null); } private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) { for (XNode context : list) { //构造statementBuilder。这几个sql语句相关的节点在这个类里解析 final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId); try { statementParser.parseStatementNode(); } catch (IncompleteElementException e) { configuration.addIncompleteStatement(statementParser); } } }
XMLStatementBuilder.java的 parseStatementNode方法,如下。这个方法比较长,但主要是读取属性值,然后包装生成一个MappedStatement,并放入configuration对象中。
public void parseStatementNode() { //读取id 属性 String id = context.getStringAttribute("id"); String databaseId = context.getStringAttribute("databaseId"); //读取databaseId 属性,但是一定要与现有存在的匹配, 否则不继续解析剩余节点 if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) return; Integer fetchSize = context.getIntAttribute("fetchSize"); Integer timeout = context.getIntAttribute("timeout"); String parameterMap = context.getStringAttribute("parameterMap"); String parameterType = context.getStringAttribute("parameterType"); Class<?> parameterTypeClass = resolveClass(parameterType); String resultMap = context.getStringAttribute("resultMap"); String resultType = context.getStringAttribute("resultType"); String lang = context.getStringAttribute("lang"); LanguageDriver langDriver = getLanguageDriver(lang); Class<?> resultTypeClass = resolveClass(resultType); String resultSetType = context.getStringAttribute("resultSetType"); //statementType 默认是PreparedStatement StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString())); ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType); String nodeName = context.getNode().getNodeName(); SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH)); boolean isSelect = sqlCommandType == SqlCommandType.SELECT; //如果是select语句,则默认值是false。否则默认值是true。 boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect); //如果是select语句,则默认值是true。否则默认值是false。 boolean useCache = context.getBooleanAttribute("useCache", isSelect); boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false); // 解析<include>子节点,其实是用引用替换<sql>节点的实际内容。 XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant); includeParser.applyIncludes(context.getNode()); // 解析<selectKey>子节点,仅对<insert>有用。后面会讲解parseSelectKeyNodes方法 List<XNode> selectKeyNodes = context.evalNodes("selectKey"); if (configuration.getDatabaseId() != null) { parseSelectKeyNodes(id, selectKeyNodes, parameterTypeClass, langDriver, configuration.getDatabaseId()); } parseSelectKeyNodes(id, selectKeyNodes, parameterTypeClass, langDriver, null); //默认是XMLLanguageDriver,parameterTypeClass用不上 //生成持有当前sql语句的sqlSource对象,后面会具体看这个sqlSource是怎么生成的 SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass); //keyProperty和keyColumn使用于<insert>语句 String keyProperty = context.getStringAttribute("keyProperty"); String keyColumn = context.getStringAttribute("keyColumn"); KeyGenerator keyGenerator; String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX; keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true); if (configuration.hasKeyGenerator(keyStatementId)) { keyGenerator = configuration.getKeyGenerator(keyStatementId); } else { keyGenerator = context.getBooleanAttribute("useGeneratedKeys", configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType)) ? new Jdbc3KeyGenerator() : new NoKeyGenerator(); } //通过解析出来的属性值,构造MappedStatement对象。并放入configuration对象中 builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, langDriver); }
<insert>节点的子节点<selectKey>的解析
<selectKey>配置例子:
<selectKey databaseId="mysql“ keyProperty="id" resultType="int" order="BEFORE" statementType="PREPARED"> select CAST(RANDOM()*1000000 as INTEGER) a from SYSIBM.SYSDUMMY1 </selectKey>
keyProperty:selectKey 语句结果应该被设置的目标属性。就是给哪个字段生成主键值。
resultType:结果的类型。MyBatis 通常可以算出来,但是写上也没有问题。 MyBatis 允许任何简单类型用作主键的类型,包括字符串
statementType:MyBatis 支持 STA TEMENT ,PREPARED 和 CALLABLE 语句的映射类型,分别代表 PreparedStatement 和 CallableStatement 类型。
order:这可以被设置为 BEFORE 或 AFTER。如果设置为 BEFORE,那 么它会首先选择主键, 设置 keyProperty 然后执行插入语句。 如果 设置为 AFTER,那么先执行插入语句,然后是 selectKey 元素- 这和如 Oracle 数据库相似,可以在插入语句中嵌入序列调用.
java代码:
public void parseSelectKeyNodes(String parentId, List<XNode> list, Class<?> parameterTypeClass, LanguageDriver langDriver, String skRequiredDatabaseId) { //可以有多个<selectKey>节点。 for (XNode nodeToHandle : list) { String id = parentId + SelectKeyGenerator.SELECT_KEY_SUFFIX; String databaseId = nodeToHandle.getStringAttribute("databaseId"); if (databaseIdMatchesCurrent(id, databaseId, skRequiredDatabaseId)) { parseSelectKeyNode(id, nodeToHandle, parameterTypeClass, langDriver, databaseId); } } } // public void parseSelectKeyNode(String id, XNode nodeToHandle, Class<?> parameterTypeClass, LanguageDriver langDriver, String databaseId) { String resultType = nodeToHandle.getStringAttribute("resultType"); Class<?> resultTypeClass = resolveClass(resultType); StatementType statementType = StatementType.valueOf(nodeToHandle.getStringAttribute("statementType", StatementType.PREPARED.toString())); String keyProperty = nodeToHandle.getStringAttribute("keyProperty"); boolean executeBefore = "BEFORE".equals(nodeToHandle.getStringAttribute("order", "AFTER")); //defaults boolean useCache = false; boolean resultOrdered = false; KeyGenerator keyGenerator = new NoKeyGenerator(); Integer fetchSize = null; Integer timeout = null; boolean flushCache = false; String parameterMap = null; String resultMap = null; ResultSetType resultSetTypeEnum = null; //解析生成主键的sql语句 SqlSource sqlSource = langDriver.createSqlSource(configuration, nodeToHandle, parameterTypeClass); SqlCommandType sqlCommandType = SqlCommandType.SELECT; //生成MappedStatement 对象,放入configuration对象的mappedStatements map中 builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, null, databaseId, langDriver); id = builderAssistant.applyCurrentNamespace(id, false); MappedStatement keyStatement = configuration.getMappedStatement(id, false); //同时将这个MappedStatement 对象放入到configuration的 keyGenerator map中 configuration.addKeyGenerator(id, new SelectKeyGenerator(keyStatement, executeBefore)); nodeToHandle.getParent().getNode().removeChild(nodeToHandle.getNode()); }
下篇会主要看看<resultMap>的解析和sqlSource的生成。
相关推荐
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
源码介绍则对项目的源码进行了详细的解释和说明,包括代码结构、模块划分、关键代码解析等。通过阅读源码介绍,可以深入理解项目的设计和实现原理,对于学习和使用该项目有很大帮助。 总的来说,这个资源包提供了从...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...
代码与实现细节:提供了完整的项目源代码,并针对关键部分进行了详细的注释和解析。 文档与笔记:整理了开发过程中的关键决策、技术难题以及学习心得,有助于深入理解项目背后的思考过程。 二、适用人群 这个项目...