【仓颉语言入门 · 第二篇 第 15 课】

【仓颉语言入门 · 第二篇 第 15 课】综合实战:把 Todo 清单搬上数据库

导航 :上一课 第 14 课 手写 DAO 数据访问层 → 本课 → 下一课 第 16 课 反射

本课内容量 :正文约 380 行,全篇约 580 行(含导航 / FAQ / 预告 / 二维码骨架)。单课只聚焦 1 个知识点:把前 8 课讲过的 SQL 能力(建表、CRUD、预编译、事务、DAO)串成一个完整的小项目,不引入新语法、新概念、新依赖。本课本身就是综合实战项目,照着跑通、输出和教程对上、自己改改再跑一遍就是最好的练习------不再额外设课后练习题。

代码下载 :本课完整项目打包在 lesson15_todo.zip


一、从文件到数据库:为什么又来一次 Todo?

你可能会问:Todo 清单有啥好写的?第一篇第 29 课不就是一个文件版 Todo 清单吗?

那我们回头看看第 29 课的方案:

  • 用 std.fs 读写一个 txt 文件,每条 Todo 一行
  • 业务逻辑全堆在 main 里,没有分层
  • Todo 本身是 struct Todo(title: String, done: Bool) 这样的纯数据
  • 读写文件一卡,整个程序就卡

现在换一个版本:存到数据库里 。这次我们用数据库做存储层,其他一切------项目骨架、数据模型、业务逻辑------全和第 29 课一模一样。

这就是本课要讲的核心思想:

存储层可以替换,业务逻辑不必跟着换。

文件版、数据库版、未来 Redis 版------对"用户想加一条 Todo"这件事来说,业务逻辑永远是"把这行字写进去 + 记一个已完成标记"。至于写到哪、怎么写,那是存储层的事。

这种分层在中大型项目里随处可见:电商系统换了个新数据库、消息系统换了个新中间件、缓存换了个新厂商------上面的业务代码一行不动。本课通过一个极简的 Todo 项目,让你亲手走一遍这个分层。


二、表设计:先建表再开干

项目涉及两个实体、两张表:用户(user)和他的 Todo(todo)。关系很简单------一个用户可以有很多 Todo(一对多)。

建表 SQL(和第 08 课 DDL 章节讲的语法一致):

sql 复制代码
CREATE TABLE user (
    id   BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(64) NOT NULL UNIQUE
);

CREATE TABLE todo (
    id      BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    title   VARCHAR(256) NOT NULL,
    done    TINYINT(1) NOT NULL DEFAULT 0
);

设计说明:

  • user 的 name 上加了 UNIQUE 约束------不允许两个用户重名
  • todo.done 用 TINYINT(1) 存 0/1(False/True),和数据库 BOOLEAN 的常见写法对齐
  • 没有加外键约束 (FOREIGN KEY (user_id) REFERENCES user(id))。教学简化:我们用"应用层"保证 user_id 有效(DAO 里先查 user 是否存在),不搞数据库级联。初学者阶段,能跑通比完美约束重要

如果想跟着本课演示的流程走,先往表里插两条种子数据:

sql 复制代码
INSERT INTO user (name) VALUES ('zhangsan'), ('lisi');
INSERT INTO todo (user_id, title, done) VALUES
    (1, '学第 15 课', 0),
    (1, '写教程代码', 1),
    (2, '买牛奶', 0);

插完后手动查一下确认:

text 复制代码
mysql> SELECT * FROM user;
id      name
2       lisi
1       zhangsan

mysql> SELECT * FROM todo;
id      user_id title           done
1       1       学第 15 课      0
2       1       写教程代码      1
3       2       买牛奶          0

三、Model 类:纯数据搬运工

Model 层只做一件事:把数据库里的一行数据搬到内存里 ,变成一个可以在程序里自由传递的对象。它不碰数据库、不做业务决策。

cangjie 复制代码
class User {
    let id: Int64
    let name: String
    init(id: Int64, name: String) {
        this.id = id
        this.name = name
    }
}

class Todo {
    let id: Int64
    let userId: Int64
    let title: String
    var done: Bool
    init(id: Int64, userId: Int64, title: String, done: Bool) {
        this.id = id
        this.userId = userId
        this.title = title
        this.done = done
    }
}

这两个类的写法和第 14 课 Account 类完全一致 :let 表示创建后不变(User 的 id、name 创建后不该变;Todo 的 id、userId、title 同理),var 表示可以改(Todo 的 done 可以从未完成变完成)。

Model 层的设计原则:越薄越好。别在里面写任何 SQL、任何业务判断,保持纯粹的数据容器。


四、两个 DAO:单表操作的套路

Data Access Object(DAO)层只负责一件事:把 SQL 语句翻译成 DAO 方法。一张表对应一个 DAO 类,每个方法代表一条可被复用的 SQL。

UserDao

cangjie 复制代码
class UserDao {
    let conn: Connection
    init(conn: Connection) { this.conn = conn }

    // 按名字查用户(登录场景常用)
    func findByName(name: String): ?User {
        let stmt = conn.prepareStatement("SELECT id, name FROM user WHERE name = ?")
        stmt.set(1, name)
        let rs = stmt.query()
        var result: ?User = None
        if (rs.next()) {
            result = Some(User(rs.get<Int64>(1), rs.get<String>(2)))
        }
        rs.close()
        stmt.close()
        return result
    }

    // 新建用户,返回新用户的 id
    func insert(name: String): Int64 {
        let stmt = conn.prepareStatement("INSERT INTO user (name) VALUES (?)")
        stmt.set(1, name)
        let r = stmt.update()
        stmt.close()
        return r.lastInsertId
    }
}

TodoDao

cangjie 复制代码
class TodoDao {
    let conn: Connection
    init(conn: Connection) { this.conn = conn }

    // 按 id 查单条 Todo
    func findById(id: Int64): ?Todo {
        let stmt = conn.prepareStatement(
            "SELECT id, user_id, title, done FROM todo WHERE id = ?")
        stmt.set(1, id)
        let rs = stmt.query()
        var result: ?Todo = None
        if (rs.next()) {
            let rawDone = rs.get<Int64>(4)
            let doneFlag: Bool = rawDone != 0
            result = Some(Todo(rs.get<Int64>(1), rs.get<Int64>(2),
                               rs.get<String>(3), doneFlag))
        }
        rs.close()
        stmt.close()
        return result
    }

    // 列出某用户的全部 Todo
    func listByUser(userId: Int64): Array<Todo> {
        let stmt = conn.prepareStatement(
            "SELECT id, user_id, title, done FROM todo WHERE user_id = ? ORDER BY id")
        stmt.set(1, userId)
        let rs = stmt.query()
        let list = ArrayList<Todo>()
        while (rs.next()) {
            let rawDone = rs.get<Int64>(4)
            let doneFlag: Bool = rawDone != 0
            list.add(Todo(rs.get<Int64>(1), rs.get<Int64>(2),
                          rs.get<String>(3), doneFlag))
        }
        rs.close()
        stmt.close()
        return list.toArray()
    }

    // 给某用户加一条 Todo,返回新 Todo 的 id
    func insert(userId: Int64, title: String): Int64 {
        let stmt = conn.prepareStatement(
            "INSERT INTO todo (user_id, title, done) VALUES (?, ?, 0)")
        stmt.set(1, userId)
        stmt.set(2, title)
        let r = stmt.update()
        stmt.close()
        return r.lastInsertId
    }

    // 把某条 Todo 标记为已完成 / 未完成
    func setDone(id: Int64, done: Bool): Int64 {
        let stmt = conn.prepareStatement("UPDATE todo SET done = ? WHERE id = ?")
        var flag: Int64 = 0
        if (done) {
            flag = 1
        }
        stmt.set(1, flag)
        stmt.set(2, id)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }

    // 删除某条 Todo
    func deleteById(id: Int64): Int64 {
        let stmt = conn.prepareStatement("DELETE FROM todo WHERE id = ?")
        stmt.set(1, id)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }

    // 删除某用户的全部 Todo(**只在事务里用**)
    func deleteByUserId(userId: Int64): Int64 {
        let stmt = conn.prepareStatement("DELETE FROM todo WHERE user_id = ?")
        stmt.set(1, userId)
        let rows = stmt.update().rowCount
        stmt.close()
        return rows
    }
}

DAO 层的三个套路

  1. 所有 SQL 走 conn.prepareStatement(预编译),绝不用字符串拼接。第 12 课讲过原因:防 SQL 注入
  2. Connection 是 DAO 的唯一依赖 ,通过构造器注入(init(conn: Connection))。Service 层传给 DAO 的是同一个连接,事务里所有 DAO 调用自然共享同一个事务状态
  3. ResultSet 和 Statement 用完立刻 close。不 close 虽然程序不崩,但数据库服务端会保持连接数不释放,跑久了 MySQL 会满

五、Service 层:把 DAO 串成业务

DAO 只管单表操作,但真实业务经常跨多张表 。比如"删除用户"这件事:删 user 表之前必须先删光他所有的 todo,否则会出现"孤儿 todo"。这种跨表编排------事务------ 就是 Service 层的职责。

cangjie 复制代码
class TodoService {
    let userDao: UserDao
    let todoDao: TodoDao
    let conn: Connection

    init(conn: Connection) {
        this.conn = conn
        this.userDao = UserDao(conn)
        this.todoDao = TodoDao(conn)
    }

    // 创建用户;同名已存在则直接返回,不重复插
    func createUser(name: String): User {
        let found = userDao.findByName(name)
        if (found.isSome()) {
            return found.getOrThrow()
        }
        let id = userDao.insert(name)
        return User(id, name)
    }

    // 给用户加一条 Todo;用户不存在返回 None
    func addTodo(userName: String, title: String): ?Int64 {
        let found = userDao.findByName(userName)
        if (found.isNone()) {
            println("用户 ${userName} 不存在")
            return None
        }
        let userId = found.getOrThrow().id
        return Some(todoDao.insert(userId, title))
    }

    // 把某条 Todo 标为已完成
    func completeTodo(todoId: Int64): Bool {
        let found = todoDao.findById(todoId)
        if (found.isNone()) {
            println("todo ${todoId} 不存在")
            return false
        }
        return todoDao.setDone(todoId, true) > 0
    }

    // 删除一条 Todo
    func deleteTodo(todoId: Int64): Bool {
        let found = todoDao.findById(todoId)
        if (found.isNone()) {
            println("todo ${todoId} 不存在")
            return false
        }
        return todoDao.deleteById(todoId) > 0
    }

    // 列出某用户全部 Todo
    func listUserTodos(userName: String): ?(User, Array<Todo>) {
        let found = userDao.findByName(userName)
        if (found.isNone()) {
            return None
        }
        let u = found.getOrThrow()
        return Some((u, todoDao.listByUser(u.id)))
    }

    // 事务:删用户 + 删光他的 Todo
    // 两条 SQL 要么一起成,要么一起不算
    func deleteUserAndTodos(userName: String): Bool {
        let found = userDao.findByName(userName)
        if (found.isNone()) {
            println("用户 ${userName} 不存在")
            return false
        }
        let u = found.getOrThrow()
        let tx = conn.createTransaction()
        tx.begin()
        try {
            // 先删 todo(user 表没有其他表依赖它,顺序不敏感)
            todoDao.deleteByUserId(u.id)
            let stmt = conn.prepareStatement("DELETE FROM user WHERE id = ?")
            stmt.set(1, u.id)
            stmt.update()
            stmt.close()
            tx.commit()
            return true
        } catch (e: SqlException) {
            tx.rollback()
            println("删除用户回滚:${e.message}")
            return false
        }
    }
}

事务版 deleteUserAndTodos 拆解

这是第 13 课事务三件套的标准用法:

步骤 代码 作用
领事务 let tx = conn.createTransaction() 从连接领一个事务对象(它只是一层状态标记,没开新连接)
开始 tx.begin() 之后这个连接上执行的所有改动都进草稿区
干活 todoDao.deleteByUserId + conn.prepareStatement("DELETE FROM user...") 两条 SQL;注意:仍然走 conn.prepareStatement,不是 tx.prepareStatement(第 13 课实测结论)
提交 tx.commit() 草稿全部落库,事务结束
异常回滚 catch (e: SqlException) { tx.rollback() } 草稿全部撕掉,事务结束

小提示 :先删 todo 还是先删 user?两种顺序都行------我们没加外键约束,所以不用怕"删了父表再删子表"的问题。但真实生产里加了外键约束后必须先删子表(todo)。养成先删依赖方的习惯不吃亏。


六、main:一条完整的业务流程

main 函数就是一个舞台 ------它把前面准备好的一切演员(Connection、Service)拉上台,按顺序走一遍剧情。同时还有一个辅助函数 printUserTodos 专门负责"格式化打印某用户的 Todo 列表":

cangjie 复制代码
func printUserTodos(svc: TodoService, userName: String): Unit {
    let found = svc.listUserTodos(userName)
    if (found.isNone()) {
        println("${userName} 不存在")
        return
    }
    let (user, todos) = found.getOrThrow()
    println("用户 ${user.name}(共 ${todos.size} 条 Todo):")
    for (t in todos) {
        var mark: String = " "
        if (t.done) {
            mark = "✓"
        }
        println("  [${mark}] #${t.id} ${t.title}")
    }
}

main(): Int64 {
    let driver = DriverManager.getDriver("mariadb").getOrThrow()
    let ds = driver.open("mariadb://127.0.0.1:3306", [
        ("username", "root"),
        ("password", "wangpeng"),
        ("database", "todo_tutorial")
    ])
    let conn = ds.connect()
    let svc = TodoService(conn)

    println("=== 1. 列出 zhangsan 当前的 Todo ===")
    printUserTodos(svc, "zhangsan")

    println("")
    println("=== 2. 把 zhangsan 的 #1(学第 15 课)标为已完成 ===")
    if (svc.completeTodo(1)) {
        println("#1 已完成")
    } else {
        println("#1 不存在")
    }

    println("")
    println("=== 3. 再列 zhangsan 的 Todo,#1 应该带 ✓ ===")
    printUserTodos(svc, "zhangsan")

    println("")
    println("=== 4. 新建用户 zhaoliu 并加 3 条 Todo ===")
    let u = svc.createUser("zhaoliu")
    println("创建用户成功:id=${u.id}, name=${u.name}")
    svc.addTodo("zhaoliu", "跑步 3 公里")
    svc.addTodo("zhaoliu", "背单词 50 个")
    svc.addTodo("zhaoliu", "读一章小说")

    println("")
    println("=== 5. 列出全部 zhaoliu Todo ===")
    printUserTodos(svc, "zhaoliu")

    println("")
    println("=== 6. 删除 lisi + 他的全部 Todo(事务) ===")
    if (svc.deleteUserAndTodos("lisi")) {
        println("lisi 连同 Todo 已全部删除")
    } else {
        println("删除失败")
    }
    if (svc.listUserTodos("lisi").isNone()) {
        println("确认:lisi 已不存在")
    } else {
        println("警告:lisi 还存在!")
    }

    conn.close()
    0
}

注意 :import mariadb.cdbc 虽然我们没在代码里显式调用它的类,但必须 import ------这会触发驱动包的自注册(第 07 课核心机制),让 DriverManager.getDriver("mariadb") 能找到 mariadb 驱动。漏写了 getDriver 返回 None,getOrThrow() 当场崩。


七、运行输出(cjpm run 逐字比对)

跑 cjpm run:

text 复制代码
=== 1. 列出 zhangsan 当前的 Todo ===
用户 zhangsan(共 2 条 Todo):
  [ ] #1 学第 15 课
  [✓] #2 写教程代码

=== 2. 把 zhangsan 的 #1(学第 15 课)标为已完成 ===
#1 已完成

=== 3. 再列 zhangsan 的 Todo,#1 应该带 ✓ ===
用户 zhangsan(共 2 条 Todo):
  [✓] #1 学第 15 课
  [✓] #2 写教程代码

=== 4. 新建用户 zhaoliu 并加 3 条 Todo ===
创建用户成功:id=3, name=zhaoliu

=== 5. 列出全部 zhaoliu Todo ===
用户 zhaoliu(共 3 条 Todo):
  [ ] #4 跑步 3 公里
  [ ] #5 背单词 50 个
  [ ] #6 读一章小说

=== 6. 删除 lisi + 他的全部 Todo(事务) ===
lisi 连同 Todo 已全部删除
确认:lisi 已不存在

cjpm run finished

为什么每次运行前要 reset 数据库? 因为 step 4 新建用户后不会自动删除;step 6 删了 lisi;第二次运行时 seed 数据(zhangsan、lisi、#1 #2 #3 todo)已经不存在了,listUserTodos("zhangsan") 会报"zhangsan 不存在"。生产程序不会每次都重置------这是教学演示的便利,让你能反复看同一段输出。


八、常见报错速览(我实测踩的坑)

1. NoneValueException 在 DriverManager.getDriver("mariadb").getOrThrow()

原因 :漏写 import mariadb.cdbc。mariadb 驱动包的自注册机制需要 import 才能触发;不 import,DriverManager 里就没有 mariadb 这个驱动,getDriver 返回 None。

解决 :在文件顶部补上 import mariadb.cdbc。

2. error: 'append' is not a member of struct 'Array<T>'

原因 :你把 Array 当成了动态数组,用 .append() 往里加元素。仓颉 SDK 1.2.0 的 Array 是固定长度数组,没有增删方法。

解决 :用 ArrayList<T>(import std.collection.ArrayList),追加用 .add(),动态集合用完可以 .toArray() 转回固定长度数组。

3. error: variable 'xxx' is immutable

原因 :let 声明的变量你又赋了新值。比如 let flag = 0 之后 if (done) flag = 1------flag 必须用 var。

解决 :需要修改的变量一律 var,不需要改的保持 let(编译器会帮你发现意外修改)。

4. 事务里写 tx.prepareStatement(...) 编译报错

原因 :Transaction 接口没有 prepareStatement 方法。事务只是一层"草稿区状态",从 begin() 到 commit() / rollback() 之间,这个连接上执行的所有改动自动进入草稿区。

解决 :事务里执行 SQL 仍然走 conn.prepareStatement------和事务外一模一样的代码,只是因为 begin() 已经把连接标记成"事务模式"了。第 13 课有实测结论。

5. error: generic type should be used with type argument + error: 'get' is not a member of enum 'Option<T>'

原因:Option 的判断和解包你可能用了错误方法。

解决:

  • 判断有值:.isSome() / .isNone()
  • 解包取值:.getOrThrow()(有值返回值,没值抛异常)
  • 不能写 found == None(编译器认为 None 是泛型占位符,不是具体值)

6. ResultSet 用完没 close 也能跑,但跑多了 MySQL 报错 Too many connections

原因 :驱动在 ResultSet 关闭前不释放底层 socket。程序跑完虽然进程退出,但真实服务端的连接数会累积。

解决 :每个 Statement → ResultSet 链用完立刻 close ,顺序 rs.close() → stmt.close()。这是数据库编程的基本纪律。


九、前 8 课关键 API 串烧(综合速查)

本课把前 8 课数据库知识全部串起来了,下面这张表帮你快速定位:

场景 用法 对应课程
取驱动 DriverManager.getDriver("mariadb").getOrThrow() 第 07 课
开连接 driver.open("mariadb://host:port", [...]).connect() 第 07 课
执行 DDL conn.prepareStatement("CREATE TABLE ...").update() 第 08 课
查询 conn.prepareStatement(sql).query() → while (rs.next()) { rs.get<T>(n) } 第 09 课
插入 conn.prepareStatement("INSERT ...").update().lastInsertId 第 10 课
更新 / 删除 conn.prepareStatement("UPDATE ...").update().rowCount 第 11 课
预编译(全课贯穿) SQL 里写 ?,执行前 stmt.set(n, value) 第 12 课
事务 let tx = conn.createTransaction(); tx.begin() → try/commit + catch/rollback 第 13 课
动态集合 ArrayList<T>() + .add(x) + .toArray() 第 08 课(第一篇)
Option 解包 .isSome() / .isNone() + .getOrThrow() 第 09 课

十、FAQ

Q1:Service 层和 DAO 层的边界在哪?为什么不把跨表逻辑也写在 DAO 里?

DAO 的设计哲学是**"一张表 = 一个 DAO"**。跨两张表、需要事务的操作,放在 DAO 里会导致一个 DAO 依赖另一个 DAO(UserDao 里要调 TodoDao.deleteByUserId),职责混乱。Service 层就是 DAO 之上的"编排层"------它知道有哪些 DAO、该按什么顺序调用、哪里需要事务、哪里需要异常兜底。

一个简单判断标准:方法里只出现 SELECT / INSERT / UPDATE / DELETE 单表操作 → DAO;方法里出现多个 DAO 调用,或需要事务 → Service。

Q2:和第 29 课文件版 Todo 对照一下?

维度 第 29 课文件版 第 15 课数据库版
存储介质 txt 文件,每行一条 MySQL 两张表
读取方式 整文件读入内存 + split SQL 查询 + ResultSet 遍历
写入方式 file.write(content) 覆盖写 stmt.update() 增/改/删
多用户支持 不支持(全堆一个文件) 天然支持(user 表分区)
分层 全堆 main Model + DAO + Service 三层
并发安全 进程独占(不会被两个进程同时改) 靠事务锁 + 连接池(真实生产问题)

你可以看到:Model、DAO、Service 三层在两个版本里一模一样 ,只有 DAO 里的"怎么存/怎么取"变了。这就是本课想传达的------业务逻辑和存储技术解耦。

Q3:为什么不用 ConnectionPool?

数据库连接建立一次成本很高(TCP 握手 + 认证 + 默认 schema 设置)。真实服务器应该用连接池(应用启动时先建 10 条放着,用的时候拿一条,用完还回去而不是关掉)。但本课和 mariadb-driver 1.2.0 都没内置连接池------我们只做一次连接、用完关掉的最简场景。连接池是"生产级优化",初学者阶段先把"单连接能跑通"吃透再说。

Q4:如果将来想换 SQLite?

改一行 URL:"sqlite:///path/to/db.sqlite"(前提是换用 SQLite 驱动)。Service 层和 DAO 层一行都不用改 ------因为我们用的都是标准 SQL + std.database.sql 标准接口,不依赖 mariadb 的特定扩展。这就是"分层解耦"的好处。


下一课预告 :第 16 课 反射 ------ 标准库 std.reflect 能让程序在运行时"看见"自己:类有哪些字段?方法签名是什么?字段叫什么名字?反射是 ORM、序列化框架、依赖注入的底层工具,也是仓颉类型系统的"后门"。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 + MySQL 环境下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信 直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE ------ CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载 :https://gitcode.com/wp_upala/cide
  • 打开页面后进入 发行版(Releases) ,两种包任选其一:
    • 安装版 :下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable) :下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
相关推荐
2501_944525541 小时前
开源鸿蒙平台 KMP_CMP 三方库「Clikt」适配全流程
华为·开源·harmonyos
独隅1 小时前
Claude 高效使用指南深度评测
java·开发语言·claude·claude code
Agent世界1 小时前
千问Go上线我的应用:一句话生成可部署 AI 软件的技术拆解
开发语言·人工智能·golang·ai智能体
小熊吃保安1 小时前
Windows也能用 Apple Watch 解锁
windows·rust·开源
布莱克6051 小时前
C++ 中 unordered_set 与 unordered_map 有什么不同?一篇讲透
开发语言·c++·哈希
外收内放1 小时前
FastAPI猜字谜实战(6):日志与异常
开发语言·python·学习·fastapi
疯狂学习GIS1 小时前
将谷歌地球引擎账号升级为Contributor与Partner层级的方法
java·数据库·人工智能
垆边人似月.1 小时前
预处理与参数调优(让识别率从“能看”到“可用”)
开发语言·python·自然语言处理
gf13211112 小时前
python_高德地图驾车时长查询
数据库·python·mysql