Spring實(shí)現(xiàn)文件上傳的配置詳解
添加依賴
主要用來解析request請(qǐng)求流,獲取文件字段名、上傳文件名、content-type、headers等內(nèi)容組裝成FileItem
<!--添加fileupload依賴--> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.3.3</version> </dependency>
構(gòu)建單例bean
CommonsMultipartResolver,將request請(qǐng)求從類型HttpServletRequest轉(zhuǎn)化成MultipartHttpServletRequest,從MultipartHttpServletRequest可以獲取上傳文件的各種信息文件名、文件流等內(nèi)容
注意:該bean的beanName要寫成multipartResolver,否則無法獲取到該bean
@Bean public CommonsMultipartResolver multipartResolver() { CommonsMultipartResolver commonsMultipartResolver = new CommonsMultipartResolver(); // 上傳限制最大字節(jié)數(shù) -1表示沒限制 commonsMultipartResolver.setMaxUploadSize(-1); // 每個(gè)文件限制最大字節(jié)數(shù) -1表示沒限制 commonsMultipartResolver.setMaxUploadSizePerFile(-1); commonsMultipartResolver.setDefaultEncoding(StandardCharsets.UTF_8.name()); return commonsMultipartResolver; } #DispatcherServlet public static final String MULTIPART_RESOLVER_BEAN_NAME = "multipartResolver"; private void initMultipartResolver(ApplicationContext context) { try { this.multipartResolver = context.getBean(MULTIPART_RESOLVER_BEAN_NAME, MultipartResolver.class); if (logger.isDebugEnabled()) { logger.debug("Using MultipartResolver [" + this.multipartResolver + "]"); } } catch (NoSuchBeanDefinitionException ex) { // Default is no multipart resolver. this.multipartResolver = null; if (logger.isDebugEnabled()) { logger.debug("Unable to locate MultipartResolver with name '" + MULTIPART_RESOLVER_BEAN_NAME + "': no multipart request handling provided"); } } }
校驗(yàn)請(qǐng)求
protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException { // multipartResolver不為空 且 request請(qǐng)求頭中的content-type以multipart/開頭 if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) { if (WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class) != null) { logger.debug("Request is already a MultipartHttpServletRequest - if not in a forward, " + "this typically results from an additional MultipartFilter in web.xml"); } else if (hasMultipartException(request)) { logger.debug("Multipart resolution previously failed for current request - " + "skipping re-resolution for undisturbed error rendering"); } else { try { // 解析請(qǐng)求 return this.multipartResolver.resolveMultipart(request); } catch (MultipartException ex) { if (request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) != null) { logger.debug("Multipart resolution failed for error dispatch", ex); // Keep processing error dispatch with regular request handle below } else { throw ex; } } } } // If not returned before: return original request. return request; }
解析請(qǐng)求
@Override public MultipartHttpServletRequest resolveMultipart(final HttpServletRequest request) throws MultipartException { Assert.notNull(request, "Request must not be null"); MultipartParsingResult parsingResult = parseRequest(request); return new DefaultMultipartHttpServletRequest(request, parsingResult.getMultipartFiles(), parsingResult.getMultipartParameters(), parsingResult.getMultipartParameterContentTypes()); } protected MultipartParsingResult parseRequest(HttpServletRequest request) throws MultipartException { String encoding = determineEncoding(request); // 獲取FileUpload實(shí)例 FileUpload fileUpload = prepareFileUpload(encoding); try { // 將request請(qǐng)求解析成FileItem List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request); return parseFileItems(fileItems, encoding); } catch (FileUploadBase.SizeLimitExceededException ex) { throw new MaxUploadSizeExceededException(fileUpload.getSizeMax(), ex); } catch (FileUploadBase.FileSizeLimitExceededException ex) { throw new MaxUploadSizeExceededException(fileUpload.getFileSizeMax(), ex); } catch (FileUploadException ex) { throw new MultipartException("Failed to parse multipart servlet request", ex); } } // ctx->將request進(jìn)行了包裝 public List<FileItem> parseRequest(RequestContext ctx) throws FileUploadException { List<FileItem> items = new ArrayList<FileItem>(); boolean successful = false; try { // 通過ctx構(gòu)建FileItem流的迭代器 FileItemIterator iter = getItemIterator(ctx); // FileItemFactory創(chuàng)建FileItem的工廠對(duì)象 FileItemFactory fac = getFileItemFactory(); if (fac == null) { throw new NullPointerException("No FileItemFactory has been set."); } // 判斷是否itemValid是否為true,是否有可讀文件 while (iter.hasNext()) { final FileItemStream item = iter.next(); // Don't use getName() here to prevent an InvalidFileNameException. // 文件名稱 final String fileName = ((FileItemIteratorImpl.FileItemStreamImpl) item).name; // 構(gòu)建FileItem FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(), item.isFormField(), fileName); items.add(fileItem); try { // 將FileItemStreamImpl流拷貝到fileItem的輸出流中(系統(tǒng)會(huì)自建文件) Streams.copy(item.openStream(), fileItem.getOutputStream(), true); } catch (FileUploadIOException e) { throw (FileUploadException) e.getCause(); } catch (IOException e) { throw new IOFileUploadException(format("Processing of %s request failed. %s", MULTIPART_FORM_DATA, e.getMessage()), e); } final FileItemHeaders fih = item.getHeaders(); fileItem.setHeaders(fih); } successful = true; return items; } catch (FileUploadIOException e) { throw (FileUploadException) e.getCause(); } catch (IOException e) { throw new FileUploadException(e.getMessage(), e); } finally { if (!successful) { for (FileItem fileItem : items) { try { fileItem.delete(); } catch (Throwable e) { // ignore it } } } } } #解析獲取到的fileItems protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) { MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<>(); Map<String, String[]> multipartParameters = new HashMap<>(); Map<String, String> multipartParameterContentTypes = new HashMap<>(); // Extract multipart files and multipart parameters. for (FileItem fileItem : fileItems) { // 是否是表單字段(下面的解析可以看到構(gòu)建時(shí)該字段傳參 fileName == null),也就是文件名是否為空 if (fileItem.isFormField()) { String value; String partEncoding = determineEncoding(fileItem.getContentType(), encoding); try { value = fileItem.getString(partEncoding); } catch (UnsupportedEncodingException ex) { if (logger.isWarnEnabled()) { logger.warn("Could not decode multipart item '" + fileItem.getFieldName() + "' with encoding '" + partEncoding + "': using platform default"); } value = fileItem.getString(); } String[] curParam = multipartParameters.get(fileItem.getFieldName()); if (curParam == null) { // simple form field multipartParameters.put(fileItem.getFieldName(), new String[] {value}); } else { // array of simple form fields String[] newParam = StringUtils.addStringToArray(curParam, value); multipartParameters.put(fileItem.getFieldName(), newParam); } multipartParameterContentTypes.put(fileItem.getFieldName(), fileItem.getContentType()); } else { // multipart file field 構(gòu)建MultipartFile CommonsMultipartFile file = createMultipartFile(fileItem); // 以文件字段名為key (files) multipartFiles.add(file.getName(), file); if (logger.isDebugEnabled()) { logger.debug("Found multipart file [" + file.getName() + "] of size " + file.getSize() + " bytes with original filename [" + file.getOriginalFilename() + "], stored " + file.getStorageDescription()); } } } return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes); }
主要邏輯是這行代碼FileItemIterator iter = getItemIterator(ctx);
,F(xiàn)ileItem流迭代器的構(gòu)造
#構(gòu)造方法 FileItemIteratorImpl(RequestContext ctx) throws FileUploadException, IOException { if (ctx == null) { throw new NullPointerException("ctx parameter"); } // 獲取request的content-type,需要以multipart/ 開頭 String contentType = ctx.getContentType(); if ((null == contentType) || (!contentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART))) { throw new InvalidContentTypeException( format("the request doesn't contain a %s or %s stream, content type header is %s", MULTIPART_FORM_DATA, MULTIPART_MIXED, contentType)); } // 獲取request的輸入流 InputStream input = ctx.getInputStream(); // 獲取內(nèi)容長(zhǎng)度 content-length 從request中取 @SuppressWarnings("deprecation") // still has to be backward compatible final int contentLengthInt = ctx.getContentLength(); // 通過request.getHeader()取 final long requestSize = UploadContext.class.isAssignableFrom(ctx.getClass()) // Inline conditional is OK here CHECKSTYLE:OFF ? ((UploadContext) ctx).contentLength() : contentLengthInt; // CHECKSTYLE:ON // sizeMax限制流大小 -1則不限制 if (sizeMax >= 0) { if (requestSize != -1 && requestSize > sizeMax) { throw new SizeLimitExceededException( format("the request was rejected because its size (%s) exceeds the configured maximum (%s)", Long.valueOf(requestSize), Long.valueOf(sizeMax)), requestSize, sizeMax); } input = new LimitedInputStream(input, sizeMax) { @Override protected void raiseError(long pSizeMax, long pCount) throws IOException { FileUploadException ex = new SizeLimitExceededException( format("the request was rejected because its size (%s) exceeds the configured maximum (%s)", Long.valueOf(pCount), Long.valueOf(pSizeMax)), pCount, pSizeMax); throw new FileUploadIOException(ex); } }; } // 獲取字符編碼 String charEncoding = headerEncoding; if (charEncoding == null) { charEncoding = ctx.getCharacterEncoding(); } // 通過content-type = multipart/form-data; boundary=--------------------------205940049223747054037567 // 獲取boundary的值分隔符(一串隨機(jī)字符?)并轉(zhuǎn)化為字節(jié)數(shù)組 boundary = getBoundary(contentType); if (boundary == null) { throw new FileUploadException("the request was rejected because no multipart boundary was found"); } // 進(jìn)度更新器 notifier = new MultipartStream.ProgressNotifier(listener, requestSize); try { // 構(gòu)建多元流 multi = new MultipartStream(input, boundary, notifier); } catch (IllegalArgumentException iae) { throw new InvalidContentTypeException( format("The boundary specified in the %s header is too long", CONTENT_TYPE), iae); } // 設(shè)置請(qǐng)求頭編碼 multi.setHeaderEncoding(charEncoding); // 跳過序言 skipPreamble = true; // 開始找第一個(gè)文件項(xiàng)目 findNextItem(); }
接著再來看下MultipartStream的構(gòu)建
#MultipartStream構(gòu)造 public MultipartStream(InputStream input, // request輸入流 byte[] boundary, // 邊界 字節(jié)數(shù)組 int bufSize, // 緩沖區(qū)大小 默認(rèn)4096 ProgressNotifier pNotifier) { if (boundary == null) { throw new IllegalArgumentException("boundary may not be null"); } // We prepend CR/LF to the boundary to chop trailing CR/LF from // body-data tokens. CR 回車\r LF 換行\(zhòng)n // protected static final byte[] BOUNDARY_PREFIX = {CR, LF, DASH, DASH}; this.boundaryLength = boundary.length + BOUNDARY_PREFIX.length; // 緩沖區(qū)大小判斷 if (bufSize < this.boundaryLength + 1) { throw new IllegalArgumentException( "The buffer size specified for the MultipartStream is too small"); } this.input = input; // 重新確定緩沖區(qū)大小 this.bufSize = Math.max(bufSize, boundaryLength * 2); // 創(chuàng)建緩沖區(qū) 用來從讀inputStream 接受數(shù)據(jù) this.buffer = new byte[this.bufSize]; this.notifier = pNotifier; // 邊界數(shù)組 this.boundary = new byte[this.boundaryLength]; this.keepRegion = this.boundary.length; // 將BOUNDARY_PREFIX數(shù)組和入?yún)oundary數(shù)組的內(nèi)容按序復(fù)制到新的boundary中 System.arraycopy(BOUNDARY_PREFIX, 0, this.boundary, 0, BOUNDARY_PREFIX.length); System.arraycopy(boundary, 0, this.boundary, BOUNDARY_PREFIX.length, boundary.length); // head和tail為緩沖區(qū)操作的索引 // 0 <= head < bufSize // 0 <= tail <= bufSize head = 0; tail = 0; }
接著看findNextItem方法,找第一個(gè)文件項(xiàng)目
/** * Called for finding the next item, if any. * * @return True, if an next item was found, otherwise false. * @throws IOException An I/O error occurred. */ private boolean findNextItem() throws IOException { if (eof) { return false; } // 開始為null if (currentItem != null) { currentItem.close(); currentItem = null; } for (;;) { boolean nextPart; if (skipPreamble) { // 丟棄直到邊界分隔符的所有數(shù)據(jù) 再讀取邊界 nextPart = multi.skipPreamble(); } else { // 直接讀取邊界 nextPart = multi.readBoundary(); } if (!nextPart) { if (currentFieldName == null) { // Outer multipart terminated -> No more data eof = true; return false; } // Inner multipart terminated -> Return to parsing the outer multi.setBoundary(boundary); currentFieldName = null; continue; } // 解析頭部 multi.readHeaders()從緩沖區(qū)解析到所有請(qǐng)求頭的字符串 // 接著getParsedHeaders將字符串按\r\n分隔,因?yàn)槊恳恍袛?shù)據(jù)都是一個(gè)請(qǐng)求頭內(nèi)容 FileItemHeaders headers = getParsedHeaders(multi.readHeaders()); if (currentFieldName == null) { // We're parsing the outer multipart // 獲取上傳文件字段參數(shù)名name = files String fieldName = getFieldName(headers); if (fieldName != null) { String subContentType = headers.getHeader(CONTENT_TYPE); // img/jpeg if (subContentType != null && subContentType.toLowerCase(Locale.ENGLISH) .startsWith(MULTIPART_MIXED)) { currentFieldName = fieldName; // Multiple files associated with this field name byte[] subBoundary = getBoundary(subContentType); multi.setBoundary(subBoundary); skipPreamble = true; continue; } // 文件名 IMG_0908.JPG String fileName = getFileName(headers); // 根據(jù)字段名、文件名、請(qǐng)求頭等構(gòu)建FileItemStream對(duì)象 currentItem = new FileItemStreamImpl(fileName, fieldName, headers.getHeader(CONTENT_TYPE), fileName == null, getContentLength(headers)); // 設(shè)置請(qǐng)求頭 currentItem.setHeaders(headers); // ++items notifier.noteItem(); // 當(dāng)期有可用item itemValid = true; return true; } } else { String fileName = getFileName(headers); if (fileName != null) { currentItem = new FileItemStreamImpl(fileName, currentFieldName, headers.getHeader(CONTENT_TYPE), false, getContentLength(headers)); currentItem.setHeaders(headers); notifier.noteItem(); itemValid = true; return true; } } multi.discardBodyData(); } }
下圖為緩沖區(qū)數(shù)據(jù),首行為boundary分隔符內(nèi)容也就是上文提到的----加一串計(jì)算出來的隨機(jī)字符,\r\n后接著為content-Disposition和content-type請(qǐng)求頭,可以看到 HEADER_SEPARATOR頭部分隔符\r\n\r\n 和分隔符之前的為請(qǐng)求頭數(shù)據(jù)
另外boundary字節(jié)數(shù)組對(duì)應(yīng)的內(nèi)容為--------------------------031262929361076583805179,下圖的首行內(nèi)容比其多兩個(gè)-
下圖為解析完后的頭部
接下來再看下FileItemStreamImpl的構(gòu)造過程,比較簡(jiǎn)單
#FileItemStreamImpl構(gòu)造方法 FileItemStreamImpl(String pName, String pFieldName, String pContentType, boolean pFormField, long pContentLength) throws IOException { name = pName; fieldName = pFieldName; contentType = pContentType; formField = pFormField; // 創(chuàng)建itemStream流 本質(zhì)上是從request的inputstream獲取數(shù)據(jù) // 從head位置再開始找boundary邊界分隔符,若找到將邊界的前一個(gè)索引賦值給pos變量,并且當(dāng)前文件可讀字符數(shù)為pos - head final ItemInputStream itemStream = multi.newInputStream(); InputStream istream = itemStream; // 若文件大小限制,超出長(zhǎng)度會(huì)拋出異常 if (fileSizeMax != -1) { if (pContentLength != -1 && pContentLength > fileSizeMax) { FileSizeLimitExceededException e = new FileSizeLimitExceededException( format("The field %s exceeds its maximum permitted size of %s bytes.", fieldName, Long.valueOf(fileSizeMax)), pContentLength, fileSizeMax); e.setFileName(pName); e.setFieldName(pFieldName); throw new FileUploadIOException(e); } istream = new LimitedInputStream(istream, fileSizeMax) { @Override protected void raiseError(long pSizeMax, long pCount) throws IOException { itemStream.close(true); FileSizeLimitExceededException e = new FileSizeLimitExceededException( format("The field %s exceeds its maximum permitted size of %s bytes.", fieldName, Long.valueOf(pSizeMax)), pCount, pSizeMax); e.setFieldName(fieldName); e.setFileName(name); throw new FileUploadIOException(e); } }; } stream = istream; }
再來看看ItemInputStream,上面的FileItemStreamImpl對(duì)象有這個(gè)類型參數(shù),主要用來獲取請(qǐng)求流的,因?yàn)镮temInputStream類是MultipartStream的內(nèi)部類,能夠調(diào)用MultipartStream中的input流。
ItemInputStream
public class ItemInputStream extends InputStream implements Closeable { // 目前已經(jīng)讀取的字節(jié)數(shù) private long total; // 必須保持的字節(jié)數(shù)可能是分隔符boundary的一部分 private int pad; // 緩沖區(qū)的當(dāng)前偏移 private int pos; // stream流是否關(guān)閉 private boolean closed; // 構(gòu)造方法 ItemInputStream() { findSeparator(); } // 尋找邊界分隔符boundary的前一個(gè)索引 private void findSeparator() { pos = MultipartStream.this.findSeparator(); if (pos == -1) { if (tail - head > keepRegion) { pad = keepRegion; } else { pad = tail - head; } } } // 可讀取字節(jié)數(shù) @Override public int available() throws IOException { // 可讀=尾-首-邊界長(zhǎng)度 if (pos == -1) { return tail - head - pad; } // pos !=-1 說明pos后面是邊界了 只能讀到這個(gè)邊界之前的數(shù)據(jù) // 可讀 = 邊界前的最后一個(gè)索引 - 首 return pos - head; } private static final int BYTE_POSITIVE_OFFSET = 256; // 讀取stream流的下一個(gè)字符 @Override public int read() throws IOException { if (closed) { throw new FileItemStream.ItemSkippedException(); } if (available() == 0 && makeAvailable() == 0) { return -1; } ++total; int b = buffer[head++]; if (b >= 0) { return b; } // 如果負(fù)的 加上256 return b + BYTE_POSITIVE_OFFSET; } // 讀取字節(jié)到給定的緩沖區(qū)b中 @Override public int read(byte[] b, int off, int len) throws IOException { if (closed) { throw new FileItemStream.ItemSkippedException(); } if (len == 0) { return 0; } int res = available(); if (res == 0) { res = makeAvailable(); if (res == 0) { return -1; } } res = Math.min(res, len); System.arraycopy(buffer, head, b, off, res); // head加偏移 head += res; total += res; return res; } // 關(guān)閉輸入流 @Override public void close() throws IOException { close(false); } /** * Closes the input stream. * * @param pCloseUnderlying Whether to close the underlying stream * (hard close) * @throws IOException An I/O error occurred. */ public void close(boolean pCloseUnderlying) throws IOException { if (closed) { return; } if (pCloseUnderlying) { closed = true; input.close(); } else { for (;;) { int av = available(); if (av == 0) { av = makeAvailable(); if (av == 0) { break; } } skip(av); } } closed = true; } // 跳過緩沖區(qū)中給定長(zhǎng)度的字節(jié) @Override public long skip(long bytes) throws IOException { if (closed) { throw new FileItemStream.ItemSkippedException(); } int av = available(); if (av == 0) { av = makeAvailable(); if (av == 0) { return 0; } } long res = Math.min(av, bytes); head += res; return res; } // 試圖讀取更多的數(shù)據(jù),返回可讀字節(jié)數(shù) private int makeAvailable() throws IOException { if (pos != -1) { return 0; } // 將數(shù)據(jù)移到緩沖區(qū)的開頭,舍棄邊界 total += tail - head - pad; System.arraycopy(buffer, tail - pad, buffer, 0, pad); // Refill buffer with new data. head = 0; tail = pad; for (;;) { // 讀取tail位置開始讀 bufSize-tail長(zhǎng)度的字節(jié)到buffer緩沖區(qū)中 int bytesRead = input.read(buffer, tail, bufSize - tail); if (bytesRead == -1) { // The last pad amount is left in the buffer. // Boundary can't be in there so signal an error // condition. final String msg = "Stream ended unexpectedly"; throw new MalformedStreamException(msg); } if (notifier != null) { notifier.noteBytesRead(bytesRead); } // tail加偏移 tail += bytesRead; // 再嘗試找boundary邊界,賦值pos -1 findSeparator(); int av = available(); // 返回可讀字節(jié)數(shù) if (av > 0 || pos != -1) { return av; } } } // 判斷流是否關(guān)閉 public boolean isClosed() { return closed; } }
接受請(qǐng)求
請(qǐng)求解析完成后就可以以文件對(duì)象接收了,參數(shù)類型為MultipartFile,可強(qiáng)轉(zhuǎn)為CommonsMultipartFile,參數(shù)名需要與上傳文件的fieldName相對(duì)應(yīng)或者也可以用@RequestParam注解指定參數(shù)名
@RequestMapping(value = "file/upload", method = RequestMethod.POST) @ResponseBody public Object uploadFile(MultipartFile[] files, HttpServletRequest request, HttpServletResponse response) throws IOException { ....... 上傳邏輯 return CommonResult.succ("上傳成功"); }
可以用postman測(cè)試,content-type的boundary顯示是請(qǐng)求發(fā)送時(shí)計(jì)算,不知道怎么算的反正是一串隨機(jī)數(shù),是用來分隔多個(gè)文件內(nèi)容的,在源碼中可以看到不能缺失否則解析過程中會(huì)報(bào)錯(cuò)
以上就是Spring實(shí)現(xiàn)文件上傳的配置詳解的詳細(xì)內(nèi)容,更多關(guān)于Spring文件上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
深度解析Java中volatile的內(nèi)存語義實(shí)現(xiàn)以及運(yùn)用場(chǎng)景
這篇文章主要介紹了Java中volatile的內(nèi)存語義實(shí)現(xiàn)以及運(yùn)用場(chǎng)景,通過JVM的機(jī)制來分析volatile關(guān)鍵字在線程編程中的作用,需要的朋友可以參考下2015-12-12SpringBoot項(xiàng)目的配置文件中設(shè)置server.port不生效問題
這篇文章主要介紹了SpringBoot項(xiàng)目的配置文件中設(shè)置server.port不生效問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-11-11SpringBoot集成Beetl后統(tǒng)一處理頁(yè)面異常的方法
這篇文章主要介紹了SpringBoot集成Beetl后統(tǒng)一處理頁(yè)面異常的方法,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-08-08java中實(shí)現(xiàn)創(chuàng)建目錄與創(chuàng)建文件的操作實(shí)例
用Java創(chuàng)建文件或目錄非常簡(jiǎn)單,下面這篇文章主要給大家介紹了關(guān)于java中實(shí)現(xiàn)創(chuàng)建目錄與創(chuàng)建文件的操作實(shí)例,文中通過實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2023-01-01Mybatis-Plus處理Mysql?Json類型字段的詳細(xì)教程
這篇文章主要給大家介紹了關(guān)于Mybatis-Plus處理Mysql?Json類型字段的詳細(xì)教程,Mybatis-Plus可以很方便地處理JSON字段,在實(shí)體類中可以使用@JSONField注解來標(biāo)記JSON字段,同時(shí)在mapper.xml中使用json函數(shù)來操作JSON字段,需要的朋友可以參考下2024-01-01Java實(shí)現(xiàn)多數(shù)據(jù)源的幾種方式總結(jié)
這篇文章主要給大家總結(jié)介紹了關(guān)于Java實(shí)現(xiàn)多數(shù)據(jù)源的幾種方式,最近項(xiàng)目中的工作流需要查詢多個(gè)數(shù)據(jù)源的數(shù)據(jù),數(shù)據(jù)源可能是不同種類的,需要的朋友可以參考下2023-08-08