2024/08/26

SpringMVC + Hibernate + Spring WebSocket 的 web sample project

可依照以下順序查看 source code

configurations

pom.xml

在POM檔加入Spring MVC的dependency

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>camiol</groupId>
    <artifactId>smvc</artifactId>
    <packaging>war</packaging>
    <version>0.0.1-SNAPSHOT</version>

    <name>springMVC-Demo Maven Webapp</name>
    <url>http://maven.apache.org</url>
    <dependencies>
        <!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>6.1.4</version>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-websocket</artifactId>
            <version>6.1.4</version>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-messaging</artifactId>
            <version>6.1.4</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.springframework/spring-orm -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-orm</artifactId>
            <version>6.1.4</version>
        </dependency>

        <!-- hibernate -->
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>6.4.4.Final</version>
        </dependency>

        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-c3p0</artifactId>
            <version>6.4.4.Final</version>
        </dependency>

        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.0.0</version>
            <scope>provided</scope>
        </dependency>

        <!-- JSTL -->
        <dependency>
            <groupId>jakarta.servlet.jsp.jstl</groupId>
            <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
            <version>3.0.0</version>
        </dependency>

        <dependency>
            <groupId>org.glassfish.web</groupId>
            <artifactId>jakarta.servlet.jsp.jstl</artifactId>
            <version>3.0.1</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.10.1</version>
        </dependency>

    </dependencies>


    <build>
        <finalName>smvc</finalName>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.12.1</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                </configuration>
            </plugin>
            <plugin>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
                <configuration>
                    <warSourceDirectory>src/main/webapp</warSourceDirectory>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

web.xml

<?xml version="1.0" encoding="utf-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
    version="3.1">

    <display-name>SpringMVC-Demo Project</display-name>

    <!-- Spring MVC DispatcherServlet -->
    <servlet>
        <servlet-name>dispatcher</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:config/spring-mvc.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>dispatcher</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

spring-mvc.xml

src/main/resources/config/spring-mvc.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:websocket="http://www.springframework.org/schema/websocket"
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/mvc 
                           http://www.springframework.org/schema/mvc/spring-mvc.xsd
                           http://www.springframework.org/schema/context 
                           http://www.springframework.org/schema/context/spring-context.xsd
                           http://www.springframework.org/schema/tx
                           http://www.springframework.org/schema/tx/spring-tx.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop.xsd
                           http://www.springframework.org/schema/websocket
                           http://www.springframework.org/schema/websocket/spring-websocket-4.1.xsd">

    <mvc:annotation-driven />
    <mvc:resources location="/js/" mapping="/js/**"/>
    <context:annotation-config />

    <!-- Database Configuration -->
    <import resource="../database/datasource.xml" />
    <import resource="../database/hibernate.xml" />

    <context:component-scan
        base-package="spring.demo" />

    <bean id="viewResolver"
        class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/WEB-INF/pages/" />
        <property name="suffix" value=".jsp" />
    </bean>

</beans>

database.properties

src/main/resources/properties/database.properties

# DataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/smvc?useUnicode=yes&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=pwd
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# C3P0
connectionPool.init_size=2
connectionPool.min_size=2
connectionPool.max_size=10
connectionPool.timeout=600

# SQL
jdbc.dataSource.dialect=org.hibernate.dialect.MySQLDialect
jdbc.dataSource.showSql=true

datasource.xml

src/main/resources/database/datasource.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/mvc 
                           http://www.springframework.org/schema/mvc/spring-mvc.xsd
                           http://www.springframework.org/schema/context 
                           http://www.springframework.org/schema/context/spring-context.xsd
                           http://www.springframework.org/schema/tx
                           http://www.springframework.org/schema/tx/spring-tx.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop.xsd">

    <bean
        class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
        <property name="location">
            <value>classpath:properties/database.properties</value>
        </property>
    </bean>

    <bean id="dataSource"
        class="com.mchange.v2.c3p0.ComboPooledDataSource">
        <property name="driverClass"
            value="${spring.datasource.driver-class-name}" />
        <property name="jdbcUrl" value="${spring.datasource.url}" />
        <property name="user" value="${spring.datasource.username}" />
        <property name="password" value="${spring.datasource.password}" />
        <property name="autoCommitOnClose" value="false"/> <!-- 連接關閉時默認將所有未提交的操作回復 Default:false -->
        <property name="checkoutTimeout" value="${connectionPool.timeout}"/>
        <property name="initialPoolSize" value="${connectionPool.init_size}"/>
        <property name="minPoolSize" value="${connectionPool.min_size}"/>
        <property name="maxPoolSize" value="${connectionPool.max_size}"/>
    </bean>

</beans>

hibernate.xml

src/main/resources/database/hibernate.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
                           http://www.springframework.org/schema/beans/spring-beans.xsd
                           http://www.springframework.org/schema/mvc 
                           http://www.springframework.org/schema/mvc/spring-mvc.xsd
                           http://www.springframework.org/schema/context 
                           http://www.springframework.org/schema/context/spring-context.xsd
                           http://www.springframework.org/schema/tx
                           http://www.springframework.org/schema/tx/spring-tx.xsd
                           http://www.springframework.org/schema/aop
                           http://www.springframework.org/schema/aop/spring-aop.xsd">

    <!-- Hibernate session factory -->
    <bean id="sessionFactory"
        class="org.springframework.orm.hibernate5.LocalSessionFactoryBean">

        <property name="dataSource">
            <ref bean="dataSource" />
        </property>
        <property name="packagesToScan">
            <list>
                <value>spring.demo.entity</value>
            </list>
        </property>

        <property name="hibernateProperties">
            <props>
                <prop key="hibernate.dialect">${jdbc.dataSource.dialect}</prop>
                <prop key="hibernate.show_sql">${jdbc.dataSource.showSql}</prop>
            </props>
        </property>
    </bean>

    <bean id="transactionManager" class="org.springframework.orm.hibernate5.HibernateTransactionManager">
        <property name="sessionFactory" ref="sessionFactory"/>
    </bean>
    <tx:annotation-driven transaction-manager="transactionManager"/>
</beans>

mysql database

create table Student
(
   id         int(12) not null auto_increment,
   Name       national varchar(50),
   Math_Score int,
   primary key (id)
);

insert into Student (Name, Math_Score) values ('Aron', 33);
insert into Student (Name, Math_Score) values ('Benson', 22);

java source code

HelloController

src/main/java/spring/demo/controller/HelloController.java

package spring.demo.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import spring.demo.entity.Student;
import spring.demo.service.StudentService;

@Controller
public class HelloController {
    @Autowired
    private StudentService service;

    @RequestMapping("hello")
    public ModelAndView hello() {
        return new ModelAndView("hello");
    }
    @RequestMapping("list")
    public ModelAndView list() {
        List<Student> resultList = service.findStudent();
        ModelAndView model = new ModelAndView();
        model.setViewName("list");
        model.addObject("resultList",resultList);

        return model;
    }

    @RequestMapping(value = "view" ,method = RequestMethod.POST)
    public ModelAndView view(@RequestParam("id") long id,@RequestParam("type") String type) {
        System.out.println(id);
        System.out.println(type);
        Student s = service.findStudent(id);
        ModelAndView model = new ModelAndView();
        model.setViewName("view");
        model.addObject("s", s);
        model.addObject("type",type);
        return model;
    }

    @RequestMapping(value = "update" ,method = RequestMethod.POST)
    public ModelAndView update(@ModelAttribute("s") Student s,RedirectAttributes attr) {
        service.updateStudent(s);

        attr.addFlashAttribute("message","修改成功");
        return new ModelAndView("redirect:hello");
    }
}

Student

src/main/java/spring/demo/entity/Student.java

package spring.demo.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

@Entity
@Table(name = "Student")
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;

    @Column(name="Name")
    private String name;
    @Column(name="Math_Score")
    private int mathScore;

    public long getId() {
        return id;
    }
    public void setId(long id) {
        this.id = id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getMathScore() {
        return mathScore;
    }
    public void setMathScore(int mathScore) {
        this.mathScore = mathScore;
    }

    public String toString() {
        Gson gson = new GsonBuilder().setPrettyPrinting().create();
        return gson.toJson(this);
    }

}

Student Service

src/main/java/spring/demo/service/StudentSerivce.java

package spring.demo.service;

import java.util.List;

import spring.demo.entity.Student;

public interface StudentService {
    List<Student> findStudent();
    Student findStudent(long id);
    void updateStudent(Student s);
}

src/main/java/spring/demo/service/impl/StudentSerivceImpl.java

package spring.demo.service.impl;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import spring.demo.dao.StudentDao;
import spring.demo.entity.Student;
import spring.demo.service.StudentService;

@Service
public class StudentServiceImpl implements StudentService {

    @Autowired
    private StudentDao dao;

    @Override
    @Transactional
    public List<Student> findStudent() {
        return dao.findAll();
    }

    @Override
    @Transactional
    public Student findStudent(long id) {
        return dao.findById(id);
    }

    @Override
    @Transactional
    public void updateStudent(Student s) {
        dao.update(s);
    }

}

Student DAO

src/main/java/spring/demo/dao/StudentDao.java

package spring.demo.dao;

import java.util.List;

import spring.demo.entity.Student;

public interface StudentDao {
    Student findById(long id);
    List<Student> findAll();
    void update(Student s);
}

src/main/java/spring/demo/dao/impl/StudentDaoImpl.java

package spring.demo.dao.impl;

import java.util.List;

import jakarta.persistence.Query;

import org.hibernate.SessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import spring.demo.dao.StudentDao;
import spring.demo.entity.Student;

@Repository
public class StudentDaoImpl implements StudentDao {
//    有多個sessionFactory 可以用Resource來指定
//    @Resource(name = "sessionFactory") 
//    private SessionFactory sessionFactory;

    @Autowired
    private SessionFactory sessionFactory;

    @Override
    @Transactional
    public Student findById(long id) {
        String sql = "select * from Student where id = :id";
        Query query = sessionFactory.getCurrentSession().createNativeQuery(sql).addEntity(Student.class);
        query.setParameter("id", id);
        return (Student) query.getSingleResult();
    }

    @Override
    @Transactional
    public List<Student> findAll() {
        String sql = "select * from Student";
        Query query = sessionFactory.getCurrentSession().createNativeQuery(sql).addEntity(Student.class);

        @SuppressWarnings("unchecked")
        List<Student> resultList = query.getResultList();
        return resultList;
    }

    @Override
    @Transactional
    public void update(Student s) {
        sessionFactory.getCurrentSession().update(s);
    }

}

WebSocket

src/main/java/spring/demo/ws/WebSocketConfig.java

package spring.demo.ws;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/chat").setAllowedOrigins("*").addInterceptors(new HttpSessionHandshakeInterceptor());
    }

    @Bean
    public WebSocketHandler myHandler() {
        return new ChatHandler();
    }

}

src/main/java/spring/demo/ws/ChatHandshakeInterceptor.java

package spring.demo.ws;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

import java.util.Map;

@Component
public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        System.out.println("Before Handshake");
//        //在握手之前将HttpSession中的用户,copy放到WebSocket Session中
//        if (request instanceof ServletServerHttpRequest){
//            ServletServerHttpRequest servletServerHttpRequest=
//                    (ServletServerHttpRequest) request;
//            HttpSession session=
//                    servletServerHttpRequest.getServletRequest().getSession(true);
//            if (null!=session){
//                User user=(User)session.getAttribute("user");
//                //WebSocket Session
//                attributes.put("user",user);
//            }
//        }
        return super.beforeHandshake(request,response,wsHandler,attributes);
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) {
        super.afterHandshake(request, response, wsHandler, ex);
    }
}

src/main/java/spring/demo/ws/ChatHandler.java

package spring.demo.ws;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
public class ChatHandler extends TextWebSocketHandler {
    private static CopyOnWriteArraySet<WebSocketSession> clients=new CopyOnWriteArraySet<WebSocketSession>();
    private WebSocketSession wsSession = null;
    private String username;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        this.wsSession = session;
        String name = this.getAttribute(session, "username");
        this.username = name;
        clients.add(session);

        String message = (username+" is in chatroom");
        broadcastMessage(message);
    }

    private String getAttribute(WebSocketSession webSocketSession, String key) {
        URI uri = webSocketSession.getUri();
        //userid=123&dept=4403
        String query = uri.getQuery();
        if (null != query && !"".equals(query)) {
            //??
            String[] queryArr = query.split("&");
            for (String queryItem : queryArr) {
                //userid=123
                String[] queryItemArr = queryItem.split("=");
                if (2 == queryItemArr.length) {
                    if (key.equals(queryItemArr[0]))
                        return queryItemArr[1];
                }
            }
        }
        return "";
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
//        clients.remove(session.getId());
        clients.remove(session);
    }

    public static CopyOnWriteArraySet<WebSocketSession> getClients() {
        return clients;
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        broadcastMessage(username + " broadcast: " + payload);

        TextMessage s1 = new TextMessage(username+ " echo: " + payload);
        session.sendMessage(s1);
    }

    void broadcastMessage(String json) throws IOException {
        TextMessage message = new TextMessage(json);
        for( WebSocketSession client: getClients()) {
            client.sendMessage(message);
        }
    }
}

web page

src/main/webapp/WEB-INF/pages/hello.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<script src="<c:url value='/js/jquery-3.7.1.min.js' />"></script>
<title>HelloWorld</title>
</head>
<body>
    <h1>Hello World!! Spring MVC</h1>
    <input type="button" id="getAll" value="列出全部學生" />
</body>
<script type="text/javascript">
    $(document).ready(function() {
        if ('${message}' != '') {
            alert('${message}')
        }

        $("#getAll").click(function() {
            window.location.href = "<c:url value='/list' />"
        });
    })
</script>
</html>

src/main/webapp/WEB-INF/pages/list.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<script src="<c:url value='/js/jquery-3.7.1.min.js' />"></script>
<title>List</title>
</head>
<body>
    <h1>學生列表</h1>
    <form id="viewDetail" action="view" method="post">
        <input name="id" type="hidden" /> <input name="type" type="hidden" />
        <table>
            <c:if test="${resultList.size()>0}">
                <tr>
                    <td>學號</td>
                    <td>名字</td>
                    <td>操作</td>
                </tr>
                <c:forEach items="${resultList}" var="result" varStatus="s">
                    <tr>
                        <td>${result.id}</td>
                        <td>${result.name}</td>
                        <td><input type="button" value="查看"
                            onclick="view(${result.id},'view')"></td>
                    </tr>
                </c:forEach>
            </c:if>
        </table>
    </form>
</body>
<script type="text/javascript">
    $(document).ready(function() {

    })
    function view(id,type){
        $('input[name="id"]').val(id);
        $('input[name="type"]').val(type);
        $("#viewDetail").submit();
    }
</script>
</html>

src/main/webapp/WEB-INF/pages/view.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<script src="<c:url value='/js/jquery-3.7.1.min.js' />"></script>
<title>View Detail</title>
</head>
<body>
    <h1>詳細資料</h1>
    <form:form id="viewDetail" action="view" method="post" modelAttribute="s">
        <form:input path="id" type="hidden" /> <input name="type" type="hidden" />
        <table>
            <tr>
                <td>學號</td>
                <td>名字</td>
                <td>數學成績</td>
            </tr>

            <tr>
                <td>${s.id}</td>
                <c:choose>
                    <c:when test="${type eq 'view'}">

                        <td>${s.name}</td>
                        <td align="center">${s.mathScore}</td>
                    </c:when>
                    <c:otherwise>
                            <td><form:input path="name" /> </td>
                            <td align="center"><form:input path="mathScore" /></td>
                    </c:otherwise>
                </c:choose>
            </tr>

        </table>
    </form:form>
    <input type="button" value="回上ㄧ頁" onclick="history.back()" />
    <c:choose>
        <c:when test="${type eq 'view'}">
            <input type="button" value="修改"
                onclick="modifyDetail(${s.id}),'modify'" />
        </c:when>
        <c:otherwise>
            <input type="button" value="確認修改" onclick="update()" />
        </c:otherwise>
    </c:choose>
</body>
<script type="text/javascript">
    $(document).ready(function() {

    })
    function modifyDetail(id,type){
        $('input[name="id"]').val(id);
        $('input[name="type"]').val(type);
        $("#viewDetail").submit();
    }
    function update(){
        $("#viewDetail").attr("action","update").submit();
    }
</script>
</html>

啟動測試

Student 網頁 http://localhost:8080/smvc/hello

WebSocket 網頁 http://localhost:8080/smvc/ws.jsp

References

建立一個SpringMVC + Spring + Hibernate 的Web專案 - HackMD

Spring WebSocket - using WebSocket in a Spring application

spring框架下基于websocket握手的拦截器配置(HandshakeInterceptor)-CSDN博客

2024/08/19

Hidden Class in java 15

Java 15 提供規格 JEP-371 稱為 Hidden Class 的功能,Hidden Class 提供高效且彈性的功能,可在 run time 動態產生 class。hidden class 無法直接被 byte code 或其他 classes 使用,可以用在特殊的保護的程式碼,讓這些程式碼,不會在 VM 裡面被動態修改。

Hidden Class 的特性

hidden class 動態產生的 class 有以下特性

  • non-discoverable

    無法在 bytecode 被使用,無法被 class loader 使用,無法用 reflective methods ( Class:forName, ClassLoader:findLoadedClass , Lookup:findClass) 找到這些 class

  • 不能將 hidden class 當作 superclass, field tyoe, return type, parameter type

測試

以下測試的概念,是將要被隱藏的類別,先編譯出來,然後把編譯後的 byte code,用 base 64 的方式編碼。

該編碼後的字串,就等同於一個已經編譯過的類別,可以用 Hidden Class 的方式,載入該類別並呼叫該類別的一個靜態 method

hidden/Hello.java

package hidden;

public class Hello {
    public static String greet() {
        return "hello";
    }
}

hidden/ClassToBase64Converter.java

這個 class 是用來將 class byte code 的 byte array,用 base64 編碼

package hidden;

import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
public class ClassToBase64Converter {
    public static void main(String[] args) {
        // Provide the path to your .class file
        Class<?> clazz = hidden.Hello.class;
        String className = clazz.getName();
        String classAsPath = className.replace('.', '/') + ".class";

        try {
            byte[] classBytes;
            try (InputStream stream = clazz.getClassLoader().getResourceAsStream(classAsPath)) {
                // Read the .class file as bytes
//            byte[] classBytes = Files.readAllBytes(Paths.get(classFilePath));
                classBytes = stream != null ? stream.readAllBytes() : new byte[0];
            }
            // Encode the bytes to Base64
            String base64Encoded = Base64.getEncoder().encodeToString(classBytes);
            // Print or use the Base64-encoded string
            System.out.println("Base64 Encoded Class:\n" + base64Encoded);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

執行後可得到這個結果

Base64 Encoded Class:
yv66vgAAAD0AFAoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClWCAAIAQAFaGVsbG8HAAoBAAxoaWRkZW4vSGVsbG8BAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEADkxoaWRkZW4vSGVsbG87AQAFZ3JlZXQBABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhACEACQACAAAAAAACAAEABQAGAAEACwAAAC8AAQABAAAABSq3AAGxAAAAAgAMAAAABgABAAAAAwANAAAADAABAAAABQAOAA8AAAAJABAAEQABAAsAAAAbAAEAAAAAAAMSB7AAAAABAAwAAAAGAAEAAAAFAAEAEgAAAAIAEw==

接下來可把這個 base64 字串,放到另一個 class 的 string

package hidden;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Base64;

public class HiddenClassDemo {
    static final String CLASS_IN_BASE64 = "yv66vgAAAD0AFAoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClWCAAIAQAFaGVsbG8HAAoBAAxoaWRkZW4vSGVsbG8BAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEADkxoaWRkZW4vSGVsbG87AQAFZ3JlZXQBABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhACEACQACAAAAAAACAAEABQAGAAEACwAAAC8AAQABAAAABSq3AAGxAAAAAgAMAAAABgABAAAAAwANAAAADAABAAAABQAOAA8AAAAJABAAEQABAAsAAAAbAAEAAAAAAAMSB7AAAAABAAwAAAAGAAEAAAAFAAEAEgAAAAIAEw==";
    public static void main(String[] args) throws Throwable {
        testHiddenClass();
    }
    // create a hidden class and run its static method
    public static void testHiddenClass() throws Throwable {
        byte[] classInBytes = Base64.getDecoder().decode(CLASS_IN_BASE64);
        Class<?> proxy = MethodHandles.lookup()
                .defineHiddenClass(classInBytes,
                        true, MethodHandles.Lookup.ClassOption.NESTMATE)
                .lookupClass();
        System.out.println(proxy.getName());
        MethodHandle mh = MethodHandles.lookup().findStatic(proxy,
                "greet",
                MethodType.methodType(String.class));
        String status = (String) mh.invokeExact();
        System.out.println(status);
    }
}

執行結果

hidden.Hello/0x0000000800c00400
hello

References

Hidden Classes in Java 15 | Baeldung

What are Hidden Classes in Java 15 And How Are They Used? | foojay

Hidden Class in Java - Java Code Geeks

How to use Hidden Classes in Java - Mastertheboss

2024/08/12

STOMP

Simple (or Streaming) Text Oriented Message Protocol (STOMP) 是一種單純的以純文字為基礎的協定,通常用在訊息導向的 client-server 互動的訊息交換標準,所以會在 Message Queue 以及 WebSocket 看到使用這個 Protocol 傳遞訊息。

Commands

STOMP 類似 HTTP protocol,在 client 傳送給 server 定義了以下這些 Commands

  • CONNECT
  • SEND
  • SUBSCRIBE
  • UNSUBSCRIBE
  • BEGIN
  • COMMIT
  • ABORT
  • ACK
  • NACK
  • DISCONNECT

在 Server 傳送給 client 定義了以下的 commands

  • CONNECTED
  • MESSAGE
  • RECEIPT
  • ERROR

在規格中,稱呼每一則傳遞的訊息為 frame,frame 的第一行都是 command,後面跟著多行 key:value 的 headers,然後有一行空白,最後面是 body content,這樣的訊息格式跟 http 完全一樣

frame 結束時,要有一個 NULL octet,在規格文件標記為 ^@

只有 SEND, MESSAG, ERROR 這三個 frame 能夠有 body,其他 frames 不能有 body

Headers

commands 跟 headers 必須使用 UTF-8 encoding,在 parsing header 時,必須要做這樣的轉換

  • \r (0x5C, 0x72) 要轉換為 carriage return (0x0D)

  • \n (0x5C, 0x6E) 要轉換為 line feed (0x0A)

  • \c (0x5C, 0x63) 要轉換為 : (0x3A)

  • \\ (0x5C, 0x5C) 要轉換為 \ (0x5C)

在 header 的部分,除了因應自己的需求,要定義的 headers 以外,建議要定義這些標準的 header

  • content-length

  • content-type

這兩個 headers 類似 http protocol,就是整個 frame 的長度,以及 content 的內容格式

為避免惡意的 client,故意傳送非常大的 frame,把 server 的訊息 buffer 撐爆,server 可以限制以下這些部分的長度

  • 單一 frame 裡面可使用的 header 數量

  • 每一行 header 的最大長度

  • frame body 的最大長度

Spec 裡面,將 frame 區分為 Connecting, Client, Server 三個部分

Connecting

client 連線時,要發送 CONNECT

CONNECT
accept-version:1.2
host:stomp.github.org

^@

連線也可加上其他 headers,例如 login 帳號,passcode 密碼,heart-beat

server 接受連線要回應

CONNECTED
version:1.2

^@

還可以加上 session 代表 session id,server 代表 server name


CONNECT
accept-version:1.0,1.1,2.0
host:stomp.github.org

^@

完全不接受連線,就回應ERROR,這樣是說明 client 版本不符

ERROR
version:1.2,2.1
content-type:text/plain

Supported protocol versions are 1.2 2.1^@

Heart-beating

因為 TCP 連線有可能因為太久沒有資料通過,該連線通道有可能會被網路中兼任一個節點關閉,heart-beating 功能,類似 ping pong,定時發送,維持 TCP 連線。

heart-beat header 格式有兩個正整數,用逗號隔開

第一個正整數代表 sender 的 outgoing heart-beat

  • 0: 表示無法發送 heart-beat

  • 其他: 代表該 sender 保證在多少 milliseconds 之間,可發送 heart-beat

第二個正整數代表 sender 期待收到的 heart-beat

  • 0: 表示不需要接收 heart-beat

  • 其他: 代表該 sender 希望在多少 milliseconds 之間,可收到 heart-beat

CONNECT
heart-beat:<cx>,<cy>

CONNECTED
heart-beat:<sx>,<sy>

Client Frames

  • SEND

  • SUBSCRIBE

  • UNSUBSCRIBE

  • BEGIN

  • COMMIT

  • ABORT

  • ACK

  • NACK

  • DISCONNECT

SEND

send message to a destination

SEND
destination:/queue/a
content-type:text/plain

hello queue a
^@

SUBSCRIBE

註冊要 listen to a given destination

SUBSCRIBE
id:0
destination:/queue/foo
ack:client

^@

UNSUBSCRIBE

UNSUBSCRIBE
id:0

^@

ACK

acknowledge 確認收到某個訊息,如果有 transaction header,代表這個 ACK 是某個 transaction 的一部分

ACK
id:12345
transaction:tx1

^@

NACK

ACK 的相反

BEGIN

開始某個 transaction

BEGIN
transaction:tx1

^@

COMMIT

commit transaction

COMMIT
transaction:tx1

^@

ABORT

roll back a transaction

ABORT
transaction:tx1

^@

DISCONNECT

client 在關閉 TCP connection 之前,發送 DISCONNECT,正式通知 server 要關閉連線

client

DISCONNECT
receipt:77
^@

server response

RECEIPT
receipt-id:77
^@

client 在收到 RECEIPT 後,才正式關閉連線

Server Frames

  • MESSAGE

  • RECEIPT

  • ERROR

MESSAGE

傳送訊息給 destination,一定要加上 message-id header

MESSAGE
subscription:0
message-id:007
destination:/queue/a
content-type:text/plain

hello queue a^@

RECEIPT

server 確認有收到 client 發送的某一個 FRAME

RECEIPT
receipt-id:message-12345

^@

ERROR

錯誤發生時,server 發送給 client

ERROR
receipt-id:message-12345
content-type:text/plain
content-length:170
message:malformed frame received

The message:
-----
MESSAGE
destined:/queue/a
receipt:message-12345

Hello queue a!
-----
Did not contain a destination header, which is REQUIRED
for message propagation.
^@

Frames and Headers

除了上面提到,標準的 headers: content-length, content-type, receipt,所有 frames 建議必要使用與選擇性使用的 headers 如下

  • CONNECT or STOMP
    • REQUIRED: accept-versionhost
    • OPTIONAL: loginpasscodeheart-beat
  • CONNECTED
    • REQUIRED: version
    • OPTIONAL: sessionserverheart-beat
  • SEND
    • REQUIRED: destination
    • OPTIONAL: transaction
  • SUBSCRIBE
    • REQUIRED: destinationid
    • OPTIONAL: ack
  • UNSUBSCRIBE
    • REQUIRED: id
    • OPTIONAL: none
  • ACK or NACK
    • REQUIRED: id
    • OPTIONAL: transaction
  • BEGIN or COMMIT or ABORT
    • REQUIRED: transaction
    • OPTIONAL: none
  • DISCONNECT
    • REQUIRED: none
    • OPTIONAL: receipt
  • MESSAGE
    • REQUIRED: destinationmessage-idsubscription
    • OPTIONAL: ack
  • RECEIPT
    • REQUIRED: receipt-id
    • OPTIONAL: none
  • ERROR
    • REQUIRED: none
    • OPTIONAL: message

雖然規格有提到這些 header 建議,但實際上,這份規格比較接近是針對 messaging system 的 frame 建議。

常常會看到的是只使用了類似 http frame 的格式,每一則傳遞的訊息為 frame,frame 的第一行都是 command,後面跟著多行 key:value 的 headers,然後有一行空白,最後面是 body content,就只有符合這樣的使用規則,其他部分的內容,都是讓 application 自己決定該怎麼使用。

header, body 的內容,都是讓 application 自己決定自己的 client-server 溝通的 protocol 訊息內容。

References

STOMP

Streaming Text Oriented Messaging Protocol - Wikipedia

STOMP Protocol - GeeksforGeeks

2024/08/05

WebSocket in Tomcat 10

JSR 356, Java API for WebSocket 是 java 為 WebSocket 制定的 API 規格,Tomcat 10 內建 JSR 356 的實作,以下記錄如何使用 Tomcat 支援的 WebSocket

Server Side

Server 部分,不需要調整 web.xml,只需要有一個 Java Class,加上適當的 annotation

  • @ServerEndpoint("/websocketendpoint")

    這是將這個 Java Class 註冊為處理 ws://localhost:8080/testweb/websocketendpoint 這個 WebSocket 網址的對應處理的程式

  • @OnOpen, @OnClose

    分別寫在兩個 method 上面,對應處理 WebSocket 連線及斷線的 method

  • @OnMessage

    收到 WebSocket 的一個 message 時,對應的處理的 method

  • @OnError

    錯誤處理

WsServer.java

package com.maxkit.testweb.ws;

import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

@ServerEndpoint("/websocketendpoint")
public class WsServer {
    private Logger logger = LoggerFactory.getLogger(this.getClass().getName());
    private Session wsSession = null;
    private String username;

    @OnOpen
    public void onOpen(Session session) throws IOException {

        this.wsSession = session;
        session.getBasicRemote().sendText("The session is opened.");
        WsSeverSessions.getInstance().putSession(session.getId(), session);

        // parsing url in this format
        // ws://localhost:8080/testweb/websocketendpoint?username=name
        // queryString: username=name
        String queryString = session.getQueryString();
        Map<String, String> params = parseQuerystring(queryString);
//        this.username = QueryString.substring(QueryString.indexOf("=")+1);
        this.username = params.get("username");
        logger.info("Open Connection session id={}, username={}", session.getId(), username);

        String message = (username+" is in chatroom");
        broadcastMessage(message);
    }

    @OnClose
    public void onClose(Session session) {
        logger.info("Close Connection username={}, session id={}", username, session.getId());
        this.wsSession = null;
        WsSeverSessions.getInstance().removeSession(session.getId());
    }

    @OnMessage
    public String onMessage(String message) throws IOException {
        logger.info("Message from the client username={}, session id ={}, message={}", username, this.wsSession.getId(), message);
        broadcastMessage(username + " broadcast: " + message);
        return username+ " echo: " + message;
    }

    @OnError
    public void onError(Throwable e){
        e.printStackTrace();
    }

    public void broadcastMessage(String message) throws IOException {
        for (Session session : WsSeverSessions.getInstance().getSessions()) {
            if (session.isOpen()) {
                session.getBasicRemote().sendText(message);
            }
        }
    }

    public static Map<String, String> parseQuerystring(String queryString) {
        Map<String, String> map = new HashMap<String, String>();
        if ((queryString == null) || (queryString.equals(""))) {
            return map;
        }
        String[] params = queryString.split("&");
        for (String param : params) {
            try {
                String[] keyValuePair = param.split("=", 2);
                String name = URLDecoder.decode(keyValuePair[0], StandardCharsets.UTF_8);
                if (name.equals("")) {
                    continue;
                }
                String value = keyValuePair.length > 1 ? URLDecoder.decode( keyValuePair[1], StandardCharsets.UTF_8) : "";
                map.put(name, value);
            } catch (IllegalArgumentException  e) {
                // ignore this parameter if it can't be decoded
            }
        }
        return map;
    }
}

因為 Server 是透過 session.getBasicRemote() ,以 sendText 發送訊息給 client,如果要能夠在 server side 將訊息廣播給所有連線的 clients,必須在 server 的記憶體中,紀錄所有的 jakarta.websocket.Session

以下是用 Singleton 的方式,將 jakarta.websocket.Session 記錄在 ConcurrentHashMap 裡面。上面的 @OnOpen, @OnClose 會有將 Session 儲存在 WsServerSessions 的程式,另外 broadcastMessage 會取得所有的 Session,用以發送廣播訊息。

WsServerSessions.java

package com.maxkit.testweb.ws;

import jakarta.websocket.Session;

import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;

public class WsSeverSessions {
    private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();

    private volatile static WsSeverSessions _instance = null;

    private WsSeverSessions() {
    }

    public static WsSeverSessions getInstance() {
        if (_instance == null) {
            synchronized (WsSeverSessions.class) {
                if (_instance == null) {
                    _instance = new WsSeverSessions();
                }
            }
        }
        return _instance;
    }

    public synchronized void putSession(String id, Session session) {
        sessions.put(id, session);
    }

    public synchronized void removeSession(String id) {
        sessions.remove(id);
    }

    public synchronized Collection<Session> getSessions() {
        return sessions.values();
    }
}

Client Side

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Tomcat WebSocket</title>
</head>
<body>
    <form>
        username: <input id="username" type="text">
        <input onclick="wsLogin();" value="Login" type="button">
        <br/>
        message: <input id="message" type="text">
        <input onclick="wsSendMessage();" value="Echo" type="button">
        <input onclick="wsCloseConnection();" value="Disconnect" type="button">
    </form>
    <br>
    <textarea id="echoText" rows="20" cols="100"></textarea>
    <script type="text/javascript">
        var webSocket = null;
        var username = document.getElementById("username");
        var echoText = document.getElementById("echoText");
        var message = document.getElementById("message");

        function wsLogin() {
            if( username.value === "" ) {
                username.value = getRandomInt(10000).toString();
            }
            webSocket = new WebSocket("ws://localhost:8080/testweb/websocketendpoint?username="+username.value );
            echoText.value = "";
            webSocket.onopen = function (message) {
                wsOpen(message);
            };
            webSocket.onmessage = function (message) {
                wsGetMessage(message);
            };
            webSocket.onclose = function (message) {
                wsClose(message);
            };
            webSocket.onerror = function (message) {
                wsError(message);
            };

            function wsOpen(message) {
                echoText.value += "Connected ... \n";
            }

            function wsGetMessage(message) {
                echoText.value += "Message received from to the server : " + message.data + "\n";
            }

            function wsClose(message) {
                echoText.value += "Disconnect ... \n";
            }

            function wsError(message) {
                echoText.value += "Error ... \n";
            }
        }
        function wsSendMessage() {
            if( webSocket == null ) {
                return;
            }
            webSocket.send(message.value);
            echoText.value += "Message sended to the server : " + message.value + "\n";
            message.value = "";
        }

        function wsCloseConnection() {
            if( webSocket == null ) {
                return;
            }
            webSocket.close();
        }
        function getRandomInt(max) {
            return Math.floor(Math.random() * max);
        }

    </script>
</body>
</html>

Tomcat Example

網頁部分

https://github.com/apache/tomcat/tree/main/webapps/examples/websocket

Server Side

https://github.com/apache/tomcat/tree/main/webapps/examples/WEB-INF/classes/websocket

可以直接把 server side 的 java package: websocket 複製到自己的測試 web project,程式裡面有用到 util package 的 classes,把 util 也複製過去。server side 的寫法比上面測試的還複雜一點點

網頁部分只需要直接複製 websocket 這個目錄的網頁,但因為範例程式的 webapp 是固定寫成 examples,稍微修改 xhtml 網頁,把 examples 替代為自己的 webapp,例如 testweb

只需要這些 code 就可以執行 tomcat 的 websocket 範例


範例有四個

  1. chat:chat room

  2. echo:echo message

  3. drawboard:多人在一個繪圖板上,任意繪圖

  4. snake:可多人在分享的 snake board 上面,用 keyboard 將 snake 轉向


要注意 project 加上官方 websocket examples後,上面自己寫的程式變成無法運作,原因是 java.websocket.ExamplesConfig 這個 class 實作 ServerApplicationConfig 介面,多了一些限制

  • getAnnotatedEndpointClasses

    限制只會掃描 websocket 這個 package 裡面的 Endpoint annotation,所以將上面的程式移到這個 package 裡面

  • getEndpointConfigs

    加上以下這段程式

            if (scanned.contains(WsServer.class)) {
                result.add(ServerEndpointConfig.Builder.create(
                        WsServer.class,
                        "/websocketendpoint").build());
            }

References

WebSocket – Tomcat 簡單的聊天室 – Max的程式語言筆記

Apache Tomcat Websocket Tutorial - Java Code Geeks