使用代码生成器生成的代码操作数据库
如图10-4所示,mybatis-generator自动生成了Domain、Mapper和XML文件,其中Domain包括了Entity和 Example。Entity和数据库表结构一一对应,Example是我们操作数据库使用最频繁的类,它封装了分页、排序、查询条件等方法,我们做单表CRUD时就会大量使用Example,可以达到过滤条件的目的。Mapper封装了基本的CRUD方法,它和XML定义的Mapper对应,下面是其中一个数据库表对应的Domain、Mapper和XML的部分内容:
public class User implements Serializable {private Long id;private Date gmtCreate;private Date gmtModified;private string username;private String password;...}public class UserExample implements Serializable {protected String orderByclause;protected boolean distinct;protected List oredCriteria;private static final long serialversionuID = 1L;private Integer limit;private Integer offset;public Criteria andIdIsNull(){addCriterion( "id is null");return (criteria) this;}...}public interface UserMapper {int countByExample(UserExample example);int deleteByExample(UserExample example);int deleteByPrimaryKey(Long id);int insert(User record);int insertSelective(User record);List selectByExample(UserExample example);User selectByPrimaryKey(Long id);int updateByExampleSelective(@Param(" record") User record,@Param( " example")UserExample example);int updateByExample(@Param(" record") User record,@Param("example") UserExampleexample);int updateByPrimaryKeySelective(User record);int updateByPrimaryKey(User record);}...
在操作单表时,我们无须针对每个功能都编写一个SQL语句,只需要灵活运用Example即可实现我们想要的功能,Example实现了所有字段的查询条件,如=、!=、>、<、AND、OR、BETWEEN等。
查看Mapper代码,可以发现查询方法为selectByExample,需要传入Example,因此我们可以构建一个Example并设置查询条件。以User为例,如果我们要查询用户名为xxx的用户,则构建的Example 如下:
UserExample example = new UserExample();example.createcriteria().andUsernameEqualTo( ""xxx");
然后调用selectByExample方法,如:
userMapper.selectByExample(example);
新增数据的方法以insert开头,传入的参数是Entity。insert和 insertSelective的区别在于前者不会进行判断,即如果Entity有字段为null,则会将null值保存到该字段中,而后者会判断字段是否为null,如果为null 则不会将null值保存到该字段中。
修改和删除两个方法的使用比较类似,需要注意的是,凡是名称中带有selective的方法均会先判断字段是否为null,否则不会判断,读者在调用时可根据实际场景进行选择。
查询、修改和删除都有两个方式:按ID和按条件。按ID操作时后面都会带上ByPrimaryKey。
如果数据库的某个字段为text类型,则生成时会多生成一个selectByExamplewithBLOBs 方法,在查询时如果只调用selectByExample方法,则不会查询类型为text的字段,此时若要返回该字段,则需调用selectByExamplewithBLOBs方法。
MyBatis应对复杂SQL
MyBatis的一大优势是它是操作原生SQL,因此它可以应对很多复杂场景,而一些大型应用,都存在一些较为复杂的业务场景。前面学习的代码生成器主要针对单表的操作,面对复杂的业务,我们就需要自己编写SQL。
MyBatis提供了多种实现方式,包括XML、注解和Provider,而代码生成器生成了基本的CRUD代码,为了提升代码的扩展性,这里不能直接在原有的Mapper上增加方法,而应扩展一个子Mapper继承代码生成器生成的Mapper,如:
@Mapperpublic interface SubBlogMapper extends BlogMapper {}
代码生成器生成的Entity和数据库一一对应,如果当前业务需要的字段和数据库字段不一致时,也应扩展一个子Entity。扩展方法的代码如下:
@Datapublic class SubBlog extends Blog i***用户名*/private String username;}
比如我们在返回博客列表时,往往需要返回当前博主的用户名等信息,而博客表只关联了用户ID,这时就需要扩展一个子Entity,并且查询时返回子Entity。
以上是一个比较良好的代码设计风格,也符合软件的架构模式,接下来就以博客列表为例,用注解和 Provider两种方式分别讲解如何应对复杂 SQL。
注解
通过注解来查询SQL非常简单,只需要在方法上加入@Select()即可(括号内输入SQL语句),如:
@Select("select* from blog b,user u where b.user_id = u.id limit #{offset},#{limit}")List selectBlogList(@Param( "offset") int offset,@Param("limit") int limit);
同XML一样,注解也可以使用和等标签,但必须用将SQL语句包裹,如:
@Select("select * from blog b,user u where b.user_id = u.id cif test=\ "null !-title\ ">and b.title = #{title} limit #{offset} ,#{limit}")List selectBlogList(@Param("title")String title,@Param("offset") intoffset,@Param( "limit") int limit);
当条件较少时,这种写法没有问题,但如果条件很多,用这种注解的方式就不可取了。注解是写到字符串里面的,所以当单词拼写错误时,编译器不会报错,于是在包含复杂SQL语句的情况下很难排查错误。这时候,就轮到Provider登场了。
Provider
将方法标注为Provider(查询为@selectProvider,新增为@InsertProvider,修改为@UpdateProvider,删除为@DeleteProvider ),然后通过Provider的方法动态生成SQL语句,将上述注解的SQL语句改造成Provider 如下:
SelectProvider(type= BlogProvider.class,method = "selectBlogListProvider")List selectBlogList(@Param("title")String title,@Param("offset") int offset,@Param( "limit") int limit);public class BlogProvider ipublic string selectBlogListProvider(@Param("title")String title,@Param("offset")int offset,@Param( "limit") int limit){return new sQL(O{{SELECT("*");FROM("blog b,user u");wHERE("b.user_id = u.id");if(null != title){wHERE("b.title =#{title}");}}}.toString(+ "limit #{offset},#{limit}"; }}
可以看到,上述代码没有使用@Select注解,而是采用@selectProvider注解,该注解会指定一个类,并指定该类的方法。当调用selectBlogList方法时,MyBatis就会指定BlogProvider类的selectBlogListProvider方法。
selectBlogListProvider方法的参数和 selectBlogList方法的参数保持一致,在方法体内直接返回sQL对象,并使用toString方法转换为字符串返回,其他方法的作用就是动态生成SQL语句(如SELECT(“*”)表示生成SELECT *,FROM(“blog,user u”)表示生成FROM blog b ,user u),它最终执行的是Provider生成的SQL语句。读者看到 sQL对象内的代码是否感觉似曾相识呢?没错,它和前面自己写的SQL语句是一样的,只是这里是调用了Java方法,比如SELECT(“*”)最终返回的就是select *。
通过Provider可以将一些关键词( select、from、where、order by等)用Java代码代替,大大提升了可读性。
功能开发
本节中,我们将正式进入产品的功能开发,根据第5章提供的原型设计,我们可以将产品划分为以下几大模块。
用户管理:主要操作用户表,包括注册登录,用户信息管理等功能。口博客管理:主要操作博客表,包括博客的展示、发布等。
口评论管理:主要操作评论相关表,包括评论的展示、发表、点赞等。分类管理:主要操作分类表,包括分类列表展示等。
搜索服务:主要用于提供搜索引擎服务,开放博客的搜索接口。
对这些模块都创建一个子工程,每一个工程都是一个微服务,如图10-5所示。
图中的public为各微服务的公共类库。
接下来,将以博客列表功能为例,来讲解功能的开发。
(1)创建输入参数Request和输出参数Response:
@Datapublic class BlogListRequest i//加了@NotNull注解表示参数必填@NotNullprivate Long categoryId;@NotNullprivate Integer offset;@NotNullprivate Integer limit;}@Datapublic class BlogListResponse {iprivate Long id;private string title;private string summary;private String createTime;private Integer viewCount;}
每一个接口(业务)都应该对应一个请求和一个响应,因此我们在提供接口时,首先要分析该接口接收什么参数,返回什么参数,从而定义Request和 Response。
(2)定义接口:
public interface BlogService i***根据分类ID获得博客列表* @param request*@return*/MultiResult getBlogListByCategoryId(BlogListRequest request);)
(3)实现接口:
@Servicepublic class BlogServiceImpl implements BlogService {@Autowiredprivate BlogMapper blogMapper;@overridepublic MultiResult getBlogListByCategoryId(BlogListRequest request){BlogExample example = new BlogExample();example.setOffset(request.getOffset());example.setLimit( request.getLimit())3example.createCriteria().andCategoryIdEqualTo(request.getcategoryId());int count = blogMapper.countByExample(example);if(count > 0){Listbloglist = blogMapper.selectByExample(example);if(null != blogList && blogList.size( >e){List data = new ArrayList();blogList.stream( ).forEach(blog ->{BlogListResponse response = new BlogListResponse();//将blog对象属性复制到responseBeanutils.copyProperties(blog, response);response.setCreateTime(Dateutils.parseDate2String(blog.getGmtcreate() , "yyyy-MM-dd HH : mm : ss"));data.add(response) ;});return MultiResult.buildSuccess(data, count);}return MultiResult.buildSuccess(new ArrayList(), count);}return MultiResult.buildSuccess(new ArrayList() , count);}}
上述代码实现了一个最基本的接口:通过分类ID返回博客列表,其中数据查询部分使用10.2节介绍的代码生成器。我们将查询出的数据进行了一些处理,首先通过BeanUtils.copyProperties将Entity 的数据复制到Response 中,并处理一些数据,比如格式化时间等。
(4)编写控制器,以提供HTTP 调用能力:
@RequestMapping( "{version }/open/blog")@RestControllerpublic class BlogController extends BaseV1controller {@Autowiredprivate BlogService blogService;@PostMapping( "getBlogListByCategoryId")public MultiResult getBlogListByCategoryId(@Valid @RequestBodyBlogListRequest request,BindingResult result){validate(result);return blogService.getBlogListByCategoryId(request);}}
控制器的代码其实简单,就是调用service方法。需要注意的是,在调用Service方法之前,应调用validate方法进行参数的合法性校验。
(5)测试。
分别启动register . config . gateway和 blogmgr,用postman请求地址 htp:/localhost:8080/BLOG/v1/open/blog/getBlogListByCategoryld” />
网关鉴权
前面已经提到,我们请求的所有接口都需要通过网关来转发,而不是直接请求服务。对于一个HTTP接口来说,安全是最重要的,本节将介绍博客应用的鉴权机制。
细心的读者可以发现,上一节定义的接口地址中带有open接口,其实对于接口,我们可以大致划分为开放接口和私有接口。开放接口指无须用户登录即可访问的接口,私有接口则为登录后才能访问的接口。为了便于区分开放接口和私有接口,我们可以在接口地址“做文章”,即带有open 的为开放接口,带有close的为私有接口。
防止参数被篡改
我们提供的接口是通过网络传输的,如果在传输过程中参数被拦截并将修改后的参数传输给服务器端,后果将非常严重。为了防止此类事件发生,我们需要对参数进行签名并校验。
签名的规则是,客户端将参数名按ASCII 码升序排列,构建形如 key1=valuel&key2=value2……的字符串(后面用url代替该字符串),然后将这个字符串进行MD5加密,如 MD5(url+key)(其中 key为密钥),加密后生成签名字符串,将签名字符串放到请求头( header )中,参数放到请求体( body)中,传递到服务端。服务端以同样的方式签名,将签名后的结果和客户端传递过来的结果进行比较,如果一致说明参数没有被篡改,可以放过,否则中断操作。
这样如果中途有人篡改了参数,服务器签名后和客户端签名必然是不匹配的,有效地保护了参数的合法性。下面就来改造gateway工程的ApiGlobalFilter类,具体的代码如下:
@Value("${api.encrypt.key}")private string salt;@Overridepublic Mono filter(ServerwebExchange exchange,GatewayFilterChain chain) {ServerHttpRequest serverHttpRequest = exchange.getRequest();String body = requestBody( serverHttpRequest);String uriBuilder = getUr1AuthenticationApi(body);//服务端生成额签名string sign = MessageDigestutils.encrypt(uriBuilder + salt,Algorithm.MD5);1/从header中取得签名字符串String signature = serverHttpRequest.getHeaders().getFirst("signature");if (sign l= null && sign.equals(signature)) {1/以下代码再次包装request,否则会报:Only one connection receive subscriberallowed.错误URI uri = serverHttpRequest.getURI();ServerHttpRequest request = serverHttpRequest.mutate().uri(uri) .build();DataBuffer bodyDataBuffer = stringBuffer(body);Flux bodyFlux = Flux.just(bodyDataBuffer);request = new ServerHttpRequestDecorator(request){@overridepublic Flux getBody( {return bodyFlux;}};return chain.filter(exchange.mutate( ).request(request).build());else {//签名错误ServerHttpResponse response = exchange.getResponse();byte[ ] bits =JSON.to3SONString(SingleResult.buildSuccess(Code.NO_PERMISSION,"签名错误"))-getBytes(Standardcharsets.UTF_8);DataBuffer buffer = response.bufferFactory ().wrap(bits);return response.writewith(Mono.just( buffer));}}/***将客户端传回的参数按照ASCII 码升序排序生成URL字符串*/private string getUrlAuthenticationApi(String body){if(StringUtils.isEmpty( body)) freturn nul1;}ListnameList = new ArrayList()3StringBuilder urlBuilder = new stringBuilder(;SONObject requestBodyson = null;requestBody3son = 3SON .parseobject( body);nameList.addAll(requestBodyJson. keySet();final 3SONObject requestBody3sonFinal = requestBodyson;namelist.stream() . sorted( ).forEach(name -> {if(null != requestBodysonFinal){ur1Builder. append( '&' );urlBuilder.append(name) . append( '=' ).append(requestBody3sonFinal.getstring(name)) ;}});urlBuilder.deleteCharAt(0);return urlBuilder.tostring();}/***获得body 数据*@return请求体*/private string requestBody(ServerHttpRequest serverHttpRequest){//获取请求体F1ux body = serverHttpRequest.getBody();AtomicReference bodyRef = new AtomicReference();body . subscribe( buffer ->{CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer.asByteBuffer());DataBufferUtils.release( buffer);bodyRef.set( charBuffer.toString());});return bodyRef.get();}private DataBuffer stringBuffer(String value)ibyte[] bytes = value.getBytes(StandardCharsets.UTF_8);NettyDataBufferFactory nettyDataBufferFactory = new NettyDataBufferFactory( ByteBufAllocator. DEFAULT);DataBuffer buffer = nettyDataBufferFactory.allocateBuffer(bytes.length);buffer.write(bytes);return buffer;}
上述代码的作用是判断当前请求参数是否正常(即是否被篡改)。首先,调用requestBody方法获得body里的参数(JSON格式),然后调用getUrlAuthenticationApi方法将参数名按照ASCII码升序排列,以key1=value1&key2=value2的形式拼接成字符串urlBuilder,接着通过MD5(urIlBuilder+saltR)的形式加密,返回签名字符串sign,最后从请求头中取得signature进行判断,如果sign和signature相等,则签名通过,否则签名失败,予以拦截。
由于签名验证通过后参数是放到body 中传输的,所以不能直接返回 Mono(如果以form表单形式或者直接放到请求地址中可以直接返回),需要再进行一层包装,否则会抛出“Only one connectionreceive subscriber allowed”异常。正如上述代码中,我们将 body中的参数转成DataBuffer并通过ServerHttpRequestDecorator类做一层包装后返回。
拦截非法请求
所有私有接口都带有close,而要调用私有接口则必须为已登录用户,程序确认客户端是否为登录用户的依据就是判断token是否合法。
当用户调用登录接口后,服务端会根据用户名、密码和时间戳等信息生成token,并将token保存到Redis返回给客户端。我们要求客户端在调用私有接口时,向请求头传人token,服务端在过滤器里判断当前token是否正确,如果正确,则允许调用接口,否则给出错误提示。
生成token 的方式很随意,读者可以根据自己的喜好来生成,可以用MD5、Base64和AES等算法,下面是使用AES算法生成token的代码,如:
public static String generateToken(String username,string key){try {return AesEncryptutils.aesEncrypt(username+ System.currentTimeMillis(),key);}catch (Exception e){e.printStackTrace();return null;}}
token生成后需要将它存入Redis,key为token,value为user.getId()方法获取到的userId:
redis.set(token, user.getId()+"");
这样当客户端传入token时,我们就可以从Redis里根据token读取userId,如果能取到说明token合法,反之为非法请求。私有接口需传入userId并与服务器取得的userId做比较,如果相同则允许访问,否则给出错误信息,具体代码实现如下:
if(uri.getPath().contains("close")){String token = request.getHeaders().getFirst("token" );if(StringUtils.isNotBlank(token)){String userId =(String) redis.get(token);if(Stringutils.isNotBlank(userId)){SONObject json0bject = 3SON. parse0bject(body);if(userId.equals(json0bject.getLong( "userId"))){return chain.filter(exchange.mutate().request(request).build());}elseiServerHttpResponse response = exchange.getResponse();byte[ ] bits = 3SON.to3SONString(SingleResult.buildSuccess(Code.NO_PERMISSION,"invalid token")).getBytes(StandardCharsets.UTF_8);DataBuffer buffer = response.bufferFactory( ).wrap(bits);return response.writewith(Mono.just(buffer));}}else {ServerHttpResponse response = exchange.getResponse();byte[] bits = 3SON.to3SONString(SingleResult.buildSuccess(Code.NO_PERMISSION,"invalid token")).getBytes(StandardCharsets.UTF_8);DataBuffer buffer = response.bufferFactory(). wrap(bits);return response.writewith(Mono.just(buffer));}}else{ServerHttpResponse response = exchange.getResponse();byte[] bits = ]SON.toJSONString(SingleResult.buildSuccess(Code.NO_PERMISSION,"invalid token")).getBytes(StandardCharsets.UTF_8);DataBuffer buffer = response.bufferFactory( ).wrap(bits);return response.writewith(Mono.just(buffer));}}
单元测试
我们将接口开发完成后,整个应用的开发就已接近尾声,最后需要进行测试才能发布应用。
单元测试工具有很多,本书将演示使用JUnit进行单元测试,使用步骤如下。
(1)添加JUnit依赖:
org.springframework.bootspring-boot-starter-test
(2)在子工程目录下新建单元测试类,并编写测试代码:
@SpringBootTest(classes = UserApplication.class)@Runwith(SpringJUnit4classRunner.class)public class TestDB {@Autowiredprivate UserService userService;@Testpublic void test(o{try iLoginRequest request = new LoginRequest();request.setUsername( "lynn" );request.setPassword("1");System.out. println(userService.login(request)) ;}catch (Exception e){e.printStackTrace();}}}
上述代码通过添加@SpringBootTest注解指定启动入口类,@Runwith注解用于指定单元测试启动器,在需要执行的方法上加入@Test即可。
(3)单击右键,选择Run ‘test()’运行单元测试方法,如图10-7所示。
小结
本章中我们正式开始了实战项目的功能开发。通过本章的学习,我们了解了如何高效地使用MyBatis,简化我们的持久层开发,亦了解了接口的安全性校验,达到提升系统的安全性的目的。