2024/09/02

Quartz

Quartz 是 java 的 job-scheduling framwork,可使用類似 linux 的 crontab 方式設定定時啟動某個工作。

pom.xml

        <!-- https://mvnrepository.com/artifact/org.quartz-scheduler/quartz -->
        <dependency>
            <groupId>org.quartz-scheduler</groupId>
            <artifactId>quartz</artifactId>
            <version>2.3.2</version>
        </dependency>

API

quartz API 的核心是 scheduler,啟動後,Scheduler 會產生多個 worker threads,也就是利用 ThreadPool,執行 Jobs。

主要的 API interface

  • Scheduler

  • Job

  • JobDetail

    • 定義 job instances
  • Trigger

    • 決定 scheduler 的定時機制

    • SimpleTrigger 跟 CronTrigger 兩種

  • JobBuilder

    • 用來產生 JobDetail instances
  • TriggerBuilder

    • 用來產生 Trigger instances

Example

SimpleJob.java

package quartz;

import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

// 要實作 Job interface
public class SimpleJob implements Job {
    // trigger 會透過 scheduler 的 worker 呼叫 execute 執行 job
    // JobExecutionContext 可提供 runtime environment 資訊
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 在 產生 JobDetail 時,JobData 會透過 JobDataMap 傳進 Job
        // Job 利用 JobDataMap 取得 JobData
        JobDataMap dataMap = context.getJobDetail().getJobDataMap();
        String jobSays = dataMap.getString("jobSays");
        float myFloatValue = dataMap.getFloat("myFloatValue");

        System.out.println("Job says: " + jobSays + ", and val is: " + myFloatValue);
    }
}

JobA.java

@DisallowConcurrentExecution 可限制 Job 不會同時被執行兩次

package quartz;

import org.quartz.*;

@DisallowConcurrentExecution
public class JobA implements Job {

    public void execute(JobExecutionContext context) throws JobExecutionException {
        TriggerKey triggerKey= context.getTrigger().getKey();
        System.out.println("job A. triggerKey="+triggerKey.getName()+", group="+triggerKey.getGroup() + ", fireTime="+context.getFireTime());
    }

}

JobB.java

package quartz;

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.TriggerKey;

public class JobB implements Job {

    public void execute(JobExecutionContext context) throws JobExecutionException {
        TriggerKey triggerKey= context.getTrigger().getKey();
        System.out.println("job B. triggerKey="+triggerKey.getName()+", group="+triggerKey.getGroup() + ", fireTime="+context.getFireTime());
    }

}

QuartzExample.java

package quartz;

import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SchedulerFactory;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.impl.StdSchedulerFactory;

public class QuartzExample {

    public static void main(String args[]) {
        try {
            // 透過 SchedulerFactory 產生 Scheduler
            SchedulerFactory schedFact = new StdSchedulerFactory();
            Scheduler sched = schedFact.getScheduler();
            // 使用 start 啟動,使用 shutdown 停掉這個 scheduler
            // 要先將 job, trigger 綁定 scheduler 後,再啟動 scheduler
            // sched.start();
            // sched.shutdown();

            // 產生 SimpleJob,利用 JobData 傳入資料到 Job
            JobDetail job = JobBuilder.newJob(SimpleJob.class)
                                      .withIdentity("myJob", "group1")
                                      .usingJobData("jobSays", "Hello World!")
                                      .usingJobData("myFloatValue", 3.141f)
                                      .build();

            Trigger trigger = TriggerBuilder.newTrigger()
                                            .withIdentity("myTrigger", "group1")
                                            .startNow()
                                            .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(40).repeatForever())
                                            .build();

            ///////
            JobDetail jobA = JobBuilder.newJob(JobA.class)
                                       .withIdentity("jobA", "group2")
                                       .build();
            JobDetail jobB = JobBuilder.newJob(JobB.class)
                                       .withIdentity("jobB", "group2")
                                       .build();

            // triggerA 比 triggerB 有較高的 priority,比較早先被執行
            // 每 40s 啟動一次
            Trigger triggerA = TriggerBuilder.newTrigger()
                                             .withIdentity("triggerA", "group2")
                                             .startNow()
                                             .withPriority(15)
                                             .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(40).repeatForever())
                                             .build();

            // 每 20s 啟動一次
            Trigger triggerB = TriggerBuilder.newTrigger()
                                             .withIdentity("triggerB", "group2")
                                             .startNow()
                                             .withPriority(10)
                                             .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(20).repeatForever())
                                             .build();

            sched.scheduleJob(job, trigger);
            sched.scheduleJob(jobA, triggerA);
            sched.scheduleJob(jobB, triggerB);
            sched.start();

        } catch (SchedulerException e) {
            e.printStackTrace();
        }
    }

}

執行結果

Job says: Hello World!, and val is: 3.141
job A. triggerKey=triggerA, group=group2, fireTime=Wed Aug 28 16:16:37 CST 2024
job B. triggerKey=triggerB, group=group2, fireTime=Wed Aug 28 16:16:37 CST 2024
job B. triggerKey=triggerB, group=group2, fireTime=Wed Aug 28 16:16:57 CST 2024
Job says: Hello World!, and val is: 3.141
job A. triggerKey=triggerA, group=group2, fireTime=Wed Aug 28 16:17:17 CST 2024
job B. triggerKey=triggerB, group=group2, fireTime=Wed Aug 28 16:17:17 CST 2024

QuartzExample2.java

package quartz;

import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

public class QuartzExample2 {

    public static void main(String args[]) {
        try {
            SchedulerFactory schedFact = new StdSchedulerFactory();
            Scheduler sched = schedFact.getScheduler();

            // 產生 SimpleJob,利用 JobData 傳入資料到 Job
            JobDetail job = JobBuilder.newJob(SimpleJob.class)
                                      .withIdentity("myJob", "group1")
                                      .usingJobData("jobSays", "Hello World!")
                                      .usingJobData("myFloatValue", 3.141f)
                                      .build();

            Trigger trigger = TriggerBuilder.newTrigger()
                                            .withIdentity("myTrigger", "group1")
                                            .startNow()
                                            .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(40).repeatForever())
                                            .build();

            // 跟上面的 Scheduler 一樣
            // 如果 worker thread 不足,job 可能不會被執行
            // 當 quartz 發現有這種狀況時,會使用 .withMisfireHandlingInstructionFireNow() 這個規則
            // 在 misfire 時,馬上執行一次
            Trigger trigger2 = TriggerBuilder.newTrigger()
                                            .withIdentity("myTrigger", "group1")
                                            .startNow()
                                            .withSchedule(SimpleScheduleBuilder.simpleSchedule().withMisfireHandlingInstructionFireNow().withIntervalInSeconds(40).repeatForever())
                                            .build();

            sched.scheduleJob(job, trigger);
            sched.start();

        } catch (SchedulerException e) {
            e.printStackTrace();
        }
    }

}

QuartzExample3.java

package quartz;

import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

import java.util.Date;

public class QuartzExample3 {

    public static void main(String args[]) {
        try {
            SchedulerFactory schedFact = new StdSchedulerFactory();
            Scheduler sched = schedFact.getScheduler();

            // 產生 SimpleJob,利用 JobData 傳入資料到 Job
            JobDetail jobA = JobBuilder.newJob(JobA.class)
                                       .withIdentity("jobA", "group2")
                                       .build();

            // SimpleTrigger 可設定在某個特定時間開始
            // 3 seconds 後啟動 trigger
            // 同樣可加上 SimpleScheduleBuilder 定時每 40s 啟動一次
            java.util.Calendar calendar = java.util.Calendar.getInstance();
            calendar.add(java.util.Calendar.SECOND, 3);
            Date myStartTime = calendar.getTime();
            SimpleTrigger trigger =
                    (SimpleTrigger) TriggerBuilder.newTrigger()
                                                  .withIdentity("trigger1", "group1")
                                                  .startAt(myStartTime)
                                                  .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(40).repeatForever())
                                                  .build();

            JobDetail jobB = JobBuilder.newJob(JobB.class)
                                       .withIdentity("jobB", "group1")
                                       .build();
            // 透過 CronScheduleBuilder 產生 scheduler
            // "0 0/1 * * * ?" 代表每分鐘執行一次
            // 然後產生 CronTrigger
            CronTrigger trigger2 = TriggerBuilder.newTrigger()
                                                 .withIdentity("trigger2", "group1")
                                                 .withSchedule(CronScheduleBuilder.cronSchedule("0 0/1 * * * ?"))
                                                 .build();

            sched.scheduleJob(jobA, trigger);
            sched.scheduleJob(jobB, trigger2);
            sched.start();

        } catch (SchedulerException e) {
            e.printStackTrace();
        }
    }

}

執行結果

job B. triggerKey=trigger2, group=group1, fireTime=Wed Aug 28 16:11:00 CST 2024
job A. triggerKey=trigger1, group=group1, fireTime=Wed Aug 28 16:11:00 CST 2024
job A. triggerKey=trigger1, group=group1, fireTime=Wed Aug 28 16:11:40 CST 2024
job B. triggerKey=trigger2, group=group1, fireTime=Wed Aug 28 16:12:00 CST 2024
job A. triggerKey=trigger1, group=group1, fireTime=Wed Aug 28 16:12:20 CST 2024

References

Introduction to Quartz | Baeldung

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

2024/07/29

CORS

同源政策 Same-origin policy 是在Web瀏覽器中,允許某個網頁指令碼也就是 javascript 訪問另一個網頁的資料的前提是,這兩個網頁必須有相同的URI、主機名和埠號,一旦兩個網站滿足上述條件,這兩個網站就被認定為具有相同來源。在 same origin policy 的限制下,能限制瀏覽器防止某個網頁上的惡意指令碼通過該頁面的文件物件模型存取另一網頁上的敏感資料。

滿足同源的條件有三個

  1. 使用相同的通訊協定 http/https

  2. 有相同的網域 domain

  3. 有相同的 port

跨來源請求 cross-origin http request,就是在非同源的情況下,發生的 http request,在發生這種狀況時,必須要遵守 CORS (Cross-Origin Resource Sharing) 的規範

定義

CORS 就是針對非同源的 http request 制定的規範,當 javascript 存取非同源的資源時, server 必須明確告知瀏覽器,是否允許這樣的 request,只有 server 允許的 request 能夠被瀏覽器發送,否則就會失敗

CORS 的跨來源請求有兩種:「簡單」與「非簡單」

跨來源請求

「簡單」跨來源請求有兩個條件

  1. 只能使用 GET, POST, HEAD method

  2. http reques header 只能有 AcceptAccept-LanguageContent-Language 或 Content-Type,且 Content-Type 只能是 application/x-www-form-urlencodedmultipart/form-data 或 text/plain

只要不是「簡單」跨來源請求,就是「非簡單」跨來源請求

簡單請求 simple requests 不會觸發 CORS 預檢 preflighted

sample

以下是一個 cross-origin request/response,可發現在 request 裡面,會有一個 Origin http header

Origin 標記,這是來自 http://foo.example 的 cross-orign request

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.example


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[XML Data]

下面的 response 有 Access-Control-Allow-Origin 這個 header,* 表示server 允許來自任意 origin 的 http request

預檢 preflighted request

「非簡單」request,在瀏覽器真正發送 request 之前,會先發送一個 preflight request,用途是詢問 server 是否允許這樣的 request

preflighted request 是用 http OPTIONS method 產生的

在 request 會有這兩個 header

  1. Access-Control-Request-Method:該 request 是哪一個 http method

  2. Access-Control-Request-Headers:該 request 裡面待有哪些非簡單的 http header

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

response 有以下重點

  1. Access-Control-Allow-Origin:允許的 origin

  2. Access-Control-Allow-Methods:允許的跨 origin http method

  3. Access-Control-Allow-Headers:允許使用的 http header

  4. Access-Control-Max-Age:本次預檢請求回應所可以快取的秒數,代表 browser 可 cache 這個 response 多久

cookie

通常 CORS 不允許使用 cookie,也不支援 redirect

如果真的還是要使用 cookie

fetch API 要加上 credentials

fetch('https://othersite.com/data', {
  credentials: 'include'
})

XMLHttpRequest 要加上 withCredentials = true

const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('POST', 'https://othersite.com/data');

server 端也要增加一個 response header

Access-Control-Allow-Credentials: true

使用 cookie 時,Access-Control-Allow-Origin 不能直接填寫 *

必須明確標示哪些 origin 可以使用 cookie

References

跨來源資源共用(CORS) - HTTP | MDN

[教學] 深入了解 CORS (跨來源資源共用): 如何正確設定 CORS?

2024/07/22

JAX-RS Jersey in Tomcat

在 java 開發 RESTful Web service 的方式,除了比較常見的 Spring MVC 以外,還有一個隸屬於 JSR-370 規格的 JAX-RS API,這份規格定義了在 java web container 裡面,應該提供什麼 API 介面輔助開發者開發 Representational State Transfer (REST) web service。Eclipse Jersey 則是實作了 JAX-RS API 的一組 library,以下記錄如何在 tomcat 10 裡面使用 Jersey

pom.xml

以下定義了 Maven POM xml,裡面主要引用了 jakarta.ws.rs-api JAX-RS API,以及三個 Jersey 實作的 libary,另外還用了 log4j2

<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>com.maxkit</groupId>
    <artifactId>testweb</artifactId>
    <packaging>war</packaging>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>
    <version>1.0</version>
    <name>testweb</name>
    <url>http://maven.apache.org</url>
    <dependencies>
        <!--junit5-->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.10.1</version>
        </dependency>

        <!-- log4j2 + slf4j -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.22.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.22.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-slf4j-impl</artifactId>
            <version>2.22.1</version>
        </dependency>

        <!-- the REST API -->
        <dependency>
            <groupId>jakarta.ws.rs</groupId>
            <artifactId>jakarta.ws.rs-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <!-- These next two are needed because we are using Tomcat, which is not a full JEE server - it's just a servlet container. -->

        <!-- the Jersey implementation -->
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-servlet</artifactId>
            <version>3.1.5</version>
        </dependency>

        <!-- also needed for dependency injection -->
        <dependency>
            <groupId>org.glassfish.jersey.inject</groupId>
            <artifactId>jersey-hk2</artifactId>
            <version>3.1.5</version>
        </dependency>

        <!-- support for using Jackson (JSON) with Jersey -->
        <dependency>
            <groupId>org.glassfish.jersey.media</groupId>
            <artifactId>jersey-media-json-jackson</artifactId>
            <version>3.1.5</version>
        </dependency>

    </dependencies>
    <build>
        <finalName>testweb</finalName>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.12.1</version>
                    <configuration>
                        <source>${maven.compiler.source}</source>
                        <target>${maven.compiler.target}</target>
                        <encoding>${project.build.sourceEncoding}</encoding>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-resources-plugin</artifactId>
                    <version>3.3.1</version>
                    <configuration>
                        <encoding>${project.build.sourceEncoding}</encoding>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

application

有兩種方式定義 application,一種是在 web.xml,一種是直接寫在 Java code 裡面,以 annotation 定義

方法1 web.xml

以下用 org.glassfish.jersey.servlet.ServletContainer 定義了一個 JerseyRestServlet,並對應到 /rest/* 這個 url-pattern。

jersey.config.server.provider.packages 則是這個 application 會使用到的 web service 實作的 java classes 會放在哪一個 java package 裡面

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://java.sun.com/xml/ns/javaee" xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         id="testweb" version="3.0">
    <display-name>testweb</display-name>

    <servlet>
        <servlet-name>JerseyRestServlet</servlet-name>
        <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
        <init-param>
            <param-name>jersey.config.server.provider.packages</param-name>
            <param-value>com.maxkit.testweb.jersey.rest</param-value>
        </init-param>
        <init-param>
            <param-name>jersey.config.server.provider.scanning.recursive</param-name>
            <param-value>true</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>JerseyRestServlet</servlet-name>
        <url-pattern>/rest/*</url-pattern>
    </servlet-mapping>

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Forbidden</web-resource-name>
            <url-pattern>/WEB-INF/*</url-pattern>
        </web-resource-collection>
        <auth-constraint />
    </security-constraint>
</web-app>

方法2 Annotation

透過 @ApplicationPath 這個 annotation,tomcat 在啟動時,會自動掃描,並定義一個新的 /rest2/* url-pattern 的 application。

這個 application 會使用到的 web service 實作的 java classes 會放在哪一個 java package 裡面,是用 packages() 定義

package com.maxkit.testweb.jersey;

import jakarta.ws.rs.ApplicationPath;
import org.glassfish.jersey.server.ResourceConfig;

@ApplicationPath("rest2")
public class Rest2Application extends ResourceConfig {

    public Rest2Application() {
        packages("com.maxkit.testweb.jersey.rest2");
    }
}

web service

JAX-RS 定義了一些 annotation,標註為 web service

  • @Path

    url method 的相對路徑

  • @GET,@PUT,@POST,@DELETE

    使用哪一個 http method

  • @Consumes

    可接受的 request 裡面資料的 MIME type

  • @Produces

    回傳的資料的 MIME type

  • @PathParam,@QueryParam,@HeaderParam,@CookieParam,@MatrixParam,@FormParam

    參數的來源,@PathParam來自於URL的路徑,@QueryParam來自於URL的查詢參數,@HeaderParam來自於HTTP請求的頭信息,@CookieParam來自於HTTP請求的Cookie

@Path("/demo") 的部分,定義了對應的url,以 ping method 為例,要呼叫 ping,可使用這個 url: http://localhost:8080/testweb/rest/demo/ping

getNotification, postNotification 是測試要再回傳的資料中,以 JSON 格式回傳

login 裡面有用到 Cookie,因為 Jersey 的 Cookie 沒有 maxAge 的功能,這邊加上 maxAge,在 response 透過 Response.ResponseBuilder 設定 cookie

package com.maxkit.testweb.jersey.rest;

import com.maxkit.testweb.jersey.JerseyCookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Date;
import java.util.UUID;

//
// Pingable at:
// http://localhost:8080/testweb/rest/demo/ping
//
@Path("/demo")
public class DemoCommand {
    Logger log = LoggerFactory.getLogger(this.getClass().getName());

    @GET
    @Path("/ping")
    public Response ping() {
        log.debug("ping");
        return Response.ok().entity("Service online").build();
    }

    @GET
    @Path("/get/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getNotification(@PathParam("id") int id) {
        return Response.ok()
                .entity(new DemoPojo(id, "test message"))
                .build();
    }

    @POST
    @Path("/post")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response postNotification(DemoPojo pojo) {
        log.debug("demopojo={}", pojo);
        if( pojo.getTestdate() == null ) {
            pojo.setTestdate(new Date());
        }
        return Response.status(201).entity(pojo).build();
    }

    @POST
    @Path("/login")
    @Produces(MediaType.APPLICATION_JSON)
    public Response login(@Context HttpServletRequest req, @FormParam("userid") String userid, @FormParam("pd") String pwd) {
        JerseyCookie cookie = null;
        String cookiekey = UUID.randomUUID().toString();

        int validtime = 1 * 86400;
        cookie = new JerseyCookie("randomuuid", cookiekey, "/", null, 0, null, validtime, false, false);

        DemoPojo demo = new DemoPojo();
        demo.setId(12345);
        demo.setMessage("message");
        demo.setTestdate(new Date());
        Response.ResponseBuilder builder = Response.ok(demo);
        return builder.header("Set-Cookie", cookie).build();
    }
}

DemoPojo 是單純的一個 java data class

package com.maxkit.testweb.jersey.rest;

import java.util.Date;

public class DemoPojo {

    private int id;
    private String message;
    private Date testdate;

    public DemoPojo() {
    }

    public DemoPojo(int id, String message) {
        this.id = id;
        this.message = message;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Date getTestdate() {
        return testdate;
    }

    public void setTestdate(Date testdate) {
        this.testdate = testdate;
    }
}

JerseyCookie.java

package com.maxkit.testweb.jersey;

import org.glassfish.jersey.message.internal.HttpHeaderReader;
import org.glassfish.jersey.message.internal.StringBuilderUtils;

import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.NewCookie;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
//import com.sun.jersey.core.impl.provider.header.WriterUtil;

public class JerseyCookie extends Cookie {
    public static final int DEFAULT_MAX_AGE = -1;

    private String comment = null;
    private int maxAge = DEFAULT_MAX_AGE;
    private boolean secure = false;
    private Date expires;
    private boolean httponly = false;

    public JerseyCookie(String name, String value) {
        super(name, value);
    }

    public JerseyCookie(String name, String value, String path, String domain, String comment, int maxAge,
            boolean secure) {
        super(name, value, path, domain);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
    }

    public JerseyCookie(String name, String value, String path, String domain, int version, String comment, int maxAge,
            boolean secure) {
        super(name, value, path, domain, version);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
    }

    public JerseyCookie(Cookie cookie) {
        super(cookie == null ? null : cookie.getName(), cookie == null ? null : cookie.getValue(),
                cookie == null ? null : cookie.getPath(), cookie == null ? null : cookie.getDomain(),
                cookie == null ? Cookie.DEFAULT_VERSION : cookie.getVersion());
    }

    public JerseyCookie(String name, String value, String path, String domain, int version, String comment, int maxAge,
            boolean secure, boolean httponly) {
        super(name, value, path, domain, version);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
        if (maxAge > 0) {
            expires = new Date(new Date().getTime() + maxAge * 1000);
        }
        this.httponly = httponly;
    }

    public JerseyCookie(Cookie cookie, String comment, int maxAge, boolean secure, boolean httponly) {
        this(cookie);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
        if (maxAge > 0) {
            expires = new Date(System.currentTimeMillis() + maxAge * 1000);
        }
        this.httponly = httponly;
    }

    public JerseyCookie(Cookie cookie, String comment, int maxAge, boolean secure) {
        this(cookie);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
    }

    public static NewCookie valueOf(String value) throws IllegalArgumentException {
        if (value == null)
            throw new IllegalArgumentException("NewCookie is null");

        return HttpHeaderReader.readNewCookie(value);
        // return delegate.fromString(value);
    }

    public String getComment() {
        return comment;
    }

    public int getMaxAge() {
        return maxAge;
    }

    public boolean isSecure() {
        return secure;
    }

    public Cookie toCookie() {
        return new Cookie(this.getName(), this.getValue(), this.getPath(), this.getDomain(), this.getVersion());
    }

    @Override
    public String toString() {
        StringBuilder b = new StringBuilder();

        b.append(getName()).append('=');
        StringBuilderUtils.appendQuotedIfWhitespace(b, getValue());

        b.append(";").append("Version=").append(getVersion());

        if (getComment() != null) {
            b.append(";Comment=");
            StringBuilderUtils.appendQuotedIfWhitespace(b, getComment());
        }
        if (getDomain() != null) {
            b.append(";Domain=");
            StringBuilderUtils.appendQuotedIfWhitespace(b, getDomain());
        }
        SimpleDateFormat COOKIE_EXPIRES_HEADER_FORMAT = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss z", Locale.US);
        COOKIE_EXPIRES_HEADER_FORMAT.setTimeZone(TimeZone.getTimeZone("GMT"));
        // String cookieExpire = "expires=" +
        // COOKIE_EXPIRES_HEADER_FORMAT.format(expires);
        if (getExpires() != null) {
            b.append(";Expires=");
            b.append(COOKIE_EXPIRES_HEADER_FORMAT.format(expires));
        }
        if (getPath() != null) {
            b.append(";Path=");
            StringBuilderUtils.appendQuotedIfWhitespace(b, getPath());
        }
        // if (getMaxAge() != -1) {
        // b.append(";Max-Age=");
        // b.append(getMaxAge());
        // }
        if (isSecure())
            b.append(";Secure");
        if (isHttponly())
            b.append(";HTTPOnly");
        return b.toString();
    }

    @Override
    public int hashCode() {
        int hash = super.hashCode();
        hash = 59 * hash + (this.comment != null ? this.comment.hashCode() : 0);
        hash = 59 * hash + this.maxAge;
        hash = 59 * hash + (this.secure ? 1 : 0);
        return hash;
    }


    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final JerseyCookie other = (JerseyCookie) obj;
        if (this.getName() != other.getName() && (this.getName() == null || !this.getName().equals(other.getName()))) {
            return false;
        }
        if (this.getValue() != other.getValue()
                && (this.getValue() == null || !this.getValue().equals(other.getValue()))) {
            return false;
        }
        if (this.getVersion() != other.getVersion()) {
            return false;
        }
        if (this.getPath() != other.getPath() && (this.getPath() == null || !this.getPath().equals(other.getPath()))) {
            return false;
        }
        if (this.getDomain() != other.getDomain()
                && (this.getDomain() == null || !this.getDomain().equals(other.getDomain()))) {
            return false;
        }
        if (this.getExpires() != other.getExpires()
                && (this.getExpires() == null || !this.getExpires().equals(other.getExpires()))) {
            return false;
        }
        if (this.comment != other.comment && (this.comment == null || !this.comment.equals(other.comment))) {
            return false;
        }
        if (this.maxAge != other.maxAge) {
            return false;
        }

        if (this.secure != other.secure) {
            return false;
        }
        if (this.httponly != other.httponly) {
            return false;
        }
        return true;
    }

    public Date getExpires() {
        return expires;
    }

    public boolean isHttponly() {
        return httponly;
    }

}

References

JAX-RS - 維基百科,自由的百科全書

Jakarta REST (JAX-RS) on Tomcat 10 - northCoder

Eclipse Jersey

2024/07/15

CBOR

RFC 8949 CBOR: Concise Binary Object Representation,這是一種輕量的二進位資料格式,可簡單理解為一種二進位的 JSON,設計目的是最小化處理程式碼,最小化訊息位元組大小。另外在 RFC 9052, 9053 CBOR Object Signing and Encryption (COSE) 制定了要如何對 CBOR 資料進行資料簽章與加密的方法。

online test

cbor.me

CBOR/web (plain)

可到這兩個網頁進行線上測試

測試時可以發現,不管是整數,字串,都有對應的編碼。甚至是 JSON 字串,也有對應的 Map (key-value pair) 的編碼方式

CBOR

header 有兩個資訊,前 3 bits 是 Major Type,後 5 個 bits 是 Additional Information

字節(byte)數 1 byte (CBOR Data Item Header) 動態長度 動態長度
結構 Major Type Additional Information Payload Length(可選) Data Payload(可選)
bit 3 Bits 5 Bits 動態長度 動態長度

CBOR 有八種 Major Type

首位元組的 lower 5 bits 在不同的主類型表示長度(除主類型 0 和主類型 1),如果長度指示不足,則依次使用後續位元組。

Major Type Name content
0 unsigned integer -
1 negative integer -
2 byte string N bytes
3 text string N bytes (UTF-8 text)
4 array of data items N data items
5 map of pairs of data items 2N data items (key/value pairs)
6 tag of number N 1 data item
7 simple/float -

比較特別的是 Major Type 6 裡面定義了 date time

0xC0 定義了 text-based date/time,0xC1 定義了 epoch-based date/time

還有 Major Type 7 裡面的

  • False 編碼後 0xF4
  • True 編碼後 0xF5
  • Null 編碼後 0b1110_0000 + 0b0001_01110 (22) = 0xF6

implementation

CBOR — Concise Binary Object Representation | Implementations

這邊列出了不同程式語言的 CBOR 實作 library

這邊用 js 測試

<html>
<script src="https://cdn.jsdelivr.net/npm/cbor-js@0.1.0/cbor.min.js"></script>
<script>

function hex(buffer) {
    var s = '', h = '0123456789ABCDEF';
    (new Uint8Array(buffer)).forEach((v) => { s += h[v >> 4] + h[v & 15]; });
    return s;
}

function test0() {
    console.log("");
    console.log("test0 unsigned integer");
    var initial = 1000;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =" + initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =" + decoded);
}

function test1() {
    console.log("");
    console.log("test1 negative integer");
    var initial = -1000;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =" + initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =" + decoded);
}

function test2() {
    console.log("");
    console.log("test2 byte string");
    var initial = new Uint8Array([0, 1, 2, 3]);
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test3() {
    console.log("");
    console.log("test3 text string");
    var initial = "text string";
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test4() {
    console.log("");
    console.log("test4 array");
    var initial = [1, "2"];
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test5() {
    console.log("");
    console.log("test5 map");
    var initial = { Hello: "World" };
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test6() {
    console.log("");
    console.log("test6 float");
    var initial = 3.1415;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test7() {
    console.log("");
    console.log("test7 true");
    var initial = true;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test8() {
    console.log("");
    console.log("test8 null");
    var initial = null;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

test0();
test1();
test2();
test3();
test4();
test5();
test6();
test7();
test8();

</script>
</html>

結果

test0 unsigned integer
initial =1000
encoded hex= 1903E8
decoded =1000

test1 negative integer
initial =-1000
encoded hex= 3903E7
decoded =-1000

test2 byte string
initial = Uint8Array(4) [0, 1, 2, 3, buffer: ArrayBuffer(4), byteLength: 4, byteOffset: 0, length: 4, Symbol(Symbol.toStringTag): 'Uint8Array']
encoded hex= 4400010203
decoded = Uint8Array(4) [0, 1, 2, 3, buffer: ArrayBuffer(5), byteLength: 4, byteOffset: 1, length: 4, Symbol(Symbol.toStringTag): 'Uint8Array']

test3 text string
initial = text string
encoded hex= 6B7465787420737472696E67
decoded = text string

test4 array
initial = (2) [1, '2']
encoded hex= 82016132
decoded = (2) [1, '2']

test5 map
initial = {Hello: 'World'}
encoded hex= A16548656C6C6F65576F726C64
decoded = {Hello: 'World'}

test6 float
initial = 3.1415
encoded hex= FB400921CAC083126F
decoded = 3.1415

test7 true
initial = true
encoded hex= F5
decoded = true

test8 null
initial = null
encoded hex= F6
decoded = null

references

# 物聯網專用資料交換格式 CBOR

cobr.io

CBOR_百度百科

2024/07/01

WebAuthn

WebAuthn 是 Web 與 Authentication 結合的縮寫,也就是 Web Authentication,用在網頁身份認證。這個規格是由 W3C 與 FIDO (Fast IDentity Online) 提出,該正式API 規格,透過瀏覽器的支援,可透過 Public Key Cryptography 機制註冊並認證使用者,取代傳統密碼形式的認證方式。

最傳統的身份認證機制是用密碼,但因為密碼太多了,又因為資安政策需要常常修改密碼,正常人應該已經沒辦法記得很清楚,哪個網站是用哪一個密碼。

WebAuthn 是  FIDO2 framework 的部分,透過 server, browser, authenticator 的搭配,提供一種 passwordless 無密碼的認證方式。

Demo

webauthn.io 是一個單純的網頁 demo。demo 時分兩個步驟,Register 與 Authenticate。

Register 就是註冊帳號,使用者向 browser 要求要註冊一個新帳號,browser 透過 authenticator 的搭配輔助,產生一組 public key,然後完成註冊的步驟。 authenticator 可能是本機的指紋 scanner,也可以是 USB key,或是目前最容易取得的每一個人的手機,並搭配手機的 FaceID 或是指紋 scanner 輔助做身份認證。

註冊完成後,會取得註冊的 id,然後在透過認證 API,進行使用者身份認證。

Javascript demo

註冊

// should generate from server
const challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);

const userID = '5d157c55-ea8b-45ca-870a-da877a9c9916';

const utf8EncodeText = new TextEncoder();
const id = utf8EncodeText.encode(userID);

const publicKeyCredentialCreationOptions = {
    challenge,
    rp: {
        name: "Maxkit RP",
        // 這邊只是 demo,沒有放到某個 https 網站測試,如果是網站,就要改成該網站的 domain name
        //id: "maxkit.com.tw",
    },
    user: {
        id,
        name: "test",
        displayName: "Test",
    },
    pubKeyCredParams: [{alg: -7, type: "public-key"}],
    authenticatorSelection: {
        authenticatorAttachment: "cross-platform",
    },
    timeout: 60000,
    attestation: "direct"
};

const credential = await navigator.credentials.create({
    publicKey: publicKeyCredentialCreationOptions
});

把上面這一部分的 js code 直接貼到 chrome console 執行。主要就是透過 navigator.credentials.create 註冊產生 public key

challenge 欄位是由 server 產生,最終會由 authenticator 以私鑰簽章後,在 credential response 裡面傳回來,讓 server 能夠檢查確認 authenticator

rp.id 應該要填網域 domain name,這邊只是本機測試,不填寫

authenticatorAttachment: "cross-platform" 的意思是,可搭配使用外部認證工具,例如在 NB 瀏覽認證網頁時,可使用手機作為認證工具進行認證。

在 NB 提示認證時,會看到這個畫面

這時候,就用自己的手機相機,拍 QR code,手機上會出現以下畫面

確認後,會在手機的密碼部分,儲存一個專屬的密碼。iOS 手機選單在 "設定 -> 密碼"

在 console 列印 credential 的內容

// print credential
console.log(credential)
PublicKeyCredential {rawId: ArrayBuffer(20), response: AuthenticatorAttestationResponse, authenticatorAttachment: 'cross-platform', id: 'ku22CGGrZdYlkr9cCXL4IWtyYLc', type: 'public-key'}

正常狀況應該把 credential 傳送到 server,檢查並 decode 得到使用者的 id, public key。這邊僅是測試 demo,不做處理。

response.attestationObject 裡面有 public key 跟其他 metadata,是用 CBOR Concise Binary Object Representation 編碼後的 binary data。

認證

以下是認證的 demo code,從剛剛的 credential.id 欄位取得 id,通常應該是儲存在 server,從 server 傳到網頁得到這個資料

const credentialIdStr = credential.id;
const credentialId = Uint8Array.from(window.atob(credentialIdStr), c=>c.charCodeAt(0));

const challenge2 = new Uint8Array(32);
window.crypto.getRandomValues(challenge2);

const publicKeyCredentialRequestOptions = {
    challenge: challenge2,
    //rpId: "maxkit.com.tw",
    allowCredentials: [{
        id: credentialId, // from registration
        type: 'public-key',
        transports: ['usb', 'ble', 'nfc', 'hybrid'],
    }],
    timeout: 60000,
}

const assertion = await navigator.credentials.get({
    publicKey: publicKeyCredentialRequestOptions
});

transports 是可以認證的方式

網頁同樣會出現 QR code,等待 authenticator,這邊我們用剛剛註冊的手機掃描

手機掃描時,會出現一個提示

點"使用通行密鑰登入"

繼續透過 FaceID 認證,認證完成,同樣可取得 response

console.log(assertion)

CTAP

Client-to-Authenticator Protocol,這是在瀏覽器打開註冊/認證網頁後,authnticator 跟瀏覽器之間傳輸的協定,也就是 WebAuthn API 跟外部硬體認證機制溝通的規格

References

WebAuthn guide

什么是WebAuthn:在Web上使用Touch ID和Windows Hello登录_webauthn 网站登录-CSDN博客

你可能不知道的WebAuthN(FIDO) | 又LAG隨性筆記

初探 WebAuthn UAF 的無密碼 Passwordless 登入技術 – 要改的地方太多了,那就改天吧

一起來了解 Web Authentication

CredentialsContainer - Web APIs | MDN