这个问题其实很重要,要说服大家和自己投入大量精力自研orm框架是需要充分理由的。

面临的问题

彼时,公司的目标是研发一套低代码开发平台(【低代码】这个名字是近两年冒出来的,当时还没有,不过本质上就是这类产品),简单的说,用户可以通过可视化的界面,通过拖拽等方式配置出一个表单,并生成表单对应的业务数据模型,完成包括但不限于CRUD等功能。

这是后来的成品图,大家可以感受一下(PS.这个低代码平台不是代码生成器)

我们先忽略图中的细节,大家想想,对于【商品档案】这个业务对象来说,我们通过怎样的方式去定义,至少可以满足CRUD?

mybatis & hibernate

这是两个最常见的数据持久化框架(限于关系型数据库),都需要在代码里编写一个模型类,进而通过Mapper定义sql语句或者JPA注解等方式,从而实现ORM映射——将对象和关系表映射起来。

这里,关键问题在于,我们只能在编码阶段处理模型的定义,如果需要做模型改动,不得不修改代码,这与【低代码】的初衷是相违背的。

这也是需要自研orm的首要的的原因,甚至可以认为它一票否决了上述两个开源框架。

比较

在自研之前,我还是想分享下关于这两个框架的一些想法。

客观地说,hibernate和mybatis是没有可比性的,前者注重于java对象与数据库的映射,我们可以称之为ORM框架,后者注重于SQL的配置和ResultSet映射,可以认为是一个构建sql和数据库访问的框架。两者的侧重点不同,各自有各自的优缺点。

个人认为,就开发效率来说,对于绝大多数场景,hibernate要优于mybatis,毕竟后者仍旧需要自己编写大量sql,尽管tkmybatis借鉴了hibernate的思想,实现了简单的对象映射以便自动生成sql(就这点来说,tkmybatis的诞生恰恰印证了以上观点)。

强调一句,我并非一个hibernate的拥护者,我也对hibernate内部一些功能和机制持怀疑态度,但就大多数场景,尤其是OLTP场景,它的确是适用的,而且开发效率要优于mybatis。

对于一些特殊的业务场景,特别是OLAP,直接用sql语句操作数据库可能是更好的选择,这个时候mybatis相比于hibernate就有优势了,不过话又说回来,如果不在意mybatis的sql配置功能,单单就sql访问数据库来说,为什么我们不使用更加轻量的spring data框架呢?

另外还有个关键话题:性能

很多人都说,mybatis比hibernate要轻量、灵活。没有错,只是我认为这个是特点,而非优点。至于说用mybatis性能要优于hibernate,我只能说——1.如果较真的话,也对,毕竟hibernate做了很多mybatis没有做的事(生成sql、多表映射、懒加载、主键生成等等);2.如果站在一个更加宏观的层面来说,这个说法是站不住脚的。

系统性能不佳,是多方面的原因,如果我们只考虑数据库性能的话,那么造成性能的主要原因可以分成一下几类:

数据库和sql层面
  • 不合适的数据表设计;
  • 复杂、冗长、糟糕的sql,造成执行慢和死锁;
  • 不合适的索引、没有正确利用索引;
  • 多余的查询和更新;
  • 没有使用批量操作;
缓存层面
  • 没有使用缓存(持久层,非应用层)
  • 缓存命中过低(比如:只使用一级缓存、有效时间过短)
应用层面
  • 糟糕的代码设计,产生了大量不妥的、不必要的sql;
  • 过大的事务边界、锁的生命周期过长;

我们会发现,以上问题的产生和具体框架选型没有太大的关系。对于mybatis,由于高度自由的sql配置功能,导致语句的质量完全取决于开发人员的知识水平,框架层面是不可控的。而hibernate的性能问题在于,大部分开发人员并不理解其内部API的实现机制,API的错用和滥用导致了很多性能问题。

所以,从功能和特点等角度,对hibernate和mybatis进行比较,我是认同的,但说hibernate性能比mybatis差,真的很没道理。

hibernate和mybatis都有各自的问题,同时加上代码平台的要求,我开始思索,如何设计一个不依赖代码维护、更加好用、确保性能没有明显问题的ORM框架?

思考与设计

ORM框架,除了满足基本数据的映射、CRUD功能之外,还要考虑性能、api友好度等非功能性问题。下面阐述下框架需要考虑到的各类问题和解决方案。

聚合

首先,我们先说一个概念:

什么是聚合?

一个业务对象,我们可以称之为实体,是由若干属性组成的,这些属性大部分都是值类型,比如字符串、数字、日期等,但有些属性也是实体。如果我们要为这个业务对象设计数据表的话,那么需要多张表。当我们试图保存一个业务对象时,通常会操作多张表的数据。

这里我不强调DDD(Domain Driven Design 领域驱动设计)中的聚合,对于持久化层的业务对象来说,聚合可以简单认为是——一些有相关性的对象,通过某种方式组合在一起,然后持久化层将其作为一个整体操作。

举个例子:部门是实体,员工也是实体,两者是一对多关系,我们保存一个"部门"对象的时候,也会同时对部门下的员工对象进行保存,当我们加载一个“部门”对象的时候,也会把其下的员工列表加载出来。

聚合对象在软件设计中非常常见,也非常重要。如果没有聚合,持久化层的代码会“平铺”在领域层的代码中,同时还要考虑一些本可以不用操心的内容:事务、批量执行,当然最大的缺陷就是,开发人员需要关注:“哦,我需要先保存A对象,嗯,再获取它的XX集合,调用B接口的保存方法,再…”,这个体验的确很糟糕。

另外,从分层的角度来说,聚合是模型层需要考虑的,不是由业务层考虑的,前者做了聚合后,可以简化后者的复杂度,前提是这种聚合是合理的、可复用的。


题外话

如果数据库用的是mongoDB这样的文档数据库,你会发现聚合是一种本能的设计,保存的一个复杂对象是一件很轻松的事,我希望关系型数据库的ORM框架也是同样的体验!


mybatis中,select配置可以借助association和collection配置,实现查询层面的聚合,但是保存无法支持聚合,而支持JPA标准的hibernate则没有这方面的问题,无论从功能还是开发效率来说,hibernate都更有优势(tkmybatis的出现,从某种角度来说就是为了弥补mybatis在这方面的不足,真是否定之否定规律的一个好例子)。

聚合带来的问题

聚合的好处大家都能理解,但它也有明显的缺陷——对象可能会过于庞大,进而影响加载速度(很多时候我们不需要完整的数据)、占用大量内存(在密集IO型系统+没有使用二级缓存的场景下,内存问题会被放大)。

二级缓存能缓解这个问题带来的影响,这个相信也很好理解,不多赘述。

除此之外,hibernate在设计的时候,通过懒加载的机制来避免这个问题。虽然这个方案在一定程度上缓解了大对象的问题,但也带来新的问题:

  1. 定义类的时候,就要确定懒加载的属性(包括值属性、一对一实体,一对多实体),显然在模型层掺和了业务层的内容,嗯,一股"坏味道";
  2. 懒加载字段固定了,并不能解决所有场景的问题,一旦对象的使用场景多了,无论怎样定义,要么多余加载,要么断断续续加载;

总体上,hibernate的这个瑕疵并不会对系统造成严重影响,只要合理设置懒加载的属性,大聚合的弊端可以忽略。

另一方面,这也是个把柄,很多人把大聚合的缺陷作为hibernate性能不好的一大主因,也许是不知道懒加载、也是是觉得懒加载字段的设计非常消耗心智(确实如此),而mybatis可以定义若干不同的select查询器来避免大聚合的问题,就是“我需要哪些字段就配置哪些字段”,只是带来新的问题——select标签满天飞…,什么?你想定义一个公用的select?恭喜你,成功引入了"大聚合问题"。


题外话

框架的设计有时候就是那么矛盾,多年的经验告诉我,没有十全十美的框架,所以我们需要理解不同框架的特点、内部机制,才能更好地使用它们。


还有有更好的方案吗?

tkmybatis好像没有考虑这个问题(我没有在文档中找到这方面的信息)。

我自己想的方案和DDD中的小聚合概念非常相似。
简单地说,小聚合是一个子集,在调用持久化api的时候,以参数的方式告诉框架——“我需要加载A对象,但是我只需要其中几个属性,它们是…”。

从效果上来说,没有多余加载,做到了需要什么加载什么,而且也不会存在mybatis中的“大量select配置”(一个case——设计|编码任务转化为运行时任务,这个一种提升开发效率的方法),当然也有个缺点:小聚合也是会占据内存的,不过权衡之下,还是利大于弊。

小聚合使用场景

  • 某个场景中,只需要A对象的部分属性,尤其是A对象全集是一个属性很多的时候;
  • A对象至少存在一个引用属性(即外键字段),假设被引用的对象为B,而B可以用小聚合的方式加载,通常来说这个小聚合仅包含id、名称等少数字段,没有必要加载完整的B对象;
  • 对于不同对象不同的引用属性,往往会存在引用同一类型的对象,对于这些引用,如果类型一致、聚合属性一致、id一致的时候,我们可以让它们引用同一个小聚合对象,这样做是为了节省内存、避免发生数据不一致的问题。

缓存

缓存对于任何一个系统都是非常重要的,尤其是对于读场景大于写场景的系统尤为如此。通常情况下,缓存的意义在于减少数据库的访问,反过来说,大量读请求集中到数据库层面,很容易引起单点性能问题。

缓存的话题很大,这里我专注于一个数据访问框架的缓存应该做到什么?需要注意什么?

mybatis的缓存

mybatis的缓存分为一级缓存和二级缓存,前者是sqlSession级别,后者是namespace级别的,其详细定义,请自行查阅相关资料或文档,本文不予赘述。

能如果开启一级缓存,那么查询的数据会被保存在sqlSession对象里,后续用同一个sqlSession进行查询时,可以从缓存获取。

这里的关键点就是:sqlSession。

也就是说,多个sqlSession的数据不共享。有人会问,那么多线程请求有什么意义?对了,问到点上了,对于多线程请求,的确没有意义,尤其考虑到同一个sqlSession查询相同数据的可能微乎其微,对于减少数据压力这个目的来说,我也认为一级缓存意义不大。(当前有个场景是有作用的,后文会分析)

因此,二级缓存出马了,二级缓存是以namespace为单位的,同一个namespace下的多个mapper共享缓存,自然,在多线程查询的场景下,大家可以获取到共享数据,真正做到减少数据库的压力。

什么时候清空缓存?

对于一级缓存,dml类型的接口执行后,sqlSession容器内的缓存即清空,对于二级缓存,namespace下任一mapper的dml类型的接口执行后,该二级缓存清空。

这样处理,数据一致性没有问题,但是觉得有点粗暴,举个例子,缓存里存放了N条数据,一个更新操作实际只是影响了一行,然后整个缓存都被清了,这样做会导致缓存命中率的下降,尤其是写比率比较高的场景。

分析下来,这样做已经是最好的处理方式了,因为mybatis的查询和更新不是以对象为单位的,其查询缓存的key取决于查询器的参数内容,它可以是id,也可以是一个条件,而执行更新操作的时候,mybatis无法分析出,当前更新究竟会影响哪些缓存里的数据,本着“宁可错杀一千也不可放过一个”的原则,只能清空整个缓存。

不是缓存不给力,mybatis的特性才是硬伤。

多节点缓存共享问题

mybatis本身不支持,但是可以自定义一个基于redis的二级缓存来达到这个效果。java和redis缓存一致性问题并不简单,这里不详细展开。

事务内的缓存问题

这个点大家可能不会想到,但本质上和数据库的事务隔离解决的是一个问题,即解决脏读和不可重复读的问题。

对于一级缓存,因为数据都存在于sqlSession内,即使别的线程修改了这条数据,在sqlSession的生命周期内,依然获取的是自己缓存的数据。所以我感觉一级缓存不是用来缓解数据库压力的,而是为了解决数据隔离问题。需要注意的是,一级缓存的这个特性与当前上下文有没有开启事务没有关系。

而二级缓存就会有这个问题存在的可能,假设A线程开启事务,先更新了一条记录,然后缓存清空,接着它又读取了这条记录,这个时候缓存内的数据是未提交的,B线程可能会读到它,造成了脏读。(这个例子我没有做过测试,除非mybatis在二级缓存针对事务内环境做了特殊处理,否则这个问题一定会发生)。

另外,即使不考虑事务,mybatis的namespace定义不合理也会造成脏读。

缓存要点总结

分析了mybatis的缓存机制,以及其优缺点后,我认为一个持久层框架的缓存应该需要做到的内容做了整理(hibernate的缓存没有研究过- -):

  • 以key-value的方式管理缓存,其中key是对象标识,value是聚合对象;

  • 缓存可以分成多个,每个缓存可以独立管理(比如不同的过期时间),逻辑上一种实体共享一个缓存;

  • 缓存命中率尽可能高,清空缓存时不可“一棍子打死”,只清除需要清除的对象;

  • 单节点系统的二级缓存使用本地共享缓存,多节点系统使用redis共享缓存,另:两种缓存的切换做到与代码解耦;

  • 对于多节点共享缓存:

  1. 确保多节点的数据一致性,如T1时刻,a对象保存到数据库(已事务提交为准),T1时刻以后,任何节点都不能读到老数据;
  2. 数据包的大小:不同于内存读写,访问redis的数据是要通过网络传输的,所以数据大小是一个需要关注的问题,个人认为json这类带有自描述的格式,数据包会比较大,可以尝试自定义序列化,这是一个优化项,一般数据量的情况下json序列化是足够的;
  3. 访问频度问题:访问redis的频次过高也要考虑,在设计时尽量做到“必要时才访问redis”,充分利用本地缓存;
  • 以聚合为单位清除缓存对象:举个例子,缓存里存在A对象以及A的若干小聚合对象,当A对象被清除时,其对应的小聚合也需要被清除;

  • 非事务上下文,不考虑一级缓存,直接启用二级缓存;

  • 确保二级缓存返回的对象的“不可更改性”,否则某线程对数据的更改会影响整个节点其他线程;

  • 对于事务上下文,避免缓存的“脏读”和“不可重复读”:

  1. 事务内,执行更新后的数据为脏数据,除了本事务,其他线程均不可读到,否则其他线程就产生了“脏读”;
  2. 事务内,对本线程(即本连接)产生的脏数据,应当优先读取到,否则就产生了“不可重复读”;
  3. 脏数据务必确保在事务提交后,更新到二级缓存
  • 支持注解和配置的方式,设置聚合对象的缓存有效期,或是其他缓存相关的属性(匹配约定大于配置的原则);

  • 缓存的读写接口必须支持批量,尤其是读写redis缓存时,是否批量对性能有很大影响;

映射方式

映射即如何将关系型数据表的元数据与java对象的元数据关联起来。JPA规范就是一个很好的例子,hibernate和tkMybatis都是基于JPA实现映射的。

只是我觉得JPA稍微有点重,另外低代码的要求也无法让我直接使用JPA,我必须设计一个更通用的映射关系——它当然可以像JPA一样,通过类+注解的方式,也可以通json xml等描述出来(低代码平台本质就是维护它们)。

虽说是为了更通用,但实际上其内容只会比JPA规范更少。映射关系是一个可以描述对象和数据表关系的实例,这个实例的必要内容有:

  1. 明确对象映射到哪个数据源(当系统存在多个数据源时,必须指明该对象的数据源)
  2. 对象映射哪些表,至少存在一张主表,其他表或者是一对一从表,或者是一对多从表;
  3. 出于简单的目的,要求一对一从表的主键与主表主键一致,一对多从表关联主表的外键与主表主键一致,主表和其一对一从表的字段映射到同一个java对象上(俗称:拉平);
  4. 明确java类中每个字段映射到某张表的某个列,至少明确列名和类型;
  5. java类上一定要声明主键属性,主键属性或者是Long类型或者是String类型;
  6. 对于引用属性,用于映射外键字段,可以声明引用的对象需要哪些附加属性,默认只会引入主键、代码、名称3个属性(每个类都可以声明代码和名称属性,方便对象描述)
  7. 对于一对多子表,用集合字段映射,集合中的泛型即子表映射的对象,同理,子表对应的java类也可以有若干一对一和一对多子表,可以不断递归;
  8. 主表模型上,可以声明一个Long类型的时间戳字段,保存时会依赖此字段进行乐观锁检测,通过检测后,会自动为该字段赋值;

数据查询

首先,我想区分一下两种查询类型,一种是针对ORM对象的查询,一种是相对自由的、支持随意联表、支持分组聚合、支持top、支持分页的查询,我们姑且称前者叫做对象查询,后者叫做表查询(因为逻辑上输出的是一张二维表)。

两者的主要区别如下:

  • 对象查询的结果映射到java对象,其数据结构或是一个对象,或者这个对象的集合,如果对象存在子表,那么这些数据会填充到对象中对应的集合属性中;表查询的结果,通常来说就是二维表,也有可能是一个值(比如聚合查询),结果集与任何业务对象都没有关系(当然,像mybatis那样用一个pojo对象作为sql查询结果的数据容器是另一回事),表对象结构依赖于sql结果集(ResultSet)的元数据,而orm对象的查询是sql依赖于对象映射模型;

  • 对象查询的联表是有边界约束的,比如主表和其一对一主表可以联表(不会产生笛卡尔积),而表查询的联表原则上没有限制,需要开发人员结合实际场景编写合适的sql语句;

  • 对象查询通常来说是通过标识作为条件,这样可以直接利用缓存,也可以通过属性构建条件,通常来说这些条件是主体表上的,通过条件查询出标识,然后再通过标识加载;表查询不存在利用缓存的概念,因为其结果集完全依赖于sql的select部分的内容,可能是映射单表的对象,也可能是一对多主从表的笛卡尔积,甚至是结果集分组聚合后的结果集,总之,它是不确定的。

可以做个简单的总结,对象查询就是查对象的,而表查询可以认为是做报表和分析的,例如OLAP。

题外话,复杂的OLAP想用一个sql搞定,也基本是不靠谱的。

对于持久层框架,我们需要处理的就是对象查询,当然sql查询也可以做,这个可以作为一个分支需求设计,和持久层框架的主体关系不大。

对象状态

有个很重要的特性,即“对象状态”,它有什么用呢?比如:

  1. 保存对象时,框架需要识别是insert还是update(对于多表聚合的对象,一个保存过程,可能会同时产生insert、update和delete语句);
  2. 如果一个对象从数据库读取后,一个属性都没有更改,那么保存时“什么都不做”;
  3. 如果一个对象从数据库读取后,只更新了其中N个字段,那么update语句只set这N个字段,另外可以提供额外的api知道这次更新前后的字段值(额外功能);
  4. 标识一个对象是只读的还是可编辑的,上文有述:对于二级缓存内的对象,我们需要确保其“不可更改性”;

上述几点也明确对象状态的使用场景,出于开发者使用简单的原则,我不会让它在开发层面增加使用的复杂度。


题外话

用【是否存在id】作为新增还是更新的依据,我总觉得不靠谱。因此提出这个概念


写入数据库

写入数据库,包括insert、update和delete这些都是写,我们统称这类语句为DML语句(数据操作语言Data Manipulation Language)。
我们希望达到的效果是:

  • 新增:创建对象,设置属性,添加明细,最后调用保存方法;
  • 修改:从数据库读取对象,设置属性,添加、修改、删除明细,最后调用保存方法;
  • 删除:通过id,调用删除方法;
  • 上述执行过程均以聚合为单位,必要时自动开启事务(比如涉及多表操作时);
  • 对于同一张表、相同的sql语句,确保使用jdbc的批量语句更新;
  • 对于同一个聚合,表按统一的顺序提交,对于同一张表同一类型的语句,如有多行记录,按主键递增的顺序提交,这样做可以确保在持久化框架层面避免死锁;
  • 如果有时间戳字段,保存时需要进行乐观锁检测,并负责维护时间戳字段的值;
  • 新增时,如果传入的对象没有为主键赋值,那么框架会自动生成标识并赋值,对于String类型,生成32位UUID,对于Long类型,或者使用雪花算法或者使用redis序列,之所以不考虑数据库的自增值,是为了后续可能的数据库水平拆分而考虑。

代码设计层面,我参考了ADO.NET中,对于DataSet提交的设计,简单的说,就是逐行分析对象,产生语句,分析后将语句按类型、按表进行分组,然后开启事务(如果必要的话),按合理的顺序执行这些语句。

建议:如果某个业务服务的保存操作,只需要对一个聚合进行保存,那么它完全可以依赖持久层框架内部的事务,不需要service层开启事务。即使存在多个聚合的保存,那么也建议通过编程事务的方式,而不是方法注解事务,目的是尽量缩小事务边界。

总结

以上便是我在设计持久层框架中的思考,这个过程其实分为2个阶段,其中主体的思路在13年时候就成型了,主要是元数据的设计和sql拼接的部分。但那个时候,知识广度和深度还比较欠缺,很多细节没有考虑到,比如多节点缓存、事务内缓存隔离等,对于状态维护的方式也很粗糙,现在对这些欠缺的地方重新做了思考。

预告

下一期我会给出一些代码示例,让大家体验一下pisces-orm是如何使用的,暂不包含低代码的部分。

Pisces-ORM的思考与设计相关推荐

  1. 如何用html5做个人中心,个人中心页面从思考到设计全过程

    原标题:个人中心页面从思考到设计全过程 最近刚做完一个项目,我发现我的设计效率相比之前竟然提升了30%以上,在以前做界面时总是会纠结采用什么样式,什么布局.而现在在看了原型之后就大概知道我要怎么做了, ...

  2. 从网易与淘宝的font-size思考前端设计稿与工作流

    从博主学习前端一路过来的经历了解到,前端移动开发是大部分从PC端转战移动端的小伙伴都非常头疼的一个问题,这边博主就根据一篇自己看过的移动开发文章来剖析一下网易和淘宝的rem解决方案,希望能够帮助到一些 ...

  3. font-size思考前端设计稿与工作流

    从网易与淘宝的font-size思考前端设计稿与工作流 阅读目录 1. 问题的引出 2. 简单问题简单解决 3. 网易的做法 4. 淘宝的做法 5. 比较网易与淘宝的做法 6. 如何与设计协作 7. ...

  4. Flutter+FaaS一体化任务编排的思考与设计

    简介:闲鱼flutter faas一体化提升研发体验+研发质量 作者:闲鱼技术-古风 Flutter+Serverless三端一体研发架构,客户端不仅仅是编写双端的代码,而是扩展了客户端的工作边界,形 ...

  5. 从零开始构建自己的WebGL3D引擎---思考与设计

    引言 : 学习webgl已经接近2年时间了,对常见的开源3D引擎也比较熟悉了,但是目前为止three.js.babyLon.clayGL.cesium对webgl2新特性的支持也不是特别多.从今天开始 ...

  6. java 数据权限_通用数据权限的思考与设计

    1.数据权限概述 1.1.什么是数据权限? 如果想学习Java工程化.高性能及分布式.深入浅出.微服务.Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:7877071 ...

  7. 两个形状不同的长方形周长_“解决问题——怎样围周长最短”教学思考与设计...

    安徽省淮北市第一实验小学 丁雪洁 课前慎思 本节课是人教版三年级上册第七单元的例5,是在学生已经认识了周长,并会计算长.正方形的周长的基础上进行教学的,通过运用四边形及周长的知识解决生活中的简单问题, ...

  8. B端会员模块的思考与设计

    本文由作者 阿井 于社区发布 当前,互联网产品的商业化模式主要有付费会员.广告投放(如谷歌.百度.头条的广告投放).佣金抽成(淘宝.京东等平台). 其中付费会员越来越成为企业商业化产品的主流.特别是在 ...

  9. 一个通用的单元测试框架的思考和设计02-设计篇

    第一节里介绍了我们框架设计的目标,这篇主要介绍的是这个框架主要的设计思路和关键技术点 1.如何扩展junit的功能,使junit在启动时可以做一些我们定制化的功能? junit4建立了以Runner为 ...

  10. 图文分析:如何利用Google的protobuf,来思考、设计、实现自己的RPC框架

    [CSDN 编者按]本文主要分析 Google 的 protobuf 序列化工具的基本原理和使用.利用 protobuf 序列化功能, libevent 网络通信功能,来设计.实现自己的 RPC 远程 ...

最新文章

  1. Linux 受到开发者偏爱的 9 个理由!
  2. Apache服务器 配置多个网站解决方案
  3. 常见计算机英语词汇翻译,常见计算机英语词汇解释(1)
  4. 2021-11-09类作为成员变量类型
  5. ISLR学习笔记(2)线性回归
  6. Excluding Files From Team Foundation Version Control Using .tfignore Files
  7. [随笔重写] Python3 的深拷贝与浅拷贝
  8. (2) 第二章 WCF服务与数据契约 服务契约详解(三)- [ServiceContract]特性
  9. 【elasticsearch系列】windows安装IK分词器插件
  10. go语言--竞争、原子函数、互斥锁
  11. 搜狗批量提交软件-批量提交网站链接
  12. 2020数学建模国赛A题解题思路
  13. 服务器多网卡多路由策略
  14. 报表软件选型时应该知道的
  15. 电子信息工程跨考计算机武大,我考研的一些经历吧——电气(武汉大学)
  16. 秒表计时器怎么读_秒表应该怎么读?
  17. C++在想输入三个值使成为等腰直角三角形
  18. latex 引用公式
  19. ZigBee网络基础
  20. GBA开发入门:做一个名叫Hello World的游戏

热门文章

  1. R语言使用pROC包的的plot.roc函数对单变量进行ROC分析并可视化ROC曲线、寻找最佳阈值(threshold、cutoff)、在可视化曲线中添加最佳阈值点
  2. 【苹果家庭群发推】Metal performance shader软件安装框架
  3. 读书感受 之 《好好说话2》
  4. 命令激活Windows系统
  5. 自动驾驶软件开发人才现状_一文读懂自动驾驶研究现状
  6. 小人有三种,这种最阴险,最好策略不是硬杠
  7. 不可不读的百句良言!!
  8. 武大2020/4/15-关于选派全日制在校生2020/2021学年秋季赴部分欧洲高校交流学习的通知(三)
  9. 实验:Mysql实现企业级数据库主从复制架构实战
  10. Prisma(一)——基础