在 Emacs 内使用 eglot + eglot-java + dape 开发 Java

在 Emacs 中开发 Java 一直以来的首选都是 lsp-java,但我不喜欢 company 的庞大,不喜欢 lsp-java 依赖众多。因此选择了 eglot 作为 lsp 客户端,加上 elgot-java 来简化配置,再加上 dape 来进行 debug。

这一套在 SpringBoot 下使用没什么问题,SpringBoot 内置了一些 servlet 容器,默认是 Tomcat。启动 dape 选择 jdtls 调用 dape 中默认的 dape-configs 直接启动内置的 Tomcat 是没有什么问题的。

但很多公司的项目还停留在 JDK 7 和 8上,使用的还是 Spring 框架 + 外置的 servlet 容器。这个时候本地环境的 JDK 版本众多,也不能直接拉起 Tomcat,但其实在 IDEA 当中是可以做到一键启动的,因此模仿其流程写了一些函数来照顾到低版本 JDK + 外置 servlet 容器的情况(仅包含 Tomcat)。

如何在 Emacs 中利用 dape 进行 Junit debug

先讲这个是因为后续外置 tomcat 的原理和这个类似。eglot-java 中提供了 eglot-java-run-test 运行 Junit 测试,查看函数定义可知,传入参数 t 即可开启 debug 模式,默认的端口是 8000。

那么我们只需要配置 dape-configs attach 到 Junit debug 的进程就可以了。参考 https://github.com/svaante/dape/issues/108。

(add-to-list 'dape-configs
                 `(jdtls-extra
                   modes (java-mode java-ts-mode)
                   fn (lambda (config)
                        (with-current-buffer
                            (find-file-noselect (expand-file-name (plist-get config :program)
                                                                  (project-root (project-current))))
                          (thread-first
                            config
                            (plist-put 'hostname "localhost")
                            (plist-put 'port (eglot-execute-command (eglot-current-server)
                                                                    "vscode.java.startDebugSession" nil))
                            (plist-put :projectName (project-name (project-current))))))
                   :program dape-buffer-default
                   :request "attach"
                   :hostname "localhost"
                   :port 8000))

外置 tomcat 启动及 debug

外置 tomcat 的原理其实和上面 Junit 的思路一直,也就是 dape attach 到 tomcat 进程,和上面的使用 dape-configs 配置一致。

但 Tomcat 需要 JPDA 模式启动,才能进行 debug 调试,不建议直接修改 Tomcat 本身自带的脚本,我们可以通过新增 setenv.sh 脚本放在 $TOMCAT_HOME/bin 下。(这里的 8000 端口是因为 Junit 默认的端口是 8000,就顺手也用了这个端口。)

#!/bin/sh

JPDA_TRANSPORT="dt_socket"
JPDA_ADDRESS="8000"   # Custom port, for example, 8000
JPDA_SUSPEND="n"

# Combine debug options
JPDA_OPTS="-agentlib:jdwp=transport=$JPDA_TRANSPORT,address=$JPDA_ADDRESS,server=y,suspend=$JPDA_SUSPEND"

# Append to Tomcat startup options
CATALINA_OPTS="$CATALINA_OPTS $JPDA_OPTS"

这样我们就可以在调用 $TOMCAT_HOME/bin/startup.sh 时加上 jpda start 启动,因此下面的函数中我也增加了 debug 参数,传入 t 的时候自动加上 jpda start 拉起 Tomcat。

确认 Tomcat 是否真的在 debug 模式运行,可以用 ps 或者 jps -v 查看进程参数:

jsp -v | grep tomcat

你应该可以从输出中看到类似下面的内容。

-agentlib:jdwp=transport=dt_socket,address=5005,server=y,suspend=n

下面的一些函数一方面是为了简化tomcat启动关闭,可以直接在Emacs 中操作,兼容了 arch Linux 和 macOS 的路径获取;另外一方面是 jenv 在 Emacs 中无法生效,因此写了个选择 JAVA_HOME 的函数。

;; Multiple JDK versions are installed locally,
;; especially when older code cannot be compiled with newer versions,
;; it is necessary to select an older JDK version.
(defun select-java-home ()
  "List all available JDK home paths and let the user choose one.
The selected path will be exported to JAVA_HOME, and PATH will be
updated so that the chosen JDK's `bin/` directory comes first."
  (interactive)
  (let* ((candidates
          (cond
           ;; macOS: use /usr/libexec/java_home
           ((eq system-type 'darwin)
            (split-string
             (shell-command-to-string
              "/usr/libexec/java_home -V 2>&1 | grep '/Library' | awk '{print $NF}'")
             "\n" t))
           ;; Linux (Arch and others): list /usr/lib/jvm/
           ((eq system-type 'gnu/linux)
            (split-string
             (shell-command-to-string "ls -d /usr/lib/jvm/*/ 2>/dev/null")
             "\n" t))
           (t
            (user-error "Unsupported system: %s" system-type))))
         ;; Let user pick one JDK path
         (choice (completing-read "Select JAVA_HOME: " candidates nil t)))
    ;; Set JAVA_HOME environment variable
    (setenv "JAVA_HOME" choice)
    ;; Prepend its bin/ to PATH
    (setenv "PATH" (concat (expand-file-name "bin/" choice) ":" (getenv "PATH")))
    (message "JAVA_HOME set to %s" choice)))

(defun detect-tomcat-home ()
  "Return TOMCAT_HOME path for macOS (Homebrew) or Arch Linux."
  (string-trim
   (shell-command-to-string
    (concat
     "( if command -v brew >/dev/null 2>&1; then\n"
     "    prefix=$(brew --prefix tomcat@9 2>/dev/null || brew --prefix tomcat 2>/dev/null);\n"
     "    [ -n \"$prefix\" ] && echo \"$prefix/libexec\";\n"
     "elif [ -d /usr/share/tomcat10 ]; then\n"
     "    echo /usr/share/tomcat10;\n"
     "elif [ -d /usr/share/tomcat9 ]; then\n"
     "    echo /usr/share/tomcat9;\n"
     "fi )"))))

(defun detect-project-home-and-name ()
  "Detect the project home directory and the project name based on the current project."
  (let ((project (project-current)))
    (if project
        (list :name (project-name project) :home (cdr project))
      (error "Could not determine the project root"))))

(defun compile-and-start-tomcat (debug)
  "Compile the project, copy the WAR file to Tomcat's webapps directory, and start Tomcat.
If DEBUG is non-nil, start Tomcat with JPDA debugging enabled."
  (interactive "P")
  (let* ((tomcat-home (detect-tomcat-home))
         ;; Use detect-project-home-and-name to get project details
         (project-details (detect-project-home-and-name))
         (project-name (plist-get project-details :name))
         (project-home (plist-get project-details :home))
         (webapps-path (concat tomcat-home "/webapps/"))
         (war-file (concat project-home "/target/" project-name ".war"))
         (shutdown-script (concat tomcat-home "/bin/shutdown.sh"))
         (startup-script (concat tomcat-home "/bin/startup.sh"))
         (startup-command (if debug
                              (concat startup-script " jpda start")
                            startup-script)))

    ;; Remove existing WAR and exploded directory
    (delete-file (concat webapps-path project-name ".war"))
    (delete-directory (concat webapps-path project-name) t)

    ;; Copy the new WAR file
    (copy-file war-file webapps-path)

    ;; Shutdown Tomcat
    (shell-command (concat shutdown-script " || true"))
    (sleep-for 3)

    ;; Startup Tomcat with or without JPDA
    (shell-command startup-command)
    (sleep-for 5)

    ;; Check if Tomcat is running
    (if (shell-command (format "nc -z localhost %d" tomcat-port))
        (message "Deployment successful and Tomcat is running.")
      (progn
        (message "Tomcat failed to start, retrying...")
        (shell-command startup-command)
        (sleep-for 5)
        (if (shell-command (format "nc -z localhost %d" tomcat-port))
            (message "Deployment successful and Tomcat is running.")
          (message "Tomcat failed to start after retrying. Please check the logs for more details."))))))

(defun stop-tomcat ()
  "Stop Tomcat server."
  (interactive)
  (let* ((tomcat-home (detect-tomcat-home))
         (shutdown-script (concat tomcat-home "/bin/shutdown.sh")))
    ;; Execute the shutdown script
    (shell-command shutdown-script)
    (message "Tomcat server stopped.")))

具体的可以参考我的配置 .emacs.d/lisp/init-prog.el at e446a19c9f3329d162d34925752e3f7d6c0979a2 · LuciusChen/.emacs.d · GitHub

13 个赞

c++转java的时候,也倒腾过一阵,后来发现jdtls cpu消耗太高,而且莫名其妙的其他问题,最后还是换了idea,社区版挺好使。。。

想起来我配置中有啥就写一些。

mybatis 的 xml 相关设置

mapper.java 跳到对应的 xml 中的 sql

(defun +java-to-xml-mapper ()
  "Jump from a Java mapper file to the corresponding XML mapper file.
If the cursor is on a method name in the Java file, jump to the corresponding
method definition in the XML file."
  (interactive)
  (let* ((java-file (buffer-file-name))
         (xml-file (concat (file-name-sans-extension java-file) ".xml"))
         (method-name (thing-at-point 'symbol t)))
    (if (file-exists-p xml-file)
        (progn
          (find-file xml-file)
          (goto-char (point-min))
          (if (re-search-forward (concat "id=\"\\(" method-name "\\)\"") nil t)
              (message "Jumped to method: %s" method-name)
            (message "Method '%s' not found in XML file." method-name)))
      (message "No corresponding XML file found."))))

设置 xml 中 sql 高亮,是在 nxml-mode 中嵌套了子模式

(setup mmm-mode
  (:with-mode prog-mode (:require mmm-mode))
  (:when-loaded
    (setq mmm-global-classes nil
          mmm-classes-alist nil)
    (setopt mmm-parse-when-idle t
            mmm-mode-ext-classes-alist nil
            mmm-submode-decoration-level 0)
    (:hook-into nxml-mode)
    (mmm-add-classes
     '((nxml-sql-select :submode sql-mode
                        :front "<select[^>]*>" :back "</select>")
       (nxml-sql-insert :submode sql-mode
                        :front "<insert[^>]*>" :back "</insert>")
       (nxml-sql-update :submode sql-mode
                        :front "<update[^>]*>" :back "</update>")
       (nxml-sql-delete :submode sql-mode
                        :front "<delete[^>]*>" :back "</delete>")))
    (dolist (class '(nxml-sql-select nxml-sql-insert nxml-sql-update nxml-sql-delete))
      (mmm-add-mode-ext-class 'nxml-mode nil class))))

另外用 yasnippet 写了一些模板。

# -*- mode: snippet -*-
# name: select
# key: select
# --
select id="${1:findById}"${2: parameterType="${3:Long}"}${4: resultType="${5:User}"}${6: resultMap="${7:userMap}"}></select>
# -*- mode: snippet -*-
# name: insert
# key: insert
# --
insert id="${1:insert}"${2: parameterType="${3:User}"}${4: useGeneratedKeys="${5:true}"}${6: keyProperty="${7:id}"}${8: keyColumn="${9:id}"}></insert>
# -*- mode: snippet -*-
# name: update
# key: update
# --
update id="${1:update}"${2: parameterType="${3:User}"}></update>
# -*- mode: snippet -*-
# name: delete
# key: delete
# --
delete id="${1:delete}"${2: parameterType="${3:Long}"}></delete>
2 个赞

:waving_hand: 很棒,一直用idea就是因为那边的mybatis的一些辅助很好用,现在我一般开两个,在emacs里写(以及用aider辅助),在idea调试

1 个赞

佩服楼主,现在贪图方便,都直接vscode了。

mapper xml跳转都实现了,真乃吾辈楷模

之前都是手动选择 JDK 版本设置 JAVA_HOME,改了下,从 pom.xml 中提取 JDK 版本后自动选择,如果没有找到对应的则手动选择。

(defun maven-detect-jdk-version ()
  "Detect JDK version from a Maven POM file.
Return version string (e.g. \"1.8\" or \"17\").
Support both old style (<maven.compiler.source>) and new style (<maven.compiler.release>)."
  (let* ((project-details (detect-project-home-and-name))
         (project-home (plist-get project-details :home))
         (pom (expand-file-name "pom.xml" project-home))
         (content (when (file-exists-p pom)
                    (with-temp-buffer
                      (insert-file-contents pom)
                      (buffer-string)))))
    (when content
      (or
       ;; New style: <maven.compiler.release>
       (when (string-match "<maven.compiler.release>\\([^<]+\\)</maven.compiler.release>" content)
         (match-string 1 content))
       ;; Old style: <maven.compiler.source>
       (when (string-match "<maven.compiler.source>\\([^<]+\\)</maven.compiler.source>" content)
         (match-string 1 content))
       ;; Plugin style: <source>
       (when (string-match "<source>\\([^<]+\\)</source>" content)
         (match-string 1 content))))))

(defun maven-normalize-jdk-version (version)
  "Normalize Maven JDK VERSION string to plain major version.
E.g. \"1.8\" -> \"8\", \"11\" -> \"11\", \"17\" -> \"17\"."
  (cond
   ((string-match "^1\\.\\([0-9]+\\)$" version)
    (match-string 1 version))
   (t version)))

(defun maven-list-jdk-homes ()
  "Return a list of available JDK home paths depending on system."
  (cond
   ;; macOS
   ((eq system-type 'darwin)
    (seq-filter
     (lambda (path)
       (not (string-match-p "JavaAppletPlugin.plugin" path)))
     (split-string
      (shell-command-to-string
       "/usr/libexec/java_home -V 2>&1 | grep '/Library' | awk '{print $NF}'")
      "\n" t)))
   ;; Linux
   ((eq system-type 'gnu/linux)
    (seq-filter
     (lambda (path)
       (not (string-match-p "/default" path)))
     (split-string
      (shell-command-to-string "ls -d /usr/lib/jvm/*/ 2>/dev/null")
      "\n" t)))
   (t
    (user-error "Unsupported system: %s" system-type))))

(defun maven-auto-select-java-home (&rest _)
  "Auto-select JAVA_HOME based on Maven POM JDK version.
If no matching version is found, prompt the user to choose."
  (interactive)
  (let* ((jdk-version-raw (maven-detect-jdk-version))
         (jdk-version (and jdk-version-raw
                           (maven-normalize-jdk-version jdk-version-raw)))
         (candidates (maven-list-jdk-homes))
         (match (and jdk-version
                     (seq-find (lambda (path)
                                 (string-match-p (concat jdk-version) path))
                               candidates)))
         (choice (or match
                     (completing-read
                      (if jdk-version
                          (format "No JDK %s found, select manually: " jdk-version)
                        "Select JAVA_HOME: ")
                      candidates nil t))))
    (when choice
      (setenv "JAVA_HOME" choice)
      (setenv "PATH" (concat (expand-file-name "bin/" choice) ":" (getenv "PATH")))
      (message "JAVA_HOME set to %s%s"
               choice
               (if jdk-version
                   (format " (from pom.xml JDK version %s)" jdk-version)
                 "")))))

然后挂在了 eglot-connect-hook 下,因为 eglot-server-initialized-hook 文档当中说

Use ‘eglot-connect-hook’ to hook into when a connection was successfully established and the server on the other side has received the initializing configuration.

群友有更合适的 hook 可以告诉我。

maven toolchain 可以自动配置不同版本的jdk

倒是第一次知道这个,不过我这里是为了设置 Emacs 当中的变量 JAVA_HOME,这样 build 才不会报错。

之前用 jenv 等外部切换的 JAVA_HOME 的方法不奏效。

Java 项目当用 MyBatis 的时候,有一种是 Java 代码和 SQL 分离,SQL 写在 .xml 文件当中。 比如这样一个 sql

通过快捷键 C-c '(参考 org-babel)后如下图

在通过 apheleia 格式化之后,C-c C-c 就可以塞回原来的 buffer 中的 MyBatis 标签内。

参考 .emacs.d/lisp/init-prog.el at 007c6dc97c99ecb906f3c27a53561ba6a03587d1 · LuciusChen/.emacs.d · GitHub

能在emacs中写java,膜拜一下。

我以前写的时候,试过,调试和补全,体验很糟糕,不如idea用来舒服。

现在也不写java了,写python go和c在emacs的体验很丝滑,比vscode和idea都爽。

一样,也是java用idea,c,py,go用emacs,然后偶尔py也用vscode,怎么省事怎么来,话说vscode的py的debug体验确实比emacs好很多

自己搞个启动脚本,从终端启动图形化的emacs就好了

是因为我有多个java版本,不同的项目版本也不一样需要切换。