场景

目前一个项目中数据持久化采用EF Core + MySQL,使用CodeFirst模式开发,并且对数据进行了分库,按照目前颗粒度分完之后,大概有一两百个库,每个库的数据都是相互隔离的。

借鉴了Github上一个开源的仓库 arch/UnitOfWork 实现UnitOfWork,核心操作就是每个api请求的时候带上库名,在执行CRUD之前先将DbContext切换到目标数据库,我们在切换数据库的时候加了一些操作,如检查数据库是否已创建、检查连接是否可用、判断是否需要 表结构迁移

  1. /// <summary>
  2. /// 切换数据库 这要求数据库在同一台机器上 注意:这只适用于MySQL。
  3. /// </summary>
  4. /// <param name="database">目标数据库</param>
  5. public void ChangeDatabase(string database)
  6. {
  7. // 检查连接
  8. ......
  9. // 检查数据库是否创建
  10. ......
  11. var connection = _context.Database.GetDbConnection();
  12. if (connection.State.HasFlag(ConnectionState.Open))
  13. {
  14. connection.ChangeDatabase(database);
  15. }
  16. else
  17. {
  18. var connectionString = Regex.Replace(connection.ConnectionString.Replace(" ", ""), @"(?<=[Dd]atabase=)\w+(?=;)", database, RegexOptions.Singleline);
  19. connection.ConnectionString = connectionString;
  20. }
  21. // 判断是否需要执行表结构迁移
  22. if(_context..Database.GetPendingMigrations().Any())
  23. {
  24. //自定义的迁移的一些逻辑
  25. _migrateExecutor.Migrate(_context);
  26. }
  27. }

但是当多个操作同时对一个库进行Migrate的时候,就会出现问题,比如“新增一张表”的操作已经被第一个迁移执行过了,第二个执行的迁移并不知道已经执行过了Migrate,就会报错表已存在。

于是考虑在执行Migrate的时候,加入一个锁的机制,对当前数据库执行Migrate之前先获取锁,然后再来决定接下来的操作。由于边有的服务无法访问Redis,这里使用数据库来实现锁的机制,当然用Redis来实现更好,加入锁的机制只是一种解决问题的思路。

利用数据库实现迁移锁

1. 新增 MigrationLocks 表来实现迁移锁

  • 锁的操作不依赖DbContext实例
  • 在执行Migrate之前,尝试获取一个锁,在获取锁之前,如果表不存在则创建
    1. CREATE TABLE IF NOT EXISTS MigrationLocks (
    2. LockName VARCHAR(255) PRIMARY KEY,
    3. LockedAt DATETIME NOT NULL
    4. );
  • 成功往表中插入一条记录,视为获取锁成功,主键为需要迁移的库的名称
    1. INSERT INTO MigrationLocks (LockName, LockedAt) VALUES (@database, NOW());
  • 迁移完成后,删除这条记录,视为释放锁成功;
    1. DELETE FROM MigrationLocks WHERE LockName = @database;
  • 为防止 “死锁” 发生,每次尝试获取锁之前,会对锁的状态进行检查,释放超过5分钟的锁(正常来说,上一个迁移的执行时间不会超过5分钟)。
    1. SELECT COUNT(*) FROM MigrationLocks WHERE LockName = @database AND LockedAt > NOW() - INTERVAL 5 MINUTE;

2. 封装一下MigrateLock的实现

  1. /// <summary>
  2. /// 迁移锁
  3. /// </summary>
  4. public interface IMigrateLock
  5. {
  6. /// <summary>
  7. /// 尝试获取锁
  8. /// </summary>
  9. /// <param name="connection"></param>
  10. /// <returns></returns>
  11. bool TryAcquireLock(IDbConnection connection);
  12. /// <summary>
  13. /// 尝试获取锁
  14. /// </summary>
  15. /// <param name="connection"></param>
  16. /// <returns></returns>
  17. Task<bool> TryAcquireLockAsync(IDbConnection connection);
  18. /// <summary>
  19. /// 释放锁
  20. /// </summary>
  21. void ReleaseLock(IDbConnection connection);
  22. /// <summary>
  23. /// 释放锁
  24. /// </summary>
  25. /// <returns></returns>
  26. Task ReleaseLockAsync(IDbConnection connection);
  27. }
  28. /// <summary>
  29. /// 迁移锁
  30. /// </summary>
  31. public class MigrateLock : IMigrateLock
  32. {
  33. private readonly ILogger<MigrateLock> _logger;
  34. public MigrateLock(ILogger<MigrateLock> logger)
  35. {
  36. _logger = logger;
  37. }
  38. private const string CreateTableSql = @"
  39. CREATE TABLE IF NOT EXISTS MigrationLocks (
  40. LockName VARCHAR(255) PRIMARY KEY,
  41. LockedAt DATETIME NOT NULL
  42. );";
  43. private const string CheckLockedSql = "SELECT COUNT(*) FROM MigrationLocks WHERE LockName = @database AND LockedAt > NOW() - INTERVAL 5 MINUTE;";
  44. private const string AcquireLockSql = "INSERT INTO MigrationLocks (LockName, LockedAt) VALUES (@database, NOW());";
  45. private const string ReleaseLockSql = "DELETE FROM MigrationLocks WHERE LockName = @database;";
  46. /// <summary>
  47. /// 尝试获取锁
  48. /// </summary>
  49. /// <param name="connection"></param>
  50. /// <returns></returns>
  51. public bool TryAcquireLock(IDbConnection connection)
  52. {
  53. try
  54. {
  55. CheckLocked(connection);
  56. var result = connection.Execute(AcquireLockSql, new { database = connection.Database });
  57. if (result == 1)
  58. {
  59. _logger.LogInformation("Lock acquired: {LockName}", connection.Database);
  60. return true;
  61. }
  62. _logger.LogWarning("Failed to acquire lock: {LockName}", connection.Database);
  63. return false;
  64. }
  65. catch (Exception ex)
  66. {
  67. if (ex.Message.StartsWith("Duplicate"))
  68. {
  69. _logger.LogWarning("Failed acquiring lock due to duplicate entry: {LockName}", connection.Database);
  70. }
  71. else
  72. {
  73. _logger.LogError(ex, "Error acquiring lock: {LockName}", connection.Database);
  74. }
  75. return false;
  76. }
  77. }
  78. /// <summary>
  79. /// 尝试获取锁
  80. /// </summary>
  81. /// <param name="connection"></param>
  82. /// <returns></returns>
  83. public async Task<bool> TryAcquireLockAsync(IDbConnection connection)
  84. {
  85. try
  86. {
  87. await CheckLockedAsync(connection);
  88. var result = await connection.ExecuteAsync(AcquireLockSql, new { database = connection.Database });
  89. if (result == 1)
  90. {
  91. _logger.LogInformation("Lock acquired: {LockName}", connection.Database);
  92. return true;
  93. }
  94. _logger.LogWarning("Failed to acquire lock: {LockName}", connection.Database);
  95. return false;
  96. }
  97. catch (Exception ex)
  98. {
  99. if (ex.Message.StartsWith("Duplicate"))
  100. {
  101. _logger.LogWarning("Failed acquiring lock due to duplicate entry: {LockName}", connection.Database);
  102. }
  103. else
  104. {
  105. _logger.LogError(ex, "Error acquiring lock: {LockName}", connection.Database);
  106. }
  107. return false;
  108. }
  109. }
  110. /// <summary>
  111. /// 释放锁
  112. /// </summary>
  113. public void ReleaseLock(IDbConnection connection)
  114. {
  115. try
  116. {
  117. connection.ExecuteAsync(ReleaseLockSql, new { database = connection.Database });
  118. _logger.LogInformation("Lock released: {LockName}", connection.Database);
  119. }
  120. catch (Exception ex)
  121. {
  122. _logger.LogError(ex, "Error releasing lock: {LockName}", connection.Database);
  123. }
  124. }
  125. /// <summary>
  126. /// 释放锁
  127. /// </summary>
  128. public async Task ReleaseLockAsync(IDbConnection connection)
  129. {
  130. try
  131. {
  132. await connection.ExecuteAsync(ReleaseLockSql, new { database = connection.Database });
  133. _logger.LogInformation("Lock released: {LockName}", connection.Database);
  134. }
  135. catch (Exception ex)
  136. {
  137. _logger.LogError(ex, "Error releasing lock: {LockName}", connection.Database);
  138. }
  139. }
  140. /// <summary>
  141. /// 检查锁
  142. /// </summary>
  143. private void CheckLocked(IDbConnection connection)
  144. {
  145. connection.Execute(CreateTableSql);
  146. var databaseParam = new
  147. {
  148. database = connection.Database
  149. };
  150. var lockExists = connection.QueryFirstOrDefault<int>(CheckLockedSql, databaseParam);
  151. if (lockExists <= 0)
  152. {
  153. return;
  154. }
  155. _logger.LogWarning("Lock exists and is older than 5 minutes. Releasing old lock.");
  156. connection.Execute(ReleaseLockSql, databaseParam);
  157. }
  158. /// <summary>
  159. /// 检查锁
  160. /// </summary>
  161. private async Task CheckLockedAsync(IDbConnection connection)
  162. {
  163. await connection.ExecuteAsync(CreateTableSql);
  164. var databaseParam = new
  165. {
  166. database = connection.Database
  167. };
  168. var lockExists = await connection.QueryFirstOrDefaultAsync<int>(CheckLockedSql, databaseParam);
  169. if (lockExists <= 0)
  170. {
  171. return;
  172. }
  173. _logger.LogWarning("Lock exists and is older than 5 minutes. Releasing old lock.");
  174. await connection.ExecuteAsync(ReleaseLockSql, databaseParam);
  175. }
  176. }

3. 封装一下MigrateExecutor的实现

  1. /// <summary>
  2. /// 数据库迁移执行器
  3. /// </summary>
  4. public interface IMigrateExcutor
  5. {
  6. /// <summary>
  7. /// 执行迁移
  8. /// </summary>
  9. /// <param name="dbContext"></param>
  10. void Migrate(DbContext dbContext);
  11. /// <summary>
  12. /// 执行迁移
  13. /// </summary>
  14. /// <param name="dbContext"></param>
  15. /// <returns></returns>
  16. Task MigrateAsync(DbContext dbContext);
  17. /// <summary>
  18. /// 并发场景执行迁移
  19. /// </summary>
  20. /// <param name="dbContext"></param>
  21. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  22. void ConcurrentMigrate(DbContext dbContext, bool wait = true);
  23. /// <summary>
  24. /// 并发场景执行迁移
  25. /// </summary>
  26. /// <param name="dbContext"></param>
  27. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  28. /// <returns></returns>
  29. Task ConcurrentMigrateAsync(DbContext dbContext, bool wait = true);
  30. /// <summary>
  31. /// 并发场景执行迁移
  32. /// </summary>
  33. /// <param name="dbContext"></param>
  34. /// <param name="connection"></param>
  35. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  36. void ConcurrentMigrate(DbContext dbContext, IDbConnection connection, bool wait = true);
  37. /// <summary>
  38. /// 并发场景执行迁移
  39. /// </summary>
  40. /// <param name="dbContext"></param>
  41. /// <param name="connection"></param>
  42. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  43. Task ConcurrentMigrateAsync(DbContext dbContext, IDbConnection connection, bool wait = true);
  44. }
  45. /// <summary>
  46. /// 数据库迁移执行器
  47. /// </summary>
  48. public class MigrateExcutor : IMigrateExcutor
  49. {
  50. private readonly IMigrateLock _migrateLock;
  51. private readonly ILogger<MigrateExcutor> _logger;
  52. public MigrateExcutor(
  53. IMigrateLock migrateLock,
  54. ILogger<MigrateExcutor> logger)
  55. {
  56. _migrateLock = migrateLock;
  57. _logger = logger;
  58. }
  59. /// <summary>
  60. /// 执行迁移
  61. /// </summary>
  62. /// <param name="dbContext"></param>
  63. /// <returns></returns>
  64. public void Migrate(DbContext dbContext)
  65. {
  66. try
  67. {
  68. if (dbContext.Database.GetPendingMigrations().Any())
  69. {
  70. dbContext.Database.Migrate();
  71. }
  72. }
  73. catch (Exception e)
  74. {
  75. _logger.LogError(e, "Migration failed");
  76. HandleError(dbContext, e);
  77. }
  78. }
  79. /// <summary>
  80. /// 执行迁移
  81. /// </summary>
  82. /// <param name="dbContext"></param>
  83. /// <returns></returns>
  84. public async Task MigrateAsync(DbContext dbContext)
  85. {
  86. try
  87. {
  88. if ((await dbContext.Database.GetPendingMigrationsAsync()).Any())
  89. {
  90. await dbContext.Database.MigrateAsync();
  91. }
  92. }
  93. catch (Exception e)
  94. {
  95. _logger.LogError(e, "Migration failed");
  96. await HandleErrorAsync(dbContext, e);
  97. }
  98. }
  99. /// <summary>
  100. /// 并发场景执行迁移
  101. /// </summary>
  102. /// <param name="dbContext"></param>
  103. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  104. /// <returns></returns>
  105. public void ConcurrentMigrate(DbContext dbContext, bool wait = true)
  106. {
  107. if (!dbContext.Database.GetPendingMigrations().Any())
  108. {
  109. return;
  110. }
  111. using var connection = MySqlConnectionHelper.CreateConnection(dbContext.Database.GetDbConnection().Database);
  112. ConcurrentMigrate(dbContext, connection, wait);
  113. }
  114. /// <summary>
  115. /// 并发场景执行迁移
  116. /// </summary>
  117. /// <param name="dbContext"></param>
  118. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  119. /// <returns></returns>
  120. public async Task ConcurrentMigrateAsync(DbContext dbContext, bool wait = true)
  121. {
  122. if ((await dbContext.Database.GetPendingMigrationsAsync()).Any())
  123. {
  124. return;
  125. }
  126. await using var connection = await MySqlConnectionHelper.CreateConnectionAsync(dbContext.Database.GetDbConnection().Database);
  127. await ConcurrentMigrateAsync(dbContext, connection, wait);
  128. }
  129. /// <summary>
  130. /// 并发场景执行迁移(供数据同步相关服务使用,”迁移锁“ 使用传入的 <see cref="IDbConnection"/> 对象来完成)
  131. /// </summary>
  132. /// <param name="dbContext"></param>
  133. /// <param name="connection"></param>
  134. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  135. public void ConcurrentMigrate(DbContext dbContext, IDbConnection connection, bool wait = true)
  136. {
  137. if (!dbContext.Database.GetPendingMigrations().Any())
  138. {
  139. return;
  140. }
  141. while (true)
  142. {
  143. if (_migrateLock.TryAcquireLock(connection))
  144. {
  145. try
  146. {
  147. Migrate(dbContext);
  148. break;
  149. }
  150. finally
  151. {
  152. _migrateLock.ReleaseLock(connection);
  153. }
  154. }
  155. if (wait)
  156. {
  157. _logger.LogWarning("Migration is locked, wait for 2 seconds");
  158. Thread.Sleep(20000);
  159. continue;
  160. }
  161. _logger.LogInformation("Migration is locked, skip");
  162. }
  163. }
  164. /// <summary>
  165. /// 并发场景执行迁移(供数据同步相关服务使用,”迁移锁“ 使用传入的 <see cref="IDbConnection"/> 对象来完成)
  166. /// </summary>
  167. /// <param name="dbContext"></param>
  168. /// <param name="connection"></param>
  169. /// <param name="wait">是否等待至正在进行中的迁移完成</param>
  170. public async Task ConcurrentMigrateAsync(DbContext dbContext, IDbConnection connection, bool wait = true)
  171. {
  172. if ((await dbContext.Database.GetPendingMigrationsAsync()).Any())
  173. {
  174. return;
  175. }
  176. while (true)
  177. {
  178. if (await _migrateLock.TryAcquireLockAsync(connection))
  179. {
  180. try
  181. {
  182. await MigrateAsync(dbContext);
  183. break;
  184. }
  185. finally
  186. {
  187. await _migrateLock.ReleaseLockAsync(connection);
  188. }
  189. }
  190. if (wait)
  191. {
  192. _logger.LogWarning("Migration is locked, wait for 2 seconds");
  193. Thread.Sleep(20000);
  194. continue;
  195. }
  196. _logger.LogInformation("Migration is locked, skip");
  197. break;
  198. }
  199. }
  200. private void HandleError(DbContext dbContext, Exception e)
  201. {
  202. var needChangeList = dbContext.Database.GetPendingMigrations().ToList();
  203. var allChangeList = dbContext.Database.GetMigrations().ToList();
  204. var hasChangeList = dbContext.Database.GetAppliedMigrations().ToList();
  205. if (needChangeList.Count + hasChangeList.Count > allChangeList.Count)
  206. {
  207. int errIndex = allChangeList.Count - needChangeList.Count;
  208. if (hasChangeList.Count - 1 == errIndex && hasChangeList[errIndex] != needChangeList[0])
  209. {
  210. int index = needChangeList[0].IndexOf("_", StringComparison.Ordinal);
  211. string errSuffix = needChangeList[0].Substring(index, needChangeList[0].Length - index);
  212. if (hasChangeList[errIndex].EndsWith(errSuffix))
  213. {
  214. dbContext.Database.ExecuteSqlRaw($"Update __EFMigrationsHistory set MigrationId = '{needChangeList[0]}' where MigrationId = '{hasChangeList[errIndex]}'");
  215. dbContext.Database.Migrate();
  216. }
  217. else
  218. {
  219. throw e;
  220. }
  221. }
  222. else
  223. {
  224. throw e;
  225. }
  226. }
  227. else
  228. {
  229. throw e;
  230. }
  231. _logger.LogInformation("Migration failed, but success on second try.");
  232. }
  233. private async Task HandleErrorAsync(DbContext dbContext, Exception e)
  234. {
  235. var needChangeList = (await dbContext.Database.GetPendingMigrationsAsync()).ToList();
  236. var allChangeList = dbContext.Database.GetMigrations().ToList();
  237. var hasChangeList = (await dbContext.Database.GetAppliedMigrationsAsync()).ToList();
  238. if (needChangeList.Count + hasChangeList.Count > allChangeList.Count)
  239. {
  240. int errIndex = allChangeList.Count - needChangeList.Count;
  241. if (hasChangeList.Count - 1 == errIndex && hasChangeList[errIndex] != needChangeList[0])
  242. {
  243. int index = needChangeList[0].IndexOf("_", StringComparison.Ordinal);
  244. string errSuffix = needChangeList[0].Substring(index, needChangeList[0].Length - index);
  245. if (hasChangeList[errIndex].EndsWith(errSuffix))
  246. {
  247. await dbContext.Database.ExecuteSqlRawAsync($"Update __EFMigrationsHistory set MigrationId = '{needChangeList[0]}' where MigrationId = '{hasChangeList[errIndex]}'");
  248. await dbContext.Database.MigrateAsync();
  249. }
  250. else
  251. {
  252. throw e;
  253. }
  254. }
  255. else
  256. {
  257. throw e;
  258. }
  259. }
  260. else
  261. {
  262. throw e;
  263. }
  264. _logger.LogInformation("Migration failed, but success on second try.");
  265. }
  266. }

EntityFramework Core并发迁移解决方案的更多相关文章

  1. EntityFramework Core并发深挖详解,一纸长文,你准备好看完了吗?

    前言 之前有关EF并发探讨过几次,但是呢,博主感觉还是有问题,为什么会觉得有问题,其实就是理解不够透彻罢了,于是在项目中都是用的存储过程或者SQL语句来实现,利用放假时间好好补补EF Core并发的问 ...

  2. EntityFramework Core并发导致显示插入主键问题

    前言 之前讨论过EntityFramework Core中并发问题,按照官网所给并发冲突解决方案以为没有什么问题,但是在做单元测试时发现too young,too siimple,下面我们一起来看看. ...

  3. EntityFramework Core并发导致显式插入主键问题

    前言 之前讨论过EntityFramework Core中并发问题,按照官网所给并发冲突解决方案以为没有什么问题,但是在做单元测试时发现too young,too simple,下面我们一起来看看. ...

  4. EntityFramework Core解决并发详解

    前言 对过年已经无感,不过还是有很多闲暇时间来学学东西,这一点是极好的,好了,本节我们来讲讲EntityFramewoek Core中的并发问题. 话题(EntityFramework Core并发) ...

  5. EntityFramework Core高并发深挖详解,一纸长文,你准备好了吗?

    前言 之前有关EF并发探讨过几次,但是呢,博主感觉还是有问题,为什么会觉得有问题,其实就是理解不够透彻罢了,于是在项目中都是用的存储过程或者SQL语句来实现,利用放假时间好好补补EF Core并发的问 ...

  6. EntityFramework Core迁移时出现数据库已存在对象问题解决方案

    前言 刚开始接触EF Core时本着探索的精神去搞,搞着搞着发现出问题了,后来就一直没解决,觉得很是不爽,借着周末好好看看这块内容. EntityFramework Core迁移出现对象在数据库中已存 ...

  7. Cookies 初识 Dotnetspider EF 6.x、EF Core实现dynamic动态查询和EF Core注入多个上下文实例池你知道有什么问题? EntityFramework Core 运行dotnet ef命令迁移背后本质是什么?(EF Core迁移原理)

    Cookies   1.创建HttpCookies Cookie=new HttpCookies("CookieName");2.添加内容Cookie.Values.Add(&qu ...

  8. EntityFramework Core 运行dotnet ef命令迁移背后本质是什么?(EF Core迁移原理)

    前言 终于踏出第一步探索EF Core原理和本质,过程虽然比较漫长且枯燥乏味还得反复论证,其中滋味自知,EF Core的强大想必不用我再过多废话,有时候我们是否思考过背后到底做了些什么,到底怎么实现的 ...

  9. EntityFramework Core 迁移忽略主外键关系

    前言 本文来源于一位公众号童鞋私信我的问题,在我若加思索后给出了其中一种方案,在此之前我也思考过这个问题,借此机会我稍微看了下,目前能够想到的也只是本文所述方案. 为何要忽略主外键关系 我们不仅疑惑为 ...

  10. EntityFramework Core 3多次Include导致查询性能低之解决方案

    前言 上述我们简单讲解了几个小问题,这节我们再来看看如标题EF Core中多次Include导致出现性能的问题,废话少说,直接开门见山. EntityFramework Core 3多次Include ...

随机推荐

  1. Python win11 安装lxml 失败

    如果你有一个项目执行了requirements后,一直提示lxml失败,解决步骤如下 1.尝试升级pip python.exe -m pip install --upgrade pip 2.尝试下载包 ...

  2. 自己在本地搭建 git 版本仓库服务器

    请确保你安装了 git 的图形化工具和 git 软件 首先先创建一个目录作为你的项目工程目录,比如 e:/gitTest 其次右键 git init. 然后指定一个 git 服务器目录,例如:e:/g ...

  3. c++ 快速复习第一部份

    去年有事无事学过一c++ ,,由于工作用不上,学来没有用,所以学得乱七八的,最近需要复习一下,因为最近想学习一下硬 件相关 第一   引用头文件和自定义头 #include <iostream& ...

  4. scratch源码下载 | 超大太空游戏【80MB】

    按方向键或AWSD键控制角色移动,按空格键或X键攻击. 程序超级大,共80MB,耐心等待加载. 截图: 点击下载源码 更多源码访问:小虎鲸scratch资源站

  5. 【Web】 通过浏览器打开本地应用程序

    首先需要编写注册表: 以Steam为例: "C:\Program Files (x86)\Steam\Steam.exe" 然后编写注册表: Windows Registry Ed ...

  6. 【H5】13 表单 其二 如何构造

    有了基础知识,我们现在更详细地了解了用于为表单的不同部分提供结构和意义的元素. 前提条件: 基本的计算机能力, 和基本的 对HTML的理解. 目标: 要理解如何构造HTML表单并赋予它们语义,以便它们 ...

  7. 【Redis】04 配置文件分析

    配置文件Redis.conf注释信息: 1.启动项: 启动Redis要求必须加上配置文件redis.conf路径作为第一参数加载 文档样例: ./redis-server /path/to/redis ...

  8. 2024-08-03:用go语言,给定一个从 0 开始的字符串数组 `words`, 我们定义一个名为 `isPrefixAndSuffix` 的布尔函数,该函数接受两个字符串参数 `str1` 和

    2024-08-03:用go语言,给定一个从 0 开始的字符串数组 words, 我们定义一个名为 isPrefixAndSuffix 的布尔函数,该函数接受两个字符串参数 str1 和 str2. ...

  9. 什么是3D扫描技术?

    相关: https://www.bilibili.com/video/BV1fN4y1z7uD/?vd_source=f1d0f27367a99104c397918f0cf362b7 接触式:就是使用 ...

  10. 【转载】 DQN玩Atari游戏安装atari环境bug指南

    版权声明:本文为CSDN博主「好程序不脱发」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明.原文链接:https://blog.csdn.net/ningmengzh ...