Mybatis源码阅读(二):动态节点解析2.1 —— SqlSource和SqlNode

前言

前面的文章介绍了mybatis核心配置文件和mapper文件的解析,之后因为加班比较重,加上个人也比较懒,一拖就是将近半个月,今天抽空开始第二部分的阅读。

由前面的文章可知,mapper文件中定义的Sql节点会被解析成MappedStatement,其中的SQL语句会被解析成SqlSource。而Sql语句中定义的动态sql节点(如if节点、foreach节点)会被解析成SqlNode。SqlNode节点的解析中会使用到Ognl表达式(没错就是是struts2用的那玩意。本以为随着struts2和jsp淡出开发环境,这种动态标签也会随之过时,没想到mybatis里依然沿用了ognl),这个内容介绍起来有点麻烦,因此感兴趣的读者请自行了解一下。

SqlSource

Sql节点中的Sql语句会被解析成SqlSource,SqlSource接口中只定义了一个方法 getBoundSql 。该方法用于表示解析后的Sql语句(带问号)。

/**
 * 该接口用于标识映射文件或者注解中定义的sql语句
 * 这里的sql可能带有#{}等标志
 *
 * @author Clinton Begin
 */
public interface SqlSource {

    /**
     * 可执行的sql
     *
     * @param parameterObject
     * @return
     */
    BoundSql getBoundSql(Object parameterObject);
}

SqlSource的继承关系如下图所示。每个实现类都比较简单,下面只做简单的说明。

DynamicSqlSource用于处理动态语句(带有动态sql标签),RawSqlSource用于处理静态语句(没有动态sql标签),二者最终会解析成StaticSqlSource。StaticSqlSource可能会带有问号。这里暂时只将代码简单的贴出来,部分内容需要结合后面才可以加注释(如SqlNode)


/**
 * 处理静态sql语句
 * @since 3.2.0
 * @author Eduardo Macarron
 */
public class RawSqlSource implements SqlSource {

    private final SqlSource sqlSource;

    public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
        this(configuration, getSql(configuration, rootSqlNode), parameterType);
    }

    public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        Class<?> clazz = parameterType == null ? Object.class : parameterType;
        sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<>());
    }

    private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
        DynamicContext context = new DynamicContext(configuration, null);
        rootSqlNode.apply(context);
        return context.getSql();
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        return sqlSource.getBoundSql(parameterObject);
    }
}
/**
 * 负责解析动态sql语句
 * 包含#{}占位符
 *
 * @author Clinton Begin
 */
public class DynamicSqlSource implements SqlSource {

    private final Configuration configuration;
    private final SqlNode rootSqlNode;

    public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
        this.configuration = configuration;
        this.rootSqlNode = rootSqlNode;
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        DynamicContext context = new DynamicContext(configuration, parameterObject);
        rootSqlNode.apply(context);
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
        SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
        BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
        context.getBindings().forEach(boundSql::setAdditionalParameter);
        return boundSql;
    }

}
/**
 * 经过DynamicSqlSource和RawSqlSource处理后
 * 这里存放的sql可能含有?占位符
 * @author Clinton Begin
 */
public class StaticSqlSource implements SqlSource {

    private final String sql;
    private final List<ParameterMapping> parameterMappings;
    private final Configuration configuration;

    public StaticSqlSource(Configuration configuration, String sql) {
        this(configuration, sql, null);
    }

    public StaticSqlSource(Configuration configuration, String sql, List<ParameterMapping> parameterMappings) {
        this.sql = sql;
        this.parameterMappings = parameterMappings;
        this.configuration = configuration;
    }

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        return new BoundSql(configuration, sql, parameterMappings, parameterObject);
    }

}

ProviderSqlSource暂时不贴出来(还没读到这里)

DynamicContext

DynamicContext用于记录解析动态Sql时产生的Sql片段。这里也先将主要代码放出来。


/**
 * 用于记录解析动态SQL语句之后产生的SQL语句片段
 * 可以认为它是一个用于记录动态SQL语句解析生产的容器
 * @author Clinton Begin
 */
public class DynamicContext {

    public static final String PARAMETER_OBJECT_KEY = "_parameter";
    public static final String DATABASE_ID_KEY = "_databaseId";

    static {
        OgnlRuntime.setPropertyAccessor(ContextMap.class, new ContextAccessor());
    }

    /**
     * 参数上下文
     */
    private final ContextMap bindings;
    /**
     * 在SQL弄得解析动态SQL时,会将解析后的SQL语句片段添加到该属性总保存
     * 最终拼凑出一条完整的SQL
     */
    private final StringJoiner sqlBuilder = new StringJoiner(" ");
    private int uniqueNumber = 0;

    /**
     * 构造中初始化bindings集合
     * @param configuration
     * @param parameterObject 运行时用户传入的参数。
     */
    public DynamicContext(Configuration configuration, Object parameterObject) {
        if (parameterObject != null && !(parameterObject instanceof Map)) {
            // 非Map就去找对应的类型处理器
            MetaObject metaObject = configuration.newMetaObject(parameterObject);
            boolean existsTypeHandler = configuration.getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass());
            bindings = new ContextMap(metaObject, existsTypeHandler);
        } else {
            bindings = new ContextMap(null, false);
        }
        bindings.put(PARAMETER_OBJECT_KEY, parameterObject);
        bindings.put(DATABASE_ID_KEY, configuration.getDatabaseId());
    }

    public Map<String, Object> getBindings() {
        return bindings;
    }

    public void bind(String name, Object value) {
        bindings.put(name, value);
    }

    /**
     * 追加SQL片段
     * @param sql
     */
    public void appendSql(String sql) {
        sqlBuilder.add(sql);
    }

    /**
     * 获取解析后的SQL语句
     * @return
     */
    public String getSql() {
        return sqlBuilder.toString().trim();
    }

    public int getUniqueNumber() {
        return uniqueNumber++;
    }

}

SqlNode

SqlNode表示Sql节点中的动态Sql。该类(接口)只有一个apply方法,用于解析动态Sql节点,并调用DynamicContext的appendSql方法去拼接sql语句。

/**
 * @author Clinton Begin
 */
public interface SqlNode {

    /**
     * 根据用户传入的实参去解析动态SQL节点
     * 并调用DynamicContext.appendSql将解析后的SQL片段
     * 追加到DynamicContext.sqlBuilder保存
     * @param context
     * @return
     */
    boolean apply(DynamicContext context);
}

SqlNode实现类很多,如图所示。光看实现类的名称,想必大家都可以猜出这些实现类的作用了。下面将对这些实现类一一解释

StaticTextSqlNode使用text字段记录非动态Sql节点,apply方法直接将text字段追加到DynamicContext.sqlBuilder;MixedSqlNode中使用contents字段存放子节点的动态sql,apply方法则是遍历contents去调用每个SqlNode的apply方法,代码都比较简单就不贴出来了。

TextSqlNode

TextSqlNode表示包含${}的sql节点,isDynamic方法用于检测sql中是否包含${}占位符。该类的apply方法会使用GenericTokenParser将${}占位符解析成实际意义的参数值,因此${}在mybatis中会有注入风险,应当慎用,尽量用于非前端传递的参数。这里比较特殊的场景就是order by。order by后面只能使用${}占位符,因此前端操作排序列时,务必要做防注入处理。

/**
 * 包含${}的sql
 * @author Clinton Begin
 */
public class TextSqlNode implements SqlNode {
    private final String text;
    private final Pattern injectionFilter;

    public TextSqlNode(String text) {
        this(text, null);
    }

    public TextSqlNode(String text, Pattern injectionFilter) {
        this.text = text;
        this.injectionFilter = injectionFilter;
    }

    public boolean isDynamic() {
        DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
        GenericTokenParser parser = createParser(checker);
        parser.parse(text);
        return checker.isDynamic();
    }

    @Override
    public boolean apply(DynamicContext context) {
        GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
        context.appendSql(parser.parse(text));
        return true;
    }

    private GenericTokenParser createParser(TokenHandler handler) {
        // 这里标识解析的是${}占位符
        return new GenericTokenParser("${", "}", handler);
    }

    private static class BindingTokenParser implements TokenHandler {

        private DynamicContext context;
        private Pattern injectionFilter;

        public BindingTokenParser(DynamicContext context, Pattern injectionFilter) {
            this.context = context;
            this.injectionFilter = injectionFilter;
        }

        @Override
        public String handleToken(String content) {
            // 获取用户提供的实参
            Object parameter = context.getBindings().get("_parameter");
            if (parameter == null) {
                context.getBindings().put("value", null);
            } else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
                context.getBindings().put("value", parameter);
            }
            // 通过ognl解析content的值
            Object value = OgnlCache.getValue(content, context.getBindings());
            String srtValue = value == null ? "" : String.valueOf(value); // issue #274 return "" instead of "null"
            checkInjection(srtValue);
            return srtValue;
        }

        private void checkInjection(String value) {
            if (injectionFilter != null && !injectionFilter.matcher(value).matches()) {
                throw new ScriptingException("Invalid input. Please conform to regex" + injectionFilter.pattern());
            }
        }
    }

    private static class DynamicCheckerTokenParser implements TokenHandler {

        private boolean isDynamic;

        public DynamicCheckerTokenParser() {
            // Prevent Synthetic Access
        }

        public boolean isDynamic() {
            return isDynamic;
        }

        @Override
        public String handleToken(String content) {
            this.isDynamic = true;
            return null;
        }
    }

}

IfSqlNode

该类表示mybatis中的if标签。if标签中使用的其实就是Ognl语句,因此可以有一些很花哨的写法,如调用参数的equals方法等,这里不对Ognl表达式做过多的介绍。


/**
 * if节点
 *
 * @author Clinton Begin
 */
public class IfSqlNode implements SqlNode {
    /**
     * if节点的test表达式值
     */
    private final ExpressionEvaluator evaluator;
    /**
     * if节点的test表达式
     */
    private final String test;
    /**
     * if节点的子节点
     */
    private final SqlNode contents;

    public IfSqlNode(SqlNode contents, String test) {
        this.test = test;
        this.contents = contents;
        this.evaluator = new ExpressionEvaluator();
    }

    @Override
    public boolean apply(DynamicContext context) {
        // 检测表达式是否为true,来决定是否执行apply方法
        if (evaluator.evaluateBoolean(test, context.getBindings())) {
            contents.apply(context);
            return true;
        }
        return false;
    }

}

TrimSqlNode

trimSqlNode用于根据解析结果添加或删除后缀活前缀。


/**
 * 根据解析结果添加或删除后缀或前缀
 *
 * @author Clinton Begin
 */
public class TrimSqlNode implements SqlNode {

    /**
     * trim节点的子节点
     */
    private final SqlNode contents;
    /**
     * 前缀
     */
    private final String prefix;
    /**
     * 后缀
     */
    private final String suffix;
    /**
     * 如果trim节点包裹的SQL是空语句,删除指定的前缀,如where
     */
    private final List<String> prefixesToOverride;
    /**
     * 如果trim节点包裹的SQL是空语句,删除指定的后缀,如逗号
     */
    private final List<String> suffixesToOverride;
    private final Configuration configuration;

    public TrimSqlNode(Configuration configuration, SqlNode contents, String prefix, String prefixesToOverride, String suffix, String suffixesToOverride) {
        this(configuration, contents, prefix, parseOverrides(prefixesToOverride), suffix, parseOverrides(suffixesToOverride));
    }

    protected TrimSqlNode(Configuration configuration, SqlNode contents, String prefix, List<String> prefixesToOverride, String suffix, List<String> suffixesToOverride) {
        this.contents = contents;
        this.prefix = prefix;
        this.prefixesToOverride = prefixesToOverride;
        this.suffix = suffix;
        this.suffixesToOverride = suffixesToOverride;
        this.configuration = configuration;
    }

    @Override
    public boolean apply(DynamicContext context) {
        FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context);
        boolean result = contents.apply(filteredDynamicContext);
        // 处理前缀和后缀
        filteredDynamicContext.applyAll();
        return result;
    }

    /**
     * 对prefixOverrides和suffixOverride属性解析
     * 并初始化两个Override集合
     *
     * @param overrides
     * @return
     */
    private static List<String> parseOverrides(String overrides) {
        if (overrides != null) {
            // 使用|分隔
            final StringTokenizer parser = new StringTokenizer(overrides, "|", false);
            final List<String> list = new ArrayList<>(parser.countTokens());
            while (parser.hasMoreTokens()) {
                list.add(parser.nextToken().toUpperCase(Locale.ENGLISH));
            }
            return list;
        }
        return Collections.emptyList();
    }

    private class FilteredDynamicContext extends DynamicContext {
        /**
         * 上下文对象
         */
        private DynamicContext delegate;
        /**
         * 标识已经处理过的前缀和后缀
         */
        private boolean prefixApplied;
        private boolean suffixApplied;
        /**
         * 记录子节点解析后的结果
         */
        private StringBuilder sqlBuffer;

        public FilteredDynamicContext(DynamicContext delegate) {
            super(configuration, null);
            this.delegate = delegate;
            this.prefixApplied = false;
            this.suffixApplied = false;
            this.sqlBuffer = new StringBuilder();
        }

        public void applyAll() {
            sqlBuffer = new StringBuilder(sqlBuffer.toString().trim());
            String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH);
            if (trimmedUppercaseSql.length() > 0) {
                applyPrefix(sqlBuffer, trimmedUppercaseSql);
                applySuffix(sqlBuffer, trimmedUppercaseSql);
            }
            delegate.appendSql(sqlBuffer.toString());
        }

        @Override
        public Map<String, Object> getBindings() {
            return delegate.getBindings();
        }

        @Override
        public void bind(String name, Object value) {
            delegate.bind(name, value);
        }

        @Override
        public int getUniqueNumber() {
            return delegate.getUniqueNumber();
        }

        @Override
        public void appendSql(String sql) {
            sqlBuffer.append(sql);
        }

        @Override
        public String getSql() {
            return delegate.getSql();
        }

        /**
         * 处理前缀
         *
         * @param sql sql
         * @param trimmedUppercaseSql 小写sql
         */
        private void applyPrefix(StringBuilder sql, String trimmedUppercaseSql) {
            if (!prefixApplied) {
                prefixApplied = true;
                if (prefixesToOverride != null) {
                    for (String toRemove : prefixesToOverride) {
                        // 遍历prefixesToOverride,如果以其中的某项开头就从SQL语句开头剔除
                        if (trimmedUppercaseSql.startsWith(toRemove)) {
                            sql.delete(0, toRemove.trim().length());
                            break;
                        }
                    }
                }
                if (prefix != null) {
                    sql.insert(0, " ");
                    sql.insert(0, prefix);
                }
            }
        }

        /**
         * 处理后缀。
         * @param sql
         * @param trimmedUppercaseSql
         */
        private void applySuffix(StringBuilder sql, String trimmedUppercaseSql) {
            if (!suffixApplied) {
                suffixApplied = true;
                if (suffixesToOverride != null) {
                    for (String toRemove : suffixesToOverride) {
                        if (trimmedUppercaseSql.endsWith(toRemove) || trimmedUppercaseSql.endsWith(toRemove.trim())) {
                            int start = sql.length() - toRemove.trim().length();
                            int end = sql.length();
                            sql.delete(start, end);
                            break;
                        }
                    }
                }
                if (suffix != null) {
                    sql.append(" ");
                    sql.append(suffix);
                }
            }
        }

    }

}

WhereSqlNode&SetSqlNode

WhereSqlNode和SetSqlNode分别表示where节点和set节点。这两个类继承了TrimSqlNode,因此自带处理前后缀的功能。

WhereSqlNode将and、or两个关键字作为需要删除的前缀。当where的第一个条件以这两个开头时,会将and或者or删除。而SetSqlNode则会删除前缀或者后缀的嘤文逗号。这里只贴出WhereSqlNode代码。

/**
 * where节点。继承了TrimSqlNode
 * 因此where节点自带处理前缀后缀功能
 *
 * @author Clinton Begin
 */
public class WhereSqlNode extends TrimSqlNode {

    /**
     * 设置前缀是OR和AND,因此解析后的SQL如果以这俩开头就会删掉前缀
     */
    private static List<String> prefixList = Arrays.asList("AND ", "OR ", "AND\n", "OR\n", "AND\r", "OR\r", "AND\t", "OR\t");

    public WhereSqlNode(Configuration configuration, SqlNode contents) {
        super(configuration, contents, "WHERE", prefixList, null, null);
    }

}

ForeachSqlNode

在动态Sql语句中构建in条件时,往往需要遍历一个集合,因此使用foreach标签。这里需要着重介绍一下FilteredDynamicContext这个内部类。该类继承了DynamicContext,用来处理foreach中的#{}占位符。这里是对其不完全的处理。如#{item}会被处理乘#{__frch_item_index值}这种格式,用来表示遍历中的每一项。


/**
 * forEach节点
 *
 * @author Clinton Begin
 */
public class ForEachSqlNode implements SqlNode {
    public static final String ITEM_PREFIX = "__frch_";

    /**
     * 判断循环终止的条件
     */
    private final ExpressionEvaluator evaluator;
    /**
     * 迭代的集合表达式
     */
    private final String collectionExpression;
    /**
     * 该节点下的节点
     */
    private final SqlNode contents;
    /**
     * 循环前以什么开头
     */
    private final String open;
    /**
     * 循环后以什么结束
     */
    private final String close;
    /**
     * 循环过程中的分隔符
     */
    private final String separator;
    /**
     * 每次循环的变量名
     */
    private final String item;
    /**
     * 当前迭代次数
     */
    private final String index;
    private final Configuration configuration;

    public ForEachSqlNode(Configuration configuration, SqlNode contents, String collectionExpression, String index, String item, String open, String close, String separator) {
        this.evaluator = new ExpressionEvaluator();
        this.collectionExpression = collectionExpression;
        this.contents = contents;
        this.open = open;
        this.close = close;
        this.separator = separator;
        this.index = index;
        this.item = item;
        this.configuration = configuration;
    }

    @Override
    public boolean apply(DynamicContext context) {
        Map<String, Object> bindings = context.getBindings();
        final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
        if (!iterable.iterator().hasNext()) {
            return true;
        }
        boolean first = true;
        // 循环之前添加open指定的字符串
        applyOpen(context);
        int i = 0;
        for (Object o : iterable) {
            DynamicContext oldContext = context;
            if (first || separator == null) {
                // 是第一个循环,并且没有间隔符
                context = new PrefixedContext(context, "");
            } else {
                context = new PrefixedContext(context, separator);
            }
            int uniqueNumber = context.getUniqueNumber();
            // 将index和item添加到DynamicContext.bindings集合
            if (o instanceof Map.Entry) {
                @SuppressWarnings("unchecked")
                Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
                applyIndex(context, mapEntry.getKey(), uniqueNumber);
                applyItem(context, mapEntry.getValue(), uniqueNumber);
            } else {
                applyIndex(context, i, uniqueNumber);
                applyItem(context, o, uniqueNumber);
            }
            // 调用子节点的apply急需处理
            contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
            if (first) {
                first = !((PrefixedContext) context).isPrefixApplied();
            }
            context = oldContext;
            i++;
        }
        // 拼接close
        applyClose(context);
        context.getBindings().remove(item);
        context.getBindings().remove(index);
        return true;
    }

    private void applyIndex(DynamicContext context, Object o, int i) {
        if (index != null) {
            context.bind(index, o);
            context.bind(itemizeItem(index, i), o);
        }
    }

    private void applyItem(DynamicContext context, Object o, int i) {
        if (item != null) {
            context.bind(item, o);
            context.bind(itemizeItem(item, i), o);
        }
    }

    private void applyOpen(DynamicContext context) {
        if (open != null) {
            context.appendSql(open);
        }
    }

    private void applyClose(DynamicContext context) {
        if (close != null) {
            context.appendSql(close);
        }
    }

    private static String itemizeItem(String item, int i) {
        return ITEM_PREFIX + item + "_" + i;
    }

    /**
     * 处理#{}(不完全处理)
     */
    private static class FilteredDynamicContext extends DynamicContext {
        private final DynamicContext delegate;
        private final int index;
        private final String itemIndex;
        private final String item;

        public FilteredDynamicContext(Configuration configuration, DynamicContext delegate, String itemIndex, String item, int i) {
            super(configuration, null);
            this.delegate = delegate;
            this.index = i;
            this.itemIndex = itemIndex;
            this.item = item;
        }

        @Override
        public Map<String, Object> getBindings() {
            return delegate.getBindings();
        }

        @Override
        public void bind(String name, Object value) {
            delegate.bind(name, value);
        }

        @Override
        public String getSql() {
            return delegate.getSql();
        }

        /**
         * 这里会将#{item}占位符解析成#{__frch_item_index值}
         *
         * @param sql
         */
        @Override
        public void appendSql(String sql) {
            GenericTokenParser parser = new GenericTokenParser("#{", "}", content -> {
                String newContent = content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));
                if (itemIndex != null && newContent.equals(content)) {
                    newContent = content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", itemizeItem(itemIndex, index));
                }
                return "#{" + newContent + "}";
            });

            delegate.appendSql(parser.parse(sql));
        }

        @Override
        public int getUniqueNumber() {
            return delegate.getUniqueNumber();
        }

    }

    private class PrefixedContext extends DynamicContext {
        private final DynamicContext delegate;
        private final String prefix;
        private boolean prefixApplied;

        public PrefixedContext(DynamicContext delegate, String prefix) {
            super(configuration, null);
            this.delegate = delegate;
            this.prefix = prefix;
            this.prefixApplied = false;
        }

        public boolean isPrefixApplied() {
            return prefixApplied;
        }

        @Override
        public Map<String, Object> getBindings() {
            return delegate.getBindings();
        }

        @Override
        public void bind(String name, Object value) {
            delegate.bind(name, value);
        }

        @Override
        public void appendSql(String sql) {
            if (!prefixApplied && sql != null && sql.trim().length() > 0) {
                delegate.appendSql(prefix);
                prefixApplied = true;
            }
            delegate.appendSql(sql);
        }

        @Override
        public String getSql() {
            return delegate.getSql();
        }

        @Override
        public int getUniqueNumber() {
            return delegate.getUniqueNumber();
        }
    }

}

剩余的如ChooseSqlNode请读者自行阅读,代码也都比较容易理解。

结语

本次文章只是介绍一下动态sql解析时常用的类和接口,之后的文章对动态sql进行介绍时将不再对这些类进行赘述。

最后说一些闲话。

其实坚持写博客是一件很难的事情。七月份入职以来,便开始考虑写博客的事,起初不知道从哪写起,博客质量并不高。后来慢慢爱上了阅读源码这件事。其实mybatis源码我已经参照某本书读完了,但是阅读完之后我并没有觉得有何收获和见解,对源码的理解也比较浅显,因此便想着通过撰写博客的方式去加深对源码的认知。Mybatis插件机制是很重要的特性,而想编写一个好的插件就需要对源码有深刻的理解,因此源码不得不读,对于一个java程序员来说这也是必修课。在这几篇博客的撰写下,我慢慢养成了写博客的习惯,也知道什么该写,什么不该写。博客中大部分的内容其实都在代码注释上,因此显得博客内容不多,需要阅读者仔细阅读代码注释(但愿我的博客有人看吧。)。养成一个习惯不容易,这段时间划水的过程中对撰写博客这件事也有所懈怠(说实话差点都忘了我还开了这么大一个坑。)

发布了30 篇原创文章 · 获赞 35 · 访问量 2771

猜你喜欢

转载自blog.csdn.net/qq_36403693/article/details/103758012