最近接手了一个前端项目,在熟悉的过程中,经常需要在项目中定位页面中的某一组件或元素,普遍的方式是通过搜索页面上的关键字或者根据路由路径来查找对应的页面。不过,在这之前,我有了解过一个 Vite 插件,能够实现通过点击页面元素定位到对应代码位置,它就是 Code Inspector。正好,接手的这个项目也是基于 Vite 构建的,于是我尝试了一下这个插件,确实非常方便。然后,我就把这个插件分享给了团队的其他成员。对于前端开发人员来说,这个插件是一个非常实用的工具,可以提高开发效率,减少查找代码的时间,大家都很喜欢。这时,有一些后端开发人员问后端有没有类似的工具,例如通过点击网络请求,定位到对应的处理代码位置。我第一时间就去网上搜寻了一番,很遗憾的是没有找到类似的工具。不过,我觉得这个功能确实很有用,于是我就想着自己实现一个类似的工具。

说干就干,简单梳理了一下思路:

  1. 在后端每个请求处理的地方,响应一些标识信息,例如处理请求的源文件路径、行号等。
  2. 开发一个浏览器插件,用于监听后端开发服务处理的网络请求。列出这些请求,点击跳转到对应的源文件位置。

最后效果如下,点击请求路径可自动在 IDEA 中定位到处理请求的方法行:

img.png

响应标识信息

为了让浏览器插件能够区分出哪些请求是需要进行定位的,以及知道定位的代码文件路径和行号,我们可以利用 HTTP 响应头来响应这些标识信息。

要在后端服务响应这些标识信息,有好几种方案:

  1. 开发 Spring MVC Interceptor,拦截请求,响应标识信息。
  2. 开发 Aspect,切面拦截请求,响应标识信息。
  3. 开发 Maven 插件,编译时修改字节码,插入标识信息。
  4. 开发 JRebel 插件,运行时修改字节码,插入标识信息。

最后,我选择了方案 4,因为前面 3 种方案都需要修改项目代码,侵入性较大。

自定义 JRebel 插件

在 JRebel 的官方文档 Custom JRebel plugins 中有详细说明如何自定义 JRebel 插件。

首先,创建一个 Maven 项目,引入 JRebel 插件开发相关的依赖,并声明插件的入口类(me.ligang.jrebel.plugin.SourceLocationPlugin):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>me.ligang.jrebel.plugin</groupId>
  <artifactId>source-location-plugin</artifactId>
  <version>1.0-SNAPSHOT</version>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <sdk.version>7.0.0</sdk.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.zeroturnaround</groupId>
      <artifactId>jr-sdk</artifactId>
      <version>${sdk.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.zeroturnaround</groupId>
      <artifactId>jr-utils</artifactId>
      <version>${sdk.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.6.1</version>
        <configuration>
          <source>8</source>
          <target>8</target>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.0.2</version>
        <configuration>
          <archive>
            <manifestEntries>
              <JavaRebel-Plugin>me.ligang.jrebel.plugin.SourceLocationPlugin</JavaRebel-Plugin>
            </manifestEntries>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>

  <repositories>
    <repository>
      <id>zt-public</id>
      <url>https://repos.zeroturnaround.com/nexus/content/groups/zt-public</url>
    </repository>
  </repositories>
</project>

插件的入口类需要实现 JRebel SDK 的 Plugin 接口,声明插件的相关信息:

public class SourceLocationPlugin implements Plugin {

    private static final Logger logger = LoggerFactory.getInstance();

    @Override
    public void preinit() {
        OpenFileServer openFileServer = new OpenFileServer();
        openFileServer.start();

        Integration integration = IntegrationFactory.getInstance();
        ClassLoader classLoader = getClass().getClassLoader();

        try {
            integration.addIntegrationProcessor(
                    classLoader,
                    new SourceLocationClassBytecodeProcessor()
            );
            logger.info("SourceLocationClassBytecodeProcessor registered successfully.");

            System.out.println("==========================================");
            System.out.println("SourceLocationPlugin preinit completed.");
            System.out.println("==========================================");
        } catch (Exception e) {
            logger.error("Failed to register SourceLocationClassBytecodeProcessor", e);
        }
    }

    @Override
    public boolean checkDependencies(ClassLoader classLoader, ClassResourceSource classResourceSource) {
        try {
            classLoader.loadClass("org.springframework.web.bind.annotation.RequestMapping");
            return true;
        } catch (ClassNotFoundException e) {
            logger.warn("Spring framework dependency check failed: {}", e.getMessage());
            return false;
        }
    }

    @Override
    public String getId() {
        return "source-location-plugin";
    }

    @Override
    public String getName() {
        return "Source Location Plugin";
    }

    @Override
    public String getDescription() {
        return "A plugin that adds source location information to HTTP responses for Spring controllers.";
    }

    @Override
    public String getAuthor() {
        return "LiGang";
    }

    @Override
    public String getWebsite() {
        return "";
    }

    @Override
    public String getSupportedVersions() {
        return "";
    }

    @Override
    public String getTestedVersions() {
        return "";
    }
}

最后就是 CBP(Class Bytecode Processor)的实现:

public class SourceLocationClassBytecodeProcessor extends JavassistClassBytecodeProcessor {

    private final Logger logger = LoggerFactory.getInstance();

    private static final String[] MAPPING_ANNOTATIONS = {
            "org.springframework.web.bind.annotation.GetMapping",
            "org.springframework.web.bind.annotation.PostMapping",
            "org.springframework.web.bind.annotation.PutMapping",
            "org.springframework.web.bind.annotation.DeleteMapping",
            "org.springframework.web.bind.annotation.PatchMapping"
    };

    @Override
    public void process(ClassPool cp, ClassLoader cl, CtClass ctClass) throws Exception {
        if (!isSpringController(ctClass)) {
            return;
        }

        logger.info("Processing class: " + ctClass.getName());

        String sourceFilePath = getSourceFilePath(cl, ctClass);
        if (sourceFilePath == null) {
            return;
        }

        CtClass httpServletResponseClass = getHttpServletResponseClass(cp);
        if (httpServletResponseClass == null) {
            System.err.println("Neither javax.servlet.http.HttpServletResponse nor jakarta.servlet.http.HttpServletResponse found.");
            return;
        }

        for (CtMethod ctMethod : ctClass.getDeclaredMethods()) {
            if (hasMappingAnnotation(ctMethod)) {
                int startLine = ctMethod.getMethodInfo().getLineNumber(0);
                String codeToInsert = buildCodeToInsert(httpServletResponseClass, sourceFilePath, ctClass.getName(), ctMethod.getName(), startLine);
                ctMethod.insertBefore(codeToInsert);
            }
        }
    }

    private boolean isSpringController(CtClass ctClass) throws Exception {
        return ctClass.hasAnnotation("org.springframework.web.bind.annotation.RestController")
                || ctClass.hasAnnotation("org.springframework.web.bind.annotation.Controller");
    }

    private boolean hasMappingAnnotation(CtMethod ctMethod) throws Exception {
        for (String annotation : MAPPING_ANNOTATIONS) {
            if (ctMethod.hasAnnotation(annotation)) {
                return true;
            }
        }
        return false;
    }

    private String getSourceFilePath(ClassLoader cl, CtClass ctClass) throws Exception {
        URL classUrl = cl.getResource(ctClass.getName().replace('.', '/') + ".class");
        if (classUrl == null) {
            return null;
        }
        String classPath = classUrl.getPath();
        String projectRoot = classPath.substring(0, classPath.indexOf("/target/classes/"));
        return projectRoot.replaceAll("^/", "") + "/src/main/java/" + ctClass.getName().replace('.', '/') + ".java";
    }

    private CtClass getHttpServletResponseClass(ClassPool cp) throws Exception {
        CtClass responseClass = cp.getOrNull("javax.servlet.http.HttpServletResponse");
        return responseClass != null ? responseClass : cp.getOrNull("jakarta.servlet.http.HttpServletResponse");
    }

    private String buildCodeToInsert(CtClass httpServletResponseClass, String sourceFilePath, String className, String methodName, int startLine) {
        return "{ " +
                httpServletResponseClass.getName() + " response = " +
                "((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.getRequestAttributes()).getResponse(); " +
                "response.addHeader(\"X-Source-Path\", \"" + sourceFilePath + "\"); " +
                "response.addHeader(\"X-Source-Class\", \"" + className + "\"); " +
                "response.addHeader(\"X-Source-Method\", \"" + methodName + "\"); " +
                "response.addHeader(\"X-Source-Line\", String.valueOf(" + startLine + ")); " +
                "}";
    }
}

从代码中可以看出,插件的核心是 SourceLocationClassBytecodeProcessor 类,它通过 Javassist 修改 Spring 控制器类的字节码,插入通过 HTTP 响应头来响应标识信息的代码。

定位至文件行

在自定义的 JRebel 插件中,不仅仅是响应标识信息,还启动了一个 HTTP 服务:

OpenFileServer openFileServer = new OpenFileServer();
openFileServer.start();

这个 HTTP 服务提供给浏览器插件来调用,将文件路径和行号作为请求参数,请求后即可在 IDEA 中定位到具体的代码行。因为浏览器插件无法直接调用系统命令。

OpenFileServer 的代码如下:

public class OpenFileServer implements HttpHandler {
    private static final int PORT = 61111;
    private static final String CONTEXT_PATH = "/open";
    private static final String IDE_COMMAND = "idea";
    private static final String CMD = "cmd";
    private static final String CMD_OPTION = "/c";

    public void start() {
        try {
            HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
            server.createContext(CONTEXT_PATH, this);
            server.setExecutor(null);
            server.start();
        } catch (IOException e) {
            throw new RuntimeException("Failed to start server", e);
        }
    }

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        String query = exchange.getRequestURI().getQuery();
        String filePath = getQueryParam(query, "file");
        String lineNumber = getQueryParam(query, "line");

        String[] command = {CMD, CMD_OPTION, IDE_COMMAND, "--line", lineNumber, filePath};
        executeCommand(command);

        String response = "File opened in IntelliJ IDEA";
        exchange.sendResponseHeaders(200, response.getBytes().length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(response.getBytes());
        }
    }

    private String getQueryParam(String query, String key) {
        return Arrays.stream(query.split("&"))
                .map(param -> param.split("="))
                .filter(pair -> pair.length == 2 && pair[0].equals(key))
                .map(pair -> pair[1])
                .findFirst()
                .orElse(null);
    }

    private void executeCommand(String[] command) {
        ProcessBuilder processBuilder = new ProcessBuilder(command);
        try {
            Process process = processBuilder.start();
            try (StreamGobbler outputGobbler = new StreamGobbler(process.getInputStream());
                 StreamGobbler errorGobbler = new StreamGobbler(process.getErrorStream())) {
                outputGobbler.start();
                errorGobbler.start();
                int exitCode = process.waitFor();
                outputGobbler.join();
                errorGobbler.join();
                if (exitCode != 0) {
                    throw new RuntimeException("Failed to open file in IntelliJ IDEA");
                }
            }
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException("Error executing command", e);
        }
    }

    static class StreamGobbler extends Thread implements AutoCloseable {
        private final BufferedReader reader;

        public StreamGobbler(InputStream inputStream) {
            this.reader = new BufferedReader(new InputStreamReader(inputStream));
        }

        @Override
        public void run() {
            reader.lines().forEach(System.out::println);
        }

        @Override
        public void close() throws IOException {
            reader.close();
        }
    }
}

如何通过命令行打开文件并定位到指定行,可以参考 IDEA 的文档 Open files from the command line

其实除了通过 IDEA 命令行工具,还可以 Jetbrains Toolbox 的 jetbrains:// 协议来打开文件,这样就不需要通过命令行命令了,具体可以参考 StackOverflow 的一个问题。但是该方式需要安装 Jetbrains Toolbox,而且打开速度较慢,所以我选择了命令行方式。

Chrome 浏览器插件

Chrome 浏览器插件的实现比较简单,主要是监听网络请求,当请求头中包含了标识信息,就在请求列表中显示出来,点击即可打开文件并定位到指定行。

首先需要一个 Manifest 文件 manifest.json

{
  "manifest_version": 3,
  "name": "API Source Location",
  "version": "1.0",
  "description": "This extension shows the source location of the API call.",
  "permissions": [],
  "devtools_page": "devtools.html",
  "icons": {
    "16": "icons/16.png",
    "32": "icons/32.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  }
}

然后 devtools.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <title>API Request Tracker</title>
    <style>
        body { font-family: Arial, sans-serif; }
        header {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        label {
            display: flex;
            align-items: center;
        }
        .time {
            color: #666;
        }
        .tag {
            width: 40px;
        }
        .get {
            color: #0a0;
        }
        .post {
            color: #00a;
        }
        li + li {
            margin-top: 8px;
        }

        ul:empty:after {
            content: "No requests yet.";
            color: #666;
        }

        a[disabled] {
            color: #666;
            cursor: not-allowed;
        }

        pre {
            background-color: #2f2f2f;
            color: #f8f8f8;
            border-radius: 8px;
            padding: 10px;
            overflow-x: auto;
            font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
        }


        pre::-webkit-scrollbar {
            width: 10px;
            height: 10px;
        }


        pre::-webkit-scrollbar-track {
            background-color: #3f3f3f;
            border-radius: 8px;
        }


        pre::-webkit-scrollbar-thumb {
            background-color: #888;
            border-radius: 8px;
            border: 2px solid #3f3f3f;
        }


        pre::-webkit-scrollbar-thumb:hover {
            background-color: #555;
        }


        pre::-webkit-scrollbar-button {
            display: none;
        }
    </style>
</head>
<body>
<header>
    <button id="toggle" data-stop="false">Stop</button>
    <button id="clear">Clear</button>
    <label>
        <input id="search" type="search" placeholder="Find">
    </label>
    <label>
        <input checked id="getToggle" type="checkbox"> GET
    </label>
    <label>
        <input checked id="postToggle" type="checkbox"> POST
    </label>
    <label>
        <input checked id="responseBodyVisible" type="checkbox"> Response Body
    </label>
</header>
<h2>Requests <small id="count">0/0</small></h2>
<ul id="requests"></ul>
<script src="devtools.js"></script>
</body>
</html>

最后是 devtools.js

function debounce(fn, delay) {
  let timer;
  return function () {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, delay);
  };
}


function createUrl(file, line) {
  return [
    'http://localhost:61111',
    '/open',
    '?file=' + encodeURIComponent(file),
    '&line=' + encodeURIComponent(line)
  ].join('');
}

// nav
const requestsEle = document.getElementById('requests');
requestsEle.addEventListener('click', function (e) {
  if (e.target.tagName === 'A') {
    e.preventDefault();
    if(e.target.getAttribute('disabled')) {
      return;
    }
    e.target.setAttribute('disabled', 'disabled');
    fetch(e.target.href)
      .finally(() => {
        e.target.removeAttribute('disabled');
      });
  }
});

// clear
document.getElementById('clear').addEventListener('click', function () {
  requestsEle.innerHTML = '';
  updateCount();
});

// toggle
const toggleEle = document.getElementById('toggle');
toggleEle.addEventListener('click', function () {
  const isStop = this.getAttribute('data-stop') === 'true';
  this.setAttribute('data-stop', isStop ? 'false' : 'true');
  this.innerText = isStop ? 'Stop' : 'Start';
});

// search
const searchEle = document.getElementById('search');
searchEle.addEventListener('input', debounce(function () {
  const keyword = this.value.trim().toLowerCase();
  requestsEle.querySelectorAll('li').forEach(li => {
    const path = li.querySelector('.path').innerText.toLowerCase();
    const content = li.querySelector('pre').innerText.toLowerCase();
    const match = keyword.startsWith('!')
      ? path.includes(keyword.slice(1))
      : path.includes(keyword) || content.includes(keyword);
    if (match) {
      li.style.display = 'list-item';
    } else {
      li.style.display = 'none';
    }
  });
  updateCount();
}, 300));

// responseBodyVisible
const responseBodyVisibleEle = document.getElementById('responseBodyVisible');
responseBodyVisibleEle.addEventListener('change', function () {
  requestsEle.querySelectorAll('pre').forEach(pre => {
    pre.style.display = this.checked ? 'block' : 'none';
  });
  updateCount();
});

// getToggle, postToggle
const getToggleEle = document.getElementById('getToggle');
getToggleEle.addEventListener('change', function () {
  requestsEle.querySelectorAll('.method.get').forEach(span => {
    span.parentElement.style.display = this.checked ? 'list-item' : 'none';
  });
  updateCount();
});
const postToggleEle = document.getElementById('postToggle');
postToggleEle.addEventListener('change', function () {
  requestsEle.querySelectorAll('.method.post').forEach(span => {
    span.parentElement.style.display = this.checked ? 'list-item' : 'none';
  });
  updateCount();
});

// count
const countEle = document.getElementById('count');

function updateCount() {
  const liEle = requestsEle.querySelectorAll('li');
  countEle.innerText = `${[...liEle].filter(li => li.style.display !== 'none').length}/${liEle.length}`;
}

function calcResponseBodyVisible() {
  return responseBodyVisibleEle.checked ? 'block' : 'none';
}

function calcVisible(method, path, content) {
  const getToggle = getToggleEle.checked;
  const postToggle = postToggleEle.checked;
  const keyword = searchEle.value.trim().toLowerCase();

  const pathMatch = keyword.startsWith('!')
    ? !path.includes(keyword.slice(1))
    : path.includes(keyword);
  const contentMatch = keyword.startsWith('!')
    ? !content.includes(keyword.slice(1))
    : content.includes(keyword);

  return (
    (method === 'GET' && getToggle) || (method === 'POST' && postToggle)
  ) && (pathMatch || contentMatch) ? 'list-item' : 'none';
}


function createItem(time, method, path, fileUrl, content) {
  const itemEle = document.createElement('li');
  itemEle.style.display = calcVisible(method, path, content);
  itemEle.innerHTML = `<span class="time">${time}</span>
            ${method === 'GET' ? '<span class="method get">GET</span>' : '<span class="method post">POST</span>'}
            <a class="path" href="${fileUrl}">${path}</a>
            <pre style="display: ${calcResponseBodyVisible()}">${content}</pre>`;
  return itemEle;
}

chrome.devtools.network.onRequestFinished.addListener(function (request) {
  const {url, method} = request.request;
  const {headers} = request.response;

  const pathHeader = headers.find(header => header.name.toUpperCase() === 'X-SOURCE-PATH');
  const lineHeader = headers.find(header => header.name.toUpperCase() === 'X-SOURCE-LINE');
  if (pathHeader == null) {
    return;
  }

  const stop = toggleEle.getAttribute('data-stop');
  if (stop === 'true') {
    return;
  }

  request.getContent(body => {
    const openFileUrl = createUrl(pathHeader.value, lineHeader.value);
    requestsEle.prepend(createItem(new Date().toLocaleTimeString(), method, new URL(url).pathname, openFileUrl, body));
    updateCount();
  });
});


chrome.devtools.panels.create("API Requests", "icons/32.png", "devtools.html");