使用程式碼生成器生成的程式碼操作資料庫
如圖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<Criteria> 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<User> 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);}<mapper namespace="com.lynn.blog .pub.mapper. UserMapper" ><resultMap id="BaseResultMap" type="com.lynn.blog.pub . domain.entity.User" ><id column= "id" property="id" jdbcType="BIGINT”/><result column="gmt_create" property="gmtCreate"jdbcType="TINESTAMP"/><result column="gmt_modified" property="gmtModified" jdbcType="TIMESTAMP"/><result column="username" property="username" jdbcType="VARCHAR" /><result column="password" property="password" jdbcType="VARCHAR"/></resultMap>...</mapper>
在操作單表時,我們無須針對每個功能都編寫一個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值儲存到該欄位中。
如果資料庫的某個欄位為text型別,則生成時會多生成一個selectByExamplewithBLOBs 方法,在查詢時如果只調用selectByExample方法,則不會查詢型別為text的欄位,此時若要返回該欄位,則需呼叫selectByExamplewithBLOBs方法。
MyBatis應對複雜SQLMyBatis的一大優勢是它是操作原生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<SubBlog> selectBlogList(@Param( "offset") int offset,@Param("limit") int limit);
同XML一樣,註解也可以使用<if>和<for>等標籤,但必須用<script></script>將SQL語句包裹,如:
@Select("<script>select * from blog b,user u where b.user_id = u.id cif test=\ "null !-title\ ">and b.title = #{title}</if> limit #{offset} ,#{limit}</script>")List<SubBlog> selectBlogList(@Param("title")String title,@Param("offset") intoffset,@Param( "limit") int limit);
當條件較少時,這種寫法沒有問題,但如果條件很多,用這種註解的方式就不可取了。註解是寫到字串裡面的,所以當單詞拼寫錯誤時,編譯器不會報錯,於是在包含複雜SQL語句的情況下很難排查錯誤。這時候,就輪到Provider登場了。
Provider
SelectProvider(type= BlogProvider.class,method = "selectBlogListProvider")List<SubBlog> 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<BlogListResponse> getBlogListByCategoryId(BlogListRequest request);)
(3)實現介面:
@Servicepublic class BlogServiceImpl implements BlogService {@Autowiredprivate BlogMapper blogMapper;@overridepublic MultiResult<BlogListResponse> 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){List<Blog>bloglist = blogMapper.selectByExample(example);if(null != blogList && blogList.size( >e){List<BlogListResponse> 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<BlogListResponse> 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?token=1,可得到如圖10-6所示的介面。
細心的讀者可以發現,上一節定義的介面地址中帶有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<Void> 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<DataBuffer> bodyFlux = Flux.just(bodyDataBuffer);request = new ServerHttpRequestDecorator(request){@overridepublic Flux<DataBuffer> 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;}List<String>nameList = 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<DataBuffer> body = serverHttpRequest.getBody();AtomicReference<String> 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依賴:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</ artifactId></ dependency>
(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,簡化我們的持久層開發,亦瞭解了介面的安全性校驗,達到提升系統的安全性的目的。