从MyBatis源码中看设计模式
注:本文转自:https://www.toutiao.com/article/7208748251846312506/?log_from=6bb6cc50b440c_1678863411017
MyBatis是一个非常优秀的ORM框架,它在设计和实现中充分应用了设计模式。通过阅读MyBatis源码并观察其中的设计模式的应用,我们可以更深入地理解设计模式,并学会如何在实际开发中应用它们。
Mybatis中的设计模式的使用:
- 工厂模式,例如SqlSessionFactory、ObjectFactory、MapperProxyFactory;
- 单例模式,例如ErrorContext和LogFactory;
- Builder模式,例如SqlSessionFactoryBuilder、XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder、CacheBuilder;
- 适配器模式,例如Log的Mybatis接口和它对jdbc、log4j等各种日志框架的适配实现;
- 代理模式,Mybatis实现的核心,比如MapperProxy、ConnectionLogger,用的jdk的动态代理;还有executor.loader包使用了cglib或者javassist达到延迟加载的效果;
- 组合模式,例如SqlNode和各个子类ChooseSqlNode等;
- 装饰者模式,例如Cache包中的cache.decorators子包中等各个装饰者的实现;
- 模板方法,例如BaseExecutor和SimpleExecutor,还有BaseTypeHandler和所有的子类例如IntegerTypeHandler;
- 迭代器模式,例如迭代器模式PropertyTokenizer;
接下来挨个模式进行解读,先介绍模式自身的知识,然后解读在Mybatis中怎样应用了该模式。
1、工厂模式
SqlSessionFactory是MyBatis框架中的一个重要组件,用于创建SqlSession对象。SqlSession是与数据库交互的核心对象,可以执行SQL语句、提交事务等操作。SqlSessionFactory的作用就是创建SqlSession对象,它使用了工厂模式来完成对象的创建。
在工厂模式中,工厂类负责创建对象,客户端代码只需要调用工厂类的方法即可获取所需的对象。对于SqlSessionFactory而言,它就是一个工厂类,负责创建SqlSession对象。在创建SqlSession对象时,SqlSessionFactory会根据一些配置信息来决定如何创建SqlSession对象。例如,它会根据配置文件中的数据源信息来创建对应的数据库连接对象,并将该连接对象封装到SqlSession中。
通过使用工厂模式,SqlSessionFactory可以隐藏创建SqlSession对象的复杂逻辑,让客户端代码只关心如何使用SqlSession对象,而不需要关心如何创建它。同时,工厂模式也提高了代码的可扩展性,因为如果需要创建不同类型的SqlSession对象,只需要修改SqlSessionFactory的实现即可,而不需要修改客户端代码。
SqlSession可以认为是一个Mybatis工作的核心的接口,通过这个接口可以执行执行SQL语句、获取Mappers、管理事务。类似于连接 MySQL 的Connection对象。
可以看到,该Factory的openSession方法重载了很多个,分别支持autoCommit、Executor、Transaction等参数的输入,来构建核心的SqlSession对象。
在DefaultSqlSessionFactory的默认工厂实现里,有一个方法可以看出工厂怎么产出一个产品:
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { Transaction tx = null; try { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); } catch (Exception e) { closeTransaction(tx); throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } }
在MyBatis框架中,SqlSession是与数据库进行交互的核心对象,它封装了对数据库的操作。在使用SqlSession之前,需要通过SqlSessionFactory创建一个SqlSession对象。在创建SqlSession对象时,实际上是通过Executor来实现对数据库的操作。
Executor是MyBatis框架中的一个重要组件,它负责执行SQL语句,并将执行结果封装成Java对象返回给SqlSession。Executor对象的创建是通过Transaction对象来完成的。Transaction是MyBatis框架中的事务管理组件,它负责管理数据库的事务。在创建Transaction对象时,需要先读取MyBatis配置文件中的TransactionFactory配置信息,然后根据配置信息创建对应的TransactionFactory对象,最终通过TransactionFactory对象创建Transaction对象。
在创建Executor对象时,需要传入SqlSession的配置信息、Transaction对象和autoCommit参数。其中,autoCommit参数用于控制事务的提交方式。如果autoCommit为true,表示每次执行完SQL语句之后都会自动提交事务;如果autoCommit为false,表示需要手动调用commit方法来提交事务。通过这些参数,Executor对象可以正确地执行SQL语句,并将执行结果返回给SqlSession。
总之,通过Transaction和Executor组合的方式,SqlSession可以实现对数据库的操作。而Transaction和Executor的创建过程都是通过MyBatis框架中的配置信息来完成的。这种设计方式让SqlSession的使用变得非常灵活,可以通过配置文件来控制不同组件的创建方式,从而实现不同的操作。
而对于LogFactory,它的实现代码:
public final class LogFactory { private static Constructor<? extends Log> logConstructor; private LogFactory() { // disable construction } public static Log getLog(Class<?> aClass) { return getLog(aClass.getName()); }
这里有个特别的地方,是Log变量的的类型是Constructor<? extends Log>,也就是说该工厂生产的不只是一个产品,而是具有Log公共接口的一系列产品,比如Log4jImpl、Slf4jImpl等很多具体的Log。
2、单例模式
单例模式是一种设计模式,它可以确保一个类只有一个实例,并提供一个全局访问这个实例的方法。在单例模式中,这个类通常会自行创建这个唯一的实例,并向整个系统提供这个实例。
单例模式的要点包括三个方面。首先,这个类只能有一个实例,这个实例可以在运行时动态地创建或者在类加载时就创建好。其次,这个实例必须由这个类自行创建,而不能依赖其他类。最后,这个实例必须提供一个全局访问的方法,使得其他类可以通过这个方法来访问这个实例。
在Mybatis框架中,有两个地方用到了单例模式,分别是ErrorContext和LogFactory。
ErrorContext是Mybatis中用于记录线程范围内执行环境错误信息的单例对象。每个线程在执行数据库操作时,都会有一个对应的ErrorContext对象来记录当前执行的上下文信息和错误信息。ErrorContext对象被设计成线程范围内的单例,这意味着每个线程只会有一个ErrorContext对象,并且这个对象可以被整个线程共享。在多线程环境下,使用单例模式可以保证每个线程都能够独立地记录自己的执行环境错误信息,从而避免了线程之间的错误信息混淆。
另一个用到单例模式的地方是LogFactory,这是一个提供给整个Mybatis使用的日志工厂,用于获得针对项目配置好的日志对象。LogFactory被设计成全局单例模式,这意味着整个应用程序只有一个LogFactory对象。LogFactory对象可以根据项目的配置信息来决定使用哪种日志框架,从而提供给应用程序一个统一的日志接口。使用单例模式可以保证整个应用程序共享同一个LogFactory对象,并且可以避免重复创建LogFactory对象,从而提高系统性能。
ErrorContext的单例实现代码:
public class ErrorContext { private static final ThreadLocal<ErrorContext> LOCAL = new ThreadLocal<ErrorContext>(); private ErrorContext() { } public static ErrorContext instance() { ErrorContext context = LOCAL.get(); if (context == null) { context = new ErrorContext(); LOCAL.set(context); } return context; } }
3、Builder模式
Builder模式是一种创建类模式,它的主要目的是将一个复杂对象的构建过程与其表示分离开来,使得同样的构建过程可以创建不同的表示形式。与工厂模式不同,Builder模式可以创建更加复杂的对象,甚至只会构建对象的一部分。
通常情况下,如果一个对象的构建过程比较简单,只需要一些基本的属性值就可以完成,那么可以直接使用构造函数或者工厂模式来创建对象。但是,如果一个对象的构建过程非常复杂,需要进行多个步骤的操作,或者需要设置大量的属性值,那么使用构造函数或工厂模式就会变得很麻烦和复杂。这时,Builder模式就可以派上用场了。
在Builder模式中,将一个复杂对象的构建过程拆分成多个步骤,每个步骤都对应着对象中的一个部分。通过定义一个Builder类来完成这些构建步骤,然后在Director类中将这些步骤组合起来,最终生成一个完整的复杂对象。Builder模式可以让我们更加灵活地控制对象的创建过程,使得同样的构建过程可以创建不同的表示形式。
在实际开发中,Builder模式经常应用于创建复杂的对象,例如XML解析器、数据库连接器、图形用户界面等等。Builder模式的优点在于它可以将复杂对象的构建过程分解成多个简单的步骤,从而更加方便地进行控制和管理。此外,Builder模式还可以使得对象的构建过程透明化,提高代码的可读性和可维护性。
在Mybatis中,Builder模式得到广泛的应用,特别是在Mybatis环境的初始化过程中。在这个过程中,不同的Builder扮演不同的角色,例如,XMLConfigBuilder的任务是解析Mybatis的配置文件,生成Configuration对象;XMLMapperBuilder的任务是解析Mapper文件,构建MappedStatement对象;XMLStatementBuilder的任务是解析SQL语句,生成StatementHandler对象等等。
这些Builder在工作时,需要读取大量的配置信息,进行解析、校验、反射生成对象等复杂的操作。如果直接在构造函数中完成这些操作,代码会变得十分复杂,可维护性和可扩展性都很差。因此,Mybatis采用了Builder模式来将对象的构建与表示分离,使得构建过程可以灵活地变化,并且可以创建不同的表示。
Builder模式的核心思想是将构建复杂对象的过程分解为多个简单的步骤,通过一个Builder类来协调这些步骤,并最终组装成一个完整的对象。在Mybatis中,不同的Builder类负责不同的构建过程,通过调用各自的方法,逐步构建出需要的对象。同时,Mybatis还利用了建造者模式的一些其他特点,例如,使用链式调用来简化代码,通过对重复操作的缓存来提升性能等等。
对于builder的具体类,方法都大都用build*开头,比如SqlSessionFactoryBuilder为例,它包含以下方法:
即根据不同的输入参数来构建SqlSessionFactory这个工厂对象。
4、适配器模式
适配器模式是一种结构型设计模式,其主要目的是解决不兼容接口之间的问题。它通过将一个类的接口转换成客户端所期望的另一个接口,从而使得原本不兼容的类可以协同工作。适配器模式可以分为两种类型:类适配器和对象适配器。
类适配器使用多重继承的方式,将一个类的接口转换成另一个类的接口。它需要继承目标接口,并实现源接口,从而完成对目标接口的适配。
对象适配器则是使用组合的方式,将一个对象的接口转换成另一个对象所期望的接口。它需要在适配器对象中持有一个源对象,并实现目标接口,从而完成对目标接口的适配。
在Mybatsi的logging包中,有一个Log接口:
public interface Log { boolean isDebugEnabled(); boolean isTraceEnabled(); void error(String s, Throwable e); void error(String s); void debug(String s); void trace(String s); void warn(String s); }
该接口定义了Mybatis直接使用的日志方法,而Log接口具体由谁来实现呢?Mybatis提供了多种日志框架的实现,这些实现都匹配这个Log接口所定义的接口方法,最终实现了所有外部日志框架到Mybatis日志包的适配:
比如对于Log4jImpl的实现来说,该实现持有了org.apache.log4j.Logger的实例,然后所有的日志方法,均委托该实例来实现。
public class Log4jImpl implements Log { private static final String FQCN = Log4jImpl.class.getName(); private Logger log; public Log4jImpl(String clazz) { log = Logger.getLogger(clazz); } @Override public boolean isDebugEnabled() { return log.isDebugEnabled(); } @Override public boolean isTraceEnabled() { return log.isTraceEnabled(); } @Override public void error(String s, Throwable e) { log.log(FQCN, Level.ERROR, s, e); } @Override public void error(String s) { log.log(FQCN, Level.ERROR, s, null); } @Override public void debug(String s) { log.log(FQCN, Level.DEBUG, s, null); } @Override public void trace(String s) { log.log(FQCN, Level.TRACE, s, null); } @Override public void warn(String s) { log.log(FQCN, Level.WARN, s, null); } }
5、代理模式
代理模式是一种结构型设计模式,它为其他对象提供一种代理以控制对这个对象的访问。代理对象可以在客户端和目标对象之间起到中介的作用,不仅可以保护目标对象,还可以扩展目标对象的功能。代理模式有许多应用场景,如远程代理、虚拟代理、安全代理等。
代理模式包含如下角色:
- Subject: 抽象主题角色
- Proxy: 代理主题角色
- RealSubject: 真实主题角色
这里有两个步骤,第一个是提前创建一个Proxy,第二个是使用的时候会自动请求Proxy,然后由Proxy来执行具体事务;
当我们使用Configuration的getMapper方法时,会调用mapperRegistry.getMapper方法,而该方法又会调用
mapperProxyFactory.newInstance(sqlSession)来生成一个具体的代理:
public class MapperProxyFactory<T> { private final Class<T> mapperInterface; private final Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<Method, MapperMethod>(); public MapperProxyFactory(Class<T> mapperInterface) { this.mapperInterface = mapperInterface; } public Class<T> getMapperInterface() { return mapperInterface; } public Map<Method, MapperMethod> getMethodCache() { return methodCache; } @SuppressWarnings("unchecked") protected T newInstance(MapperProxy<T> mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); } public T newInstance(SqlSession sqlSession) { final MapperProxy<T> mapperProxy = new MapperProxy<T>(sqlSession, mapperInterface, methodCache); return newInstance(mapperProxy); } }
在这里,先通过T newInstance(SqlSession sqlSession)方法会得到一个MapperProxy对象,然后调用T newInstance(MapperProxymapperProxy)生成代理对象然后返回。
而查看MapperProxy的代码,可以看到如下内容:
public class MapperProxy<T> implements InvocationHandler, Serializable { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } else if (isDefaultMethod(method)) { return invokeDefaultMethod(proxy, method, args); } } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } final MapperMethod mapperMethod = cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); }
非常典型的,该MapperProxy类实现了InvocationHandler接口,并且实现了该接口的invoke方法。
通过这种方式,我们只需要编写Mapper.java接口类,当真正执行一个Mapper接口的时候,就会转发给MapperProxy.invoke方法,而该方法则会调用后续的sqlSession.cud>executor.execute>prepareStatement等一系列方法,完成SQL的执行和返回。
6、组合模式
组合模式是一种对象结构型模式,它通过将对象组合成树形结构来表示“部分-整体”的层次结构,并且能够使客户端统一处理叶子对象和容器对象。组合模式中叶子对象和容器对象实现相同的接口,这样可以使得客户端处理叶子对象和容器对象的方式保持一致,从而提高了代码的复用性和可维护性。此外,组合模式还可以通过递归的方式访问整个对象树,从而方便地实现对整个树形结构的操作和管理。
Mybatis支持动态SQL的强大功能,比如下面的这个SQL:
<update id="update" parameterType="org.format.dynamicproxy.mybatis.bean.User"> UPDATE users <trim prefix="SET" prefixOverrides=","> <if test="name != null and name != ''"> name = #{name} </if> <if test="age != null and age != ''"> , age = #{age} </if> <if test="birthday != null and birthday != ''"> , birthday = #{birthday} </if> </trim> where id = ${id} </update>
在这里面使用到了trim、if等动态元素,可以根据条件来生成不同情况下的SQL;
在
DynamicSqlSource.getBoundSql方法里,调用了rootSqlNode.apply(context)方法,apply方法是所有的动态节点都实现的接口:
public interface SqlNode { boolean apply(DynamicContext context); }
对于实现该SqlSource接口的所有节点,就是整个组合模式树的各个节点:
组合模式的简单之处在于,所有的子节点都是同一类节点,可以递归的向下执行,比如对于TextSqlNode,因为它是最底层的叶子节点,所以直接将对应的内容append到SQL语句中:
@Override public boolean apply(DynamicContext context) { GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter)); context.appendSql(parser.parse(text)); return true; }
但是对于IfSqlNode,就需要先做判断,如果判断通过,仍然会调用子元素的SqlNode,即contents.apply方法,实现递归的解析。
@Override public boolean apply(DynamicContext context) { if (evaluator.evaluateBoolean(test, context.getBindings())) { contents.apply(context); return true; } return false; }
7、装饰者模式
装饰模式可以通过动态地将对象放入包装器中,来给对象增加额外的职责,而不需要修改原始对象的代码。这种方式比继承生成子类实现更为灵活,因为它不需要创建新的子类来实现功能增强。装饰模式的核心思想是将对象的行为划分成许多不同的责任,然后用装饰器来组合这些责任,从而达到增强原始对象功能的目的。因此,装饰模式也被称为包装器模式。
装饰模式的另一个重要特点是,装饰器和被装饰对象实现相同的接口,这使得它们在使用时具有相同的行为,使得客户端可以像使用原始对象一样使用装饰后的对象。同时,装饰模式也支持递归组合,也就是说,一个装饰器可以作为另一个装饰器的参数,从而使得装饰的功能可以无限扩展。
在Mybatis中,缓存的实现采用了装饰器设计模式。整个缓存体系的基础是根接口Cache,该接口定义了缓存的基本功能。PerpetualCache是一个实现了Cache接口的永久缓存类,它提供了基本的缓存功能,比如数据存储和读取等。然后,通过一系列的装饰器类,如LruCache、FifoCache、SoftCache等,对PerpetualCache永久缓存进行装饰,实现了各种缓存策略的方便控制。这些装饰器类实现了Cache接口,并持有一个被装饰的Cache对象,在增加各种缓存策略的同时,还保留了PerpetualCache永久缓存的基本功能。如下图:
用于装饰PerpetualCache的标准装饰器共有8个(全部在
org.apache.ibatis.cache.decorators包中):
- FifoCache:先进先出算法,缓存回收策略
- LoggingCache:输出缓存命中的日志信息
- LruCache:最近最少使用算法,缓存回收策略
- ScheduledCache:调度缓存,负责定时清空缓存
- SerializedCache:缓存序列化和反序列化存储
- SoftCache:基于软引用实现的缓存管理策略
- SynchronizedCache:同步的缓存装饰器,用于防止多线程并发访问
- WeakCache:基于弱引用实现的缓存管理策略
另外,还有一个特殊的装饰器TransactionalCache:事务性的缓存
正如大多数持久层框架一样, mybatis 缓存同样分为一级缓存和二级缓存
- 一级缓存,又叫本地缓存,是PerpetualCache类型的永久缓存,保存在执行器中(BaseExecutor),而执行器又在SqlSession(DefaultSqlSession)中,所以一级缓存的生命周期与SqlSession是相同的。
- 二级缓存,又叫自定义缓存,实现了Cache接口的类都可以作为二级缓存,所以可配置如encache等的第三方缓存。二级缓存以namespace名称空间为其唯一标识,被保存在Configuration核心配置对象中。
二级缓存对象的默认类型为PerpetualCache,如果配置的缓存是默认类型,则 mybatis 会根据配置自动追加一系列装饰器。
Cache对象之间的引用顺序为:
SynchronizedCache–>LoggingCache–>SerializedCache–>ScheduledCache–>LruCache–>PerpetualCache
8、模板方法模式
模板方法模式是一种基于继承的设计模式,它定义了一个算法的框架,将一些具体的步骤延迟到子类中去实现。这些具体的步骤被称为基本方法(primitive method),而将这些基本方法组合在一起形成的算法被称为模板方法(template method)。模板方法模式通过抽象出公共的算法步骤,使得不同的子类可以按照自己的需求实现这些基本方法,从而实现算法的灵活性和复用性。同时,由于模板方法模式是基于继承的,因此也具有一定的局限性,子类必须遵循父类的算法框架,因此不太灵活。
在Mybatis中,sqlSession的SQL执行,都是委托给Executor实现的,Executor包含以下结构:
其中的BaseExecutor就采用了模板方法模式,它实现了大部分的SQL执行逻辑,然后把以下几个方法交给子类定制化完成:
protected abstract int doUpdate(MappedStatement ms, Object parameter) throws SQLException; protected abstract List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException; protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException;
该模板方法类有几个子类的具体实现,使用了不同的策略:
- 简单SimpleExecutor:每执行一次update或select,就开启一个Statement对象,用完立刻关闭Statement对象。(可以是Statement或PrepareStatement对象)
- 重用ReuseExecutor:执行update或select,以sql作为key查找Statement对象,存在就使用,不存在就创建,用完后,不关闭Statement对象,而是放置于Map<String, Statement>内,供下一次使用。(可以是Statement或PrepareStatement对象)
- 批量BatchExecutor:执行update(没有select,JDBC批处理不支持select),将所有sql都添加到批处理中(addBatch()),等待统一执行(executeBatch()),它缓存了多个Statement对象,每个Statement对象都是addBatch()完毕后,等待逐一执行executeBatch()批处理的;BatchExecutor相当于维护了多个桶,每个桶里都装了很多属于自己的SQL,就像苹果蓝里装了很多苹果,番茄蓝里装了很多番茄,最后,再统一倒进仓库。(可以是Statement或PrepareStatement对象)
比如在SimpleExecutor中这样实现update方法
@Override public int doUpdate(MappedStatement ms, Object parameter) throws SQLException { Statement stmt = null; try { Configuration configuration = ms.getConfiguration(); StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null); stmt = prepareStatement(handler, ms.getStatementLog()); return handler.update(stmt); } finally { closeStatement(stmt); } }
9、迭代器模式
迭代器模式是一种行为型设计模式,它提供了一种访问聚合对象中各个元素的方法,而又不暴露聚合对象的内部结构。通过迭代器,客户端可以逐个访问聚合对象中的元素,而不必关心具体的实现方式。迭代器模式包括两个角色:迭代器和聚合对象。迭代器负责定义访问和遍历元素的接口,而聚合对象则负责创建相应的迭代器。迭代器模式可以让我们在不暴露聚合对象内部结构的情况下,以统一的方式遍历不同类型的聚合对象
Java的Iterator就是迭代器模式的接口,只要实现了该接口,就相当于应用了迭代器模式:
比如Mybatis的PropertyTokenizer是property包中的重量级类,该类会被reflection包中其他的类频繁的引用到。这个类实现了Iterator接口,在使用时经常被用到的是Iterator接口中的hasNext这个函数。
public class PropertyTokenizer implements Iterator<PropertyTokenizer> { private String name; private String indexedName; private String index; private String children; public PropertyTokenizer(String fullname) { int delim = fullname.indexOf('.'); if (delim > -1) { name = fullname.substring(0, delim); children = fullname.substring(delim + 1); } else { name = fullname; children = null; } indexedName = name; delim = name.indexOf('['); if (delim > -1) { index = name.substring(delim + 1, name.length() - 1); name = name.substring(0, delim); } } public String getName() { return name; } public String getIndex() { return index; } public String getIndexedName() { return indexedName; } public String getChildren() { return children; } @Override public boolean hasNext() { return children != null; } @Override public PropertyTokenizer next() { return new PropertyTokenizer(children); } @Override public void remove() { throw new UnsupportedOperationException( "Remove is not supported, as it has no meaning in the context of properties."); } }
这段代码实现了一个字符串解析器,它将一个字符串传入构造函数中,并提供了iterator方法来遍历解析后的子串。这是一种常见的方法类设计,在某些场景下可以方便地将字符串按照一定规则分割成子串,并对这些子串进行处理。这个类的iterator方法返回一个迭代器对象,该迭代器对象用于遍历解析后的子串序列,客户端可以通过迭代器对象的方法来访问这些子串。这个类使用了迭代器模式,将容器对象(解析后的子串序列)的遍历操作封装在迭代器对象中,从而不需要暴露容器对象的内部细节,提高了代码的封装性和安全性。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Blazor Hybrid适配到HarmonyOS系统
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· 分享4款.NET开源、免费、实用的商城系统
· 解决跨域问题的这6种方案,真香!
· 一套基于 Material Design 规范实现的 Blazor 和 Razor 通用组件库