首先是使用 Spring Boot 構建包含 WebSocket 的工程。然後定義一個 Java-Config 的 WebSocket :
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) {
stompEndpointRegistry.addEndpoint("/platoEndpoint") // 客戶端連接服務端的端點
.setAllowedOrigins("*") // 不設置前臺連接時報 403 錯誤
.withSockJS(); // 開啓SockJS支持
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/backend"); // 客戶端訂閱地址的前綴
registry.setApplicationDestinationPrefixes("/frontend"); // 客戶端請求服務端的前綴
}
}
如果沒有客戶端需要發消息給服務端,或者懶得寫一個前綴,那麼 ApplicationDestinationPrefixes 也可以不設置。
剩下的就是定製一個暴露 WebSocket 接口的 Controller 即可:
/**
* @MessageMapping:需要在此 value 的值前加上WebSocketConfig註冊的
* ApplicationDestinationPrefixes(如果有),就構成了整個請求的路徑。
* @SendTo: value 是指服務端將把消息發送到訂閱了這個路徑的所有客戶端
*
* 用法:1. 根據當前配置,客戶端使用Stomp,stompClient.send("/frontend/input", {}, obj);
* 發送 obj 給服務端,服務端調用 showLog()處理,然後將處理的結果轉發給所有通過
* stompClient.subscribe('/backend/output', function (response) { ... })
* 訂閱了服務端暴露的接口的客戶端。
* 2. 如果服務端需要在運行時,根據需要自行把信息推送給前端,則需要使用
* SimpMessagingTemplate的convertAndSend()主動調用廣播端口,也就是@SendTo的值
*/
@RestController
public class PlatoWebSocketController {
@MessageMapping("/request")
@SendTo("/backend/broadcast")
public String showLog(String fileName) {
return "Message:::filename=" + fileName;
}
//----------服務端自己直接調用,達到主動推送消息給客戶端----------
@Autowired
private SimpMessagingTemplate template;
@RequestMapping(value = "ws/message", method = RequestMethod.POST)
public void pushMessage(@RequestBody String fileName){
template.convertAndSend("/backend/broadcast", fileName);
}
}
大多數都是將這個 Controller 直接標記爲 Spring 的 @Controller, 而我需要暴露一個 RESTFul 的接口,也就是這裏的pushMessage() ,所以就標記爲 @RestController。在這個方法裏面,藉助 SimpMessagingTemplate 可直接將 Rest 請求過來的信息廣播給所有訂閱了 "/backend/broadcast" 的客戶端。
我們的業務很簡單,頁面上傳了一個文件包,應用程序批處理文件包裏的所有文件,產生的所有日誌通過 WebSocket 實時地顯示在頁面上,讓用戶知曉處理過程。程序把日誌文本當作請求體,調用 pushMessage() 就可以單方面推送消息給客戶端。服務端創建 Socket Server, 監聽 Socket 消息的實現:
@Component
public class LogServer implements InitializingBean, DisposableBean {
private Logger logger = LoggerFactory.getLogger(LogServer.class);
@Autowired
private RestTemplate restTemp;
@Autowired
private Environment env;
@Value("${websocket.port}")
private Integer wsPort;
// 當Spring應用重啓時,需要關掉當前的websocket連接釋放端口,所以把websocket句柄設置爲實例變量
private ServerSocket serverSocket;
@Override
public void destroy() {
logger.info("Shutdown Socket Service.........");
try {
serverSocket.close();
} catch (IOException e) {
logger.error("There is an exception when close socket::{}", e);
}
}
@Override
public void afterPropertiesSet() throws Exception {
logger.info("進入 LogServer.afterPropertiesSet() 啓動 Socket 服務");
String wsServerUrl = getWebSocketServerUrl();
// 必須用線程讓 socket 不佔用主線程去監聽端口,否則主程序沒辦法起來
new Thread() {
public void run() {
ServerSocketFactory serverSocketFactory = ServerSocketFactory.getDefault();
ServerSocket serverSocket = null;
try {
serverSocket = serverSocketFactory.createServerSocket(5000);
} catch (IOException ignored) {
logger.error("Unable to create server");
System.exit(-1);
}
logger.info("LogServer running on port: {}", 5000);
while (true) {
List<String> list = new ArrayList<>();
try {
handleSocket(serverSocket, wsServerUrl, list);
} catch (Exception e) {
logger.error("Socket 線程被打斷,原因是::", e);
throw new RuntimeException(e);
}
}
}
}.start();
}
private String getWebSocketServerUrl() {
// 獲取應用部署的服務器,端口號,構造 pushMessage 的請求路徑
}
private void handleSocket(ServerSocket serverSocket, String wsServerUrl, List<String> list) throws Exception {
try (Socket socket = serverSocket.accept()) {
InputStream is = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String line = null;
while ((line = br.readLine()) != null) {
line = line.trim();
int contentStartInd = line.indexOf(">") + 1;
int contentEndInd = line.lastIndexOf("<");
if (line.contains("message")) {
list.add(line.substring(contentStartInd, contentEndInd));
}
if (list.size() == 3) {
String message = StringUtils.join(list, " - ");
// 用RestTemplate調用封裝Socket處理邏輯的REST接口
restTemp.postForEntity(wsServerUrl, message, Integer.class);
list = new ArrayList<>(); // 一條日誌處理完,清空容器準備接收下一條
}
}
}
}
}
由於 Socket 服務啓動後會一直監聽給定的端口,佔用當前線程資源,所以需要新開一個線程去做,主線程繼續 Spring Boot 應用資源的加載。
客戶端多開一個 Log 的 Socket 輸出源,可以參照 Socket Logging 鏈接的實現,作爲測試的 main(),需要增加一行 handler 的 close() 方法,否則會報 java.net.SocketException: Connection reset 錯誤。
public static void main(String argv[]) throws IOException {
final Logger logger = Logger.getLogger(Test.class.getName());
Handler handler = new SocketHandler("192.168.1.82", 5000);
logger.addHandler(handler);
logger.log(Level.SEVERE, "Hello, 中國2");
logger.log(Level.INFO, "Welcome Home");
handler.close();
}