前言
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是认为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。本文将带大家介绍几款主流的单元测试使用方法,希望可以帮到大家。
公众号:「浅羽的IT小屋」
1、为什么要使用单元测试?
「好处:」
可以使用单元测试来完成模块功能的一个测试
使用单元测试可以更好的完成模块的回归测试
「在开发中一般要进行如下测试:」
单元测试:一般情况下就是完成我们模块功能的一个检测
回归测试:当我们开发好一个功能之后,这个功能是否会影响其他已经开发好的这个功能
集成测试:简单的说就是项目开发完成的时候,这个所有功能是否完整,以及功能之间是否存在bug
公测:进行公测
2、Junit的使用
「简介:」
Junit是单元测试框架工具,在项目开发中是经常用到的,利用Junit4进行单元测试非常简单方便,所以熟悉Junit是很有必要的
「主要用法:」
@Before //初始化数据用的 @BeforeClass //初始化数据用的(只是执行一次) @After //对象销毁的时候用的 @AfterClass //对象销毁的时候用的(只是执行一次) @Test(expected=xxx.class、timeout=5000) (测试:期望出现某一类异常)
3、Hamcrest的使用(Junit的一个补充)
「使用原因:」
使用过Junit的应该有体验:在实际开发中,一些基本的断言,如equal,null,true它们的可读性并不是很好。而且很多时候我们要比较对象、集合、Map等数据结构。这样我们要进行大段的字段获取再断言。或者干脆自己编写表达式并断言其结果
Junit4.4引入了Hamcrest框架,Hamcrest提供了一套匹配符,这些匹配符更接近自然语言,可读性高,更加灵活
需求:假设说加法的上面,加上一个不为null的一个断言,这个时候就出现了两个断言,这时候你就需要写两次,有没有办法只写一次呢?有
//使用这个Hamcrest来进行断言 // Assert.assertThat(result, IsNull.notNullValue()); // AllOf:所有的条件都要成立才表示校验成功 // AnyOf:一个条件校验成功那么才表示的是校验成功 // IsEqual:相等 // IsInstanceOf:这个就表示判定的是对象是否是某一个类的对象 // IsNot:不是某一个类的对象 // IsNull:判断空值 // StringEndWith:以什么结尾 // StringStartsWith:这个表示的是以什么开始 // SubStringMatcher:截取的字符串和谁匹配 // Assert.assertThat(result, AllOf.allOf(IsNull.notNullValue(), IsEqual.equalTo(30))); // Assert.assertThat(result, AnyOf.anyOf(IsNull.notNullValue(), IsEqual.equalTo(30)));
「Unit4新断言-Hamcrest的常用方法:」
字符相关匹配符
1、equalTo: assertThat(testedValue, equalTo(expectedValue)); 断言被测的testedValue等于expectedValue,equalTo可以断言数值之间,字符串之间和对象之间是否相等,相当于Object的equals方法 2、equalToIgnoringCase: assertThat(testedString, equalToIgnoringCase(expectedString)); 断言被测的字符串testedString在忽略大小写的情况下等于expectedString 3、equalToIgnoringWhiteSpace: assertThat(testedString, equalToIgnoringWhiteSpace(expectedString); 断言被测的字符串testedString在忽略头尾的任意个空格的情况下等于expectedString (注意:字符串中的空格不能被忽略) 4、containsString: assertThat(testedString, containsString(subString) ); 断言被测的字符串testedString包含子字符串subString 5、endsWith: assertThat(testedString, endsWith(suffix)); 断言被测的字符串testedString以子字符串suffix结尾 6、startsWith: assertThat(testedString, startsWith(prefix)); 断言被测的字符串testedString以子字符串prefix开始
一般匹配符
1、nullValue(): assertThat(object,nullValue()); 断言被测object的值为null 2、notNullValue(): assertThat(object,notNullValue()); 断言被测object的值不为null 3、is: assertThat(testedString, is(equalTo(expectedValue))); 断言被测的object等于后面给出匹配表达式 1)is匹配符简写应用之一: assertThat(testedValue, is(expectedValue)); is(equalTo(x))的简写,断言testedValue等于expectedValue 2)is匹配符简写应用之二: assertThat(testedObject, is(Cheddar.class)); is(instanceOf(SomeClass.class))的简写,断言testedObject为Cheddar的实例 4、not: assertThat(testedString, not(expectedString)); 与is匹配符正好相反,断言被测的object不等于后面给出的object 5、allOf: assertThat(testedNumber, allOf( greaterThan(8), lessThan(16) ) ); 断言符合所有条件,相当于“与”(&&) 6、anyOf: assertThat(testedNumber, anyOf( greaterThan(16), lessThan(8) ) ); 断言符合条件之一,相当于“或”(||)
数值相关匹配符
1、closeTo: assertThat(testedDouble, closeTo( 20.0, 0.5 )); 断言被测的浮点型数testedDouble在20.0-0.5 ~ 20.0+0.5范围之内 2、greaterThan: assertThat(testedNumber, greaterThan(16.0)); 断言被测的数值testedNumber大于16.0 3、lessThan: assertThat(testedNumber, lessThan (16.0)); 断言被测的数值testedNumber小于16.0 4、greaterThanOrEqualTo: assertThat(testedNumber, greaterThanOrEqualTo (16.0)); 断言被测的数值testedNumber大于等于16.0 5、lessThanOrEqualTo: assertThat(testedNumber, lessThanOrEqualTo (16.0)); 断言被测的testedNumber小于等于16.0
集合相关匹配符
1、hasEntry: assertThat(mapObject, hasEntry("key", "value" ) ); 断言被测的Map对象mapObject含有一个键值为"key"对应元素值为"value"的Entry项 2、hasItem: assertThat(iterableObject, hasItem (element)); 表明被测的迭代对象iterableObject含有元素element项则测试通过 3、hasKey: assertThat(mapObject, hasKey ("key")); 断言被测的Map对象mapObject含有键值“key” 4、hasValue: assertThat(mapObject, hasValue(value)); 断言被测的Map对象mapObject含有元素值value
4、Suit的使用
「需求:」
现在有30个实体,每个DAO和每个Service都编写了测试用例。所以至少有60个测试类,当我们开发一个功能的时候,我们需要检测当前开发好的功能是否影响了其他已经开发好的功能,这个时候需要运行这60个测试用例,只有所有的测试用例都没有问题,才确定当前开发的功能对其他功能是没有影响的
这个时候就需要运用Suit,Suit的作用就是可以一次性的运行多个测试用例
@RunWith(Suite.class) //表示的是使用什么类来运行 @Suite.SuiteClasses({TestCaltureB.class,TestCaltureA.class}) //这个表示的是要运行哪些类 public class TestCaltureAB { }
5、Stub(装)的使用
「解决的问题:」
假设两个人做开发,一个人做的是DAO,另外一个人做的是Service,现在的问题是DAO层还没有来得及开发,只是有约束规范(只有接口没有实现),现在是Service层需要测试,那怎么办呢?
Stub的思想就是:自己编写DAO的实现类使用Map集合来模拟数据库的数据以及访问的这个过程,就叫做Stub
「具体使用:」
首先声明DAO的接口
public interface IUserDAO { /** * 通过id找用户 * @param userId * @return */ User getUserById(Serializable userId); }
编写Service的实现类
public class UserService { public IUserDAO userDAO=null; public void setUserDAO(IUserDAO userDAO) { this.userDAO = userDAO; } /** * 通过id找用户 * @param userId * @return */ public User getUserById(Serializable userId){ User user=userDAO.getUserById(userId); return user; } }
编写Stub的DAO的实现类
public class UserDAOStub implements IUserDAO{ //使用map集合来模拟我们的数据库 private Map<Integer,User> users=new HashMap<>(); public UserDAOStub() { for (int i=0;i< 10;i++){ users.put(i+1,new User(i+1,i+1+"",i+1+"")); } } @Override public User getUserById(Serializable userId) { return users.get(userId); } }
编写测试的类
public class TestUserService { private UserService userService=null; private User exUser=null; @Before public void init(){ userService=new UserService(); exUser=new User(); //期望返回的的这个用户对象 exUser.setPassword("1"); exUser.setUserId(1); exUser.setUserName("1"); UserDAOStub userDAOStub = new UserDAOStub(); userService.setUserDAO(userDAOStub); } @Test public void testGetUserById(){ User user=userService.getUserById(1); //接下来就进行断言了 Assert.assertEquals(exUser.getUserId(),user.getUserId()); Assert.assertEquals(exUser.getPassword(),user.getPassword()); Assert.assertEquals(exUser.getUserName(),user.getUserName()); } @After public void close(){ userService=null; } }
6、dbunit的使用
「主要用途:」
dbunit是专门用来测试DAO层的,以后开发中DAO的测试就可以使用dbunit来进行
「使用流程:」
备份所有的表
private void backAllTable() throws SQLException, IOException, DataSetException { //第一步:获取连接上的dataSet对象 IDataSet dataSet = conn.createDataSet(); //第二步:将数据进行备份 //使用属性来描述要备份的这个数据 // FlatXmlDataSet.write(dataSet,new FileOutputStream(new File("G:/allTable.xml"))); //使用节点来描述要备份的这个数据 XmlDataSet.write(dataSet, new FileOutputStream(new File("G:/allTable1.xml"))); }
备份一张表
/** * 备份一张表的数据 */ private void backOneTable() throws DataSetException, IOException { //创建一个查询的DataSet对象 QueryDataSet dataSet = new QueryDataSet(conn); //第二步:添加要备份的表名 dataSet.addTable("t_user"); //第三步:进行备份 FlatXmlDataSet.write(dataSet, new FileOutputStream(new File("G:/allTable.xml"))); }
插入测试数据
/** * 插入准备好的数据到数据库 */ private void insertDataTable() throws DatabaseUnitException, SQLException { //获取插入数据的DataSet对象 IDataSet dataSet = new FlatXmlDataSet(new FlatXmlProducer(new InputSource(TestUserDAO.class.getClassLoader().getResourceAsStream("table.xml")))); DatabaseOperation.CLEAN_INSERT.execute(conn, dataSet); }
测试
@Test public void testFindUserById() throws SQLException, IOException, DatabaseUnitException { backOneTable(); insertDataTable(); // 编写测试代码的地方 User acUser=userDAO.findUserById(78); //实际返回的用户对象 //下一步:进行断言 Assert.assertEquals(exUser.getUserId(),acUser.getUserId()); Assert.assertEquals(exUser.getUserName(),acUser.getUserName()); Assert.assertEquals(exUser.getPassword(),acUser.getPassword()); //还原数据库的数据 resumeTable(); }
还原这个数据
/** * 还原数据库的数据 */ private void resumeTable() throws IOException, DatabaseUnitException, SQLException { //备份数据的DataSet对象 IDataSet dataSet=new FlatXmlDataSet(new InputSource(new FileInputStream(new File("G:/allTable.xml")))); DatabaseOperation.CLEAN_INSERT.execute(conn,dataSet); }
整体代码如下:
package com.qy.dbunit; import com.qy.utils.JdbcUtils; import org.apache.commons.dbutils.QueryRunner; import org.dbunit.DatabaseUnitException; import org.dbunit.database.AmbiguousTableNameException; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.QueryDataSet; import org.dbunit.dataset.DataSetException; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.xml.FlatXmlDataSet; import org.dbunit.dataset.xml.FlatXmlProducer; import org.dbunit.dataset.xml.XmlDataSet; import org.dbunit.operation.DatabaseOperation; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.xml.sax.InputSource; import java.io.*; import java.sql.SQLException; /** * @Auther: qianyu * @Date: 2020/11/17 10:02 * @Description: */ public class TestUserDAO { //维护的是用户的DAO的对象 private UserDAO userDAO=null; //实例化dbunit中的这个数据库的连接 private DatabaseConnection conn = null; //期望返回的用户对象值 private User exUser=null; @Before public void init() throws Exception { conn = new DatabaseConnection(JdbcUtils.getConnection()); exUser=new User(); exUser.setUserId(78); exUser.setUserName("78"); exUser.setPassword("78"); userDAO=new UserDAO(); } /** * 第一步:对数据库的数据进行备份 * 备份一张表的数据 * 备份整个数据库中的数据 * 第二步:插入提前准备好的测试数据 * 第三步:测试 * 第四步:将数据库的数据清空 * 第五步:还原数据库的数据 */ @Test public void testFindUserById() throws SQLException, IOException, DatabaseUnitException { backOneTable(); insertDataTable(); // 编写测试代码的地方 User acUser=userDAO.findUserById(78); //实际返回的用户对象 //下一步:进行断言 Assert.assertEquals(exUser.getUserId(),acUser.getUserId()); Assert.assertEquals(exUser.getUserName(),acUser.getUserName()); Assert.assertEquals(exUser.getPassword(),acUser.getPassword()); //还原数据库的数据 resumeTable(); } /** * 还原数据库的数据 */ private void resumeTable() throws IOException, DatabaseUnitException, SQLException { //备份数据的DataSet对象 IDataSet dataSet=new FlatXmlDataSet(new InputSource(new FileInputStream(new File("G:/allTable.xml")))); DatabaseOperation.CLEAN_INSERT.execute(conn,dataSet); } /** * 插入准备好的数据到数据库 */ private void insertDataTable() throws DatabaseUnitException, SQLException { //获取插入数据的DataSet对象 IDataSet dataSet = new FlatXmlDataSet(new FlatXmlProducer(new InputSource(TestUserDAO.class.getClassLoader().getResourceAsStream("table.xml")))); DatabaseOperation.CLEAN_INSERT.execute(conn, dataSet); } /** * 备份数据库中所有表中的数据 */ private void backAllTable() throws SQLException, IOException, DataSetException { //第一步:获取连接上的dataSet对象 IDataSet dataSet = conn.createDataSet(); //第二步:将数据进行备份 //使用属性来描述要备份的这个数据 // FlatXmlDataSet.write(dataSet,new FileOutputStream(new File("G:/allTable.xml"))); //使用节点来描述要备份的这个数据 XmlDataSet.write(dataSet, new FileOutputStream(new File("G:/allTable1.xml"))); } /** * 备份一张表的数据 */ private void backOneTable() throws DataSetException, IOException { //创建一个查询的DataSet对象 QueryDataSet dataSet = new QueryDataSet(conn); //第二步:添加要备份的表名 dataSet.addTable("t_user"); //第三步:进行备份 FlatXmlDataSet.write(dataSet, new FileOutputStream(new File("G:/allTable.xml"))); } }
编写测试的基类
package com.qy.base; import org.apache.commons.dbutils.QueryRunner; import org.dbunit.DatabaseUnitException; import org.dbunit.database.AmbiguousTableNameException; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.QueryDataSet; import org.dbunit.dataset.DataSetException; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.xml.FlatXmlDataSet; import org.dbunit.operation.DatabaseOperation; import org.xml.sax.InputSource; import java.io.*; import java.sql.Connection; import java.sql.SQLException; /** * @Auther: qianyu * @Date: 2020/11/17 10:58 * @Description: */ public class AbstractDbunitTestCase implements Serializable{ //dbunit的这个连接 private DatabaseConnection conn=null; //传入测试数据的dataSet对象 private IDataSet dataSetTest=null; //创建一个临时文件 private File temFile=null; public AbstractDbunitTestCase(Connection connection,IDataSet dataSetTest) throws DatabaseUnitException { conn=new DatabaseConnection(connection); this.dataSetTest=dataSetTest; } /** * 备份多张表的数据 * @param tabNames */ public void backManyTable(String ... tabNames) throws DataSetException, IOException { QueryDataSet queryDataSet=new QueryDataSet(conn); for (int i=0;i<tabNames.length;i++){ queryDataSet.addTable(tabNames[i]); } temFile=File.createTempFile("table",".xml"); //进行备份 FlatXmlDataSet.write(queryDataSet,new FileOutputStream(temFile)); } /** * 备份一张表 * @param tableName */ public void backOneTable(String tableName) throws IOException, DataSetException { backManyTable(tableName); } /** * 插入测试数据 */ public void insertTestData() throws DatabaseUnitException, SQLException { DatabaseOperation.CLEAN_INSERT.execute(conn,dataSetTest); } /** * 还原这个表的数据 */ public void resumeTable() throws IOException, DatabaseUnitException, SQLException { IDataSet dataSet=new FlatXmlDataSet(new InputSource(new FileInputStream(temFile))); DatabaseOperation.CLEAN_INSERT.execute(conn,dataSet); } }
使用基类来完成测试
package com.qy.dbunit; import com.qy.base.AbstractDbunitTestCase; import com.qy.utils.JdbcUtils; import org.dbunit.dataset.xml.FlatXmlDataSet; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.xml.sax.InputSource; import java.sql.SQLException; /** * @Auther: qianyu * @Date: 2020/11/17 11:12 * @Description: */ public class TestUserDAO1 extends AbstractDbunitTestCase { private UserDAO userDAO=null; //期望返回的用户对象值 private User exUser=null; public TestUserDAO1() throws Exception { super(JdbcUtils.getConnection(),new FlatXmlDataSet(new InputSource(TestUserDAO1.class.getClassLoader().getResourceAsStream("table.xml")))); } @Before public void init() throws Exception { exUser=new User(); exUser.setUserId(78); exUser.setUserName("78"); exUser.setPassword("79"); userDAO=new UserDAO(); backOneTable("t_user"); insertTestData(); } @Test public void testFindUserById() throws SQLException { // 编写测试代码的地方 User acUser=userDAO.findUserById(78); //实际返回的用户对象 //下一步:进行断言 Assert.assertEquals(exUser.getUserId(),acUser.getUserId()); Assert.assertEquals(exUser.getUserName(),acUser.getUserName()); Assert.assertEquals(exUser.getPassword(),acUser.getPassword()); } @After public void destory() throws Exception { resumeTable(); } }
7、EasyMock的使用
「使用场景:」
dbunit是专门用来测试DAO层的 EasyMock是专门用来测试Service层的 DAO层的测试的重点:数据的准确性 Service层测试的重点是DAO的调用次数、DAO层的调用的顺序 EasyMocK的适用场景:就是当Service开发好之后 DAO层还没有来得及开发的时候 Service层需要测试
第一个案例
public class TestUserService { private UserService userService=null; private User exUser=null; @Before public void init(){ userService=new UserService(); exUser=new User(); exUser.setUserId(1); exUser.setUserName("浅羽"); exUser.setPassword("123"); } /** * 有返回值的情况 */ @Test public void testFindUserById(){ //第一步:使用EasyMock生成接口的实现类 IUserDAO userDAO=EasyMock.createMock(IUserDAO.class); //第二步:进行记录 //下面表示的意思是调用了上面对象的哪一个方法传递,某一个值的时候 希望的返回值是什么? EasyMock.expect(userDAO.findUserById(1)).andReturn(exUser); EasyMock.expect(userDAO.findUserById(1)).andReturn(exUser); //第三步:进行replay(使能:使上面的设置生效) EasyMock.replay(userDAO); //第四步:进行设置 userService.setUserDAO(userDAO); //第五步:进行测试 userService.findUserById(1); //第六步:进行校验 EasyMock.verify(userDAO); } /** * 没有返回值的情况 */ @Test public void testSave(){ IUserDAO userDAO=EasyMock.createMock(IUserDAO.class); //没有返回值的情况 //第一步:调用(记录中的调用) userDAO.save(); //第二步:告诉他没有返回值 EasyMock.expectLastCall(); //第一步:调用(记录中的调用) userDAO.save(); //第二步:告诉他没有返回值 EasyMock.expectLastCall(); //第一步:调用(记录中的调用) userDAO.save(); //第二步:告诉他没有返回值 EasyMock.expectLastCall(); //使能 EasyMock.replay(userDAO); //设置 userService.setUserDAO(userDAO); //调用 userService.save(); //校验 EasyMock.verify(userDAO); } /** * 测试有参数和没有参数混用的情况 * 注意:即时改变了顺序 只要你记录了都不会报错(非严格意义上的Mock) * */ @Test public void testDelete(){ IUserDAO userDAO=EasyMock.createMock(IUserDAO.class); //进行记录 //记录第一个调用 EasyMock.expect(userDAO.findUserById(1)).andReturn(exUser); //记录第二个调用 userDAO.delete(); EasyMock.expectLastCall(); //第三个:使能 EasyMock.replay(userDAO); //设置 userService.setUserDAO(userDAO); //调用 userService.delete(1); //校验 EasyMock.verify(userDAO); } /** * 这个玩的是有严格顺序的Mock * 严格意义上的Mock对调用的顺序都有联系 */ @Test public void testDelete1(){ IUserDAO userDAO=EasyMock.createStrictMock(IUserDAO.class); //进行记录 //记录第一个调用 EasyMock.expect(userDAO.findUserById(1)).andReturn(exUser); //记录第二个调用 userDAO.delete(); EasyMock.expectLastCall(); //第三个:使能 EasyMock.replay(userDAO); //设置 userService.setUserDAO(userDAO); //调用 userService.delete(1); //校验 EasyMock.verify(userDAO); } }
关于这个的高级应用
public class TestABService { private ABService abService=null; @Before public void init(){ abService=new ABService(); } /** * 测试顺序的问题 */ @Test public void testMM(){ //创建实现类 A a=EasyMock.createStrictMock(A.class); B b=EasyMock.createStrictMock(B.class); //进行记录 a.a(); EasyMock.expectLastCall(); a.b(); EasyMock.expectLastCall(); b.c(); EasyMock.expectLastCall(); b.d(); EasyMock.expectLastCall(); //使能 EasyMock.replay(a,b); //设置 abService.setA(a); abService.setB(b); //调用 abService.mm(); //进行认证 EasyMock.verify(a,b); } @Test public void testMMM(){ IMocksControl strictControl = EasyMock.createStrictControl(); A a=strictControl.createMock(A.class); B b=strictControl.createMock(B.class); //进行记录 a.a(); EasyMock.expectLastCall(); a.b(); EasyMock.expectLastCall(); b.c(); EasyMock.expectLastCall(); b.d(); EasyMock.expectLastCall(); //使能 strictControl.replay(); //设置 abService.setA(a); abService.setB(b); //调用 abService.mm(); //进行认证 strictControl.verify(); } @Test public void testMM1(){ IMocksControl strictControl = EasyMock.createStrictControl(); A a=strictControl.createMock(A.class); B b=strictControl.createMock(B.class); //进行记录 a.a(); EasyMock.expectLastCall(); a.b(); EasyMock.expectLastCall(); b.c(); EasyMock.expectLastCall(); b.d(); EasyMock.expectLastCall(); //使能 strictControl.replay(); //设置 abService.setA(a); abService.setB(b); //调用 abService.mm(); //进行认证 strictControl.verify(); } }
8、SpringTest的使用
「简介:」
整合了Junit4框架,来做单元测试
「具体使用:」
编写基类
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = {"classpath:bean-base.xml"}) public class AbstractSpringTestCase { }
测试
public class TestUserDAO extends AbstractSpringTestCase { @Autowired private UserDAO userDAO; private User exUser=null; @Before public void init(){ exUser=new User(1,"浅羽","123"); } @Test public void testFindUserById(){ User acUser= userDAO.findUserById(1); //断言 //下一步:进行断言 Assert.assertEquals(exUser.getUserId(),acUser.getUserId()); Assert.assertEquals(exUser.getUserName(),acUser.getUserName()); Assert.assertEquals(exUser.getPassword(),acUser.getPassword()); } }
结语
本篇关于单元测试的主流框架以及工具的介绍就先到这里结束了,后续会出更多关于单元测试系列文章,谢谢大家支持!
如果你觉得浅羽的文章对你有帮助的话,请在微信搜索并关注「 浅羽的IT小屋 」微信公众号,我会在这里分享一下计算机信息知识、理论技术、工具资源、软件介绍、后端开发、面试、工作感想以及一些生活随想等一系列文章。所见所领,皆是生活。慢慢来,努力一点,你我共同成长...
往期推荐
组件必知必会|那些年我们使用过的轮子—Filter和Proxy
玩转Redis|学会这10点让你分分钟拿下Redis,满足你的一切疑问
点点点,一键三连都在这儿!