工欲善其事必先利其器,对于研发人员来说,拥有一款趁手的工具来高效工作是一件梦寐以求的事情。特别是每天都要频繁用到的框架类的工具,其易用性的好坏显得尤为重要。易用性一般从学习难度,集成难度,使用难度等几个方面衡量。本文将以信也数据库访问框架DAS为例,具体介绍易用性设计如何落实到框架类产品设计上面。本文可以作为框架类软件的设计参考,希望能为大家带来些启发。
DAS是信业科技自研的数据库访问中间件,支持数据库配置管理,ORM,动态SQL构建和分库分表支持等功能。
DAS的研发目标是最大化研发人员的工作效率,最小化数据库开发成本,从而支持研发人员将精力主要放在业务逻辑层面。
我们先通过一个简单的例子来体会一下DAS的便利性。
DasClient dao = DasClientFactory.getClient("logicDbName");
Person pk = new Person();
pk.setPeopleID (10);
Person p = dao.queryByPk(pk);
这段代码很好理解,几乎就是大白话:
按照给定的数据库名字从工厂类中获取dao实例
构建一个表实例,为主键赋值
调用dao实例完成按照主键值的查询并返回符合条件的实例。
貌似平平无奇?我们来对比一下类似操作用mybatis长啥样:
InputStream resourceAsstream = Resources.getResourceAsStream("cc/sq1MupConfig.xml");
SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(resourceAsstream);
SqlSession sqlSession = ssf.openSession();
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User u = mapper.finduserById(10);
相信大部分人对mybatis都比较熟悉,我这里就不逐行解释。这里的主要工作首先是创建一个工厂类,其次根据dao的类型获得mapper实例,最后调用实例方法
例子很直观显示DAS的代码更少,更好理解。这只是初步的印象,接下来我们会通过深入的剖析来详细介绍DAS的易用性设计。
信息最少原则是贯穿DAS设计始终的最重要原则。信息最少原则的含义是完成一件工作需要用户提供的信息越少越好。我们首先用创建dao实例为大家做介绍。
不知道大家有没有思考过完成一件工作的抽象过程应该是怎样的?完成一件工作首先需要提供必要外部信息,其次基于这些信息通过一系列步骤达成的效果。对应于程序就是方法定义。如果把代码复杂度定义为F(C)= P * N,其中P为完全由外部提供的参数个数,N为代码行数,可以看到需要的参数和执行的步骤越少,则该工作的难度越小,体现在易用性方面就越高。
我们回顾一下之前的例子,其中DAS创建dao实例仅需提供数据库名称,全部创建过程只需要一行代码:
DasClient dao = DasClientFactory.getClient("logicDbName");
从道理上讲,要获得操作一个特定数据库的dao,最基本所需要的信息只有数据库名称。从dao实例的创建过程来看,DAS仅需要提供数据库名称通过一步操作完成,已经是理论上最简化的做法。
而mybatis dao实例的创建首先需要提供xml配置文件路径创建流资源,其次创建工厂实例和会话实例,最后提供特定的类名称创建dao实例,需要提供两个信息,前后一共4行代码:
InputStream resourceAsstream = Resources.getResourceAsStream("cc/sq1MupConfig.xml");
SqlSessionFactory ssf = new SqlSessionFactoryBuilder().build(resourceAsstream);
SqlSession sqlSession = ssf.openSession();
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
按照之前的复杂度公式DAS的复杂度为1 * 1,mybatis的复杂度为2 * 4 = 8,两者相差8倍。如果单纯以代码行数衡量,两者效率相差4倍。如果按照F(C)= P + N来算,也相差3倍。虽然因为产品设计理念不同,不能完全1比1类比,但是mybatis的做法在达到类似目的所需要的信息和步骤更多,并且其中还包含与数据库无关的中间步骤。很明显遵循信息最少原则的DAS的设计更加简洁和直观。
其实这还不是真正的效率差距,因为DAS创建的dao是面向给定数据库的通用的dao,所有该数据库的操作都可以使用这个dao。而mybatis创建的dao其实是基于给定mapper定义的dao,能完成的操作仅限于mapper中定义的范围,同时定义mapper也需要花费成本。总体而言DAS以更少的代码创建了能力更广泛的实例。
事务处理也能很好的体现DAS设计理念中的信息最少原则。常规事务处理需要开启事务,结束前提交事务,出错时回滚事务,一般会是一个巨大的try-catch-final,事务处理代码和业务逻辑代码混在一起。其实事务处理里真正的核心就是数据库操作逻辑,除此以外的所有信息或步骤都是附加的,可以隐藏掉。DAS事务处理秉持这种原则,仅需要用户提供业务逻辑,其他事情交给框架处理:
PersonDefinition p = Person.PERSON;
dao.execute(() -> {
SqlBuilder builder = selectAllFrom(p).where(p.PeopleID.gt(0)).orderBy(p.PeopleID.asc()).into(Person.class);
List<Person> plist = dao.query(builder);
assertArrayEquals(new int[] {1, 1, 1, 1}, dao.batchDelete(plist));
});
上例中,仅dao需要通过数据库名获取,剩下所有代码都是用户自己的业务逻辑。DAS还提供注解的方式支持事务,仅需在方法声明上提供事务标签即可。
@DasTransactional(logicDbName = "logicDBName")
public void execute() {
//DAS的操作
}
恰如《给加西亚的信》一样,运用信息最少原则,DAS可以让研发人员集中精力在做什么,而不是怎么做上面。
虽然数据库操作主要就是就是CRUD,但由于用户的表结构千差万别,如果不能在框架操作层面消除这种差异,那么必然会外溢到应用层面从而导致自定义方法过多的情况。为避免这种情况,DAS在设计上采用了操作通用化原则,通过对方法的精心设计,简化了学习与使用成本。
比如按照主键查询大概是用得最多的数据库操作,在最开始的例子里,DAS的做法是:
Person pk = new Person();
pk.setPeopleID (10);
Person p = dao.queryByPk(pk);
Mybatis的做法是:
User u = mapper.finduserById(10);
乍一看似乎DAS的更麻烦。但这只是因为这个例子里面包含了构造pk的过程,这一过程其实是应用逻辑的一部分,pk完全可以作为参数传进来,直接与dao相关的代码只有第三行:
Person p = dao.queryByPk(pk);
虽然看起来das和mybatis完成按主键查询都是一行代码,但mybatis这个是用户自定义专用的,只能查询定义中指定的表。而das的方法却允许用户查询任意表。作为通用方法,DAS的可以在任意应用中使用,完全无需重复定义。
有人可能会问为什么DAS用表实体作为参数,而不是像mybatis这样只用主键值。这么做主要出于以下几点:
最小化参数传递。通过DAS自带的工具可以方便的生成包括元信息的表实体。作为通用查询,das需要知道表名,主键名和返回的表实体类型作为参数,这些元信息都已经包含在表实体定义里。如果这些元信息不作为参数提供,则必须在系统某处预先定义,这种信息的离散会降低系统的可理解性,或者说透明度,从而导致易用性降低。
支持联合主键场景。主键并不一定只包含一个字段,虽然现在主流的数据库规范都要求使用单一主键,即使是联合表或者子表也这样要求。但是本质上这是规范,并不是标准,因此DAS必须支持多主键查询。要处理多主键的情况,要么单独定义一个主键类,包含所有主键字段;要么在方法参数定义中要求传多个参数。前一种情况会导致每个表对应的类定义增加一倍,如果非主键字段较少,那么主键类和实体类将非常相像,差别仅在几个字段上,单独定义的费效比很差,利用继承来复用又会造成可读性较低;后一种情况会增加参数数量,顺序,名字等复杂度。综合考虑复用表实体来传递主键取值更加合理。
支持分片数据传输。DAS支持分库分表,用于分片的字段值可以通过表实体一起传给DAS,从而避免其以参数或者其他方式额外传输的成本。
关于通用性再举一个例子。按照部分字段取值对单表进行查询是非常常见的操作。DAS通过queryBySample来直接支持这种做法,只需要一行代码即可实现:
DasClient dao = DasClientFactory.getClient("logicDbName");
Person sample = new Person();
sample.setName(userName);
sample. setCityID (cityId);
List<Person> p = dao.queryBySample(sample);
类似的还有countBySample,deleteBySample方法,方法名字已经解释一切。同样的功能使用mybatis只能在XML中为每个表写一个包含字段判断的复杂mapper,如果表结构变了还得修改XML,搞过的自然懂。
利用通用性原则,DAS仅用有限的预定义方法就满足了绝大部分复杂的数据库操作功能要求。能操作一个表就能操作所有表,避免了由于用户表结构等因素可能导致的方法爆炸。少而精的方法既可以降低学习难度,还提高了框架的易用性。
操作是不是简洁直观,能不能直接的实现开发者的意图是框架易用性的直接体现。常见的数据库操作按动作类型来说包括增删改查,也即insert,delete,update,select。从操作方式来说分为单步操作,批处理。从是否包含在事务角度来说分为普通操作和事务操作。当然还有现在已经很少见的调用存储过程。如何支持上述所有功能并同时保持易用性是一个很大的挑战。为达成这一目标,DAS在通过直观的方法设计,让研发人员通过方法名字就能毫不费力的选择最合适的方式完成工作。让我们用案例说话。
比如想要插入一条数据,对应的SQL命令是insert,那么对应的DAS命令就是insert:
Person p = new Person();
p.setName("jerry");
p.setCountryID(k);
p.setCityID(k);
assertEquals(1, dao.insert(p));
如果想要同时插入几条,只要调用重载的insert命令即可,DAS会使用INSERT INTO VALUES语法执行:
List<Person> pl = new ArrayList<>();
for(int k = 0; k < 4;k++) {
Person p = new Person();
p.setName("jerry");
p.setCountryID(k);
p.setCityID(k);
pl.add(p);
}
assertEquals(4, dao.insert(pl));
想要update,设置好记录的主键和想要更新的字段,直接调用dao的update方法即可,为空的字段会自动忽略:
Person pk = new Person();
pk.setPeopleID(1);
pk.setName("Tom");
pk.setCountryID(100);
pk.setCityID(200);
assertEquals(1, dao.update(pk));
delete操作我们也可以依葫芦画瓢,但为了与deleteBySample相区别,名字是deleteByPk:
Person pk = new Person();
pk.setPeopleID(1);
assertEquals(1, dao.deleteByPk(pk));
如果想要利用SQL的批量功能,可以直接调用batchInsert,batchDelete,batchUpdate:
int[] ret = dao.batchInsert(pl);
int[] ret = dao.batchDelete(pl);
int[] ret = dao.batchUpdate(pl);
很明显只要具备基本的SQL知识就可以利用关键字找到对应方法,轻松的实现数据库操作。
可能这么介绍比较没有说服力,我们对比一下mybatis插入多条数据需要在xml里面怎么做:
<insert id="insertAuthor" useGeneratedKeys="true"
keyProperty="id">
insert into Author (username, password, email, bio) values
<foreach item="item" collection="list" separator=",">
(#{item.username}, #{item.password}, #{item.email}, #{item.bio})
</foreach>
</insert>
通过提供直观的操作,DAS的设计让研发人员更多的聚焦在做什么,而不是怎么做上面,从而提高了易用性。研发人员能干净利落的实现自己的意图,没有额外的接口定义,没有语句映射,没有模板编写。
虽然DAS通用直观的API可以覆盖大部分场景,但是用户还是会有些特殊场景,这种情况下,DAS通过在原方法上提供操作hints来实现用户意图。我们以insert方法做例子来说明DAS通用方法的定义形式:
public <T> int insert(T entity, Hints...hints) throws SQLException
insert方法定义了可变参数Hints,普通操作的时候用户可以不提供该参数,需要的情况下用户可以提供最多一个hints实例。每个hints实例可以用链式的方法指定多个提示。既可以hints.xxx().yyy().zzz()。
对于ID自增长的表来说,插入操作会忽略实体自带的主键值,但如果用户希望插入的时候由应用指定主键,则可以:
dao.insert(p, hints().insertWithId());
再比如利用主从库实现读写分离时,查询一般缺省路由到读库。但是由于同步延迟,读库可能并没有及时和主库同步,在必须保障获取最新数据的场景下,利用DAS可以很方便的修改缺省行为,要求读必须走主库:
dao.queryBySample(sample, hints().masterOnly());
如果觉得上面指定的方式太机械,还可以指定从库的新鲜度来达到灵活性。如果从库新鲜度小于此值,则读从库,反之读主库:
dao.queryBySample(sample, hints().freshness(5));
利用可变参数Hints,既保障了一般操作的简单直接,又保障了特殊操作的机动灵活。而这都是在一个简洁的操作集合之上完成的,避免了采用重载可能带来的方法膨胀。
研发人员要完成各种CRUD操作,主要通过dao和实体类,这部分代码可以由用户自己创建,也可以通过工具生成。很显然通过工具生成更省事,质量也有保障。使用是不是方便,生成的代码是不是精练,适用范围是不是广泛都是易用性的表现。DAS通过最小化代码生成来提高框架的易用性。
首先从设计上减少必须生成的代码种类。由于DAS已经缺省提供了通用直观的dao,因此这部分代码不用自动生成。用户只需要考虑用于参数和返回值的实体类如何创建。实体类分为表实体类和查询实体类,分别对应单表的CRUD操作和复杂查询结果集表示。
其次提供方便的实体类生成手段。登录DAS控制台后,只能看到自己有权限访问的库表,选好表后就可以生成表实体类。要生成查询实体类仅需要提供一段样例查询语句,DAS就会根据查询结果生成代码。
最后通过一物多用来尽可能扩大生成代码的使用范围。DAS生成的表实体类不仅仅可用于一般的CRUD操作,还可以用于自定义SQL的创建。这是由于表实体里面不但包括对应表结构的属性字段,还包括能直接使用的表名,字段等表的元信息。通过这些信息可以让研发人员以类似直接写SQL的方式创建自定义的SQL语句。
为方便大家理解其结构,以下是表实体类的代码片段:
@Table
public class Person {
public static final PersonDefinition PERSON = new PersonDefinition();
public static class PersonDefinition extends TableDefinition {
public final ColumnDefinition PeopleID;
public final ColumnDefinition Name;
public final ColumnDefinition CityID;
public final ColumnDefinition ProvinceID;
public final ColumnDefinition CountryID;
public final ColumnDefinition DataChange_LastTime;
public PersonDefinition as(String alias) {return _as(alias);}
。。。
}
}
@Id
@Column(name="PeopleID")
@GeneratedValue(strategy = GenerationType.AUTO)
private Integer peopleID;
@Column(name="Name")
private String name;
。。。
可以看到表实体内部包含一个跟该表对应,用于存储元信息的表定义类,本例为PersonDefinition。定义类里包含了可直接引用的列定义,如PeopleID等。有了表定义类,列定义等元信息,在构建SQL的时候就可以利用IDE工具的代码自动填充功能和编译校验功能。在减轻记忆负担的同时防范直接用字符串拼接时容易犯的拼写错误。
其实数据库操作所需的信息主要就是表结构,而表结构可以从数据库获得,因此理论上讲,最少需要生成的代码就是表结构相关的实体类。当然为支持复杂查询场景,DAS也提供查询类。DAS的代码生成通过逼近理论上的最小极限。从而减少研发工作量,提高可理解性与易用性。
尽管绝大部分数据库操作都可以通过dao与实体类配合完成,但是还是有部分操作,例如复杂的查询语句免不了自定义。DAS借鉴DSL技术来降低用户创建自定义语句的难度。
在进一步介绍之前,我们先了解一下创建自定义SQL的难点何在。由于Java与SQL是两种语法结构完全不一样的语言,创建自定义SQL一般的做法是在代码或配置里面根据情况手动拼接包含SQL语句片段的字符串和参数占位符。这些SQL片段在Java IDE里面仅被当做普通的字符串。这导致三种问题。
首先是纯写文本的工作量大,非常容易写错;其次只有在程序运行起来后才能发现语法错误,并且根据出错信息难以定位。最后在拼接过程中往往会根据业务逻辑包含或者排除某些部分,在代码里包含条件判断语句,损害了代码可读性。整体来说,拼字符串的方式自定义SQL相当于在一种语言里面,用支离破碎的方式编写另一种语言的代码,是对研发人员非常不友好的方式。
DAS通过SqlBuilder降低自定义SQL的创建难度。SqlBuilder基于SQL命令,语法和关键字定义了一系列链式方法,让研发人员在Java编程环境中以符合SQL语法和使用习惯的方式创建自定义SQL语句。
首先是数据库操作的静态创建方式。普通的CRUD命令以静态方法的方式提供,可以在Java8及以上的版本中通过static import的方式在代码中直接引用,配合表实体类中的元数据可以达到直接写SQL的效果。以select为例:
PersonDefinition p = Person.PERSON;
SqlBuilder builder = selectAll().from(p).where(p.PeopleID.between(1, 3)).orderBy(p.PeopleID.asc()).into(Person.class);
List<Person> pl = dao.query(builder);
对于常用的语句,例如SELECT TOP,SELECT DISTINCT可以直接调用同名方法:
SqlBuilder builder = selectTop(3, p.PeopleID, p.Name).from(p).orderBy(p.PeopleID.asc()).into(Person.class);
SqlBuilder builder = selectDistinct(p.Name).from(p).orderBy(p.Name.asc()).intoObject();
除了查询,INSERT,DELTE,UPDATE也可以依葫芦画瓢,以INSERT为例:
PersonDefinition p = Person.PERSON;
SqlBuilder builder = insertInto(p, p.Name, p.CountryID, p.CityID).
values(p.Name.of("Jerry" + k), p.CountryID.of(k+100), p.CityID.of(k+200));
assertEquals(1, dao.update(builder));
SqlBuilder还提供简洁的条件拼接,避免代码中出现大量的if-else,使代码读起来更加的顺畅。比如只有在条件满足时才拼接指定语句,可以这样写:
builder.appendWhen(true, "ABC", "DEF");
assertEquals("ABC DEF", builder.build(ctx));
还支持参数为null时自动忽略对应条件的场景。如果用户条件参数为null,DAS会自动判断并在消除参数所在的表达式片段,同时调整逻辑判断操作,必要时还调整括号来保证整体表达式语法的准确性。例如下例参数strategyid为null时,会自动忽略对Strategyid字段的相等判断:
SqlBuilder builder = SqlBuilder.selectAllFrom(definition).where()
.allOf(
definition.Userid.eq(userId),
definition.Isactive.eq(1),
definition.Strategyid.eq(strategyid).nullable(),
definition.Typeid.eq(typeId).nullable(),
definition.Inserttime.greaterThanOrEqual(beginInserttime).nullable(),
definition.Inserttime.lessThanOrEqual(endInserttime).nullable())
.orderBy(definition.Inserttime.desc())
.into(Strategyaccountdetail.class)
.offset(pageNum, pageSize).withLock();
return client.query(builder);
}
利用SqlBuilder和表实体类,可以按照原生SQL甚至更简洁的方式创建自定义语句,且不用担心语法错误或者拼写错误。SqlBuilder还有很多巧妙的设计来减轻工作量,是整个DAS中非常出彩的部分。例如使用AND连接多个表达式时,可以直接使用allOf方法,可以消除大量的and操作符,同理还有anyOf,限于篇幅这里就不再一一介绍。
开发时能否快速调试程序也是易用性的一部分。查看SQL语句,执行时间等信息是开发数据库应用的刚需,DAS通过扩展点与第三方监控系统相集成方便用户查看,但这需要登录第三方系统进行查看,要切换工作环境,不直接。为了提高本地调试的便利性与准确性,DAS允许用户利用Hints.diagnose()方法指定哪些操作需要记录详细步骤信息。不仅可以在调试时看到生成的SQL语句,还可以看到对应数据库信息,分库分表时每个分片的执行详情等。
Hints hints = Hints.hints();
pk = dao.queryByPk(pk, hints.diagnose());
System.out.println(hints.getDiagnose());
如果觉得通过hints的做法略显麻烦,还可以直接通过启动参数
-Ddas.client.debug=true
开启Debug模式。这样在出错时会自动打印诊断信息到System.err。
对研发人员来说,要不要使用一个框架需要很慎重的下决定。因为即使功能强大到无所不能,但如果配置繁琐,文档过时,集成时问题花样百出,都可以轻易的让人打退堂鼓。作为研发人员,DAS团队深深的理解大家的顾虑,将集成DAS的全部动作简化到只需要引入一个简单的内部组件依赖。没有本地配置文件,没有环境参数,没有POM中诡异的profile,仅仅是一个jar包,不需要任何额外操作。而对于需要研发人员参与的部分,例如配置数据库,也只需要通过自助平台完成,本地无需做任何改动。DAS通过覆盖率超过90%的3000多个单元测试保证自身的质量,让研发人员能够放心使用。
DAS在信也科技内部已经使用快4年,接入400个应用,获得用户的一致好评:
开发环境配置顺利,省去了mybatis的xml操作
上手方便,直接DasClient dao = DasClientFactory.getClient(逻辑数据库名);即可
缺省提供的API可以满足大部分数据操作需求
复杂查询可以使用SqlBuilder,链式接口功能强大
可以使用hints进行特殊操作和调试
集成了应用监控和配置中心,免去了客户端配置
为什么要强调易用性?因为框架类产品会被无数人使用,即使是一点点缺陷,一点点麻烦都会放大无数倍,无形中推高研发成本。框架产品应该易于集成,易于学习,易于使用,易于调试,低调的支撑业务研发工作。DAS看起来平凡,用起来顺手,能让研发人员能集中全部精力做业务,而不必为如何使用DAS分心。
为了提供一款易用性极高的数据库访问框架,节约每一行代码,消除每一个多余动作,避免每一个潜在风险,DAS研发团队默默的付出了大量的努力。相比与常见的ORM框架搭配分库分表中间件的方式,使用DAS完成同样功能所需代码和工作量会大大减少,开发效率和代码质量显著提高。
希望DAS的易用性设计能为大家带来一些启发和鼓励,在编程之道上共同精进。
信也科技架构研究员,DAS产品负责人。对架构设计,中间件,低代码等方面有较深入研究。
https://github.com/ppdaicorp/das