当前位置:   article > 正文

如何用Java写一个整理Java方法调用关系网络的程序

如何用Java写一个整理Java方法调用关系网络的程序

        大家好,我是猿码叔叔,一位 Java 语言工作者,也是一位算法学习刚入门的小学生。很久没有为大家带来干货了。

        最近遇到了一个问题,大致是这样的:如果给你一个 java 方法,如何找到有哪些菜单在使用。我的第一想法是,这不很简单吗?!使用 IDEA 自带的右键 Find Usage 功能,一步一步往上溯源最终找到 Controller 中的方法,找到 requestMapping 的映射路径,然后去数据库一查便知。     

目录

一、问题真的这么简单吗?我们先分析一下这种问题的出现场景

二、有没有更快的解决方案?

三、如何提取 Java 对象中的方法以及方法中的被调用方法

        IO 流读取 .java 文件

        JDK 编译后的 .class 字节码文件

四、解读 -javap 命令反编译后的内容

五、用代码解析出类的方法与被调方法

六、让方法与被调方法的关系可视化


一、问题真的这么简单吗?我们先分析一下这种问题的出现场景

        我所在的这个项目是一个接近15年的老项目,使用的还是 SSM 框架。前后端没有做到分离开来,耦合度极高。所以刚才那个问题的目的大致就是要将部分方法拆分出来,降低耦合度,使得后期的维护更加方便,亦或是扩展起来更加容易。 

        这种项目模块或方法之间耦合度高的问题,大多出现在老项目中。而仍然使用老项目的企业中国企居多。成本与安全也是阻碍老项目得到升级的两大关键问题。随着AI的兴起,这种问题的彻底解决或许能够看到一些希望,但是否有大模型专注于解决这种问题仍然需要考虑到成本和价值问题了。

二、有没有更快的解决方案?

        除了刚才使用 IDEA 的 Find Usage 右键功能。我们或许可以调用 IDEA 的 API 也就是 Find Usage 功能,然后将项目中的所有方法串联成一个 N 叉树。对于 Controller 中的方法可以放在 Root 节点的下一层节点中。

        但,IDEA 工具真的会给你提供这个 API 吗?答案是否定的,至少我搜索了很多相关内容,也没有得到一个准确的结果。或许有相关的开源组件提供这种方法溯源菜单的功能,但也都不尽如人意。

        那我们能否自己写一个这样的程序呢?

三、如何提取 Java 对象中的方法以及方法中的被调用方法

        这个程序实现起来其实很简单。我们只需要使用 IO 流去读取 .java 文件或者反射取出 class 中的 declaredMethods 即可。前者更开放,也更有挑战性。后者除了能取到声明方法以外,方法中的被调用方法反射做不到这一点。

        IO 流读取 .java 文件

        IO 流我们使用 BufferedReader 一行一行地读取 .java 文件中的内容,然后根据方法的特征解析出方法与被调用方法即可。听起来是不是很简单,怎么写代码?

        考虑到 Java 中代码的多变性,比如换行、注释、内部类、静态代码块、字段等等,这些都是需要我们用算法来处理的。但这么搞下去,真的可以自己写一个 JDK 了。如果你肯坚持和足够动脑,也不是不可能实现。

        JDK 编译后的 .class 字节码文件

        如果你动手能力强,你会发现刚才说的一部分要处理的内容,jdk 可以帮你解决。比如注释。在项目编译后的 target 目录下,原来的 .java 文件会被编译成 .class 文件,这些文件中原有的注释内容100%都不会被保留。此时,我们可以考虑去读取 .class 文件来进一步实现我们的计划。

        当拿到 .class 文件的数据时,我傻眼了。读取到的流数据并非我们眼睛看到的数据那样,而是二进制的字节码内容,要想解析这些数据,我们得学会解读这些内容。当然现在有很多工具可以反编译字节码文件。为了不重复造轮子,我去网上找到了如下代码,可以在 java 代码中执行反编译命令,将指定目录下的 .class 文件反编译成我们能读懂的内容。

  1. private void decodeClassFile(File clazzFile) {
  2. String absPath = clazzFile.getAbsolutePath();
  3. try {
  4. String command = "javap -c " + absPath;
  5. Process process = runtime.exec(command);
  6. // 读取命令执行结果
  7. BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
  8. StringBuilder sb = new StringBuilder();
  9. String line;
  10. while ((line = reader.readLine()) != null) {
  11. sb.append(line).append("\n");
  12. }
  13. // 等待命令执行完成
  14. process.waitFor();
  15. // 获取退出值
  16. process.exitValue();
  17. reader.close();
  18. absPath = absPath.substring(absPath.indexOf("cn"));
  19. absPath = absPath.replace('\\', '.');
  20. absPath = absPath.replace(".class", ".txt");
  21. // 将反编译后的内容写入到指定的文件内
  22. writeDecodedClazzFileDown(sb.toString(), absPath);
  23. } catch (IOException | InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. }

四、解读 -javap 命令反编译后的内容

        打开反编译后的文件,你会发现方法的内容区域会有数字序列号,这些序列号其实是执行顺序的一个标识。序列号右侧的 “//” 后面会跟随描述当前行的内容类别。如果是方法,// 会写着  “Method”,如果是接口方法则是“InterfaceMethod”,如果是字段定义则是“Field”。对于其他的描述,读者有兴趣也可以去研究一下。紧随这些描述后的内容就是 java 源代码中的真实内容了。

  1. 46: invokevirtual #27 // Method cn/com/xxx/xxx/pojo/dev/Admin.getUserList:()Ljava/util/List;
  2. 97: invokeinterface #7, 3 // InterfaceMethod org/springframework/ui/Model.addAttribute:(Ljava/lang/String;Ljava/lang/Object;)Lorg/springframework/ui/Model;

        编号46,是一个普通方法;编号97是一个接口方法。46 的 Method 后面我给他分为 3 个部分。

1、方法名与方法所在的包路径。

2、() 中的内容代表参数信息。这些参数信息只包含包路径与类型,没有参数名称。

3、括号后面的内容是返回值信息。

        我们看到,参数内容与返回值区域路径首字符多了一个 “L” 字符。这个代表对象类型区别于 Java 自己的 8 大基本类型。 这 8 个类型的指令如下:

dataType.put('[', "[]");  // 代表数组
dataType.put('I', "int");
dataType.put('J', "long");
dataType.put('F', "float");
dataType.put('D', "double");
dataType.put('Z', "boolean");
dataType.put('B', "byte");
dataType.put('C', "char");
dataType.put('S', "short");

        对于其他更多的指令,读者可以去咨询 AI。比如文心一言或者GPT。

五、用代码解析出类的方法与被调方法

        解析方法与被调方法前我们需要注意几点:

  • Service 方法与 ServiceImpl 实现类的方法转换。在 Service 方法中的方法体是没有内容的,其内容会在他的实现类对应的方法体里。这时候你应该知道我要强调的是什么了。
  • 方法与被调方法的参数信息在反编译文件里的内容是不同的,比如 double 类型与 int 类型同时存在时,你看到的是 “DI”,如果前者是数组,你看到的是“[DI”。所以为了在拿到被调方法时能够准确找到下一个节点,我们必须对这些内容进行还原
  • 如上一条所说,我们解析出方法与被调方法的目的是能够准确的通过方法找到有哪些被调方法,拿到被调方法,能够准确找到被调方法中的被调方法。这是一个 N-ary 树的遍历思想,直到找不到为止。
  • 当前类的自有方法互相调用,需要拼接其包路径。这是为了统一管理。
  • 解析后的内容,在存放时应当有一个便于处理的格式。比如一级方法名前面没有空格,而二级的被调方法则应当在前面加上四个“-”字符,这样可以明确他们之间的关系。

      下面是解析代码:

  1. public class ProjectMethodCallingTreeGraph {
  2. private final static char[] METHOD_PREFIX = {'M', 'e', 't', 'h', 'o', 'd'};
  3. private final static char[] INTERFACE_METHOD_PREFIX = {'I', 'n', 't', 'e', 'r', 'f'};
  4. private final static String TARGET_ROOT_PATH = "D:\\WORK\\xxx\\pro-info\\xxx\\xxx-graph";
  5. private static Map<Character, String> dataType = new HashMap<>();
  6. static {
  7. dataType.put('[', "[]");
  8. dataType.put('I', "int");
  9. dataType.put('J', "long");
  10. dataType.put('F', "float");
  11. dataType.put('D', "double");
  12. dataType.put('Z', "boolean");
  13. dataType.put('B', "byte");
  14. dataType.put('C', "char");
  15. dataType.put('S', "short");
  16. }
  17. public static void main(String[] args) {
  18. String path = "D:\\WORK\\xx\\pro-info\\xxx\\xxxx";
  19. ProjectMethodCallingTreeGraph p = new ProjectMethodCallingTreeGraph();
  20. p.readDecodedClazzFile(path);
  21. }
  22. private void readDecodedClazzFile(String path) {
  23. File file = new File(path);
  24. for (File f : file.listFiles()) {
  25. String method = null;
  26. String fName = f.getName().substring(0, f.getName().length() - 3);
  27. boolean mapperOrService = f.getName().endsWith("Service.txt") || f.getName().endsWith("Mapper.txt");
  28. LinkedHashSet<String> callMethods = new LinkedHashSet<>();
  29. try (BufferedReader br = new BufferedReader(new FileReader(f))) {
  30. String line;
  31. StringBuilder sb = new StringBuilder();
  32. while ((line = br.readLine()) != null) {
  33. char[] cs = line.toCharArray();
  34. String res = findMethodLine(cs, callMethods, mapperOrService);
  35. if (res != null) {
  36. if (method != null && method.length() > 2) {
  37. sb.append("----").append(fName).append(method).append("\n");
  38. append(sb, callMethods, fName);
  39. callMethods.clear();
  40. }
  41. method = res;
  42. }
  43. }
  44. writeDown(f.getName(), sb.toString());
  45. } catch (Exception e) {
  46. System.out.println(e.getMessage());
  47. }
  48. }
  49. }
  50. private void append(StringBuilder sb, LinkedHashSet<String> callMethods, String fName) {
  51. for (String m : callMethods) {
  52. sb.append("--------");
  53. if (localMethod(m)) {
  54. sb.append(fName);
  55. }
  56. sb.append(m).append("\n");
  57. }
  58. }
  59. private boolean localMethod(String str) {
  60. int n = str.length(), leftParenthesis = -1;
  61. for (int i = 0; i < n; ++i) {
  62. if (leftParenthesis == -1 && str.charAt(i) == '.') {
  63. return false;
  64. }
  65. if (leftParenthesis == -1 && str.charAt(i) == '(') {
  66. leftParenthesis = i;
  67. }
  68. }
  69. return true;
  70. }
  71. private void writeDown(String fname, String content) {
  72. try (BufferedWriter bw = new BufferedWriter(new FileWriter(TARGET_ROOT_PATH + "\\" + fname))) {
  73. bw.write(content);
  74. } catch (Exception e) {
  75. e.fillInStackTrace();
  76. }
  77. }
  78. private String findMethodLine(char[] cs, LinkedHashSet<String> calledMethods, boolean mapperOrService) {
  79. int x = 0, n = cs.length;
  80. while (x < n && cs[x] == ' ') {
  81. ++x;
  82. }
  83. return x == 2 ? getSpecialCharIndex(cs, mapperOrService) : (x > 4 ? findCalledMethods(cs, x, calledMethods) : null);
  84. }
  85. private String findCalledMethods(char[] cs, int x, LinkedHashSet<String> calledMethods) {
  86. // interfaceMethod
  87. StringBuilder sb = new StringBuilder();
  88. int n = cs.length;
  89. boolean canAppend = false, inParenthesis = false, simpleDataTypePrior = false;
  90. String typeMask = "";
  91. for (; x < n; ++x) {
  92. if (cs[x] == '/' && cs[x - 1] == '/') {
  93. if (cs[x + 2] == 'M' && compare2Arrays(cs, x + 2, METHOD_PREFIX)) {
  94. x += 8;
  95. canAppend = true;
  96. } else if (cs[x + 2] == 'I' && compare2Arrays(cs, x + 2, INTERFACE_METHOD_PREFIX)) {
  97. canAppend = true;
  98. x += 17;
  99. } else {
  100. return null;
  101. }
  102. continue;
  103. }
  104. if (cs[x] == '[' || (x + 1 < n && cs[x + 1] == ')' && cs[x] == ';')) {
  105. continue;
  106. }
  107. if (canAppend && cs[x] != ':') {
  108. if (cs[x] == '/') {
  109. sb.append('.');
  110. } else if (cs[x] == ';') {
  111. sb.append(typeMask).append(", ");
  112. typeMask = "";
  113. simpleDataTypePrior = false;
  114. } else {
  115. if (inParenthesis && cs[x - 1] == '[') {
  116. typeMask = "[]";
  117. }
  118. if (cs[x] == 'L' && (cs[x - 1] == '(' || cs[x - 1] == '[' || cs[x - 1] == ';')) {
  119. continue;
  120. }
  121. if ((cs[x - 1] == '(' || cs[x - 1] == ';' || simpleDataTypePrior || cs[x - 1] == '[') && dataType.containsKey(cs[x])) {
  122. simpleDataTypePrior = true;
  123. sb.append(dataType.get(cs[x]));
  124. if (cs[x - 1] == '[') {
  125. sb.append("[]");
  126. }
  127. if ((x + 1 < n && cs[x + 1] != ')') || (x + 1 < n && cs[x + 1] == ';' && cs[x + 2] != ')')) {
  128. sb.append(", ");
  129. }
  130. } else {
  131. sb.append(cs[x]);
  132. }
  133. }
  134. if (cs[x] == '(') {
  135. inParenthesis = true;
  136. }
  137. }
  138. if (cs[x] == ')') {
  139. break;
  140. }
  141. }
  142. if (sb.length() > 0) {
  143. calledMethods.add(sb.toString());
  144. }
  145. return null;
  146. }
  147. private boolean compare2Arrays(char[] a, int x, char[] b) {
  148. return Arrays.equals(a, x, x + b.length, b, 0, b.length);
  149. }
  150. private String getSpecialCharIndex(char[] cs, boolean mapperOrService) {
  151. int pre = 0, cnt = 0, leftParenthesis = -1;
  152. StringBuilder sb = new StringBuilder();
  153. for (int i = 2; i < cs.length; ++i) {
  154. if (leftParenthesis != -1) {
  155. sb.append(cs[i]);
  156. }
  157. if (leftParenthesis == -1 && cs[i] == ' ') {
  158. pre = i;
  159. ++cnt;
  160. }
  161. if (leftParenthesis == -1 && cs[i] == '(') {
  162. leftParenthesis = i;
  163. i = pre;
  164. }
  165. if (cs[i] == ')') {
  166. break;
  167. }
  168. }
  169. return cnt > 1 ? sb.toString() : null;
  170. }
  171. }

配置好 .class 文件的路径以及写入的目标路径后,执行代码,等待几秒后,就可以去看看写入的方法与被调方法信息了。

六、让方法与被调方法的关系可视化

        为了更直观的表达各方法之间的调用关系。我们可以为此创建一个 web 页面,来展现这些方法与方法之间的调用关系。由于时间有限,目前只能向读者提供方法与方法之间的调用关系,后续会丰富功能,并向大家展示。

  • web 页面
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>方法调用关系图</title>
  6. <style>
  7. html {
  8. background: orange;
  9. }
  10. .container {
  11. margin: 0;
  12. padding: 0;
  13. text-align: center;
  14. flex-direction: column;
  15. }
  16. .list-box {
  17. margin-top: 10px;
  18. height: 80px;
  19. overflow-y: hidden;
  20. border: solid 5px lightgray;
  21. overflow-x: scroll;
  22. }
  23. .list-item {
  24. list-style: none;
  25. padding: 2px 3px;
  26. }
  27. ul {
  28. display: flex;
  29. }
  30. li > button:hover {
  31. background: black;
  32. color: white;
  33. }
  34. button {
  35. background: white;
  36. color: rgba(0, 0, 0, 0.6);
  37. padding: 4px 6px;
  38. border: none;
  39. cursor: pointer;
  40. border-radius: 3px;
  41. }
  42. .clicked_current {
  43. color: white;
  44. background: black;
  45. }
  46. </style>
  47. </head>
  48. <body>
  49. <div class="container">
  50. </div>
  51. </body>
  52. <script type="text/javascript">
  53. const XMLRequest = new XMLHttpRequest();
  54. let level = 0;
  55. window.onload = function () {
  56. const url = "http://localhost/methodGraph";
  57. request(url, 'get', false);
  58. XMLRequest.onreadystatechange = function () {
  59. if (XMLRequest.readyState === XMLHttpRequest.DONE && (XMLRequest.status === 200 || XMLRequest.status === 304)) {
  60. renderElements(JSON.parse(XMLRequest.responseText));
  61. }
  62. }
  63. XMLRequest.send(null);
  64. }
  65. function request(url, method, async) {
  66. XMLRequest.open(method, url, async);
  67. XMLRequest.setRequestHeader('Content-Type', 'application/json');
  68. }
  69. let curLevel = -1;
  70. function renderElements(arr) {
  71. const parentDom = document.querySelector(".container");
  72. const frag = document.createDocumentFragment();
  73. for (const item of arr) {
  74. const li = document.createElement("li");
  75. li.classList.add("list-item");
  76. const btn = document.createElement("button");
  77. btn.onclick = function () {
  78. curLevel = parseInt(this.parentNode.parentNode.classList[0].substring(5));
  79. const docs = document.querySelectorAll(".clicked_current");
  80. for (const doc of docs) {
  81. doc.classList.remove("clicked_current");
  82. }
  83. btn.classList.add("clicked_current");
  84. search(item);
  85. }
  86. btn.title = item;
  87. btn.textContent = getNameFromLongString(item);
  88. li.appendChild(btn);
  89. frag.append(li);
  90. }
  91. if (curLevel === level - 1) {
  92. if (arr.length > 0) {
  93. const div = document.createElement("div");
  94. div.classList.add("list-box");
  95. const ul = document.createElement("ul");
  96. ul.classList.add(`level${level++}`)
  97. ul.appendChild(frag);
  98. div.appendChild(ul);
  99. parentDom.appendChild(div);
  100. }
  101. } else {
  102. let rem = curLevel + 2;
  103. if (arr.length > 0) {
  104. const ulExist = document.querySelector(`.level${curLevel + 1}`);
  105. ulExist.innerHTML = "";
  106. ulExist.appendChild(frag);
  107. rem++;
  108. }
  109. while (parentDom.childNodes.length > rem) {
  110. const last = parentDom.childNodes.length;
  111. parentDom.removeChild(parentDom.childNodes[last - 1]);
  112. level--;
  113. }
  114. }
  115. }
  116. function getNameFromLongString(longName) {
  117. if (level === 0) {
  118. return longName.substring(longName.lastIndexOf('.') + 1);
  119. }
  120. longName = longName.substring(0, longName.indexOf('('));
  121. return longName.substring(longName.lastIndexOf('.') + 1);
  122. }
  123. function search(name) {
  124. const url = `http://localhost/findByName?name=${name}`;
  125. request(url, 'get', false);
  126. XMLRequest.onreadystatechange = function () {
  127. if (XMLRequest.readyState === XMLHttpRequest.DONE && (XMLRequest.status === 200 || XMLRequest.status === 304)) {
  128. renderElements(JSON.parse(XMLRequest.responseText));
  129. }
  130. }
  131. XMLRequest.send(null);
  132. }
  133. </script>
  134. </html>
  • controller
  1. @RequestMapping("/findByName")
  2. @ResponseBody
  3. public List<String> findByName(@RequestParam(name = "name", defaultValue = "unknown") String name) {
  4. return projectInformationService.findByClazzName(name);
  5. }
  • 实现类
  1. package com.example.develper.demos.service;
  2. import org.springframework.beans.factory.annotation.Value;
  3. import org.springframework.stereotype.Service;
  4. import java.io.BufferedReader;
  5. import java.io.File;
  6. import java.io.FileReader;
  7. import java.util.*;
  8. @Service
  9. public class ProjectInformationServiceImpl implements ProjectInformationService{
  10. Map<String, Map<String, List<String>>> name2Clazz;
  11. // 在 properties 中配置之前保存的那个目录里
  12. @Value("${methodConnection.analyze.target.path}")
  13. private String methodConnectionPath;
  14. private void initialized() {
  15. if (name2Clazz != null) { return; }
  16. if (methodConnectionPath == null) {
  17. throw new IllegalArgumentException("请配置已经解析好的方法关系网络文档路径!");
  18. }
  19. name2Clazz = new HashMap<>();
  20. File file = new File(methodConnectionPath);
  21. if (!file.exists()) {
  22. throw new IllegalArgumentException("请配置正确的文档路径!");
  23. }
  24. loadsClazzInfo(file);
  25. }
  26. private void loadsClazzInfo(File file) {
  27. File[] files = file.listFiles();
  28. for (File f : files) {
  29. String name = f.getName().substring(0, f.getName().length() - 4);
  30. Map<String, List<String>> method2CalledMethods = new HashMap<>();
  31. name2Clazz.put(name, method2CalledMethods);
  32. try (BufferedReader br = new BufferedReader(new FileReader(f))) {
  33. String line;
  34. String methodName = null;
  35. while ((line = br.readLine()) != null) {
  36. String[] ret = countPlaceholder(line);
  37. if ("4".equals(ret[0])) {
  38. methodName = ret[1];
  39. method2CalledMethods.put(methodName, new ArrayList<>());
  40. } else if (methodName != null) {
  41. method2CalledMethods.get(methodName).add(ret[1]);
  42. }
  43. }
  44. } catch (Exception e) {
  45. e.fillInStackTrace();
  46. }
  47. }
  48. }
  49. private String[] countPlaceholder(String line) {
  50. int x = 0, cnt = 0, n = line.length();
  51. StringBuilder sb = new StringBuilder();
  52. while (x < n) {
  53. if (line.charAt(x) == '-') {
  54. ++cnt;
  55. } else {
  56. sb.append(line.charAt(x));
  57. }
  58. ++x;
  59. }
  60. String[] ret = new String[2];
  61. ret[0] = Integer.toString(cnt);
  62. ret[1] = sb.toString();
  63. return ret;
  64. }
  65. @Override
  66. public List<String> loadAllControllers() {
  67. initialized();
  68. List<String> ret = new ArrayList<>();
  69. for (String name : name2Clazz.keySet()) {
  70. if (name.endsWith("Controller")) {
  71. ret.add(name);
  72. }
  73. }
  74. return ret;
  75. }
  76. @Override
  77. public List<String> findByClazzName(String name) {
  78. // cn.com.xx.xx.common.Base58.encode(
  79. if (name == null || name.trim().isEmpty()) {
  80. return new ArrayList<>();
  81. }
  82. if (name.endsWith("Controller")) {
  83. return new ArrayList<>(name2Clazz.getOrDefault(name, new HashMap<>()).keySet());
  84. }
  85. char[] cs = name.toCharArray();
  86. StringBuilder prefix = new StringBuilder();
  87. StringBuilder suffix = new StringBuilder();
  88. int i = 0;
  89. while (i < cs.length && cs[i] != '(') {
  90. if (cs[i] == '.') {
  91. prefix.append(prefix.length() > 0 ? '.' : "").append(suffix);
  92. suffix.setLength(0);
  93. } else {
  94. suffix.append(cs[i]);
  95. }
  96. ++i;
  97. }
  98. while (i < cs.length) {
  99. suffix.append(cs[i++]);
  100. }
  101. char[] service = {'S', 'e', 'r', 'v', 'i', 'c', 'e'};
  102. int x = prefix.length() - 1, k = service.length - 1;
  103. while (k >= 0 && prefix.charAt(x) == service[k]) {
  104. --x; --k;
  105. }
  106. String clazzName = prefix.toString();
  107. if (k == -1) {
  108. x = prefix.length();
  109. while (prefix.charAt(x - 1) != '.') {
  110. --x;
  111. }
  112. prefix.insert(x, "impl.");
  113. clazzName = prefix.append("Impl").toString();
  114. name = prefix.append(".").append(suffix).toString();
  115. }
  116. Map<String, List<String>> clazz2Methods = name2Clazz.getOrDefault(clazzName, new HashMap<>());
  117. return clazz2Methods.getOrDefault(name, new ArrayList<>());
  118. }
  119. }

七、结语

        创作不易,期待读者的支持。web 页面效果不是很理想,后续会持续更新。毕竟这个功能给我平时的工作帮助挺大的。况且先前说的根据方法找菜单的功能并没有完全实现,但就目前的方向来看,一定是正确的。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/在线问答5/article/detail/810160
推荐阅读
相关标签
  

闽ICP备14008679号