SpringBoot中集成串口通信的項(xiàng)目實(shí)踐
串口通信介紹
- 串口通信是一種按位發(fā)送和接收字節(jié)的簡(jiǎn)單概念,盡管比并行通信慢,但串口可以同時(shí)使用一根線發(fā)送數(shù)據(jù)和接收數(shù)據(jù)。
- 串口通信簡(jiǎn)單且能夠?qū)崿F(xiàn)遠(yuǎn)距離通信,例如,串口的長(zhǎng)度可達(dá)1200米,而并行通信的長(zhǎng)度限制為20米.
- 串口通常用于ASCII碼字符的傳輸,通信使用地線、發(fā)送線和接收線三根線完成。
- 重要的參數(shù)有波特率、數(shù)據(jù)位、停止位和奇偶校驗(yàn)。
波特率
這是一個(gè)衡量符號(hào)傳輸速率的參數(shù)。指的是信號(hào)被調(diào)制以后在單位時(shí)間內(nèi)的變化,即單位時(shí)間內(nèi)載波參數(shù)變化的次數(shù),如每秒鐘傳送240個(gè)字符,而每個(gè)字符格式包含10位(1個(gè)起始位,1個(gè)停止位,8個(gè)數(shù)據(jù)位),這時(shí)的波特率為240Bd,比特率為10位*240個(gè)/秒=2400bps。一般調(diào)制速率大于波特率,比如通常電話線的波特率為14400,28800和36600。波特率可以遠(yuǎn)遠(yuǎn)大于這些值,但是波特率和距離成反比。高波特率常常用于放置的很近的儀器間的通信,典型的例子就是GPIB設(shè)備的通信
數(shù)據(jù)位
這是衡量通信中實(shí)際數(shù)據(jù)位的參數(shù)。當(dāng)計(jì)算機(jī)發(fā)送一個(gè)信息包,實(shí)際的數(shù)據(jù)往往不會(huì)是8位的,標(biāo)準(zhǔn)的值是6、7和8位。如何設(shè)置取決于你想傳送的信息。比如,標(biāo)準(zhǔn)的ASCII碼是0~127(7位)。擴(kuò)展的ASCII碼是0~255(8位)。如果數(shù)據(jù)使用簡(jiǎn)單的文本(標(biāo)準(zhǔn) ASCII碼),那么每個(gè)數(shù)據(jù)包使用7位數(shù)據(jù)。每個(gè)包是指一個(gè)字節(jié),包括開始/停止位,數(shù)據(jù)位和奇偶校驗(yàn)位。由于實(shí)際數(shù)據(jù)位取決于通信協(xié)議的選取,術(shù)語(yǔ)“包”指任何通信的情況。
停止位
用于表示單個(gè)包的最后一位。典型的值為1,1.5和2位。由于數(shù)據(jù)是在傳輸線上定時(shí)的,并且每一個(gè)設(shè)備有其自己的時(shí)鐘,很可能在通信中兩臺(tái)設(shè)備間出現(xiàn)了小小的不同步。因此停止位不僅僅是表示傳輸?shù)慕Y(jié)束,并且提供計(jì)算機(jī)校正時(shí)鐘同步的機(jī)會(huì)。適用于停止位的位數(shù)越多,不同時(shí)鐘同步的容忍程度越大,但是數(shù)據(jù)傳輸率同時(shí)也越慢。
奇偶校驗(yàn)位
在串口通信中一種簡(jiǎn)單的檢錯(cuò)方式。有四種檢錯(cuò)方式:偶、奇、高和低。當(dāng)然沒有校驗(yàn)位也是可以的。對(duì)于偶和奇校驗(yàn)的情況,串口會(huì)設(shè)置校驗(yàn)位(數(shù)據(jù)位后面的一位),用一個(gè)值確保傳輸?shù)臄?shù)據(jù)有偶個(gè)或者奇?zhèn)€邏輯高位。例如,如果數(shù)據(jù)是011,那么對(duì)于偶校驗(yàn),校驗(yàn)位為0,保證邏輯高的位數(shù)是偶數(shù)個(gè)。如果是奇校驗(yàn),校驗(yàn)位為1,這樣就有3個(gè)邏輯高位。高位和低位不真正的檢查數(shù)據(jù),簡(jiǎn)單置位邏輯高或者邏輯低校驗(yàn)。這樣使得接收設(shè)備能夠知道一個(gè)位的狀態(tài),有機(jī)會(huì)判斷是否有噪聲干擾了通信或者是否傳輸和接收數(shù)據(jù)是否不同步。
開始集成
組件介紹
對(duì)于Java集成串口通信,常見的選擇有 原生Java串口通信API、RXTX庫(kù)、jSerialComm庫(kù),
- 原生Java串口通信API只支持到Java6版本,后續(xù)便不再維護(hù),所以不推薦使用
- RXTX庫(kù)是過(guò)去主流開發(fā)串口通信使用的依賴組件,但是由于需要在jvm包中添加指定的依賴組件,其次,RXTX的穩(wěn)定性和兼容性可能存在一些問題,且僅維護(hù)至Jdk8版本,后續(xù)不再持續(xù)維護(hù)了,所以本次也不考慮使用它
- 所以本次采用的是jSerialComm庫(kù),以下是jSerialComm庫(kù)的一些主要特點(diǎn)和功能:
- 跨平臺(tái)支持:jSerialComm可以在多個(gè)操作系統(tǒng)上使用,包括Windows、Linux和MacOS等。
- 多串口支持:它可以同時(shí)管理多個(gè)串口,通過(guò)獲取和管理已連接的串口列表,方便選擇和使用特定的串口。
- 簡(jiǎn)單的API:jSerialComm提供了簡(jiǎn)潔易用的API,使串口的打開、讀取、寫入和關(guān)閉等操作變得簡(jiǎn)單和直觀。
- 支持異步讀?。嚎梢允褂没卣{(diào)函數(shù)或監(jiān)聽器來(lái)異步讀取串口數(shù)據(jù),實(shí)現(xiàn)非阻塞的讀取操作。
- 高性能:jSerialComm使用了底層的串口通信庫(kù),具有高效的讀寫性能,適用于處理大量的串口數(shù)據(jù)。
- 可靠性和穩(wěn)定性:它經(jīng)過(guò)了充分測(cè)試和優(yōu)化,具有良好的穩(wěn)定性和可靠性,能夠處理各種串口通信場(chǎng)景。
- 開源免費(fèi):jSerialComm是一個(gè)開源庫(kù),使用MIT許可證,可以免費(fèi)使用和修改。
Maven依賴導(dǎo)入
<!-- COM串口通信 --> <dependency> <groupId>com.fazecast</groupId> <artifactId>jSerialComm</artifactId> <version>2.6.2</version> </dependency> <!-- hutool工具 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.6.5</version> </dependency>
配置類
創(chuàng)建一個(gè) SerialConfig 用于定義串口通用信息配置
import com.fazecast.jSerialComm.SerialPort; import lombok.Data; import org.springframework.context.annotation.Configuration; import java.util.HashMap; import java.util.Map; /** ?* ?用于定義串口通用信息配置 ?* */ @Configuration public class SerialConfig { ? ? /** ? ? ?* ?波特率 ? ? ?* */ ? ? public static int baudRate = 19200; ? ? /** ? ? ?* 數(shù)據(jù)位 ? ? ?*/ ? ? public static int dataBits = 8; ? ? /** ? ? ?* 停止位 ( 1停止位 = 1 ?、 1.5停止位 = 2 、2停止位 = 3) ? ? ?* */ ? ? public static int stopBits = 1; ? ? /** ? ? ?* 校驗(yàn)?zāi)J?( 無(wú)校驗(yàn) = 0 ?、奇校驗(yàn) = 1 、偶校驗(yàn) = 2、 標(biāo)記校驗(yàn) = 3、 空格校驗(yàn) = 4 ?) ? ? ?* */ ? ? public static int parity = 1; ? ? /** ? ? ?* ?是否為 Rs485通信 ? ? ?* */ ? ? public static boolean rs485Mode = true; ? ? /** ? ? ?* ?串口讀寫超時(shí)時(shí)間(毫秒) ? ? ?* */ ? ? public static int timeOut = 300; ? ? /** ? ? ?* 消息模式 ? ? ?* 非阻塞模式: #TIMEOUT_NONBLOCKING ? ? ? ? ? 【在該模式下,readBytes(byte[], long)和writeBytes(byte[], long)調(diào)用將立即返回任何可用數(shù)據(jù)?!? ? ? ?* 寫阻塞模式: #TIMEOUT_WRITE_BLOCKING ? ? ? ?【在該模式下,writeBytes(byte[], long)調(diào)用將阻塞,直到所有數(shù)據(jù)字節(jié)都成功寫入輸出串口設(shè)備?!? ? ? ?* 半阻塞讀取模式: #TIMEOUT_READ_SEMI_BLOCKING 【在該模式下,readBytes(byte[], long)調(diào)用將阻塞,直到達(dá)到指定的超時(shí)時(shí)間或者至少可讀取1個(gè)字節(jié)的數(shù)據(jù)。】 ? ? ?* 全阻塞讀取模式:#TIMEOUT_READ_BLOCKING ? ? ? 【在該模式下,readBytes(byte[], long)調(diào)用將阻塞,直到達(dá)到指定的超時(shí)時(shí)間或者可以返回請(qǐng)求的字節(jié)數(shù)?!? ? ? ?* 掃描器模式:#TIMEOUT_SCANNER ? ? ? ? ? ? ? ?【該模式適用于使用Java的java.util.Scanner類從串口進(jìn)行讀取,會(huì)忽略手動(dòng)指定的超時(shí)值以確保與Java規(guī)范的兼容性】 ? ? ?* */ ? ? public static int messageModel = SerialPort.TIMEOUT_READ_BLOCKING; ? ? /** ? ? ?* ?已打開的COM串口 (重復(fù)打開串口會(huì)導(dǎo)致后面打開的無(wú)法使用,所以打開一次就要記錄到公共變量存儲(chǔ)) ? ? ?* */ ? ? public final static Map<String, SerialPort> portMap = new HashMap<>(); }
串口工具類
準(zhǔn)備一個(gè)SerialService 用于創(chuàng)建串口,關(guān)閉串口,收發(fā)消息
import cn.hutool.core.codec.BCD; import com.fazecast.jSerialComm.SerialPort; import com.tce.station.common.config.SerialConfig; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.InputStream; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 串口服務(wù)類 * */ @AllArgsConstructor @Slf4j @Service public class SerialService { /** * 獲取串口及狀態(tài) * */ public Map<String, Boolean> getPortStatus(){ Map<String, Boolean> comStatusMap = new HashMap<>(); List<SerialPort> commPorts = Arrays.asList(SerialPort.getCommPorts()); commPorts.forEach(port->{ comStatusMap.put(port.getSystemPortName(), port.isOpen()); }); return comStatusMap; } /** * 添加串口連接 * */ public void connectSerialPort(String portName){ SerialPort commPort = SerialPort.getCommPort(portName); if (commPort.isOpen()){ throw new RuntimeException("該串口已被占用"); } if (SerialConfig.portMap.containsKey(portName)){ throw new RuntimeException("該串口已被占用"); } // 打開端口 commPort.openPort(); if (!commPort.isOpen()){ throw new RuntimeException("打開串口失敗"); } // 設(shè)置串口參數(shù) (波特率、數(shù)據(jù)位、停止位、校驗(yàn)?zāi)J?、是否為Rs485) commPort.setComPortParameters(SerialConfig.baudRate, SerialConfig.dataBits,SerialConfig.stopBits, SerialConfig.stopBits, SerialConfig.rs485Mode); // 設(shè)置串口超時(shí)和模式 commPort.setComPortTimeouts(SerialConfig.messageModel ,SerialConfig.timeOut, SerialConfig.timeOut); // 添加至串口記錄Map SerialConfig.portMap.put(portName, commPort); } /** * 關(guān)閉串口連接 * */ public boolean closeSerialPort(String portName){ if (!SerialConfig.portMap.containsKey(portName)){ throw new RuntimeException("該串口未啟用"); } // 獲取串口 SerialPort port = SerialConfig.portMap.get(portName); // 關(guān)閉串口 port.closePort(); // 需要等待一些時(shí)間,否則串口關(guān)閉不完全,會(huì)導(dǎo)致無(wú)法打開 try { Thread.sleep(3000); } catch (InterruptedException e) { throw new RuntimeException(e); } if (port.isOpen()){ return false; }else { // 關(guān)閉成功返回 return true; } } /** * 串口發(fā)送數(shù)據(jù) * */ public void sendComData(String portName, byte[]sendBytes){ if (!SerialConfig.portMap.containsKey(portName)){ throw new RuntimeException("該串口未啟用"); } // 獲取串口 SerialPort port = SerialConfig.portMap.get(portName); // 發(fā)送串口數(shù)據(jù) int i = port.writeBytes(sendBytes, sendBytes.length); if (i == -1){ log.error("發(fā)送串口數(shù)據(jù)失敗{}, 數(shù)據(jù)內(nèi)容{}",portName, BCD.bcdToStr(sendBytes)); throw new RuntimeException("發(fā)送串口數(shù)據(jù)失敗"); } } /** * 串口讀取數(shù)據(jù) * */ public byte[] readComData(String portName){ if (!SerialConfig.portMap.containsKey(portName)){ throw new RuntimeException("該串口未啟用"); } // 獲取串口 SerialPort port = SerialConfig.portMap.get(portName); // 讀取串口流 InputStream inputStream = port.getInputStream(); // 獲取串口返回的流大小 int availableBytes = 0; try { availableBytes = inputStream.available(); } catch (Exception e) { e.printStackTrace(); } // 讀取指定的范圍的數(shù)據(jù)流 byte[] readByte = new byte[availableBytes]; int bytesRead = 0; try { bytesRead = inputStream.read(readByte); } catch (Exception e) { e.printStackTrace(); } return readByte; } }
串口業(yè)務(wù)類使用
基于以上的工具類就已經(jīng)可以對(duì)串口通信進(jìn)行開發(fā)了,以下是使用案例
1.創(chuàng)建串口連接
可以使用監(jiān)聽器方式接收數(shù)據(jù),但是需要進(jìn)行綁定,后續(xù)會(huì)介紹
// 從數(shù)據(jù)庫(kù)或者配置表中讀取設(shè)定要打開的串口 List<String> comList = comService.list(); // 關(guān)閉之前的監(jiān)聽連接(提取所有串口避免重復(fù)關(guān)閉) ? SerialConfig.portMap.forEach((com,serialPort) ->{ ? ?? ?serialService.closeSerialPort(com); ? }); // 等待之前的串口發(fā)送和 2倍監(jiān)聽超時(shí),避免還有串口通信線程未關(guān)閉 ? try { ? ?? ?Thread.sleep((SerialConfig.timeOut + 1) * 2); ? } catch (InterruptedException e) { ? ?? ?throw new RuntimeException(e); ? } // 清空COM口記錄 ? SerialConfig.portMap.clear(); // 重新連接串口 ? gunList.forEach(gun->{ ? ?? ?// 如果COM口沒有就打開 ? ?? ?if (!SerialConfig.portMap.containsKey(gun.getCom())){ ? ?? ??? ?// 創(chuàng)建連接 ? ?? ??? ?SerialPort serialPort = serialService.connectSerialPort(gun.getCom()); ? ?? ??? ?// 綁定監(jiān)聽器 ? ?? ??? ?// serialPort.addDataListener(new MessageListener()); ? ?? ?} ?? });
2.關(guān)閉串口連接
String com = "COM1"; serialService.closeSerialPort(com);
3.定時(shí)發(fā)送串口數(shù)據(jù)
/** * 周期性向串口發(fā)送數(shù)據(jù) * */ @Scheduled(fixedRate = 1500L) public void send{ // 因?yàn)槭亲枞潜O(jiān)聽線程,所以使用線程處理 Thread thread = new Thread(() -> { try { SerialConfig.portMap.forEach((com,serialPort)->{ // 等待0.1秒 try { Thread.sleep(100); }catch (Exception e){ e.printStackTrace(); } // 調(diào)用業(yè)務(wù)邏輯獲取需要推送的數(shù)據(jù) byte[] sendBytes = getPushData(com); // 發(fā)送串口數(shù)據(jù) serialService.sendComData(com, sendBytes); log.info("向串口發(fā)送 {}",gun.getGunNum(), com, BCD.bcdToStr(sendBytes)); }); }catch (ConcurrentModificationException e){ log.info("COM口配置發(fā)生變化,等待配置生效"); } }); // 開啟發(fā)送線程 thread.start(); }
4.周期性讀取串口數(shù)據(jù)
/** * 周期性讀取串口數(shù)據(jù) * */ @Scheduled(fixedRate = 1000L) public void readComData() { // 遍歷監(jiān)聽 SerialConfig.portMap.forEach((com,serialPort)->{ // 因?yàn)槭亲枞潜O(jiān)聽線程,所以使用線程處理,否則某個(gè)讀取失敗,會(huì)阻塞整個(gè)程序 Thread thread = new Thread(() -> { byte[] readByte = serialService.readComData(com); // 有數(shù)據(jù)才執(zhí)行 if (readByte.length > 1) { try { log.info("收到串口數(shù)據(jù): {}", BCD.bcdToStr(readByte)); // 調(diào)用串口響應(yīng)業(yè)務(wù)操作 comOperationByData(comResult,BCD.strToBcd(res), com); }catch (Exception e){ e.printStackTrace(); } }); // 開啟線程 thread.start(); }
5.監(jiān)聽式讀取串口數(shù)據(jù)
監(jiān)聽式讀取數(shù)據(jù)使用的是非阻塞行讀取數(shù)據(jù),有數(shù)據(jù)就會(huì)觸發(fā)
創(chuàng)建一個(gè)監(jiān)聽器
@Slf4j ? public class MessageListener implements SerialPortDataListener { ? @Autowired ? ICommandService commandService; ? /** ? * 監(jiān)聽事件設(shè)置 ? * */ ? @Override ? public int getListeningEvents() { ? ?? ?// 持續(xù)返回?cái)?shù)據(jù)流模式 ? ?? ?return SerialPort.LISTENING_EVENT_DATA_AVAILABLE; ? ?? ?// 收到數(shù)據(jù)立即返回 ? ?? ?// return SerialPort.LISTENING_EVENT_DATA_RECEIVED; ? } ? /** ? * 收到數(shù)據(jù)監(jiān)聽回調(diào) ? * */ ? @Override ? public void serialEvent(SerialPortEvent event) { ? ?? ?// 因?yàn)槭亲枞潜O(jiān)聽線程,所以使用線程處理 ? ?? ?Thread thread = new Thread(() -> { ? ?? ??? ?// 讀取串口流 ? ?? ??? ?InputStream inputStream = event.getSerialPort().getInputStream(); ? ?? ??? ?// 獲取串口返回的流大小 ? ?? ??? ?int availableBytes = 0; ? ?? ??? ?try { ? ?? ??? ??? ?availableBytes = inputStream.available(); ? ?? ??? ?} catch (Exception e) { ? ?? ??? ??? ?e.printStackTrace(); ? ?? ??? ?} ? ?? ??? ?// 讀取指定的范圍的數(shù)據(jù)流 ? ?? ??? ?byte[] readByte = new byte[availableBytes]; ? ?? ??? ?int bytesRead = 0; ? ?? ??? ?try { ? ?? ??? ??? ?bytesRead = inputStream.read(readByte); ? ?? ??? ?} catch (Exception e) { ? ?? ??? ??? ?e.printStackTrace(); ? ?? ??? ?} ? ?? ??? ?try { ? ?? ??? ??? ?inputStream.close(); ? ?? ??? ?} catch (IOException e) { ? ?? ??? ??? ?throw new RuntimeException("關(guān)閉串口流失敗"+e.getMessage()); ? ?? ??? ?} ? ?? ??? ?// 有數(shù)據(jù)才執(zhí)行 ?? ??? ?if (readByte.length > 1) { ? ?? ??? ??? ?try { ? ?? ??? ??? ??? ?log.info("收到串口數(shù)據(jù): {}", BCD.bcdToStr(readByte)); ? ?? ??? ??? ??? ?// 調(diào)用串口響應(yīng)業(yè)務(wù)操作 ? ?? ??? ??? ??? ?comOperationByData(comResult,BCD.strToBcd(res), com); ? ?? ??? ??? ?}catch (Exception e){ ? ?? ??? ??? ??? ?e.printStackTrace(); ? ?? ??? ??? ?}? ?? ??? ?}? ?? ?}); ? // 開啟線程 thread.start(); ? } ? }
給串口連接進(jìn)行綁定監(jiān)聽器
// 創(chuàng)建連接 SerialPort serialPort = serialService.connectSerialPort(gun.getCom()); // 綁定監(jiān)聽器 serialPort.addDataListener(new MessageListener());
需要注意的是監(jiān)聽器接收數(shù)據(jù)和定時(shí)接收數(shù)據(jù)選取其中一個(gè)就好了
到此這篇關(guān)于SpringBoot中集成串口通信的項(xiàng)目實(shí)踐的文章就介紹到這了,更多相關(guān)SpringBoot 串口通信內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringBoot+Kotlin中使用GRPC實(shí)現(xiàn)服務(wù)通信的示例代碼
- SpringBoot實(shí)現(xiàn)WebSocket全雙工通信的項(xiàng)目實(shí)踐
- Springboot集成SSE實(shí)現(xiàn)單工通信消息推送流程詳解
- SpringBoot整合websocket實(shí)現(xiàn)即時(shí)通信聊天
- SpringBoot實(shí)現(xiàn)jsonp跨域通信的方法示例
- SpringMvc/SpringBoot HTTP通信加解密的實(shí)現(xiàn)
- 使用 Spring Boot 實(shí)現(xiàn) WebSocket實(shí)時(shí)通信
- Springboot實(shí)現(xiàn)阿里云通信短信服務(wù)有關(guān)短信驗(yàn)證碼的發(fā)送功能
- Spring Boot 開發(fā)私有即時(shí)通信系統(tǒng)(WebSocket)
相關(guān)文章
@RefreshScope在Quartz 觸發(fā)器類導(dǎo)致異常問題解決分析
這篇文章主要為大家介紹了@RefreshScope在Quartz 觸發(fā)器類導(dǎo)致異常問題解決分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02Java 數(shù)組獲取最大和最小值的實(shí)例實(shí)現(xiàn)
這篇文章主要介紹了Java 數(shù)組獲取最大和最小值的實(shí)例實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-09-09springboot配置請(qǐng)求超時(shí)時(shí)間(Http會(huì)話和接口訪問)
本文主要介紹了springboot配置請(qǐng)求超時(shí)時(shí)間,包含Http會(huì)話和接口訪問兩種,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2024-07-07Java 8 Stream.distinct() 列表去重的操作
這篇文章主要介紹了Java 8 Stream.distinct() 列表去重的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-12-12Spring Boot和Vue跨域請(qǐng)求問題原理解析
這篇文章主要介紹了Spring Boot和Vue跨域請(qǐng)求問題原理解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-12-12解決Java字符串JSON轉(zhuǎn)換異常:cn.hutool.json.JSONException:?Mismatched?
這篇文章主要給大家介紹了關(guān)于如何解決Java字符串JSON轉(zhuǎn)換異常:cn.hutool.json.JSONException:?Mismatched?hr?and?body的相關(guān)資料,文中將解決的辦法通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2024-01-01