筆者好久以前就有個想法:參考現有的主流ORM
框架的設計,造一個ORM
輪子,在基本不改變使用體驗的前提下把框架依賴的大量的反射設計去掉,這些反射API
構築的組件使用動態編譯加載的實例去替代,從而能夠獲得接近於直接使用原生JDBC
的性能。因而帶着這樣的想法,深刻學習Java
的動態編譯。編寫本文的時候使用的是JDK11
。前端
下面這個很眼熟的圖來源於《深刻理解Java虛擬機》前端編譯與優化的章節,主要描述編譯的過程:java
上圖看起來只有三步,其實每一步都有大量的步驟,下圖嘗試相對詳細地描述具體的步驟(圖比較大難以分割,直接放原圖):mysql
實際上,僅僅對於編譯這個過程來講,開發者或者使用者沒必要要徹底掌握其中的細節,JDK
提供了一個工具包javax.tools
讓使用者能夠用簡易的API
進行編譯(其實在大多數請下,開發者是面向業務功能開發,像編譯和打包這些細節通常直接由開發工具、Maven
、Gradle
等工具完成):sql
具體的使用過程包括:shell
javax.tools.JavaCompiler
實例。Java
文件對象初始化一個編譯任務javax.tools.JavaCompiler$CompilationTask
實例。CompilationTask
實例執行結果表明着編譯過程的成功與否。咱們熟知的
javac
編譯器其實就是JavaCompiler
接口的實現,在JDK11
中,對應的實現類爲com.sun.tools.javac.api.JavacTool
。在JDK8
中不存在JavaCompiler
接口,具體的編譯入口類爲com.sun.tools.javac.main.JavaCompiler
。編程
由於JVM
裏面的Class
是基於ClassLoader
隔離的,因此編譯成功以後能夠經過自定義的類加載器加載對應的類實例,而後就能夠應用反射API
進行實例化和後續的調用。api
JDK
動態編譯的步驟在上一節已經清楚地說明,這裏造一個簡單的場景。假設存在一個接口以下:數組
package club.throwable.compile; public interface HelloService { void sayHello(String name); } // 默認實現 package club.throwable.compile; public class DefaultHelloService implements HelloService { @Override public void sayHello(String name) { System.out.println(String.format("%s say hello [by default]", name)); } }
咱們能夠經過字符串SOURCE_CODE
定義一個類:緩存
static String SOURCE_CODE = "package club.throwable.compile;\n" + "\n" + "public class JdkDynamicCompileHelloService implements HelloService{\n" + "\n" + " @Override\n" + " public void sayHello(String name) {\n" + " System.out.println(String.format(\"%s say hello [by jdk dynamic compile]\", name));\n" + " }\n" + "}"; // 這裏不須要定義類文件,還原類文件內容以下 package club.throwable.compile; public class JdkDynamicCompileHelloService implements HelloService{ @Override public void sayHello(String name) { System.out.println(String.format("%s say hello [by jdk dynamic compile]", name)); } }
在組裝編譯任務實例以前,還有幾項工做須要完成:session
JavaFileObject
標準實現SimpleJavaFileObject
是面向類源碼文件,因爲動態編譯時候輸入的是類源碼文件的內容字符串,須要自行實現JavaFileObject
。JavaFileManager
是面向類路徑下的Java
源碼文件進行加載,這裏也須要自行實現JavaFileManager
。ClassLoader
實例去加載編譯出來的動態類。自行實現一個JavaFileObject
,其實能夠簡單點直接繼承SimpleJavaFileObject
,覆蓋須要用到的方法便可:
public class CharSequenceJavaFileObject extends SimpleJavaFileObject { public static final String CLASS_EXTENSION = ".class"; public static final String JAVA_EXTENSION = ".java"; private static URI fromClassName(String className) { try { return new URI(className); } catch (URISyntaxException e) { throw new IllegalArgumentException(className, e); } } private ByteArrayOutputStream byteCode; private final CharSequence sourceCode; public CharSequenceJavaFileObject(String className, CharSequence sourceCode) { super(fromClassName(className + JAVA_EXTENSION), Kind.SOURCE); this.sourceCode = sourceCode; } public CharSequenceJavaFileObject(String fullClassName, Kind kind) { super(fromClassName(fullClassName), kind); this.sourceCode = null; } public CharSequenceJavaFileObject(URI uri, Kind kind) { super(uri, kind); this.sourceCode = null; } @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { return sourceCode; } @Override public InputStream openInputStream() { return new ByteArrayInputStream(getByteCode()); } // 注意這個方法是編譯結果回調的OutputStream,回調成功後就能經過下面的getByteCode()方法獲取目標類編譯後的字節碼字節數組 @Override public OutputStream openOutputStream() { return byteCode = new ByteArrayOutputStream(); } public byte[] getByteCode() { return byteCode.toByteArray(); } }
若是編譯成功以後,直接經過自行添加的CharSequenceJavaFileObject#getByteCode()
方法便可獲取目標類編譯後的字節碼對應的字節數組(二進制內容)。這裏的CharSequenceJavaFileObject
預留了多個構造函數用於兼容原有的編譯方式。
只要簡單繼承ClassLoader
便可,關鍵是要覆蓋原來的ClassLoader#findClass()
方法,用於搜索自定義的JavaFileObject
實例,從而提取對應的字節碼字節數組進行裝載,爲了實現這一點能夠添加一個哈希表做爲緩存,鍵-值分別是全類名的別名(xx.yy.MyClass
形式,而非URI
模式)和目標類對應的JavaFileObject
實例。
public class JdkDynamicCompileClassLoader extends ClassLoader { public static final String CLASS_EXTENSION = ".class"; private final Map<String, JavaFileObject> javaFileObjectMap = Maps.newConcurrentMap(); public JdkDynamicCompileClassLoader(ClassLoader parentClassLoader) { super(parentClassLoader); } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { JavaFileObject javaFileObject = javaFileObjectMap.get(name); if (null != javaFileObject) { CharSequenceJavaFileObject charSequenceJavaFileObject = (CharSequenceJavaFileObject) javaFileObject; byte[] byteCode = charSequenceJavaFileObject.getByteCode(); return defineClass(name, byteCode, 0, byteCode.length); } return super.findClass(name); } @Nullable @Override public InputStream getResourceAsStream(String name) { if (name.endsWith(CLASS_EXTENSION)) { String qualifiedClassName = name.substring(0, name.length() - CLASS_EXTENSION.length()).replace('/', '.'); CharSequenceJavaFileObject javaFileObject = (CharSequenceJavaFileObject) javaFileObjectMap.get(qualifiedClassName); if (null != javaFileObject && null != javaFileObject.getByteCode()) { return new ByteArrayInputStream(javaFileObject.getByteCode()); } } return super.getResourceAsStream(name); } /** * 暫時存放編譯的源文件對象,key爲全類名的別名(非URI模式),如club.throwable.compile.HelloService */ void addJavaFileObject(String qualifiedClassName, JavaFileObject javaFileObject) { javaFileObjectMap.put(qualifiedClassName, javaFileObject); } Collection<JavaFileObject> listJavaFileObject() { return Collections.unmodifiableCollection(javaFileObjectMap.values()); } }
JavaFileManager
是Java
文件的抽象管理器,它用於管理常規的Java
文件,可是不侷限於文件,也能夠管理其餘來源的Java
類文件數據。下面就經過實現一個自定義的JavaFileManager
用於管理字符串類型的源代碼。爲了簡單起見,能夠直接繼承已經存在的ForwardingJavaFileManager
:
public class JdkDynamicCompileJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> { private final JdkDynamicCompileClassLoader classLoader; private final Map<URI, JavaFileObject> javaFileObjectMap = Maps.newConcurrentMap(); public JdkDynamicCompileJavaFileManager(JavaFileManager fileManager, JdkDynamicCompileClassLoader classLoader) { super(fileManager); this.classLoader = classLoader; } private static URI fromLocation(Location location, String packageName, String relativeName) { try { return new URI(location.getName() + '/' + packageName + '/' + relativeName); } catch (URISyntaxException e) { throw new IllegalArgumentException(e); } } @Override public FileObject getFileForInput(Location location, String packageName, String relativeName) throws IOException { JavaFileObject javaFileObject = javaFileObjectMap.get(fromLocation(location, packageName, relativeName)); if (null != javaFileObject) { return javaFileObject; } return super.getFileForInput(location, packageName, relativeName); } /** * 這裏是編譯器返回的同(源)Java文件對象,替換爲CharSequenceJavaFileObject實現 */ @Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException { JavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, kind); classLoader.addJavaFileObject(className, javaFileObject); return javaFileObject; } /** * 這裏覆蓋原來的類加載器 */ @Override public ClassLoader getClassLoader(Location location) { return classLoader; } @Override public String inferBinaryName(Location location, JavaFileObject file) { if (file instanceof CharSequenceJavaFileObject) { return file.getName(); } return super.inferBinaryName(location, file); } @Override public Iterable<JavaFileObject> list(Location location, String packageName, Set<JavaFileObject.Kind> kinds, boolean recurse) throws IOException { Iterable<JavaFileObject> superResult = super.list(location, packageName, kinds, recurse); List<JavaFileObject> result = Lists.newArrayList(); // 這裏要區分編譯的Location以及編譯的Kind if (location == StandardLocation.CLASS_PATH && kinds.contains(JavaFileObject.Kind.CLASS)) { // .class文件以及classPath下 for (JavaFileObject file : javaFileObjectMap.values()) { if (file.getKind() == JavaFileObject.Kind.CLASS && file.getName().startsWith(packageName)) { result.add(file); } } // 這裏須要額外添加類加載器加載的全部Java文件對象 result.addAll(classLoader.listJavaFileObject()); } else if (location == StandardLocation.SOURCE_PATH && kinds.contains(JavaFileObject.Kind.SOURCE)) { // .java文件以及編譯路徑下 for (JavaFileObject file : javaFileObjectMap.values()) { if (file.getKind() == JavaFileObject.Kind.SOURCE && file.getName().startsWith(packageName)) { result.add(file); } } } for (JavaFileObject javaFileObject : superResult) { result.add(javaFileObject); } return result; } /** * 自定義方法,用於添加和緩存待編譯的源文件對象 */ public void addJavaFileObject(Location location, String packageName, String relativeName, JavaFileObject javaFileObject) { javaFileObjectMap.put(fromLocation(location, packageName, relativeName), javaFileObject); } }
注意在這個類中引入了自定義類加載器JdkDynamicCompileClassLoader
,目的是爲了實現JavaFileObject
實例的共享以及爲文件管理器提供類加載器實例。
前置準備工做完成,咱們能夠經過JavaCompiler
去編譯這個前面提到的字符串,爲了字節碼的兼容性更好,編譯的時候能夠指定稍低的JDK
版本例如1.6
:
public class Client { static String SOURCE_CODE = "package club.throwable.compile;\n" + "\n" + "public class JdkDynamicCompileHelloService implements HelloService{\n" + "\n" + " @Override\n" + " public void sayHello(String name) {\n" + " System.out.println(String.format(\"%s say hello [by jdk dynamic compile]\", name));\n" + " }\n" + "}"; /** * 編譯診斷收集器 */ static DiagnosticCollector<JavaFileObject> DIAGNOSTIC_COLLECTOR = new DiagnosticCollector<>(); public static void main(String[] args) throws Exception { // 獲取系統編譯器實例 JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); // 設置編譯參數 - 指定編譯版本爲JDK1.6以提升兼容性 List<String> options = new ArrayList<>(); options.add("-source"); options.add("1.6"); options.add("-target"); options.add("1.6"); // 獲取標準的Java文件管理器實例 StandardJavaFileManager manager = compiler.getStandardFileManager(DIAGNOSTIC_COLLECTOR, null, null); // 初始化自定義類加載器 JdkDynamicCompileClassLoader classLoader = new JdkDynamicCompileClassLoader(Thread.currentThread().getContextClassLoader()); // 初始化自定義Java文件管理器實例 JdkDynamicCompileJavaFileManager fileManager = new JdkDynamicCompileJavaFileManager(manager, classLoader); String packageName = "club.throwable.compile"; String className = "JdkDynamicCompileHelloService"; String qualifiedName = packageName + "." + className; // 構建Java源文件實例 CharSequenceJavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, SOURCE_CODE); // 添加Java源文件實例到自定義Java文件管理器實例中 fileManager.addJavaFileObject( StandardLocation.SOURCE_PATH, packageName, className + CharSequenceJavaFileObject.JAVA_EXTENSION, javaFileObject ); // 初始化一個編譯任務實例 JavaCompiler.CompilationTask compilationTask = compiler.getTask( null, fileManager, DIAGNOSTIC_COLLECTOR, options, null, Lists.newArrayList(javaFileObject) ); // 執行編譯任務 Boolean result = compilationTask.call(); System.out.println(String.format("編譯[%s]結果:%s", qualifiedName, result)); Class<?> klass = classLoader.loadClass(qualifiedName); HelloService instance = (HelloService) klass.getDeclaredConstructor().newInstance(); instance.sayHello("throwable"); } }
輸出結果以下:
編譯[club.throwable.compile.JdkDynamicCompileHelloService]結果:true throwable say hello [by jdk dynamic compile]
可見經過了字符串的類源碼,實現了動態編譯、類加載、反射實例化以及最終的方法調用。另外,編譯過程的診斷信息能夠經過DiagnosticCollector
實例獲取。爲了複用,這裏能夠把JDK
動態編譯的過程抽取到一個方法中:
public final class JdkCompiler { static DiagnosticCollector<JavaFileObject> DIAGNOSTIC_COLLECTOR = new DiagnosticCollector<>(); @SuppressWarnings("unchecked") public static <T> T compile(String packageName, String className, String sourceCode, Class<?>[] constructorParamTypes, Object[] constructorParams) throws Exception { // 獲取系統編譯器實例 JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); // 設置編譯參數 List<String> options = new ArrayList<>(); options.add("-source"); options.add("1.6"); options.add("-target"); options.add("1.6"); // 獲取標準的Java文件管理器實例 StandardJavaFileManager manager = compiler.getStandardFileManager(DIAGNOSTIC_COLLECTOR, null, null); // 初始化自定義類加載器 JdkDynamicCompileClassLoader classLoader = new JdkDynamicCompileClassLoader(Thread.currentThread().getContextClassLoader()); // 初始化自定義Java文件管理器實例 JdkDynamicCompileJavaFileManager fileManager = new JdkDynamicCompileJavaFileManager(manager, classLoader); String qualifiedName = packageName + "." + className; // 構建Java源文件實例 CharSequenceJavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, sourceCode); // 添加Java源文件實例到自定義Java文件管理器實例中 fileManager.addJavaFileObject( StandardLocation.SOURCE_PATH, packageName, className + CharSequenceJavaFileObject.JAVA_EXTENSION, javaFileObject ); // 初始化一個編譯任務實例 JavaCompiler.CompilationTask compilationTask = compiler.getTask( null, fileManager, DIAGNOSTIC_COLLECTOR, options, null, Lists.newArrayList(javaFileObject) ); Boolean result = compilationTask.call(); System.out.println(String.format("編譯[%s]結果:%s", qualifiedName, result)); Class<?> klass = classLoader.loadClass(qualifiedName); return (T) klass.getDeclaredConstructor(constructorParamTypes).newInstance(constructorParams); } }
既然有JDK
的動態編譯,爲何還存在Javassist
這樣的字節碼加強工具?撇開性能或者效率層面,JDK
動態編譯存在比較大的侷限性,比較明顯的一點就是沒法完成字節碼插樁,換言之就是沒法基於原有的類和方法進行修飾或者加強,可是Javassist
能夠作到。再者,Javassist
提供的API
和JDK
反射的API
十分相近,若是反射平時用得比較熟練,Javassist
的上手也就變得比較簡單。這裏僅僅列舉一個加強前面提到的DefaultHelloService
的例子,先引入依賴:
<dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.27.0-GA</version> </dependency>
編碼以下:
public class JavassistClient { public static void main(String[] args) throws Exception { ClassPool pool = ClassPool.getDefault(); CtClass cc = pool.get("club.throwable.compile.DefaultHelloService"); CtMethod ctMethod = cc.getDeclaredMethod("sayHello", new CtClass[]{pool.get("java.lang.String")}); ctMethod.insertBefore("System.out.println(\"insert before by Javassist\");"); ctMethod.insertAfter("System.out.println(\"insert after by Javassist\");"); Class<?> klass = cc.toClass(); System.out.println(klass.getName()); HelloService helloService = (HelloService) klass.getDeclaredConstructor().newInstance(); helloService.sayHello("throwable"); } }
輸出結果以下:
club.throwable.compile.DefaultHelloService insert before by Javassist throwable say hello [by default] insert after by Javassist
Javaassist
這個單詞實際上是Java
和Assist
兩個單詞拼接在一塊兒,意爲Java
助手,是一個Java
字節碼加強類庫:
不像ASM
(ASM
的學習曲線比較陡峭,屬於相對底層的字節碼操做類庫,固然從性能上來看ASM
對字節碼加強的效率遠高於其餘高層次封裝的框架)那樣須要對字節碼編程十分了解,Javaassist
下降了字節碼加強功能的入門難度。
如今定義一個接口MysqlInfoMapper
,用於動態執行一條已知的SQL
,很簡單,就是查詢MySQL
的系統表mysql
裏面的用戶信息SELECT Host,User FROM mysql.user
:
@Data public class MysqlUser { private String host; private String user; } public interface MysqlInfoMapper { List<MysqlUser> selectAllMysqlUsers(); }
假設如今只提供一個MySQL
的驅動包(mysql:mysql-connector-java:jar:8.0.20
),暫時不能依賴任何高層次的框架,要動態實現MysqlInfoMapper
接口,優先整理須要的組件:
MySQL
的鏈接。SQL
執行器用於執行查詢SQL
。爲了簡單起見,筆者在定義這三個組件接口的時候順便在接口中經過單例進行實現(部分配置徹底寫死):
// 鏈接管理器 public interface ConnectionManager { String USER_NAME = "root"; String PASS_WORD = "root"; String URL = "jdbc:mysql://localhost:3306/mysql?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false"; Connection newConnection() throws SQLException; void closeConnection(Connection connection); ConnectionManager X = new ConnectionManager() { @Override public Connection newConnection() throws SQLException { return DriverManager.getConnection(URL, USER_NAME, PASS_WORD); } @Override public void closeConnection(Connection connection) { try { connection.close(); } catch (Exception ignore) { } } }; } // 執行器 public interface SqlExecutor { ResultSet execute(Connection connection, String sql) throws SQLException; SqlExecutor X = new SqlExecutor() { @Override public ResultSet execute(Connection connection, String sql) throws SQLException { Statement statement = connection.createStatement(); statement.execute(sql); return statement.getResultSet(); } }; } // 結果處理器 public interface ResultHandler<T> { T handleResultSet(ResultSet resultSet) throws SQLException; ResultHandler<List<MysqlUser>> X = new ResultHandler<List<MysqlUser>>() { @Override public List<MysqlUser> handleResultSet(ResultSet resultSet) throws SQLException { try { List<MysqlUser> result = Lists.newArrayList(); while (resultSet.next()) { MysqlUser item = new MysqlUser(); item.setHost(resultSet.getString("Host")); item.setUser(resultSet.getString("User")); result.add(item); } return result; } finally { resultSet.close(); } } }; }
接着須要動態編譯MysqlInfoMapper
的實現類,它的源文件的字符串內容以下(注意不要在類路徑下新建這個DefaultMysqlInfoMapper
類):
package club.throwable.compile; import java.sql.Connection; import java.sql.ResultSet; import java.util.List; public class DefaultMysqlInfoMapper implements MysqlInfoMapper { private final ConnectionManager connectionManager; private final SqlExecutor sqlExecutor; private final ResultHandler resultHandler; private final String sql; public DefaultMysqlInfoMapper(ConnectionManager connectionManager, SqlExecutor sqlExecutor, ResultHandler resultHandler, String sql) { this.connectionManager = connectionManager; this.sqlExecutor = sqlExecutor; this.resultHandler = resultHandler; this.sql = sql; } @Override public List<MysqlUser> selectAllMysqlUsers() { try { Connection connection = connectionManager.newConnection(); try { ResultSet resultSet = sqlExecutor.execute(connection, sql); return (List<MysqlUser>) resultHandler.handleResultSet(resultSet); } finally { connectionManager.closeConnection(connection); } } catch (Exception e) { // 暫時忽略異常處理,統一封裝爲IllegalStateException throw new IllegalStateException(e); } } }
而後編寫一個客戶端進行動態編譯和執行:
public class MysqlInfoClient { static String SOURCE_CODE = "package club.throwable.compile;\n" + "import java.sql.Connection;\n" + "import java.sql.ResultSet;\n" + "import java.util.List;\n" + "\n" + "public class DefaultMysqlInfoMapper implements MysqlInfoMapper {\n" + "\n" + " private final ConnectionManager connectionManager;\n" + " private final SqlExecutor sqlExecutor;\n" + " private final ResultHandler resultHandler;\n" + " private final String sql;\n" + "\n" + " public DefaultMysqlInfoMapper(ConnectionManager connectionManager,\n" + " SqlExecutor sqlExecutor,\n" + " ResultHandler resultHandler,\n" + " String sql) {\n" + " this.connectionManager = connectionManager;\n" + " this.sqlExecutor = sqlExecutor;\n" + " this.resultHandler = resultHandler;\n" + " this.sql = sql;\n" + " }\n" + "\n" + " @Override\n" + " public List<MysqlUser> selectAllMysqlUsers() {\n" + " try {\n" + " Connection connection = connectionManager.newConnection();\n" + " try {\n" + " ResultSet resultSet = sqlExecutor.execute(connection, sql);\n" + " return (List<MysqlUser>) resultHandler.handleResultSet(resultSet);\n" + " } finally {\n" + " connectionManager.closeConnection(connection);\n" + " }\n" + " } catch (Exception e) {\n" + " // 暫時忽略異常處理,統一封裝爲IllegalStateException\n" + " throw new IllegalStateException(e);\n" + " }\n" + " }\n" + "}\n"; static String SQL = "SELECT Host,User FROM mysql.user"; public static void main(String[] args) throws Exception { MysqlInfoMapper mysqlInfoMapper = JdkCompiler.compile( "club.throwable.compile", "DefaultMysqlInfoMapper", SOURCE_CODE, new Class[]{ConnectionManager.class, SqlExecutor.class, ResultHandler.class, String.class}, new Object[]{ConnectionManager.X, SqlExecutor.X, ResultHandler.X, SQL}); System.out.println(JSON.toJSONString(mysqlInfoMapper.selectAllMysqlUsers())); } }
最終的輸出結果是:
編譯[club.throwable.compile.DefaultMysqlInfoMapper]結果:true [{"host":"%","user":"canal"},{"host":"%","user":"doge"},{"host":"localhost","user":"mysql.infoschema"},{"host":"localhost","user":"mysql.session"},{"host":"localhost","user":"mysql.sys"},{"host":"localhost","user":"root"}]
而後筆者查看本地安裝的MySQL
中的結果,驗證該查詢結果是正確的。
這裏筆者爲了簡化整個例子,沒有在MysqlInfoMapper#selectAllMysqlUsers()
方法中添加查詢參數,能夠嘗試一下查詢的SQL
是SELECT Host,User FROM mysql.user WHERE User = 'xxx'
場景下的編碼實現。
若是把動態實現的
DefaultMysqlInfoMapper
註冊到IOC
容器中,就能夠實現MysqlInfoMapper
按照類型自動裝配。
若是把SQL
和參數處理能夠抽離到單獨的文件中,而且實現一個對應的文件解析器,那麼就能夠把類文件和SQL
隔離,Mybatis
和Hibernate
都是這樣作的。
動態編譯或者更底層的面向字節碼層面的編程,實際上是一個十分有挑戰性可是能夠創造無限可能的領域,本文只是簡單分析了一下Java
源碼編譯的過程,而且經過一些簡單的例子進行動態編譯的模擬,離使用於實際應用中還有很多距離,後面須要花更多的時間去分析一下相關領域的知識。
參考資料:
JDK11
部分源碼(本文完 c-4-d e-a-20200606 0:23)