MyBatis学习日记——DAY03(手写MyBatis框架实现简单功能)

MyBatis框架简单的手写与实现,今天我来为大家介绍一下我的思路:

1.准备工作

我是从MyBatis本体应用层的使用入手的,在使用MyBatis时我们需要有对应的Config文件,如下:

SqlConfig.xml

XML 复制代码
<configuration>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"></transactionManager>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url"
                          value="jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf8&amp;useUnicode=true&amp;serverTimezone=GMT%2B8&amp;useSSL=false"/>
                <property name="username" value="你的数据库用户名"/>
                <property name="password" value="你的数据库密码"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <mapper resource="com/djw/mapper/StudentMapper.xml"></mapper>
        <mapper class="com/djw/mapper/StudentDao"></mapper>
    </mappers>
</configuration>

这里面我已经加入了相应的功能配置项标签;

在main方法中是这样的:

Test

java 复制代码
package com.djw.test;

import com.djw.mapper.StudentMapper;
import com.djw.model.Student;
import com.mysql.cj.conf.PropertyDefinitions;
import com.djw.util.*;
import java.io.InputStream;
import java.util.List;

public class Test {
    public static void main(String[] args) {
        InputStream inputStream = Resources.getResourceAsStream("SqlConfig.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);
        List<Student> students = mapper.selectList();
        students.forEach(System.out::println);
        sqlSession.close();
    }
}

针对main方法的操作流程我们进行相应代码的编写

已知的我们需要解析XML文件,访问数据库进行jdbc等操作所以我在Maven下的pom文件配置如下:

XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>MyBatis-day03-custom</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <!--解析xml文件-->
        <dependency>
            <groupId>dom4j</groupId>
            <artifactId>dom4j</artifactId>
            <version>1.6.1</version>
        </dependency>
        <!--解析xpath-->
        <dependency>
            <groupId>jaxen</groupId>
            <artifactId>jaxen</artifactId>
            <version>1.2.0</version>
        </dependency>
        <!--取消从父工程引入的mybatis-->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.5.7</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.26</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.7</version>
        </dependency>

    </dependencies>
    <build>
        <resources>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
            </resource>
        </resources>
    </build>
</project>

我们还需要创建对应的实体类来方便进行ORM

Student

java 复制代码
package com.djw.model;
import lombok.Data;
/**
 * @author djw
 */
@Data
public class Student {
    private int id;
    private String name;
    private int age;
    private String gender;
}

我们要实现MyBatis对应的mapper功能,所以我们需要编写在使用MyBatis时用到的Mapper接口和xml文件

StudentMapper

java 复制代码
package com.djw.mapper;

import com.djw.model.Student;

import java.util.List;

/**
 * @author djw
 */
public interface StudentMapper {
    List<Student> selectList();
}

StudentMapper.xml

XML 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<mapper namespace="com.djw.mapper.StudentMapper">
    <select id="selectList" resultType="com.djw.model.Student">
        select * from student;
    </select>
</mapper>

2.编写Util类

我们知道在使用MyBatis时需要用到SqlSession获取mapper,具体的流程是SqlSessionFactoryBuilder---build(InputStream)--->SqlSessionFactory---openSession()--->SqlSession所以我们就编写这三个功能

SqlSessionFactoryBuilder

java 复制代码
package com.djw.util;

import java.io.InputStream;

/**
 * @author djw
 */
public class SqlSessionFactoryBuilder {
    public SqlSessionFactory build(InputStream in) {
        try {
                DbProfile dbProfile = XMLParser.parseXml(in);
                return new CostumSqlSessionFactory(dbProfile);
        }catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

在上一节中,我们提到了 SqlSessionFactoryBuilder 会调用 XMLParser.parseXml(in) 来读取配置。接下来,我们需要实现这个解析器,以及它所依赖的数据模型。

2.1 定义配置模型 DbProfileMapper

为了方便存储 XML 中读取出来的数据,我们需要定义两个简单的 POJO 类。

DbProfile.java (用于存储数据库连接信息和 Mapper 集合)

java 复制代码
package com.djw.util;

import lombok.Data;

import java.util.HashMap;
import java.util.Map;

@Data
public class DbProfile {
    private String driver;
    private String url;
    private String username;
    private String password;

    // 关键点:这里存储了所有的 SQL 映射信息
    // Key: 接口全限定名 + "." + 方法名 (例如: com.djw.mapper.StudentMapper.selectList)
    // Value: 对应的 SQL 语句和返回类型
    private Map<String, Mapper> mappers = new HashMap<>();
}

Mapper.java (用于存储单条 SQL 的信息)

复制代码
1package com.djw.util;
2
3import lombok.Data;
4
5@Data
6public class Mapper {
7    // 存储 SQL 语句
8    private String sqlStatement;
9    // 存储返回值类型 (全限定类名)
10    private String className;
11}
2.2 实现 XML 解析器 XMLParser

这是 MyBatis 的"心脏"起搏器,负责将 XML 文件中的字符串转化为 Java 对象。

复制代码
1package com.djw.util;
2
3import org.dom4j.Document;
4import org.dom4j.DocumentException;
5import org.dom4j.Element;
6import org.dom4j.io.SAXReader;
7
8import java.io.InputStream;
9import java.util.List;
10import java.util.Map;
11
12public class XMLParser {
13
14    /**
15     * 解析 mybatis-config.xml 和 mapper.xml 文件
16     */
17    public static DbProfile parseXml(InputStream inputStream) throws DocumentException {
18        DbProfile profile = new DbProfile();
19        SAXReader reader = new SAXReader();
20        Document document = reader.read(inputStream);
21        Element rootElement = document.getRootElement();
22
23        // 1. 解析 environments 标签,获取数据库连接信息
24        Element dataSource = (Element) rootElement
25                .selectSingleNode("//dataSource"); // 使用 XPath 简化查找
26
27        if (dataSource != null) {
28            profile.setDriver(dataSource.elementTextTrim("driver"));
29            profile.setUrl(dataSource.elementTextTrim("url"));
30            profile.setUsername(dataSource.elementTextTrim("username"));
31            profile.setPassword(dataSource.elementTextTrim("password"));
32        }
33
34        // 2. 解析 mappers 标签,加载所有的 Mapper XML 文件
35        List<Element> mapperElements = rootElement.selectNodes("//mappers/mapper");
36        for (Element mapperElement : mapperElements) {
37            String resource = mapperElement.attributeValue("resource");
38            if (resource != null) {
39                // 调用方法解析具体的 Mapper 文件
40                parseMapperResource(resource, profile);
41            }
42        }
43        return profile;
44    }
45
46    /**
47     * 解析具体的 Mapper.xml 文件
48     */
49    private static void parseMapperResource(String resourcePath, DbProfile profile) {
50        try {
51            InputStream mapperStream = Resources.getResourceAsStream(resourcePath);
52            SAXReader reader = new SAXReader();
53            Document document = reader.read(mapperStream);
54            Element root = document.getRootElement();
55
56            // 获取命名空间 (通常是接口的全限定名)
57            String namespace = root.attributeValue("namespace");
58
59            // 解析 <select> 标签
60            List<Element> selectNodes = root.selectNodes("//select");
61            for (Element select : selectNodes) {
62                String id = select.attributeValue("id"); // 方法名
63                String resultType = select.attributeValue("resultType"); // 返回类型
64                String sql = select.getTextTrim(); // SQL 语句
65
66                // 组装 Key
67                String key = namespace + "." + id;
68                Mapper mapper = new Mapper();
69                mapper.setSqlStatement(sql);
70                mapper.setClassName(resultType);
71
72                // 存入配置对象
73                profile.getMappers().put(key, mapper);
74            }
75        } catch (Exception e) {
76            e.printStackTrace();
77        }
78    }
79}

3. 实现核心执行流程

解析完配置后,我们需要创建 SqlSessionFactorySqlSession,这是用户与数据库交互的入口。

3.1 创建 SqlSessionFactory
复制代码
1package com.djw.util;
2
3public interface SqlSessionFactory {
4    SqlSession openSession();
5}

CostumSqlSessionFactory.java (实现类)

复制代码
1package com.djw.util;
2
3public class CostumSqlSessionFactory implements SqlSessionFactory {
4
5    // 持有解析好的配置信息
6    private final DbProfile dbProfile;
7
8    public CostumSqlSessionFactory(DbProfile dbProfile) {
9        this.dbProfile = dbProfile;
10    }
11
12    @Override
13    public SqlSession openSession() {
14        // 每次打开会话,都创建一个新的连接和执行器
15        return new CostumSqlSession(dbProfile);
16    }
17}
3.2 实现 SqlSession 与 动态代理

这是最精彩的部分。我们知道在 MyBatis 中,我们只需要写接口,不需要写实现类。这是怎么做到的?答案是 JDK 动态代理

SqlSession.java (接口)

复制代码
1package com.djw.util;
2
3public interface SqlSession {
4    <T> T getMapper(Class<T> clazz);
5    void close();
6}

CostumSqlSession.java (核心实现)

复制代码
1package com.djw.util;
2
3import java.lang.reflect.Proxy;
4import java.sql.Connection;
5
6public class CostumSqlSession implements SqlSession {
7
8    private final DbProfile dbProfile;
9    private final Connection connection;
10
11    public CostumSqlSession(DbProfile dbProfile) {
12        this.dbProfile = dbProfile;
13        // 初始化数据库连接
14        this.connection = DBUtil.getConnection(dbProfile);
15    }
16
17    @Override
18    public <T> T getMapper(Class<T> mapperInterfaceClass) {
19        // 动态代理:拦截接口方法的调用
20        return (T) Proxy.newProxyInstance(
21                mapperInterfaceClass.getClassLoader(),
22                new Class[]{mapperInterfaceClass},
23                // 传入一个 InvocationHandler,即我们的 ProxyImpl
24                new ProxyImpl(dbProfile.getMappers(), connection)
25        );
26    }
27
28    @Override
29    public void close() {
30        if (connection != null) {
31            try {
32                connection.close();
33            } catch (Exception e) {
34                e.printStackTrace();
35            }
36        }
37    }
38}
3.3 编写代理处理器 ProxyImpl

当我们在测试代码中调用 mapper.selectList() 时,实际上会跳转到这里。

复制代码
1package com.djw.util;
2
3import org.slf4j.Logger;
4import org.slf4j.LoggerFactory;
5
6import java.lang.reflect.InvocationHandler;
7import java.lang.reflect.Method;
8import java.sql.Connection;
9import java.util.Map;
10
11public class ProxyImpl implements InvocationHandler {
12
13    private static final Logger log = LoggerFactory.getLogger(ProxyImpl.class);
14
15    private final Map<String, Mapper> mapperMap;
16    private final Connection connection;
17
18    public ProxyImpl(Map<String, Mapper> mapperMap, Connection connection) {
19        this.mapperMap = mapperMap;
20        this.connection = connection;
21    }
22
23    @Override
24    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
25        // 1. 拼接 Key: 接口全限定名 + 方法名
26        String key = method.getDeclaringClass().getName() + "." + method.getName();
27        
28        log.debug("正在执行方法: " + key);
29
30        // 2. 根据 Key 获取 SQL 信息
31        Mapper mapper = mapperMap.get(key);
32        if (mapper == null) {
33            throw new RuntimeException("未找到对应的 SQL 映射: " + key);
34        }
35
36        // 3. 执行 SQL (这里简化处理,只处理查询)
37        // 实际上这里应该根据 SQL 的类型(insert/update/select)来调用不同的 DBUtil 方法
38        return DBUtil.selectList(mapper, connection);
39    }
40}

4. 封装 JDBC 工具类 DBUtil

最后,我们需要一个工具类来处理底层的 JDBC 操作,包括加载驱动、获取连接、处理结果集。

复制代码
1package com.djw.util;
2
3import java.sql.*;
4
5public class DBUtil {
6
7    public static Connection getConnection(DbProfile profile) {
8        try {
9            Class.forName(profile.getDriver());
10            return DriverManager.getConnection(
11                    profile.getUrl(),
12                    profile.getUsername(),
13                    profile.getPassword()
14            );
15        } catch (Exception e) {
16            e.printStackTrace();
17            return null;
18        }
19    }
20
21    /**
22     * 简单的查询列表实现(简化版)
23     */
24    public static Object selectList(Mapper mapper, Connection conn) {
25        try {
26            // 1. 准备 Statement
27            PreparedStatement ps = conn.prepareStatement(mapper.getSqlStatement());
28            
29            // 2. 执行查询
30            ResultSet rs = ps.executeQuery();
31
32            // 3. 这里为了简化,假设我们只需要打印结果
33            // 实际的 MyBatis 会通过反射将 rs 映射为 mapper.getClassName() 对应的实体类
34            ResultSetMetaData metaData = rs.getMetaData();
35            int columnCount = metaData.getColumnCount();
36            
37            while (rs.next()) {
38                for (int i = 1; i <= columnCount; i++) {
39                    System.out.print(metaData.getColumnName(i) + ": " + rs.getObject(i) + " ");
40                }
41                System.out.println();
42            }
43            
44            // 注意:这里只是演示流程,实际应返回映射后的 List 对象
45            return null; 
46        } catch (Exception e) {
47            e.printStackTrace();
48            return null;
49        }
50    }
51}

5. 总结

运行流程回顾:

  1. 启动: Test.main 调用 SqlSessionFactoryBuilder.build()
  2. 解析: XMLParser 读取 SqlConfig.xmlStudentMapper.xml,将数据库配置和 SQL 语句存入 DbProfile
  3. 创建工厂: SqlSessionFactory 拿到 DbProfile
  4. 打开会话: SqlSession.openSession() 创建数据库连接。
  5. 获取代理: SqlSession.getMapper() 利用 JDK 动态代理生成接口的实现类(ProxyImpl)。
  6. 执行: 调用 mapper.selectList() -> 被代理拦截 -> 拼接 Key -> 从 Map 中查找 SQL -> DBUtil 执行 JDBC -> 返回结果。

1. SAXReader (DOM4J) 与 XPath ------ 配置的"读取器"

在 MyBatis 启动时,它需要读取 SqlMapConfig.xmlMapper.xml 里的数据库连接信息和 SQL 语句。

  • SAXReader:这是 DOM4J 库中的核心类。它的作用是将 XML 文件解析成一棵"树"(Document Object Model)。
  • XPath :这是一门在 XML 文档中查找信息的语言。在代码中,我们使用 selectSingleNode("//dataSource")selectNodes("//select")。这就是 XPath 语法,它能让我们像写 SQL 一样精准地从 XML 树中提取节点,而不需要我们手动去遍历整个树结构。
  • 用途 :在 XMLParser 类中,我们用它把 XML 字符串变成了 Java 对象(如 DbProfileMapper)。

2. 注解(Annotation)与反射(Reflection)------ 代码的"元数据"与"窥探者"

MyBatis 的注解(如 @Select)和 XML 其实是两种不同的配置存储方式。要让程序"看到"代码上的注解,必须用到反射。

2.1 RetentionPolicy 与生命周期

Java 注解有三个生命周期阶段,定义了注解保留到什么时候:

表格

类型 说明 生命周期范围 使用场景
SOURCE 源码级 只在 .java 文件中保留,编译时被丢弃。 仅用于编译时检查(如 @Override)。
CLASS 字节码级 保留在 .class 文件中,但 JVM 加载时通常丢弃。 一般很少用,某些字节码处理工具使用。
RUNTIME 运行时级 保留在 .class 文件中,并且在 JVM 运行时仍然存在 MyBatis 必须用这个,因为程序运行时需要通过反射读取 SQL。

在框架中的应用:

我们在定义 @Select 时,必须写 @Retention(RetentionPolicy.RUNTIME)。如果写成 SOURCECLASS,当 XMLParser 运行 method.isAnnotationPresent(Select.class) 时,是找不到这个注解的。

2.2 反射机制(Reflection)

反射是 Java 的"自省"能力,它允许程序在运行时:

  1. 加载类Class.forName(className)
  2. 获取方法clazz.getMethods()
  3. 判断注解method.isAnnotationPresent(Select.class)
  4. 获取注解值method.getAnnotation(Select.class).value()

用途 :在 XMLParserparseMapperAnnotation 方法中,我们利用反射加载 Mapper 接口,遍历其方法,读取 @Select 里的 SQL 字符串,并将其塞进 Mapper 对象中。


3. PropertyDescriptorgetWriteMethod() ------ 结果集的"自动填充器"

这是手写 ORM(对象关系映射)中最核心、也是最巧妙的部分。我们在 DBUtilselectList 方法中用到了它。

3.1 为什么要用它?

JDBC 查询出来的结果是 ResultSet(结果集),它本质上是一个表格(行和列)。而我们需要的是 Java 对象(Object)。

我们需要把数据库的一行数据(如 id=1, name='Tom')自动映射到一个 Java 对象(Student)的属性中。

3.2 它们的关系与功能

想象一下,如果我们不用框架,我们需要这样写:

复制代码
1Student s = new Student();
2s.setId(1); // 手动调用 setter
3s.setName("Tom");

在手写框架中,我们不知道具体的类是 Student 还是 User,所以我们不能写死 s.setId。我们需要动态 地找到并调用这个 setId 方法。

这就是 PropertyDescriptor 的作用:

  1. PropertyDescriptor (属性描述器)

    • 功能:它是一个桥梁,连接了"属性名"和"getter/setter 方法"。
    • 原理 :Java Bean 规范规定,如果有一个属性叫 name,那么它通常对应一个方法 setName (setter) 和 getName (getter)。
    • 用法new PropertyDescriptor("name", clazz)。它能帮我们通过属性名找到对应的方法对象。
  2. getWriteMethod() (写方法)

    • 功能PropertyDescriptor 有两个方法:
      • getWriteMethod():获取 Setter 方法(用于赋值)。
      • getReadMethod():获取 Getter 方法(用于取值)。
    • 关系 :我们用 PropertyDescriptor 封装了"属性名 -> 方法名"的关系,然后调用它的 getWriteMethod() 拿到 Method 对象,最后通过 method.invoke(obj, value) 利用反射执行这个 Setter 方法。
3.3 在 DBUtil 中的完整流程

结合你手写框架中的代码,这段逻辑的执行流如下:

  1. 遍历结果集ResultSet rs 有一行数据。
  2. 获取元数据ResultSetMetaData 告诉我们列名(如 "name")。
  3. 实例化对象E obj = (E) clazz.getConstructor().newInstance(null); (new 一个空对象)。
  4. 核心映射循环
    • 获取列名String columnName = meta.getColumnName(i); (例如 "name")
    • 获取列值Object columnValue = rs.getObject(i); (例如 "Tom")
    • 建立桥梁PropertyDescriptor descriptor = new PropertyDescriptor(columnName, clazz);
      • 此时,descriptor 知道了:属性名是 "name",它应该去找 setName 方法。
    • 获取 SetterMethod method = descriptor.getWriteMethod();
      • 此时,method 就是 Student 类中的 setName 方法。
    • 执行赋值method.invoke(obj, columnValue);
      • 相当于执行了:student.setName("Tom");

针对一些工具类的总结:

1. Document(整本书)

  • 是什么Document 代表整个 XML 文档。当你使用 SAXReader 读取完一个 XML 文件后,内存中生成的就是这棵完整的"树"。
  • 作用 :它是所有节点的根容器。就像一本书包含了封面、目录、所有章节和页码一样,Document 包含了 XML 里的所有标签、属性和文本。
  • 获取方式 :通常通过 new SAXReader().read(inputStream) 得到。

2. Element(具体的章节、段落或文字)

  • 是什么Element 代表 XML 中的一个节点(标签)
  • 作用 :它是 Document 树上的具体组成部分。比如 <configuration> 是根元素,<dataSource> 是子元素,<property> 是更深层的子元素。
  • 关系Document 是由无数个嵌套的 Element 组成的。你可以通过 getRootElement() 拿到书的"第一章"(根节点),也可以通过父 Element 去获取它的子 Element

3. selectNodes(全书内容检索/精准定位)

  • 是什么selectNodesDocumentElement 都具备的一个核心方法 。它的作用是接收 XPath 语法作为参数,返回符合条件的 Element 列表(List<Node>)。
  • 作用 :它就像书的**"智能搜索引擎"** 。
    • 如果你对着 Document 调用 selectNodes("//property"),相当于在全书 中搜索所有叫 property 的段落。
    • 如果你对着某个特定的 Element(比如 <dataSource>)调用 selectNodes("property"),相当于只在当前章节 里查找 property

它们在实际代码中的联动关系

结合你手写 MyBatis 的 XMLParser 场景,它们的协作流程是这样的:

  1. SAXReader 生成 Document

    首先,SAXReaderSqlConfig.xml 文件读入内存,生成一个 Document 对象(整本书)

    复制代码
    1Document document = reader.read(inputStream); 
  2. Document 获取根 Element

    我们需要先找到这本书的入口,也就是根标签 <configuration>

    复制代码
    1Element root = document.getRootElement(); // 拿到了 <configuration> 这个 Element
  3. Element 调用 selectNodes 精准查找

    现在我们已经站在 <configuration> 这个节点上了,我们想找到它下面所有的 <property> 标签来提取数据库账号密码

    复制代码
    1// 从 root 元素出发,利用 XPath 查找所有 property 子节点
    2List<Element> list = root.selectNodes("//property"); 
  4. 遍历 Element 提取数据

    最后,遍历找到的这些 Element,提取出我们想要的属性值

    复制代码
    1for(Element element : list) {
    2    // element 就是每一个具体的 <property> 标签
    3    String name = element.attributeValue("name"); 
    4    String value = element.attributeValue("value");
    5}

总结一下:
SAXReader 负责把 XML 变成内存中的 Document(整棵树);DocumentElement 是树上的节点(整体与局部);而 selectNodes 是我们手里拿着 XPath 地图,在 DocumentElement 上快速定位并抓取目标节点的神器。

相关推荐
山楂树の2 小时前
原生 WebGL + Canvas 实现鱼眼图像去畸变(Shader逐像素计算)
图像处理·数码相机·学习·程序人生
**蓝桉**2 小时前
容器服务学习笔记
笔记·学习
乔代码嘚2 小时前
Agentic-KGR:多智能体强化学习驱动的知识图谱本体渐进式扩展技术
人工智能·学习·大模型·知识图谱·ai大模型·大模型学习·大模型教程
zhangrelay3 小时前
三分钟云课实践速通--模拟电子技术-模电--SimulIDE
linux·笔记·学习·ubuntu·lubuntu
木木_王3 小时前
嵌入式Linux学习 | 数据结构 (Day05) 栈与队列详解(原理 + C 语言实现 + 实战实验 + 易错点剖析)
linux·c语言·开发语言·数据结构·笔记·学习
OSwich3 小时前
【 Godot 4 学习笔记】数组(Array)
笔记·学习·godot
程序员-小李3 小时前
uv 学习总结:从零到一掌握现代化 Python 工具链
python·学习·uv
nashane4 小时前
HarmonyOS 6学习:页面跳转弹窗状态保持全解析
学习·华为·harmonyos·harmonyos 5
山楂树の4 小时前
图像标注大坑:img图片 + Canvas 叠加标注,同步放大后标注位置偏移、对不齐?详解修复方案及亚像素处理原理
前端·css·学习·canva可画