2025/11/24

JavaFX Application

JavaFX 製作的 GUI application,因為以 java 實作,能夠跨平台運作。

application structure

Application (程式入口)
└── Stage (主視窗)
    └── Scene (場景)
        └── Scene Graph (節點樹)
            ├── Layouts (VBox, HBox, BorderPane, etc.)
            │   └── Controls (Button, Label, etc.)
            └── Other nodes (Canvas, WebView, etc.)
Scene (有一個 Root Node)
└── Root Node (Parent)
    ├── Branch Node (Parent)
    │   ├── Leaf Node (Control)
    │   └── Leaf Node (Shape)
    └── Branch Node (Group)
        └── Leaf Node (ImageView)

Stage

包含application內所有 objects,類別是 javafx.stage.Stage,primary stage 由 platform 建立,並以參數傳遞給 Application 的 start()

stage 有兩個參數決定位置:Width, Height,並分為 content area 與 decorations,呼叫show() 可顯示 stage 的 contents

Stage 是最上層的 container (window),只有一個主要的 Stage,javafx 支援五種類型的 stages

  • Primary Stage

    javafx runtime 產生的第一個初始 windows

    Main application window,由 star(Stage) 取得

    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Primary Stage");
        primaryStage.show();
    }
  • Secondary Stage

    可用來產生dialogs, tools, secondary views

    其他 window/dialog,以 new Stage() 產生

    Stage secondaryStage = new Stage();
    secondaryStage.setTitle("Secondary Stage");
    secondaryStage.show();
  • Model Stage

    特殊的 secondary stage,會阻斷跟其他 window 的互動

    Blocking dialogs,new Stage() + initModality(..) 產生

    Stage modalStage = new Stage();
    modalStage.initModality(Modality.APPLICATION_MODAL);
    modalStage.setTitle("Modal Dialog");
    modalStage.showAndWait();  // blocks until closed
  • Transparent Stage

    沒有 title bar, border, shadow,沒有 decorations 的 stage

    Custom UI, shaped windows,以 initStyle(StageStyle.TRANSPARENT) 產生

    Stage transparentStage = new Stage();
    transparentStage.initStyle(StageStyle.TRANSPARENT);
    transparentStage.setScene(new Scene(root, 300, 200));
    transparentStage.getScene().setFill(Color.TRANSPARENT);
    transparentStage.show();
  • Undecorated/Utility

    Undecorated Stage: no title bar, no close/minimize/maximize buttons

    Utility Stage: styled like a small tool window (depends on OS), useful for small popups or tool palettes.

    Tool windows, splash screens,以 StageStyle.UNDECORATED/UTILITY 產生

    Stage undecoratedStage = new Stage();
    undecoratedStage.initStyle(StageStyle.UNDECORATED);
    undecoratedStage.show();
    
    Stage utilityStage = new Stage();
    utilityStage.initStyle(StageStyle.UTILITY);
    utilityStage.show();

Scene

scene 包含了 scene graph 所有內容,類別是 javafx.scene.Scene,每一個 scene object instance 只能加入一個唯一的 stage

Scene Graph and Nodes

scene graph 是樹狀結構,裡面是 scene 的內容, node 是 scene graph 裡面的 visual/graphical object

  • Container (Parent)

    可包含子節點,用來排版,是 branch node 不是 leaf

    類別 說明
    Group 不處理排版,純粹容器
    Region 所有 layout class 基礎
    Pane 絕對位置排版
    VBox, HBox 垂直 / 水平排版容器
    BorderPane 上下左右中五區域排版
    StackPane 子節點重疊排列
    GridPane 表格式佈局
    AnchorPane 固定邊界排版(類似 CSS)
  • Control (互動元件)

    是 leaf node,可直接跟 user 互動

    類別 說明
    Button 按鈕
    Label 顯示文字
    TextField 單行文字輸入
    TextArea 多行文字輸入
    CheckBox 核取框
    RadioButton 單選按鈕
    ComboBox 下拉選單
    ListView 清單顯示
    TableView 表格資料顯示
    TreeView 樹狀階層顯示
    Slider 拖拉式數值選擇器
    ProgressBar 進度條
    MenuBar 選單列
    TabPane 分頁容器
  • Geometrical (graphical) 2D/3D object (Shape)

    leaf node,用在自訂 UI,或是繪圖功能

    類別 說明
    Rectangle 矩形
    Circle 圓形
    Ellipse 橢圓
    Line 線條
    Polygon 多邊形
    Polyline 折線
    Arc 弧形
    Path 任意路徑
  • Media

    Audio/Video/Image

    類別 說明
    ImageView 顯示圖片
    MediaView 播放音訊或影片視訊
    Canvas 提供 2D 自訂繪圖區域
    WebView 嵌入網頁(HTML/CSS/JS)
    Text 顯示純文字(非 Control)
  • Transform/特效用 node

    類別 說明
    SubScene 場景中的獨立子場景
    SnapshotView 拍攝其他節點的快照
    Group 提供無排版的節點群組
    Clip 裁剪圖形
    Effect (陰影等) 雖不是 Node,但可附加在 Node 上

Node 有三類

  • Root Node

  • Branch/Parent Node

    • Group

    • Region

    • WebView

  • Leaf Node

Example

import javafx.application.Application;
import javafx.collections.ObservableList;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class JavafxSample extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        //Creating a line object
        Line line = new Line();

        //Setting the properties to a line
        line.setStartX(100.0);
        line.setStartY(150.0);
        line.setEndX(500.0);
        line.setEndY(150.0);

        //Creating a Text object
        Text text = new Text();
        //Setting font to the text
        text.setFont(new Font(45));
        //setting the position of the text
        text.setX(50);
        text.setY(150);
        //Setting the text to be added.
        text.setText("Welcome to JavaFx Demo");

        //creating a Group object
        Group group = new Group( );
        //Retrieving the observable list object
        ObservableList list = group.getChildren();
        //Setting the text object as a node to the group object
        list.add(text);
        list.add(line);


        //Creating a Scene by passing the group object, height and width   
        Scene scene = new Scene(group, 600, 300);
        //setting color to the scene 
        scene.setFill(Color.ALICEBLUE);

        //Setting the title to Stage. 
        primaryStage.setTitle("Sample Application");
        //Adding the scene to Stage 
        primaryStage.setScene(scene);
        //Displaying the contents of the stage 
        primaryStage.show();
    }

    public static void main(String args[]) {
        launch(args);
    }
}
java --module-path /java/javafx-sdk-25.0.1/lib/ --add-modules javafx.controls JavafxSample

執行結果

2025/11/17

JavaFX Architecture

JavaFX 是 Java 製作 Content-Rich Client application 的 API。

Architecture

整個 API 的架構如下,包含了 2D 3D Graphic 以及 Web Engine,還有 GUI 的 UI 元件

重要的 API package 如下:

  • javafx.animation

    產生 transition based animations,例如 javafx nodes 的 fill, fade, rotate, scale, translation

  • javafx.application

    控制 JavaFX application 的 life cycle

  • javafx.css

    對 JavaFX GUI application 增加類似 css styling 的功能

  • javafx.event

    傳遞及處理事件

  • javafx.geometry

    產生及控制 2D object

  • javafx.stage

    JavaFX application 的最上層的 container class

  • javafx.scene

    支援 scene graph 的類別,提供 canvas, chart, control, effect, image, input, layout, media, paint, shape, text, transform, web 等等功能

scene graph, node

JavaFX application 是以 Scene Graph 實作,建構 GUI application 的起點就是 scene graph,裡面是 nodes。

node 是一個 visual/graphical object,可能是

  • geometrical (graphical) objects

    2D/3D 物件,例如 circle, rectangle, polygon

  • UI controls

    例如 Button, Checkbox, Text Area

  • Containers

    layut panes,例如 Border Pane, Grid Pane, Flow Pane

  • Media elements

    例如 audio, video, image objects

scene graph 就是由很多 nodes 組合而成的,nodes 會有類似以下的階層式架構

類似這樣的架構,在實際上的 application 可能會是這樣

scene graph 裡面的每一個 node 都有單一一個 parent,唯一一個沒有 parents 的就是 root node。每一個 node 可以有多個 children,沒有 children 的 node 就是 leaf node,如果有 children 的 node 就稱為 branch node。

每一個 node instance 只能被加入到一個 scene graph 一次,node 可能會是 Effects, Opacity, Transforms, Event Handlers, Event Handlers, Application Specific States

Prism

prism 是高效硬體加速的 graphical pipeline,在 JavaFX 用來 render graphics,可 render 2D/3D graphics

為了產生 graphic,prism 會使用

  • DirectX 11 on Windows 7,D3D11
  • DirectX 12 on Windows 10/11,DX12 跟 DX11 相容,D3D11 最常見
  • OpenGL on Mac and Linux, Embedded Systems.

在啟動 JavaFX application 時,加上 -Dprism.verbose=true 參數,可列印 prism 資訊。如果遇到沒有硬體加速的系統,會自動使用軟體 render。

GWT (Glass Windowing Toolkit)

JavaFX 的底層視窗系統抽象層,對平台視窗、輸入事件、螢幕等系統資源提供抽象,供 JavaFX API 使用,負責處理平台相關的:

  • 視窗建立與管理(window creation)

  • 輸入事件(滑鼠、鍵盤、觸控)

  • 螢幕資訊(顯示器解析度、DPI 等)

  • 視窗裝飾(標題列、邊框)

  • 與作業系統的整合

針對不同 OS(Windows / macOS / Linux)有不同的實作

Quantum Toolkit

是 JavaFX 的主執行環境工具包:

  • 它提供了一個 UI 執行緒(JavaFX Application Thread)

  • 負責把 JavaFX API 呼叫分派到底層子系統(例如 Prism, Glass, Media, WebEngine 等),也就是抽象化 Prism, Glass, Media Engine, and Web Engine 底層元件

  • 實作事件分派、場景圖(Scene Graph)渲染與更新調度

會將 Prism 及 GWT 整合在一起

Quantum Toolkit 的主要任務包括:

功能 說明
管理 JavaFX 主執行緒 即 JavaFX Application Thread
事件循環(Event Loop)處理 包括滑鼠、鍵盤、動畫、定時器等事件
計畫場景更新(Pulse) 定時發出 pulse 信號來觸發重繪
安排渲染與場景圖(Scene Graph)同步 確保 UI 狀態與畫面一致

WebView

JavaFX 可嵌入 html content 到 scene graph,透過 WebView 使用 Web Kit 處理這種 content。Web Kit 是 open source web browser engine,可支援 HTML5, CSS, JavaScript, DOM and SVG

WebView 裡面可以使用以下功能

  • 從 local file 或是 remote URL render HTML content
  • 支援 history,可往前或往後瀏覽
  • reload content
  • 為 web component 套用 effects
  • 編輯 html
  • 執行 javascript
  • 處理事件

Media Engine

JavaFX Media engine 是使用 "Streamer" 這個 open source engine 實作,可支援 video/audio content playback

audio 支援 mp3, aac(*.acc *.m4a部分平台預設支援), wav, aiff,不支援 flac, ogg, wma,需要自己處理 GStreamer Plugin

video 支援 mp4 (*.mp4, *.m4v ) 視訊編碼是 h.264,聲音編碼 aac。flv 部分,視訊編碼 h.264 或 Sorenson,聲音編碼為 aac 或 mp3。不支援 WebM (vp8/vp9), mkv, avi, mov

提供三種 component

  • Media Object

  • Media Player

  • Media View

遇到不支援的格式時,會發生 MediaException

2025/11/10

JavaFX Markdown Viewer

在 Java 處理 Markdown 文件,可使用 flexmark 套件,而 JavaFX,是使用 openjfx,因為 jfx 並不在 openjdk 裡面。

這邊因為使用舊版的 macos,所以使用很舊版本的openjfx

        <!-- markdown -->
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-all</artifactId>
            <version>0.64.8</version>
        </dependency>

        <!-- java FX -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>17.0.2</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-web</artifactId>
            <version>17.0.2</version>
        </dependency>

pom.xml下面的 plugin 這樣寫

    <build>
        <plugins>
            <!-- JavaFX Maven Plugin -->
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>MarkdownViewerApp</mainClass>
                </configuration>
            </plugin>

            <!-- Build fat JAR -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.7.1</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>${main.class}</mainClass>
                        </manifest>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

            <!-- Java compiler -->
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.14.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

MarkdownTable

在 flexmark,要處理 table 時,需要加入 extension,以下是一個處理 Markdown Table 的 sample

import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.util.data.MutableDataSet;
import com.vladsch.flexmark.ext.tables.TablesExtension;

import java.util.Arrays;

public class MarkdownTableExample {
    public static void main(String[] args) {
        String markdown =
                "| Name  | Age |\n" +
                "|-------|-----|\n" +
                "| John  | 30  |\n" +
                "| Alice | 25  |";

        MutableDataSet options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create()));

        Parser parser = Parser.builder(options).build();
        HtmlRenderer renderer = HtmlRenderer.builder(options).build();

        String html = renderer.render(parser.parse(markdown));
        System.out.println(html);
    }
}

執行結果

<table>
<thead>
<tr><th>Name</th><th>Age</th></tr>
</thead>
<tbody>
<tr><td>John</td><td>30</td></tr>
<tr><td>Alice</td><td>25</td></tr>
</tbody>
</table>


Process finished with exit code 0

MarkdownViewer

html table 加上 css

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.util.data.MutableDataSet;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Scanner;

public class MarkdownViewerApp extends Application {

    private MutableDataSet options;
    private Parser parser;
    private HtmlRenderer renderer;

    @Override
    public void start(Stage primaryStage) {
        options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create()));
        parser = Parser.builder(options).build();
        renderer = HtmlRenderer.builder(options).build();

        // CSS
        String css = """
                body {
                    font-family: sans-serif;
                    margin: 20px;
                    line-height: 1.6;
                }
                table {
                    border-collapse: collapse;
                    width: 100%;
                }
                th, td {
                    border: 1px solid #ccc;
                    padding: 6px 10px;
                }
                th {
                    background-color: #f0f0f0;
                }
                """;

        TextArea markdownInput = new TextArea();
        WebView htmlView = new WebView();

        // Default text
//        markdownInput.setText("# Hello Markdown\n\nThis is **bold** and this is _italic_.");
        try (InputStream in = getClass().getResourceAsStream("/test.md")) {
            if (in != null) {
                Scanner scanner = new Scanner(in, StandardCharsets.UTF_8).useDelimiter("\\A");
                String htmlContent = scanner.hasNext() ? scanner.next() : "";

                markdownInput.setText(htmlContent);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        // Convert and display Markdown as HTML
        markdownInput.textProperty().addListener((obs, oldVal, newVal) -> {
            Node document = parser.parse(newVal);
            String htmlBody = renderer.render(document);
            String html = """
                <html>
                <head>
                <style>%s</style>
                </head>
                <body>
                    %s
                </body>
                </html>
            """.formatted(css, htmlBody);
            htmlView.getEngine().loadContent(html);
        });

        // Initial render
        Node document = parser.parse(markdownInput.getText());
        String htmlBody = renderer.render(document);
        String html = """
                <html>
                <head>
                <style>%s</style>
                </head>
                <body>
                    %s
                </body>
                </html>
            """.formatted(css, htmlBody);
        htmlView.getEngine().loadContent(html);

        BorderPane root = new BorderPane();
        root.setLeft(markdownInput);
        root.setCenter(htmlView);

        Scene scene = new Scene(root, 800, 600);
        primaryStage.setTitle("JavaFX Markdown Viewer");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

可直接用 mvn 啟動

mvn javafx:run

獨立執行需下載 openjfx sdk,可到 JavaFX - Gluon 下載,openjfx-17.0.2_osx-x64_bin-sdk.zip

執行 mvn package 可產生 jar,然後就能這樣啟動

java --module-path ~/Downloads/javafx-sdk-17.0.2/lib/ --add-modules javafx.controls,javafx.web -jar target/markdownviewer-1.0-SNAPSHOT-jar-with-dependencies.jar MarkdownViewerApp

這是執行結果

2025/11/03

JSch

Jsch 是 Java 實作的 ssh, sftp library,這個函式庫原本是由 com.jcraft:jsch 實作,但在 2018 就停止更新,故現在要改使用 com.github.mwiede:jsch

只需要在 maven 加入 library 即可,最低可使用 Java 8

<dependency>
  <groupId>com.github.mwiede</groupId>
  <artifactId>jsch</artifactId>
  <version>2.27.0</version>
</dependency>

exec

jsch 最基本是使用 exec channel,一次執行一個指令,沒有 context,單獨執行的指令。

以下程式另外用 Scanner 包裝一個簡單的互動介面

import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

import org.apache.commons.io.IOUtils;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

public class JSchExecTest {
    private Session session;

    public void connect(String host, int port, String user, String pwd) throws JSchException {
        disconnect();
        JSch jsch = new JSch();
        Session session = jsch.getSession(user, host, port);
        session.setPassword(pwd);
        // yes / no / ask
        session.setConfig("StrictHostKeyChecking", "no");
        session.connect();
        this.session = session;
    }

    public void disconnect() throws JSchException {
        if( session!=null ) {
            session.disconnect();
            session = null;
        }
    }

    public void execCmd(String command) throws JSchException, IOException {
        if( session==null ) {
            return;
        }
//        ChannelExec    單指令,單獨執行(適合非交互式)
//        ChannelShell    多指令、有狀態、有上下文需求(如 cd)
        ChannelExec channelExec = (ChannelExec) session.openChannel("exec");
        channelExec.setCommand(command);

        // set error output stream
        // channelExec.setErrStream(System.err);
        ByteArrayOutputStream errorOutputStream = new ByteArrayOutputStream();
        channelExec.setErrStream(errorOutputStream);

        // get command output
        InputStream in = channelExec.getInputStream();

        // execute command
        channelExec.connect();

        //// get output content
//        BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
//        String line;
//        while ((line = reader.readLine()) != null) {
//            System.out.println(line);
//        }
//        reader.close();

        String output = IOUtils.toString(in, StandardCharsets.UTF_8);
        if (!output.isEmpty()) {
            System.out.println("Exec Output: " + command + "\r\n" + output);
        }

        // error
        String errorMsg = errorOutputStream.toString();
        if (!errorMsg.isEmpty()) {
            System.err.println("Error Output: " + errorMsg);
        }

        channelExec.disconnect();
    }

    public void printhelp() {
        System.out.println("connect host port username password");
        System.out.println("exec command");
        System.out.println("disconnect");
        System.out.println("");
    }

    public static void main(String[] args) {
        JSchExecTest jSchExecTest = new JSchExecTest();
        try {
//            session = JschTest1.connect("192.168.1.89", 22, "user", "pass");
//            JschTest1.execCmd(session, "ls -al");
//            JschTest1.execCmd(session, "ls -al ggg");
//            JschTest1.disconnect(session);

            Scanner scanner = new Scanner(System.in);

            System.out.println("jsch CLI: Enter a command (type 'exit' to quit)");
            while (true) {
                System.out.print("> ");
                String input = scanner.nextLine().trim();
                if (input.isEmpty()) continue;

                // 分割整行輸入,第一個為指令,其餘為參數
                String[] parts = input.split("\\s+", 2); // 最多分兩段:指令 和 其餘參數
                String command = parts[0];
                String arguments = parts.length > 1 ? parts[1] : "";

                if ("exit".equalsIgnoreCase(command)) {
                    System.out.println("Goodbye!");
                    break;
                }

                switch (command.toLowerCase()) {
                    case "h":
                        jSchExecTest.printhelp();
                        break;
                    case "help":
                        jSchExecTest.printhelp();
                        break;
                    case "?":
                        jSchExecTest.printhelp();
                        break;
                    case "connect":
                        String[] argparts = arguments.split("\\s+");
                        if( argparts.length == 4 ) {
                            try {
                                jSchExecTest.connect(argparts[0], Integer.parseInt(argparts[1]), argparts[2], argparts[3]);
                            } catch (JSchException je) {
                            }
                        } else {
                            System.out.println("connect host port username password");
                        }
                        break;
                    case "disconnect":
                        System.out.println("Current time: " + java.time.LocalTime.now());
                        jSchExecTest.disconnect();
                        break;
                    case "exec":
                        jSchExecTest.execCmd(arguments);
                        break;
                    default:
                        System.out.println("Unknown command: " + input);
                }
            }
            scanner.close();
        } catch (JSchException e) {
            e.printStackTrace();
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

測試

jsch CLI: Enter a command (type 'exit' to quit)
> connect 192.168.1.89 22 user pass
> ls -al
Unknown command: ls -al
> exec ls -al
Exec Output: ls -al
總計 24
drwx------. 5 larzio larzio 159  6月 11 16:31 .
.....
> disconnect
Current time: 17:50:59.521429
> exit
Goodbye!

shell

像是 cd 指令,需要記錄目錄,就需要改用 shell channell

import com.jcraft.jsch.*;

import java.io.*;

public class JSchInteractiveShell {

    public static void main(String[] args) throws Exception {
        String user = "user";
        String host = "192.168.1.89";
        int port = 22;
        String password = "s2papago";
        String sudoPassword = "pass";

        JSch jsch = new JSch();
        Session session = jsch.getSession(user, host, port);
        session.setPassword(password);

        // 不驗證 host key(開發用,正式環境請換成嚴格檢查)
//        session.setConfig("StrictHostKeyChecking", "no");
        // 指定 known_hosts 檔案位置 (通常是 ~/.ssh/known_hosts)
        String knownHostsPath = System.getProperty("user.home") + "/.ssh/known_hosts";
        jsch.setKnownHosts(knownHostsPath);
        session.setConfig("StrictHostKeyChecking", "yes");

        session.connect();

        ChannelShell channel = (ChannelShell) session.openChannel("shell");

        InputStream in = channel.getInputStream();
        OutputStream out = channel.getOutputStream();

        PrintWriter writer = new PrintWriter(out, true);
        channel.connect();

        // thread 持續讀取遠端輸出
        Thread readerThread = new Thread(() -> {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    System.out.println(line);
//                    // 判斷是否有 sudo 密碼提示 (常見字串,可視環境調整)
//                    if (line.toLowerCase().contains("[sudo] password") ||
//                        line.toLowerCase().contains("password for " + user.toLowerCase()) || line.toLowerCase().contains("密碼:")) {
//                        System.out.println("[INFO] Detected sudo password prompt, sending password...");
//                        writer.println(sudoPassword);  // 自動送 sudo 密碼
//                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        readerThread.start();
        Thread.sleep(1000);
        // main thread: 從 System.in 讀使用者輸入,傳給遠端 shell
        try (BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in)) ) {

            String command;
            System.out.print("$ ");  // prompt
            while ((command = consoleReader.readLine()) != null) {
                writer.println(command);
                if ("exit".equalsIgnoreCase(command.trim())) {
                    break;
                }
                System.out.print("$ ");
            }
        }

        readerThread.join();
        channel.disconnect();
        session.disconnect();
    }
}

測試

$ ls -al
[larzio@lzstg2 ~]$ ls -al
總計 24
drwx------. 5 larzio larzio 159  6月 11 16:31 .
drwxr-xr-x. 3 root   root    20  9月 11  2024 ..
.............
cd download
$ [larzio@lzstg2 ~]$ cd download
l s-al
$ [larzio@lzstg2 download]$ l s-al
-bash: l:命令找不到
ls -al
$ [larzio@lzstg2 download]$ ls -al
總計 16
drwxr-xr-x  2 root   root     70  6月 11 16:29 .
drwx------. 5 larzio larzio  159  6月 11 16:31 ..
.............
exit
[larzio@lzstg2 download]$ exit
登出

sftp

上傳或下載檔案,如果要能支援目錄,要使用 sftp channel

import com.jcraft.jsch.*;

import java.io.*;
import java.util.Vector;

public class JSchSftpUtil {

    private Session session;
    private ChannelSftp sftp;

    public JSchSftpUtil(Session session) throws JSchException {
        this.session = session;
        Channel channel = session.openChannel("sftp");
        channel.connect();
        sftp = (ChannelSftp) channel;
    }

    public void disconnect() {
        if (sftp != null && sftp.isConnected()) sftp.disconnect();
        if (session != null && session.isConnected()) session.disconnect();
    }

    public void upload(String localPath, String remotePath, int permission) throws Exception {
        File localFile = new File(localPath);
        if (!localFile.exists()) throw new FileNotFoundException(localPath);

        if (localFile.isFile()) {
            uploadFileWithResume(localFile, remotePath, permission);
        } else {
            uploadDirectory(localFile, remotePath, permission);
        }
    }

    public void download(String remotePath, String localPath) throws Exception {
        SftpATTRS attrs = null;
        try {
            attrs = sftp.stat(remotePath);
        } catch (Exception e) {
            throw new FileNotFoundException("Remote path not found: " + remotePath);
        }

        File localFile = new File(localPath);
        if (attrs.isDir()) {
            downloadDirectory(remotePath, localFile);
        } else {
            downloadFileWithResume(remotePath, localFile);
        }
    }

    // 進度監控
    private static class MyProgressMonitor implements SftpProgressMonitor {
        private long max = 0;
        private long count = 0;
        private long percent = -1;

        public MyProgressMonitor(long max) {
            this.max = max;
        }

        @Override
        public void init(int op, String src, String dest, long max) {
            System.out.println("Start transfer: " + src + " -> " + dest);
        }

        @Override
        public boolean count(long count) {
            this.count += count;
            long newPercent = this.count * 100 / max;
            if (newPercent != percent) {
                percent = newPercent;
                System.out.print("\rProgress: " + percent + "%");
            }
            return true;
        }

        @Override
        public void end() {
            System.out.println("\nTransfer complete");
        }
    }

    // 斷點續傳上傳單檔
    private void uploadFileWithResume(File localFile, String remoteFilePath, int permission) throws Exception {
        long localFileSize = localFile.length();
        long remoteFileSize = 0;

        try {
            SftpATTRS attrs = sftp.stat(remoteFilePath);
            remoteFileSize = attrs.getSize();
        } catch (SftpException e) {
            // 檔案不存在
            remoteFileSize = 0;
        }

        if (remoteFileSize == localFileSize) {
            System.out.println("Remote file already complete, skipping upload: " + remoteFilePath);
            return;
        } else if (remoteFileSize > localFileSize) {
            System.out.println("Remote file is larger than local file, overwriting");
            remoteFileSize = 0;
        }

        try (RandomAccessFile raf = new RandomAccessFile(localFile, "r")) {
            raf.seek(remoteFileSize);

            InputStream fis = new InputStream() {
                @Override
                public int read() throws IOException {
                    return raf.read();
                }

                @Override
                public int read(byte[] b, int off, int len) throws IOException {
                    return raf.read(b, off, len);
                }
            };

            sftp.put(fis, remoteFilePath, new MyProgressMonitor(localFileSize), ChannelSftp.APPEND);
            fis.close();
        }

        // 設定遠端檔案權限
        sftp.chmod(permission, remoteFilePath);
    }

    // 遞迴上傳目錄
    private void uploadDirectory(File localDir, String remoteDir, int permission) throws Exception {
        try {
            sftp.cd(remoteDir);
        } catch (SftpException e) {
            sftp.mkdir(remoteDir);
            sftp.cd(remoteDir);
        }

        for (File file : localDir.listFiles()) {
            if (file.isFile()) {
                uploadFileWithResume(file, remoteDir + "/" + file.getName(), permission);
            } else if (file.isDirectory()) {
                uploadDirectory(file, remoteDir + "/" + file.getName(), permission);
            }
        }

        sftp.cd("..");
    }

    // 斷點續傳下載單檔
    private void downloadFileWithResume(String remoteFilePath, File localFile) throws Exception {
        long remoteFileSize = sftp.stat(remoteFilePath).getSize();
        long localFileSize = 0;

        if (localFile.exists()) {
            localFileSize = localFile.length();
            if (localFileSize > remoteFileSize) {
                System.out.println("Local file larger than remote, overwrite");
                localFileSize = 0;
            } else if (localFileSize == remoteFileSize) {
                System.out.println("Local file already complete, skipping download: " + localFile.getAbsolutePath());
                return;
            }
        }

        OutputStream os;
        if (localFileSize > 0) {
            os = new FileOutputStream(localFile, true);
        } else {
            os = new FileOutputStream(localFile);
        }

        try (OutputStream outputStream = os) {
            sftp.get(remoteFilePath, outputStream, new MyProgressMonitor(remoteFileSize), ChannelSftp.RESUME, localFileSize);
        }
    }

    // 遞迴下載目錄
    private void downloadDirectory(String remoteDir, File localDir) throws Exception {
        if (!localDir.exists()) {
            localDir.mkdirs();
        }

        Vector<ChannelSftp.LsEntry> list = sftp.ls(remoteDir);

        for (ChannelSftp.LsEntry entry : list) {
            String filename = entry.getFilename();
            if (".".equals(filename) || "..".equals(filename)) continue;

            String remoteFilePath = remoteDir + "/" + filename;
            File localFilePath = new File(localDir, filename);

            if (entry.getAttrs().isDir()) {
                downloadDirectory(remoteFilePath, localFilePath);
            } else {
                downloadFileWithResume(remoteFilePath, localFilePath);
            }
        }
    }

    public static void main(String[] args) {
        String user = "user";
        String host = "192.168.1.89";
        int port = 22;
        String password = "pass";

        String localUploadPath = "/Users/charley/Downloads/temp";
        String remoteUploadPath = "/home/larzio/temp";

        String remoteDownloadPath = "/home/larzio/download";
        String localDownloadPath = "/Users/charley/Downloads/testdownload";

        int permission = 0644;

        JSch jsch = new JSch();
        Session session = null;
        JSchSftpUtil sftpUtil = null;

        try {
            session = jsch.getSession(user, host, port);
            session.setPassword(password);
            session.setConfig("StrictHostKeyChecking", "no");
            session.connect();

            sftpUtil = new JSchSftpUtil(session);

            // 上傳
            sftpUtil.upload(localUploadPath, remoteUploadPath, permission);
            System.out.println("Upload done.");

            // 下載
            sftpUtil.download(remoteDownloadPath, localDownloadPath);
            System.out.println("Download done.");

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (sftpUtil != null) sftpUtil.disconnect();
        }
    }
}

References

JSch 的使用(SSH、SFTP) · Homurax's Blog

GitHub - mwiede/jsch: fork of the popular jsch library

2025/10/27

Varnish

varnish 是一種反向代理伺服器軟體,以記憶體方式存取 cache。透過 VCL (Varnish Configuration Language) 讓使用者設定 varnish。

安裝

在 Rocky Linux 8 安裝

dnf -y install varnish

設定

修改原本的設定檔

mv /etc/varnish/default.vcl /etc/varnish/default.vcl.bak
vi /etc/varnish/default.vcl

此設定檔只會 cache png 圖片檔案 7 天

backend default {
    .host = "127.0.0.1";  # Your app server
    .port = "8081";       # Your app server port
}

sub vcl_recv {
    # 僅對 GET 和 HEAD 快取
    if (req.method != "GET" && req.method != "HEAD") {
        return (pass);
    }

    # 僅快取 .png 圖檔,其餘請求不快取
    # 比對 URL 結尾為 .png
    if (req.url ~ "\.png$") {
        # cache
        return (hash);
    } else {
        # pass
        return (pass);
    }
}

sub vcl_backend_response {
    if (beresp.status == 200) {
        # Cache for 7 day
        set beresp.ttl = 7d;
    } else {
        set beresp.ttl = 0s;
    }
}

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
}

啟動

systemctl start varnish
systemctl enable varnish

修改 disk cache

Varnish 無法永久將 cache 儲存到 disk (decprecated),但可透過 memory mapping,將記憶體的資料存放到 file

sudo systemctl edit varnish

修改 service

[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd \
  -a :6081 \
  -f /etc/varnish/default.vcl \
  -s file,/var/lib/varnish/varnish_storage.bin,2G

確認 file folder 存在

sudo mkdir -p /var/lib/varnish
sudo chown varnish: /var/lib/varnish

restart Varnish

# restart varnish
sudo systemctl daemon-reexec
sudo systemctl restart varnish

2025/10/13

如何使用 vue3-draggable-next esm module

vue3-draggable-next 是 draggable 套件的 vue3 版本,預設在 dist 裡面,只提供 umd 及 commonjs module,如果要在 browser 裡面,透過 esm 的方式 import module,必須要先自己製作一個簡單的 esm js

vuedraggable.umd.js 檔案,就跟 vuedraggable.umd.min.js 放在同一個目錄

// 確保 UMD 先載入(它會掛在 window.vuedraggable)
import "./vuedraggable.umd.min.js";
// 把全域變數 export 出去,提供 ESM 的 default
export default window.vuedraggable;

然後在 html 裡面,先以 importmap 方式列出 import list

因為 draggable 有使用到 sortablejs,故這邊要先放到 import list

另外在下面的 module 裡面,要先將 Vue 及 Sortable 掛載到全域變數裡面,因為 draggable 是這樣直接呼叫 Vue 跟 Sortable,所以必須這樣掛載

    <script type="importmap">
    {
        "imports": {
            "vue": "../js/lib/vue-3.5.13/vue.esm-browser.prod.min.js",
            "vuedraggable": "../js/lib/vue3-draggable-next-4.1.4/vuedraggable.esm.js",
            "sortablejs": "../js/lib/sortable-1.15.6/sortable.esm.js"
        }
    }
    </script>

    <script type="module">
        import * as Vue from 'vue';
        window.Vue = Vue; // 讓 draggable UMD 找得到 Vue.defineComponent

        import Sortable from 'sortablejs';
        window.Sortable = Sortable;
    </script>

最後可製作 App

  <script type="module">
    import { createApp, reactive } from "vue";
    import Draggable from "vuedraggable";

    const state = reactive({
      rows: [
        { id: 1, name: "Item A" },
        { id: 2, name: "Item B" },
        { id: 3, name: "Item C" }
      ]
    });

    createApp({
      components: { Draggable },
      setup() {
        return { state };
      }
    }).mount("#app");
  </script>

html 的部分,可放在 tbody 裡面。

這邊注意 #item="{ element, index } #item 裡面,只能用 element, index,不能改成其他變數名稱。否則會一直遇到 undefined 物件的問題。

<div id="app">
    <table border="1">
      <thead>
        <tr>
          <th>Drag</th>
          <th>Name</th>
        </tr>
      </thead>
      <!-- draggable tbody -->
      <draggable
        tag="tbody"
        v-model="state.rows"
        handle=".drag-handle"
      >
        <template #item="{ element, index }">
          <tr :key="element.id">
            <td class="drag-handle" style="cursor: grab;">☰</td>
            <td>{{ element.name }}</td>
          </tr>
        </template>
      </draggable>
    </table>
    <pre>{{ state.rows }}</pre>
  </div>

vuedraggable 是 SortableJS 的包裝,所以大部分事件都對應到 SortableJS events,最常用的是:

  • @start 拖曳開始

  • @end 拖曳結束

  • @add 新元素被加入(跨清單)

  • @remove 元素被移除

  • @update 同一清單內順序改變

  • @change 綜合事件(新增、刪除、移動都會觸發)

實際上操作時

  • 拖曳開始到結束:會觸發 @start 與 @end

  • 順序有變動:會觸發 @update 和 @change

  • 跨清單拖曳:會觸發 @add 與 @remove

2025/09/22

vue3 runtime

如果要使用 vue3 runtime,也就是 vue.runtime.esm-browser.js,而不使用 vue.esm-browser.js。這兩個版本的差異是使用後者,有支援 template,可直接將 template 字串寫在 component 裡面,但如果使用 runtime library,因為這個檔案大小比較小,缺少了動態編譯 template 的功能,必須改寫為使用 render function。

實例

以下是可以直接在 browser 執行的範例

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title></title>
</head>
<body>
<div id="app"></div>

<script type="importmap">
    {
        "imports": {
            "vue": "https://unpkg.com/vue@3.5.13/dist/vue.runtime.esm-browser.js",
            "vue-i18n": "https://unpkg.com/vue-i18n@11.1.1/dist/vue-i18n.runtime.esm-browser.js"
        }
    }
</script>

<script type="module">
    import { createApp, h } from 'vue';
    import { createI18n, useI18n } from 'vue-i18n';

    const messages = {
        en: { greeting: 'Hello!' },
        zh: { greeting: '你好!' }
    };

    const i18n = createI18n({
        legacy: false,
        locale: 'en',
        messages
    });

    const App = {
        setup() {
            const { t, locale } = useI18n(); // 正確取得 t() 函數

            const toggleLang = () => {
                locale.value = locale.value === 'en' ? 'zh' : 'en';
            };

            return () =>
                h('div', [
                    h('h1', t('greeting')),
                    h('button', { onClick: toggleLang }, '🌐 Switch Language')
                ]);
        }
    };

    createApp(App).use(i18n).mount('#app');
</script>
</body>
</html>

compiler-dom

vue3 官方提供了一個 compiler-dom,可以將 template 字串轉換為 render function。

安裝首先要安裝 compiler-dom

npm i @vue/compiler-dom

撰寫一個 convert.js

// https://www.npmjs.com/package/@vue/compiler-dom
// npm i @vue/compiler-dom

const fs = require('fs');
const path = require('path');
const { compile } = require('@vue/compiler-dom');

const file = process.argv[2];

if (!file || !file.endsWith('.template.html')) {
  console.error('請提供一個 .template html 檔案,例如:node convert.js MyComponent.html');
  process.exit(1);
}

const filePath = path.resolve(process.cwd(), file);

const template = fs.readFileSync(filePath, 'utf-8');

// 編譯 template 成 render 函數
const { code } = compile(template, {
  mode: 'module',
  prefixIdentifiers: true, // 避免 with()
  filename: file
});

// 產出 JS 檔案名稱
const baseName = path.basename(file, '.template.html');
const outputFile = `${baseName}.template.render.js`;

// // 包裝為可匯入的模組
const outputContent = `
${code}
`;
// 寫入檔案
fs.writeFileSync(outputFile, outputContent.trim());

console.log(`Render function 已輸出為:${outputFile}`);

使用方法

把 template 的部分,改為獨立的 test.template.html 檔案

<div>
    <h1> {{ t("greeting") }} </h1>
    <button @click="toggleLang()">🌐 Switch Language</button>
</div>

透過 nodejs 將 template 轉換為 render function

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("h1", null, _toDisplayString(_ctx.$t("greeting")), 1 /* TEXT */),
    _createElementVNode("button", {
      onClick: $event => (_ctx.toggleLang())
    }, "🌐 Switch Language", 8 /* PROPS */, ["onClick"])
  ]))
}

改寫原本的測試網頁

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title></title>
</head>
<body>
<div id="app"></div>

<script type="importmap">
    {
        "imports": {
            "vue": "https://unpkg.com/vue@3.5.13/dist/vue.runtime.esm-browser.js",
            "vue-i18n": "https://unpkg.com/vue-i18n@11.1.1/dist/vue-i18n.runtime.esm-browser.js",

            "render": "./test.template.render.js"
        }
    }
</script>

<script type="module">
    import { createApp, h } from 'vue';
    import { createI18n, useI18n } from 'vue-i18n';
    import {render} from 'render';

    const renderFn = render;

    const i18n = createI18n({
        legacy: false,
        locale: 'en',
        fallbackLocale: "en",
        messageCompiler: null,
        messages: {
            "en": {
                "greeting": 'Hello!'
            },
            "zh": {
                "greeting": '你好!'
            },
        },
    });

    const App = {
        setup() {
            const { t, locale } = useI18n();

            const toggleLang = () => {
                locale.value = locale.value === 'en' ? 'zh' : 'en';
            };

            return {
                t, locale, toggleLang
            };
        },
        render: renderFn,
        methods: {
        }
    };

    createApp(App).use(i18n).mount('#app');
</script>
</body>
</html>

note

點擊切換語言時,網頁 console 會出現這樣的警告訊息

[intlify] the message that is resolve with key 'greeting' is not supported for jit compilation

不影響網頁操作,但還不知道為什麼會出現這個 warning

2025/09/15

Container vs VM

Container 是類似 docker 這樣建構在某個 OS 的虛擬機器,VM 是類似 Hypervisor VMWare 建立的虛擬機器。

VM

優點:

  • 從硬體開始就虛擬化,機器獨立

  • 可在不同 VM 安裝不同的 OS

  • 不同應用程式內的相關套件耦合,不會互相影響

  • 適合比較大型,需要很多不同的整合服務的應用

缺點:

  • 耗用的硬碟空間較多,使用的硬體資源比較高

  • 啟動服務需要比較長的時間

Container

優點:

  • 檔案比較小

  • 啟動速度快

  • 耗用系統資源比較少

  • 容易更新

  • 通常以應用程式為單位

缺點:

  • 主要依賴 Host OS的操作,無法同時安裝不同的 OS

  • container 之間的元件部署比較複雜

2025/09/08

Open Street Map Local Server

Switch2OSM

https://switch2osm.org/

因為 Open Street Map 的免費公用特性,所以我們可以複製這些地圖資料到自己的地圖 server,Switch2OSM 是一個推廣 OSM 的專案,我們可以透過這個專案,建置自己的 Map Server

PBF format

.pbf空間數據詳解

PBF 就是 Protocolbuffer Binary Format 這是基於 ProtoBuffer 的一種 binary data format,這是一種比 XML, GeoJSON 等等地圖格式還要精簡的檔案格式。

在 OSM 通常是以 *.osm.pbf 這個 file extension 代表 OSM PBF file

Open Street Map 在 PBF Format - OpenStreetMap Wiki 有說明該檔案格式的定義。

Taiwan OSM

Geofabrik Download Server Taiwan 這個網站,有固定更新從 Open Street Map 下載的 Taiwan 離線地圖,我們可以到這個網站下載 taiwan-latest.osm.pbf

取得 osm PBF 檔案以後,就可以匯入 switch2osm local map server

docker

參考 Using a Docker container – Switch2OSM 的說明,最簡單的方式就是透過 container 建置 switch2osm

以下以 Redhat 系列的 podman 進行測試

匯入 osm.pbf 檔案

podman volume create osm-data

podman run  --name osmtile-import -v /root/download/osm/taiwan-latest.osm.pbf:/data/region.osm.pbf  -v osm-data:/data/database/  overv/openstreetmap-tile-server  import

tile server

podman volume create osm-tiles
# 啟動 tile server
podman run --name osmtile -p 8081:80 -v osm-data:/data/database/ -v osm-tiles:/data/tiles -d overv/openstreetmap-tile-server run

啟動後就可以在 http://localhost:8081/tile/ 看到透過 Leaflet 取得的 OSM 地圖

2025/09/01

WhisperSpeech

WhisperSpeech是一種反轉Whisper技術,實做的TTS系統。

安裝測試

在 Rocky Linux 8 的 Python 3.11 安裝測試

dnf install python3.11
# 在執行測試時,會需要 python.h,故需要安裝 devel 套件
dnf install python3.11-devel

python3 -m venv /root/venv/whisperspeech
source /root/venv/whisperspeech/bin/activate

pip3 install WhisperSpeech
pip3 install webdataset

測試程式

import torch
import torch.nn.functional as F
from whisperspeech.pipeline import Pipeline

pipe = Pipeline(s2a_ref='collabora/whisperspeech:s2a-q4-tiny-en+pl.model', torch_compile=True)
pipe.generate_to_file("output.wav", "Hello from WhisperSpeech.")

以 time 測試執行時間

time python3 test.py
real    0m38.452s
user    2m19.176s
sys    0m1.683s

真實時間大約花了 40s,這邊是用 Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz 的機器,沒有用到 GPU

這個網站可以聽到 WhisperSpeech 產生的語音結果

WhisperSpeech - New Text-To-Speech Model In Town

References

GitHub - WhisperSpeech/WhisperSpeech: An Open Source text-to-speech system built by inverting Whisper.

whisperspeech 英文TTS的实现_whisper speech-CSDN博客

2025/08/25

Spring Boot 3 JPA multiple datasource

在一個 Spring Boot 3 project 同時連接到兩個 database,需要用兩個設定檔指定兩個 datasource,分別設定不同的 entityManager, transactonManager, jdbcTemplate。

  • application.yml

    spring:
      application:
        name: project
      jpa:
        properties:
          hibernate:
            # dialect: org.hibernate.dialect.MySQLDialect
            dialect: org.hibernate.community.dialect.MySQLLegacyDialect
    
    project:
      kokods:
        url: jdbc:mariadb://localhost:3306/koko
        username: root
        password: password
        schema: koko
        type: com.zaxxer.hikari.HikariDataSource
        hikari:
          connection-timeout: 30000 # milliseconds that a client will wait for a new connection from the pool  30 seconds
          minimum-idle: 1           # minimum number of idle connections
          maximum-pool-size: 100    # maximum number of connections
          idle-timeout: 600000      # maximum amount of time that a connection may sit idle in the pool of connections  10 mins
          max-lifetime: 1800000     # a connection can be pooled for before being destroyed  30 mins
          auto-commit: true
          connection-test-query: SELECT CURRENT_TIMESTAMP
      db2ds:
        url: jdbc:mariadb://localhost:3306/db2
        username: root
        password: password
        schema: db2
        type: com.zaxxer.hikari.HikariDataSource
        hikari:
          connection-timeout: 30000 # milliseconds that a client will wait for a new connection from the pool  30 seconds
          minimum-idle: 1           # minimum number of idle connections
          maximum-pool-size: 100    # maximum number of connections
          idle-timeout: 600000      # maximum amount of time that a connection may sit idle in the pool of connections  10 mins
          max-lifetime: 1800000     # a connection can be pooled for before being destroyed  30 mins
          auto-commit: true
          connection-test-query: SELECT CURRENT_TIMESTAMP
    
    logging:
      level:
        com.zaxxer.hikari: TRACE
        com.zaxxer.hikari.HikariConfig: DEBUG
  • @Configuration設定

    • KoKoConfig.java

      package tw.com.maxkit.koko.config;
      
      import com.zaxxer.hikari.HikariDataSource;
      import org.springframework.beans.factory.annotation.Qualifier;
      import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
      import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
      import org.springframework.context.annotation.*;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.context.properties.ConfigurationProperties;
      import org.springframework.boot.jdbc.DataSourceBuilder;
      import org.springframework.core.env.Environment;
      import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
      import org.springframework.jdbc.core.JdbcTemplate;
      import org.springframework.orm.jpa.JpaTransactionManager;
      import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
      import org.springframework.transaction.PlatformTransactionManager;
      import org.springframework.transaction.annotation.EnableTransactionManagement;
      
      import javax.sql.DataSource;
      
      @Configuration
      //@PropertySource({"classpath:persistence-multiple-db.properties"})
      @EnableJpaRepositories(
              basePackages = "tw.com.maxkit.koko.dao.jpa",
              entityManagerFactoryRef = "kokoEntityManagerFactory",
              transactionManagerRef = "kokoTransactionManager"
      )
      @EnableTransactionManagement
      public class KoKoConfig {
          @Autowired
          private Environment env;
      
          @Primary
          @Bean("kokoDataSourceProperties")
          @ConfigurationProperties("project.kokods")
          public DataSourceProperties kokoDataSourceProperties() {
              return new DataSourceProperties();
          }
      
          @Primary
          @Bean("kokoDataSource")
          @Qualifier(value="kokoDataSourceProperties")
          @ConfigurationProperties(prefix = "project.kokods.hikari")
          public HikariDataSource kokoDataSource() {
              return kokoDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build();
          }
      
          // 如果不修改 Hikari 的參數,可直接使用這個 datasource,但要注意設定檔 url 要改為 jdbc-url
      //    @Bean("lartelDataSource")
      //    @ConfigurationProperties("lartel.kokods")
      //    public DataSource lartelDataSource() {
      //        return DataSourceBuilder.create().build();
      //    }
      
          @Primary
          @Bean("kokoEntityManagerFactory")
          public LocalContainerEntityManagerFactoryBean kokoEntityManagerFactory(
                  @Qualifier("kokoDataSource") DataSource kokoDataSource,
                  EntityManagerFactoryBuilder builder) {
              return builder //
                      .dataSource(kokoDataSource) //
                      .packages("tw.com.maxkit.koko.data.entity") //
                      .persistenceUnit("kokoDs") //
                      .build();
          }
      
          @Primary
          @Bean("kokoTransactionManager")
          public PlatformTransactionManager kokoTransactionManager(
                  @Qualifier("kokoEntityManagerFactory") LocalContainerEntityManagerFactoryBean kokoEntityManagerFactory) {
              return new JpaTransactionManager(kokoEntityManagerFactory.getObject());
          }
      
          @Primary
          @Bean("kokoJdbcTemplate")
          public JdbcTemplate kokoJdbcTemplate(
                  @Qualifier("kokoDataSource") DataSource kokoDataSource) {
              return new JdbcTemplate(kokoDataSource);
          }
      }
    • Db2Config.java

      package tw.com.maxkit.db2.config;
      
      import com.zaxxer.hikari.HikariDataSource;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.beans.factory.annotation.Qualifier;
      import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
      import org.springframework.boot.context.properties.ConfigurationProperties;
      import org.springframework.boot.jdbc.DataSourceBuilder;
      import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.context.annotation.Primary;
      import org.springframework.core.env.Environment;
      import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
      import org.springframework.jdbc.core.JdbcTemplate;
      import org.springframework.orm.jpa.JpaTransactionManager;
      import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
      import org.springframework.transaction.PlatformTransactionManager;
      import org.springframework.transaction.annotation.EnableTransactionManagement;
      
      import javax.sql.DataSource;
      
      @Configuration
      @EnableJpaRepositories(
              basePackages = "tw.com.maxkit.db2.dao.jpa",
              entityManagerFactoryRef = "lartelEntityManagerFactory",
              transactionManagerRef = "lartelTransactionManager"
      )
      @EnableTransactionManagement
      public class LartelConfig {
          @Autowired
          private Environment env;
      
          // 這兩個 method 會套用修改 Hikari 的參數
          @Bean("db2DataSourceProperties")
          @ConfigurationProperties("project.db2ds")
          public DataSourceProperties db2DataSourceProperties() {
              return new DataSourceProperties();
          }
      
          @Bean("db2DataSource")
          @Qualifier(value="db2DataSourceProperties")
          @ConfigurationProperties(prefix = "project.db2ds.hikari")
          public HikariDataSource db2DataSource() {
              return db2DataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build();
          }
      
          // 如果不修改 Hikari 的參數,可直接使用這個 datasource,但要注意設定檔 url 要改為 jdbc-url
      //    @Bean("lartelDataSource")
      //    @ConfigurationProperties("lartel.lartelds")
      //    public DataSource lartelDataSource() {
      //        return DataSourceBuilder.create().build();
      //    }
      
          @Bean("db2EntityManagerFactory")
          public LocalContainerEntityManagerFactoryBean db2EntityManagerFactory(
                  @Qualifier("db2DataSource") DataSource db2DataSource,
                  EntityManagerFactoryBuilder builder) {
              return builder //
                      .dataSource(db2DataSource) //
                      .packages("tw.com.maxkit.db2.data.entity") //
                      .persistenceUnit("db2Ds") //
                      .build();
          }
      
          @Bean("db2TransactionManager")
          public PlatformTransactionManager db2TransactionManager(
                  @Qualifier("db2EntityManagerFactory") LocalContainerEntityManagerFactoryBean lartelEntityManagerFactory) {
              return new JpaTransactionManager(db2EntityManagerFactory.getObject());
          }
      
          @Bean("db2JdbcTemplate")
          public JdbcTemplate db2JdbcTemplate(
                  @Qualifier("db2DataSource") DataSource db2DataSource) {
              return new JdbcTemplate(db2DataSource);
          }
      }
  • 第一個 datasource 部分的 DAO,另一個是類似的作法

    package tw.com.maxkit.koko.dao.jpa;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.stereotype.Repository;
    import tw.com.maxkit.koko.data.entity.Uservo;
    
    import java.util.List;
    
    @Repository
    public interface UservoDAO extends JpaRepository<Uservo, Long> {
        public List<Uservo> queryAll();
    }
    • DAO 的 implementation

      package tw.com.maxkit.koko.dao.jpa;
      
      import jakarta.persistence.EntityManager;
      import jakarta.persistence.TypedQuery;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.data.jpa.repository.JpaContext;
      import org.springframework.stereotype.Component;
      import tw.com.maxkit.koko.data.entity.Uservo;
      
      import java.util.List;
      
      @Component
      public class UservoDAOImpl {
          private final EntityManager em;
      
          @Autowired
          public UservoDAOImpl(JpaContext context) {
              this.em = context.getEntityManagerByManagedType(Uservo.class);
          }
      
          public List queryAll() {
              String jpql = "SELECT u FROM Uservo u";
              TypedQuery query = this.em.createQuery(jpql, Uservo.class);
              return query.getResultList();
          }
      }
  • Service

    package tw.com.maxkit.koko.service;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import tw.com.maxkit.koko.dao.jpa.UservoDAO;
    import tw.com.maxkit.koko.data.entity.Uservo;
    
    @Service
    public class UservoService {
    
        @Autowired
        private UservoDAO uservoDAO;
    
        public void findAll() {
            System.out.println("\n使用 Spring Data JPA 衍生方法查詢 uservo 的資料:");
    //        this.uservoRepository.findAll().forEach(System.out::println);
            this.uservoDAO.findAll().forEach(
                    uservo -> System.out.println("uservoseq="+uservo.getUservoseq()+", userid="+uservo.getUserid())
            );
        }
    
        @Transactional("kokoTransactionManager")
        public Uservo testTransactional(String userid, String username) {
            // 刪除全部
    //        this.uservoRepository.deleteAll();
    
            // 寫入一筆
            Uservo a = new Uservo();
            a.setUserid(userid);
            a.setUsername(username);
            a = this.uservoDAO.save(a);
    
            // 故意埋入 RuntimeException: ArrayIndexOutOfBoundsException
            System.out.println(new String[] {}[1]);
    
            return a;
        }
    
        public void queryAll() {
            System.out.println("\n使用 queryAll 查詢 uservo 的資料:");
    //        this.uservoRepository.findAll().forEach(System.out::println);
            this.uservoDAO.queryAll().forEach(
                    uservo -> System.out.println("uservoseq="+uservo.getUservoseq()+", userid="+uservo.getUserid())
            );
        }
    }

References

Spring Boot + Spring Data JPA 配置多個 DataSource

HikariCP 连接池多数据源配置

2025/08/18

espeak-ng

How To Install espeak-ng on CentOS 8 | Installati.one

How to install eSpeak on CentOS 8 / Ubuntu 20.04 / 18.04? - Linux Windows and android Tutorials

eSpeak NG 安装和配置指南-CSDN博客

eSpeak NG,適用於 Linux 的文本到語音合成器

eSpeak NG (Next Generation) Text-to-Speech 是 open source speech synthesizer。使用了共振峰合成方法,用比較小的程式提供多種語言的合成方法。

1995年 Jonathan Duddington 提出在 RISC OS 運作,支援 British English 的語音合成器,後來在 2006/2/17,speak 1.05 以 GPLv2 released。目前已重新命名為 eSpeak

2010/6/25 Reece Dunn 在 github 以 1.43.46 版發起 eSpeak 的 fork,致力於讓 eSpeak 運作在其他 POSIX plarforms。到了 2015/12/11 epeak-ng 啟動,支援更多語言,且做了更多 bug fix。

CLI

# espeak-ng --help
eSpeak NG text-to-speech: 1.49.2  Data at: /usr/share/espeak-ng-data

espeak-ng [options] ["<words>"]

-f <text file>   Text file to speak
--stdin    Read text input from stdin instead of a file

If neither -f nor --stdin, then <words> are spoken, or if none then text
is spoken from stdin, each line separately.

-a <integer>
       Amplitude, 0 to 200, default is 100
-d <device>
       Use the specified device to speak the audio on. If not specified, the
       default audio device is used.
-g <integer>
       Word gap. Pause between words, units of 10mS at the default speed
-k <integer>
       Indicate capital letters with: 1=sound, 2=the word "capitals",
       higher values indicate a pitch increase (try -k20).
-l <integer>
       Line length. If not zero (which is the default), consider
       lines less than this length as end-of-clause
-p <integer>
       Pitch adjustment, 0 to 99, default is 50
-s <integer>
       Speed in approximate words per minute. The default is 175
-v <voice name>
       Use voice file of this name from espeak-ng-data/voices
-w <wave file name>
       Write speech to this WAV file, rather than speaking it directly
-b       Input text encoding, 1=UTF8, 2=8 bit, 4=16 bit
-m       Interpret SSML markup, and ignore other < > tags
-q       Quiet, don't produce any speech (may be useful with -x)
-x       Write phoneme mnemonics to stdout
-X       Write phonemes mnemonics and translation trace to stdout
-z       No final sentence pause at the end of the text
--compile=<voice name>
       Compile pronunciation rules and dictionary from the current
       directory. <voice name> specifies the language
--compile-debug=<voice name>
       Compile pronunciation rules and dictionary from the current
       directory, including line numbers for use with -X.
       <voice name> specifies the language
--compile-mbrola=<voice name>
       Compile an MBROLA voice
--compile-intonations
       Compile the intonation data
--compile-phonemes=<phsource-dir>
       Compile the phoneme data using <phsource-dir> or the default phsource directory
--ipa      Write phonemes to stdout using International Phonetic Alphabet
--path="<path>"
       Specifies the directory containing the espeak-ng-data directory
--pho      Write mbrola phoneme data (.pho) to stdout or to the file in --phonout
--phonout="<filename>"
       Write phoneme output from -x -X --ipa and --pho to this file
--punct="<characters>"
       Speak the names of punctuation characters during speaking.  If
       =<characters> is omitted, all punctuation is spoken.
--sep=<character>
       Separate phonemes (from -x --ipa) with <character>.
       Default is space, z means ZWJN character.
--split=<minutes>
       Starts a new WAV file every <minutes>.  Used with -w
--stdout   Write speech output to stdout
--tie=<character>
       Use a tie character within multi-letter phoneme names.
       Default is U+361, z means ZWJ character.
--version  Shows version number and date, and location of espeak-ng-data
--voices=<language>
       List the available voices for the specified language.
       If <language> is omitted, then list all voices.
-h, --help Show this help.
# espeak-ng --voices
Pty Language       Age/Gender VoiceName          File                 Other Languages
 5  af              --/M      Afrikaans          gmw/af
 5  am              --/M      Amharic            sem/am
 5  an              --/M      Aragonese          roa/an
 5  ar              --/M      Arabic             sem/ar
 5  as              --/M      Assamese           inc/as
 5  az              --/M      Azerbaijani        trk/az
 5  bg              --/M      Bulgarian          zls/bg
 5  bn              --/M      Bengali            inc/bn
 5  bpy             --/M      Bishnupriya_Manipuri inc/bpy
 5  bs              --/M      Bosnian            zls/bs
 5  ca              --/M      Catalan            roa/ca
 5  cmn             --/M      Chinese_(Mandarin) sit/cmn              (zh-cmn 5)(zh 5)
 5  cs              --/M      Czech              zlw/cs
 5  cy              --/M      Welsh              cel/cy
 5  da              --/M      Danish             gmq/da
 5  de              --/M      German             gmw/de
 5  el              --/M      Greek              grk/el
 5  en-029          --/M      English_(Caribbean) gmw/en-029           (en 10)
 2  en-gb           --/M      English_(Great_Britain) gmw/en               (en 2)
 5  en-gb-scotland  --/M      English_(Scotland) gmw/en-GB-scotland   (en 4)
 5  en-gb-x-gbclan  --/M      English_(Lancaster) gmw/en-GB-x-gbclan   (en-gb 3)(en 5)
 5  en-gb-x-gbcwmd  --/M      English_(West_Midlands) gmw/en-GB-x-gbcwmd   (en-gb 9)(en 9)
 5  en-gb-x-rp      --/M      English_(Received_Pronunciation) gmw/en-GB-x-rp       (en-gb 4)(en 5)
 2  en-us           --/M      English_(America)  gmw/en-US            (en 3)
 5  eo              --/M      Esperanto          art/eo
 5  es              --/M      Spanish_(Spain)    roa/es
 5  es-419          --/M      Spanish_(Latin_America) roa/es-419           (es-mx 6)(es 6)
 5  et              --/M      Estonian           urj/et
 5  eu              --/M      Basque             eu
 5  fa              --/M      Persian            ira/fa
 5  fa-Latn         --/M      Persian_(Pinglish) ira/fa-Latn
 5  fi              --/M      Finnish            urj/fi
 5  fr-be           --/M      French_(Belgium)   roa/fr-BE            (fr 8)
 5  fr-ch           --/M      French_(Switzerland) roa/fr-CH            (fr 8)
 5  fr-fr           --/M      French_(France)    roa/fr               (fr 5)
 5  ga              --/M      Gaelic_(Irish)     cel/ga
 5  gd              --/M      Gaelic_(Scottish)  cel/gd
 5  gn              --/M      Guarani            sai/gn
 5  grc             --/M      Greek_(Ancient)    grk/grc
 5  gu              --/M      Gujarati           inc/gu
 5  hi              --/M      Hindi              inc/hi
 5  hr              --/M      Croatian           zls/hr               (hbs 5)
 5  hu              --/M      Hungarian          urj/hu
 5  hy              --/M      Armenian_(East_Armenia) ine/hy               (hy-arevela 5)
 5  hy-arevmda      --/M      Armenian_(West_Armenia) ine/hy-arevmda       (hy 8)
 5  ia              --/M      Interlingua        art/ia
 5  id              --/M      Indonesian         poz/id
 5  is              --/M      Icelandic          gmq/is
 5  it              --/M      Italian            roa/it
 5  ja              --/M      Japanese           jpx/ja
 5  jbo             --/M      Lojban             art/jbo
 5  ka              --/M      Georgian           ccs/ka
 5  kl              --/M      Greenlandic        esx/kl
 5  kn              --/M      Kannada            dra/kn
 5  ko              --/M      Korean             ko
 5  kok             --/M      Konkani            inc/kok
 5  ku              --/M      Kurdish            ira/ku
 5  ky              --/M      Kyrgyz             trk/ky
 5  la              --/M      Latin              itc/la
 5  lfn             --/M      Lingua_Franca_Nova art/lfn
 5  lt              --/M      Lithuanian         bat/lt
 5  lv              --/M      Latvian            bat/lv
 5  mi              --/M      poz/mi             poz/mi
 5  mk              --/M      Macedonian         zls/mk
 5  ml              --/M      Malayalam          dra/ml
 5  mr              --/M      Marathi            inc/mr
 5  ms              --/M      Malay              poz/ms
 5  mt              --/M      Maltese            sem/mt
 5  my              --/M      Burmese            sit/my
 5  nb              --/M      Norwegian_Bokmål  gmq/nb               (no 5)
 5  nci             --/M      Nahuatl_(Classical) azc/nci
 5  ne              --/M      Nepali             inc/ne
 5  nl              --/M      Dutch              gmw/nl
 5  om              --/M      Oromo              cus/om
 5  or              --/M      Oriya              inc/or
 5  pa              --/M      Punjabi            inc/pa
 5  pap             --/M      Papiamento         roa/pap
 5  pl              --/M      Polish             zlw/pl
 5  pt              --/M      Portuguese_(Portugal) roa/pt               (pt-pt 5)
 5  pt-br           --/M      Portuguese_(Brazil) roa/pt-BR            (pt 6)
 5  ro              --/M      Romanian           roa/ro
 5  ru              --/M      Russian            zle/ru
 5  sd              --/M      Sindhi             inc/sd
 5  si              --/M      Sinhala            inc/si
 5  sk              --/M      Slovak             zlw/sk
 5  sl              --/M      Slovenian          zls/sl
 5  sq              --/M      Albanian           ine/sq
 5  sr              --/M      Serbian            zls/sr
 5  sv              --/M      Swedish            gmq/sv
 5  sw              --/M      Swahili            bnt/sw
 5  ta              --/M      Tamil              dra/ta
 5  te              --/M      Telugu             dra/te
 5  tn              --/M      Setswana           bnt/tn
 5  tr              --/M      Turkish            trk/tr
 5  tt              --/M      Tatar              trk/tt
 5  ur              --/M      Urdu               inc/ur
 5  vi              --/M      Vietnamese_(Northern) aav/vi
 5  vi-vn-x-central --/M      Vietnamese_(Central) aav/vi-VN-x-central
 5  vi-vn-x-south   --/M      Vietnamese_(Southern) aav/vi-VN-x-south
 5  yue             --/M      Chinese_(Cantonese) sit/yue              (zh-yue 5)(zh 8)

espeak-ng-data 的路徑

ls /usr/share/espeak-ng-data
af_dict   ca_dict  eu_dict   hu_dict      kl_dict   lv_dict    ne_dict            phontab  sr_dict  voices
am_dict   cs_dict  fa_dict   hy_dict      kn_dict   mbrola_ph  nl_dict            pl_dict  sv_dict  zh_dict
an_dict   cy_dict  fi_dict   ia_dict      ko_dict   mi_dict    no_dict            pt_dict  sw_dict  zhy_dict
ar_dict   da_dict  fr_dict   id_dict      kok_dict  mk_dict    om_dict            ro_dict  ta_dict
as_dict   de_dict  ga_dict   intonations  ku_dict   ml_dict    or_dict            ru_dict  te_dict
az_dict   el_dict  gd_dict   is_dict      ky_dict   mr_dict    pa_dict            sd_dict  tn_dict
bg_dict   en_dict  grc_dict  it_dict      la_dict   ms_dict    pap_dict           si_dict  tr_dict
bn_dict   eo_dict  gu_dict   ja_dict      lang      mt_dict    phondata           sk_dict  tt_dict
bpy_dict  es_dict  hi_dict   jbo_dict     lfn_dict  my_dict    phondata-manifest  sl_dict  ur_dict
bs_dict   et_dict  hr_dict   ka_dict      lt_dict   nci_dict   phonindex          sq_dict  vi_dict

從已編譯的 espeak-ng 版本是 1.49.2 版,但安裝後發現該版本不支援中文

# rpm -qa|grep espeak
espeak-ng-1.49.2-4.el8.x86_64

到 github 下載 1.52 版

wget https://github.com/espeak-ng/espeak-ng/archive/refs/tags/1.52.0.tar.gz

直接編譯

ref: questions about mandarin data packet · Issue #1044 · espeak-ng/espeak-ng · GitHub

./autogen.sh
./configure --with-extdict-cmn

這邊最後要看到

        Extended Dictionaries:
            Russian:                   yes
            Chinese (Mandarin):        yes
            Chinese (Cantonese):       yes

編譯

make
make install

測試

espeak-ng -v cmn "english text 你好 more english text" -w test1.wav

2025/08/11

Spring Boot 3 Actuator

Actuator 一般用在 production 環境,使用 http endpoint 或 JMX 方式監控 app,然後抓取指標數值給資料監控平台。

以此順序介紹 Actuator -> Endpoints -> Metrics -> Grafana


要啟用 actuator,只需要加上 library

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

Endpoint

ref: Endpoints :: Spring Boot

endpoint 是用來監控 spring boot app,spring boot 內建很多endpoints,每個 endpoint 都可透過 http/JMX 方式暴露出去,大部分都是用 http。endpoint 會映射為 /actuator/${ID} ID 就是 endpoint 的 ID,ex: /actuator/health

啟動 app 後,瀏覽網址 http://localhost:8080/actuator/health

結果 UP 代表健康運作中

{
    "status": "UP"
}

spring boot 內建的 endpoints

name desc
auditevents 當前 app 的 audit event
beans 所有 spring beans
caches 可使用的 cache
conditions 設定類別上評估條件及匹配成功與否的原因
configprops 所有 @ConfigurationProperties list
env spring 環境中暴露的所有 properties
flyway 所有 flyway 遷移記錄
health 健康資訊
httpexchanges http exchange message(預設顯示最新的 100個)
info app basic info
integrationgraph spring integration graph
loggers 顯示/修改 logger 設定
liquibase 顯示 liquibase db migration
metrics metric infos
mappings 顯示所有 @RequestMapping
quartz 顯示 quartz jobs
scheduledtasks 顯示 scheduled tasks
sessions 當有 servlet-based web app 使用 Spring Session 時,查詢/刪除 user sessions
shutdown 關閉 app
startup 顯示啟動的資料
threaddump thread dump

使用 web app 時,還有以下這些

name desc
heapdump 回傳 heap dump。HotSpot VM 是回傳 HPROF-format file。OpenJ9 JVM 是回傳 PHD-format file
logfile log file content (需要設定 logging.file.name/logging.file.path)
prometheus 可被 prometheus server 提取的 metric

除了 shotdown 其他所有 endpoint 預設都是開啟的。可透過 management.endpoint.<id>.enabled 啟用/禁用 endpoint。

ex:

management:
  endpoint:
    shutdown:
      enabled: true

禁用所有 endpoint, 只啟用某一個 endpoint

management:
  endpoints:
    enable-by-default: false
  endpoint:
    shutdown:
      enabled: true

expose endpoint

啟用後,不一定能被存取。spring boot 3 預設只以 JMX, Web 方式暴露了 health

Property Default
management.endpoints.jmx.exposure.exclude
management.endpoints.jmx.exposure.include health
management.endpoints.web.exposure.exclude
management.endpoints.web.exposure.include health
management:
  endpoints:
    jmx:
      exposure:
        include: "health,info"
    web:
      exposure:
        include: "*"
        exclude: "env,beans"

security

endpoint 提供的資訊需要安全保護

只要有 Spring Security library,就會自動保護所有 endpoints

實作 actuator 的 ManagementWebSecurityAutoConfiguration

註冊在 spring-boot-actuator-autoconfigure

修改 pom.xml 後

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

存取 http://localhost:8080/actuator/info 會自動轉到 Please sign in


如果不想使用 spring boot 預設機制,可改用 SecurityFilterChain

package com.test.actuator;

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests((authorize) -> {
                    authorize.requestMatchers("/").permitAll()
                            .requestMatchers(EndpointRequest.to("health")).hasRole("ENDPOINT_ADMIN")
                            .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll();
                })
                //                .csrf(csrf -> csrf.disable())
                .csrf(csrf -> csrf.ignoringRequestMatchers(EndpointRequest.toAnyEndpoint()))
                .formLogin(withDefaults())
                .logout(logout -> logout.logoutUrl("/"))
                .build();
    }

    @Bean
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("test").password("{noop}test")
                .roles("ENDPOINT_ADMIN", "ADMIN", "TEST").build());
        manager.createUser(User.withUsername("root").password("{noop}root")
                .roles("ADMIN").build());
        return manager;
    }

}

permit all

@Configuration(proxyBeanMethods = false)
public class MySecurityConfiguration {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint());
        http.authorizeHttpRequests((requests) -> requests.anyRequest().permitAll());
        return http.build();
    }

}

customize endpoint mapping

預設 http://localhost:8080/actuator 可取得所有暴露的 endpoints

ex:

{
    "_links": {
        "self": {
            "href": "http://localhost:8080/actuator",
            "templated": false
        },
        "health-path": {
            "href": "http://localhost:8080/actuator/health/{*path}",
            "templated": true
        },
        "health": {
            "href": "http://localhost:8080/actuator/health",
            "templated": false
        }
    }
}

可用以下設定禁用

management:
  endpoints:
    web:
      discovery:
        enabled: false

也可以修改 mapping,這樣要改用 http://localhost:8080/act/hth 取得 health

management:
  endpoints:
    web:
      base-path: /act
      path-mapping:
        health: hth

也可以修改 web port,不使用 service 的 web port。

management:
  server:
    port: 8088
    address: 127.0.0.1

把 port 改為 -1 等同 expose.exclude: "*"

management:
  server:
    port: -1

實作自訂 endpoint

endpoint 啟用時,會自動設定

heath endpoint 是用 HealthEndpointAutoConfiguration。 然後用 @Import 找到不同 web 類型的 endpoint class

health endpoint 是用 HealthEndpoint 類別實作的,該類別有 @Endpoint 註解,且要註冊為 Bean。支援以 @JmxEndpoint @WebEndpoint 暴露方式

@ReadOperation @WriteOperation @DeleteOperation 對應不同的 http method: GET/POST/DELETE

POST request Content-Type 只接受 application/vnd.spring-boot.actuator.v2+json, application/json


新增 User.java

package com.test.actuator;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class User {
    private int id;
    private String name;
    private int age;

}

TestEndpoint.java

package com.test.actuator.endpoint;

import com.test.actuator.User;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

@Component
@WebEndpoint(id = "test")
public class TestEndpoint {

    @ReadOperation
    public User getUser(@Selector Integer id) {
        return new User(id, "james", 18);
    }

    @WriteOperation
    public User updateUser(int id, @Nullable String name, @Nullable Integer age) {
        User user = getUser(id);
        user.setName(StringUtils.defaultIfBlank(name, user.getName()));
        user.setAge(ObjectUtils.defaultIfNull(age, user.getAge()));
        return user;
    }

}

注意要 expose endpoint

management:
  endpoints:
    web:
      exposure:
        include: "*"

瀏覽網址 http://localhost:8080/actuator/test/1

結果

{"id":1,"name":"james","age":18}

POST/DELETE 預設會回應 403,因為 spring boot 預設會打開 CSRF

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests((authorize) -> {
                    authorize.requestMatchers("/").permitAll()
                            .requestMatchers(EndpointRequest.to("health")).hasRole("ENDPOINT_ADMIN")
                            .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll();
                })
                .csrf(csrf -> csrf.disable())
//                .csrf(csrf -> csrf.ignoringRequestMatchers(EndpointRequest.toAnyEndpoint()))
                .formLogin(withDefaults())
                .logout(logout -> logout.logoutUrl("/"))
                .build();
    }

enpoint 也支援 CORS 設定

management:
  endpoints:
    web:
      cors:
        allowed-origins: "https://test.com"
        allowed-methods: "GET,POST"

Observability

系統外部觀測正在運作的內部狀態的能力,spring boot 3 透過 Micrometer 提高可觀測性,支援 Micrometer 1.10+

引用可觀測 API,並自動設定 micrometer 追蹤,支援 Brave, OpenTelemetry, Zipkin, Wavefront

使用 micrometer API 完成觀測後,可將數據交給 Zipkin


Metrics

/actuator/metrics 指標,包含了 JVM, system, tomcat, Logger, Spring MVC...

預設不會 expose

瀏覽網址 http://localhost:8080/actuator/metrics 可查閱所有 metrics

http://localhost:8080/actuator/metrics/jvm.memory.max 就是取得 jvm.memory.max 的資訊


可自訂 metric

package com.test.actuator.metrics;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;

@Configuration
public class MetricsConfig {

    @Bean
    public MeterBinder initDate(Environment env) {
        return (registry) -> Gauge.builder("init.date", this::date).register(registry);
    }

    @Bean
    public MeterBinder systemDate(Environment env) {
        return (registry) -> Gauge.builder("system.date", this::date).register(registry);
    }

    private Number date() {
        return 2024.11;
    }

}

網址 http://localhost:8080/actuator/metrics/init.date

結果

{
    "name":"init.date",
    "measurements":[
        {
            "statistic":"VALUE",
            "value":2024.11
        }
    ],
    "availableTags":[

    ]
}

Tracing

  • 使用 OpenTelemetry 結合 Zipkin 或 Wavefront

  • 使用 OpenZipkin Brave 結合 Zipkin 或 Wavefront

OpenTelemetry 可生成/收集/匯出觀測資料 metrics, logs, traces,他只會收集資料,分析跟展示要用其他軟體

Zipkin 是 Twitter 開源的分佈式追蹤系統,可收集解決服務的延遲問題的時間資料。如果 log 有 Trace ID,可直接展示。

       <!-- 將 Micrometer Observation API 橋接到 OpenTelemetry -->
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-tracing-bridge-otel</artifactId>
        </dependency>

        <!-- 向 Zipkin 報告跟蹤資訊 -->
        <dependency>
            <groupId>io.opentelemetry</groupId>
            <artifactId>opentelemetry-exporter-zipkin</artifactId>
        </dependency>

採樣設定,預設為 0.1,也就是只採樣 10% 的 request

management:
  tracing:
    sampling:
      probability: 1.0

在 log 顯示 Trace ID, Span ID

logging:
  pattern:
    level: ${spring.application.name:},%X{traceId:-},%X{spanId:-}

Spring Boot Admin

GitHub - codecentric/spring-boot-admin: Admin UI for administration of spring boot applications

這個是社群開發的

  • 有個 server 提供 Spring Boot Actuators UI

  • 有個 client 註冊到 server,並能存取所有 actuator endpoints.

ref: Spring Boot Admin – Getting started

配置 Admin Server

<dependency>
    <groupId>de.codecentric</groupId>
    <artifactId>spring-boot-admin-starter-server</artifactId>
    <version>3.4.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

在 application 加上 @EnableAdminServer

@SpringBootApplication
@EnableAdminServer
public class SpringBootAdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootAdminApplication.class, args);
    }
}

配置 Client

<dependency>
    <groupId>de.codecentric</groupId>
    <artifactId>spring-boot-admin-starter-client</artifactId>
    <version>3.4.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

加上設定

spring:
  boot:
    admin:
      client:
        url: http://localhost:8080
management:
  endpoints:
    web:
      expose:
        include: '*'
  info:
    env:
      enabled: true

禁用所有安全機制

@Configuration
public static class SecurityPermitAllConfig {
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) {
        return http.authorizeHttpRequests((authorizeRequests) -> authorizeRequests.anyRequest().permitAll())
            .csrf().disable().build();
    }
}

網址 http://localhost:8088/


參考 Spring Boot Admin server notification 可設定通知

Prometheus

要在 pom.xml 加上 library

        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
            <version>${micrometer-registry-prometheus.version}</version>
        </dependency>

spring boot 就會提供 endpoint /actuator/prometheus 可 pull 資料


加上 libary

        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>simpleclient_pushgateway</artifactId>
            <version>${simpleclient_pushgateway.version}</version>
        </dependency>

修改設定

management:
  prometheus:
    metrics:
      export:
        pushgateway:
          enabled: true

可 push 資料


Prometheus 通常結合 Grafana 使用