造一个方形的轮子9--编译打包

造一个方形轮子文章目录:造一个方形的轮子

01、解决遗留问题

上一篇的最后说要把BeansInitUtil类代码优化一下,先来弄一下这个吧,顺便把加入AOP导致多遍历了一遍目录的问题解决一下。

调整的有点多,就不放代码了,写一下主要改动的文件及改动内容吧:

com.jisuye.core包:

BeansMap.java 全方法静态化调用静态的个各map容器

BeanObject.java 添加了BeanClass记录bean类型,在加载AOP切面时使用

ControllerObject.java 添加了beanKey字段,将获取Bean从初始化阶段放到调用阶段,这么做是为了去掉添加AOP功能后多的一遍目录遍历

ControllerObject.java 部分代码:

    /**
     * 反射执行controller方法
     * @param req
     * @return
     */
    public Object invoke(HttpServletRequest req){
        //...
        try {
            // 延时从容器中获取对象
            if(object == null){
                this.setObject(BeansMap.get(beanKey).getObject());
            }
            Object o = this.getMethod().invoke(this.getObject(), os);
            return o;
        } catch (Exception e) {
            log.error("Controller method.invoke() is error!", e);
            throw new SquareException("Controller method.invoke() is error!", e);
        }
    }

com.jisuye.util包:

BeansInitUtil.java 调整顺序把AOP放到IOC初始化之后DI初始化之前,减少了一次目录遍历

BeansInitUtil.java 放部分代码:

    public static void init(Class clazz){
        String path = clazz.getResource("").getPath();
        log.info("===bean init path:{}", path);
        File root = new File(path);
        // 处理控制反转(加载aop切面,controller)
        initFile(root);
        // 处理aop类
        initAop();
        // 处理依赖注入
        initDI();
    }
    private static void initAop(){
        List<BeanObject> list = new ArrayList<>();
        // 循环所有Bean处理Aop
        for(Map.Entry entry : BeansMap.entrySet()) {
            BeanObject beanObject = (BeanObject) entry.getValue();
            // 如果已经处理过,则跳过
            if (beanObject.getObject() != null) {
                break;
            }
            beanObject.setObject(getInstance(beanObject.getBeanClass(), beanObject.getSrcObj()));
        }
    }
    // ...
    private static void loadClass(File file){
                    // ...
                    if(annotation instanceof Service){
                        tmp_name = ((Service)annotation).value();
                    } else if(annotation instanceof Component) {
                        tmp_name = ((Component)annotation).value();
                    } else if(annotation instanceof Controller) {
                        initController(clzz, ((Controller)annotation).value());
                    } else if(annotation instanceof Aspect){
                        // 添加加载AOP切面
                        loadAop(clzz);
                    }
                    // ...
    }

02、编译打包问题整理

在真正开始写编译打包功能之前,我一直觉得这应该是一个比较简单的功能,实现应该也没什么难度,事实是我的脸现在依然很疼。。。

起初我想使用maven自带的打包插件,打一个jar包出来,看了一下要指定主方法所在类,看起来有点麻烦,而且打出来看包是不包含依赖的,不可以直接运行,当然有其它的插件能够实现把依赖jar包打包进去的功能,但还要是配置主类,于是我想自己定一个插件吧(这大概是打脸的开始)。

只实现自定义插件很容易,但要实现我的功能,第一个功能就是指定Main-Class 看了一下,编译完的文件里并没有MANIFEST.MF清单文件,只有最终打出来的jar包里才有,于是第一个问题就是:

如何向jar包里的文件写入内容?

看了spring-boot-maven-plugin 的源码,发现他是在package完成后跟着执行了自己的repackage 流程,将package打出来的jar包,复制了一份。于是我也按这个思路实现了一下,在复制的过程中判断是不是MANIFEST.MF 如果是的话向文件尾追加Main-Class配置 ,Main-Class指定的main方法所在类是通过遍历目录文件找到带有@SquareApplication 注解的类获得的。Main-Class的问题解决了,再来看一下没有打包依赖的问题。

如何将依赖的jar打包到一起?

有了上边的经验,这次和想直接把依赖的jar找出来,在复制的过程中保存进去这样应该就可以了,事实是我做到了,保存到了jar包的lib目录下,结果依赖还是找不到,后来查了一下,在jar内部的文件,引用的时候都是通过***.jar!/a/b/c 这样的方式标记的,Java中自带的ClassLoader只能接受一个“!”的这种路径,也就是可以加载jar包里的类,但对jar包里的jar包,也就是路径上要带两个“!” 这种就无能为力了,SpringBoot 是自己实现了一个ClassLoader,重写加载路径方法,搞定的,简单实现的话我参考了一下maven-assembly-plugin 他是将依赖jar包里的内容全部解压到当前jar里,这样就不会出来找不到依赖的类的问题(依然简单粗暴)。

编译打包的问题解决了,打出了可以执行的jar包,然后就发现我的程序实现有很大的问题,之前大量使用的File加载反射类,是没有问题的,但是放到Jar包中就不行了,因为File不能直接使用***.jar!/a/b这种形式的路径。也就不能使用File进行目录遍历。

如何在Jar包中遍历路径?

这个问题我能想到的办法就是在程序中特殊处理了,判断一下当前是在jar包中还是在程序中,如果是在jar包中,则加载JarFile 获取Jar包中的条目,遍历是否需要加载就可以了。

03、编译打包插件

这里先看一下核心的代码,全部代码文章最后会给出下载地址。

BuildMojo.java:

package com.jisuye;
// import ...
@Mojo(name = "repackage", defaultPhase = LifecyclePhase.PACKAGE, requiresProject = true,
        requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
        requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME)
public class BuildMojo extends AbstractMojo {
    @Parameter(defaultValue = "${project}", readonly = true, required = true)
    private MavenProject project;

    @Component
    private MavenProjectHelper projectHelper;

    @Parameter(defaultValue = "${project.build.directory}", required = true)
    private File outputDirectory;

    @Parameter(defaultValue = "${project.build.finalName}", readonly = true)
    private String finalName;

    public void execute() throws MojoExecutionException, MojoFailureException {
        getLog().info("square maven plugin exe...");
        getLog().info("outputDir:"+outputDirectory.getPath());
        Artifact source = project.getArtifact();
        try {
            // 取package打出的jar包
            File file = source.getFile();
            getLog().info("file name name ======"+file.getName());
            JarFile jarFile = new JarFile(file);
            // 创建临时jar包
            File tmpFile = new File(outputDirectory.getPath()+"/temp.jar");
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile));
            List<String> fileList = new ArrayList<String>();
            Enumeration<?> jarEntries = jarFile.entries();
            // 循环复制文件
            while (jarEntries.hasMoreElements()) {
                JarEntry entry = (JarEntry) jarEntries.nextElement();
//                getLog().info("filename:"+entry.getName());
                fileList.add(entry.getName());
                InputStream entryInputStream = jarFile.getInputStream(entry);
                // 如果是清单文件,则追加主类
                if(entry.getName().endsWith("MANIFEST.MF")){
                    BufferedReader br = new BufferedReader(new InputStreamReader(entryInputStream));
                    String line;
                    StringBuilder sb = new StringBuilder();
                    while ((line = br.readLine()) != null) {
                        if(!line.equals("")) {
                            sb.append(line).append("\n");
                        }
                    }
                    String mainClass = findMainClass();
                    getLog().info("mainclass:"+mainClass);
                    if(mainClass.equals("")){
                        getLog().error("No found Main-Class!!  Plase use the @SquareApplication settings");
                    } else {
                        sb.append("Main-Class: ").append(mainClass).append("\n");
                    }
                    jarOutputStream.putNextEntry(new JarEntry(entry.getName()));
                    jarOutputStream.write(sb.toString().getBytes());
                } else {
                    jarOutputStream.putNextEntry(entry);
                    byte[] buffer = new byte[1024];
                    int bytesRead = 0;
                    while ((bytesRead = entryInputStream.read(buffer)) != -1) {
                        jarOutputStream.write(buffer, 0, bytesRead);
                    }
                }
            }
            addDependenceJar(project.getArtifacts(), jarOutputStream, fileList);
            jarOutputStream.close();
            updateJar(file, tmpFile);
        } catch (IOException e) {
            getLog().error("load jar file error!", e);
        }
    }

    /**
     * 更新jar文件,将原文件保存为**.jar.old
     * @param oldFile 原文件
     * @param newFile 添加清单及依赖后的完整包
     * @throws IOException
     */
    private void updateJar(File oldFile, File newFile) throws IOException {
        String fileName = oldFile.getName();
        File backup = new File(outputDirectory.getPath()+"/"+fileName+".old");
        FileOutputStream fos = new FileOutputStream(backup);
        FileInputStream fis = new FileInputStream(oldFile);
        byte[] buffer = new byte[1024];
        int bytesRead = 0;
        while ((bytesRead = fis.read(buffer)) != -1) {
            fos.write(buffer, 0, bytesRead);
        }
        fis.close();
        fos.close();
        oldFile.delete();
        if(finalName != null && !finalName.equals("")){
            fileName = finalName+".jar";
        }
        File newJar = new File(outputDirectory.getPath()+"/"+fileName);
        fos = new FileOutputStream(newJar);
        fis = new FileInputStream(newFile);
        buffer = new byte[1024];
        while ((bytesRead = fis.read(buffer)) != -1) {
            fos.write(buffer, 0, bytesRead);
        }
        fis.close();
        fos.close();
        newFile.delete();
    }

    /**
     * 添加依赖的jar
     * @param artifacts 依赖Jar列表
     * @param jarOutputStream 当前repackage包输出流
     * @param fileList 已有文件列表(防止重复)
     * @throws IOException
     */
    private void addDependenceJar(Set<Artifact> artifacts, JarOutputStream jarOutputStream, List<String> fileList) throws IOException {
        // save dependence jar
        for (Artifact artifact : artifacts) {
            JarFile jarFile = new JarFile(artifact.getFile());
            Enumeration<?> jarEntries = jarFile.entries();
            while (jarEntries.hasMoreElements()) {
                JarEntry entry = (JarEntry) jarEntries.nextElement();
                if(fileList.contains(entry.getName())){
                    // already added skipping
//                    getLog().info(entry.getName()+" already added, skipping");
                    continue;
                }
                fileList.add(entry.getName());
                InputStream entryInputStream = jarFile.getInputStream(entry);
                jarOutputStream.putNextEntry(entry);
                byte[] buffer = new byte[1024];
                int bytesRead = 0;
                while ((bytesRead = entryInputStream.read(buffer)) != -1) {
                    jarOutputStream.write(buffer, 0, bytesRead);
                }
            }
        }
    }

    /**
     * 遍历目录查找主类
     * @return
     */
    private String findMainClass(){
        Build build = project.getBuild();
        getLog().info("build source dir:"+build.getSourceDirectory());
        File f = new File(build.getSourceDirectory());
        StringBuilder mainClass = new StringBuilder();
        getMainClass(f, mainClass);
        return mainClass.toString();
    }

    /**
     * 查找还@SquareApplication注解的类
     * @param file 文件
     * @param mainClass 要返回的主类
     */
    private void getMainClass(File file, StringBuilder mainClass){
        if(!mainClass.toString().equals("")){
            return;
        }
        File[] fs = file.listFiles();
        for (File f : fs) {
            if(f.isDirectory()){
                // 递归目录
                getMainClass(f, mainClass);
            } else {
                // 处理class
                try {
                    if(!f.getName().endsWith(".java")){
                        return;
                    }
                    BufferedReader br = new BufferedReader(new FileReader(f));
                    String line, packageStr = "";
                    while((line = br.readLine()) != null){
                        line = line.trim();
                        if(line.startsWith("package ")){
                            packageStr = line.substring(8).replace(";", "");
                        }
                        if(line.equals("@SquareApplication")){
                            mainClass.append(packageStr+"."+f.getName().replace(".java", ""));
                        }
                    }
                } catch (Exception e) {
                    getLog().error("Find Main-Class error!", e);
                }
            }
        }
    }
}

代码中有注释,基本就是按前边说的思路实现了一下。这块除了对maven插件不太熟悉,别的都还好,多看看前辈们写的插件,习惯就好了。

04、Square框架修改

如上文所说,打包这后,原来的代码问题很大,改了一些地方。

ClassesPathUtil.java:

    //...
    public ClassesPathUtil(Class clzz){
        String basePath = clzz.getResource("").getPath();
        log.info("basePath+++++{}", basePath);
        //  ..../classes
        if(basePath.indexOf("classes")>0) {
            projectPath = basePath.substring(0, basePath.indexOf("classes") + 7);
        } else {
            projectPath = basePath.substring(0, basePath.indexOf("!")+1);
        }
        publicPath = setPublic(projectPath, "/public");
    }
    //...

这里要加上判断是在jar包中的情况,要特殊处理。

LoadApplicationYmlUtil.java:

    // ...
    public static Map<String, Object> load(String projectPath){
        Map<String, Object> retMap = new HashMap<>();
        try {
            InputStream is;
            if(projectPath != null &&  projectPath.indexOf("!") > 0){
                is = ClassLoader.getSystemResourceAsStream("application.yml");
            } else {
                projectPath += "/application.yml";
                log.info("load yml file path:{}", projectPath);
                is = new FileInputStream(projectPath);
            }
            Yaml yaml = new Yaml();
            Map<String, Object> map = (Map<String, Object>)yaml.load(is);
            if(map != null && map.size()>0){
                for(Map.Entry e : map.entrySet()) {
                    convert("", retMap, e);
                }
            }
        } catch (FileNotFoundException e) {
            log.error("load application.yml file error.", e);
        }
        return retMap;
    }
    // ...

这里也要判断如果是在jar包里就不能使用File的方式获取文件流,可以通过ClassLoader.getSystemResourceAsStream()方法获得。

BeanInitUtil.java:

    // ...
     public static void init(Class clazz){
        String path = clazz.getResource("").getPath();
        log.info("===bean init path:{}", path);
        if(path.indexOf("!")<0) {
            File root = new File(path);
            // 处理控制反转(加载aop切面,controller)
            initFile(root);
        } else {
            // 处理jar包内的反射逻辑
            initJar(path);
        }
        // 处理aop类
        initAop();
        // 处理依赖注入
        initDI();
    }
    
    private static void initJar(String jarPath){
        try {
            String packageStr = jarPath.substring(jarPath.indexOf("!")+2).replaceAll("/", ".");
            log.info("packageStr :{}", packageStr);
            jarPath = jarPath.substring(0, jarPath.indexOf("!")).replace("file:/", "");
            log.info("jar file path:{}", jarPath);
            JarFile jarFile = new JarFile(new File(jarPath));
            // 获取jar文件条目
            Enumeration<JarEntry> enumFiles = jarFile.entries();
            JarEntry entry;
            while(enumFiles.hasMoreElements()){
                entry = enumFiles.nextElement();
                String className = entry.getName().replaceAll("/", ".");
                // 只处理自己包下的class文件
                if(className.startsWith(packageStr) && className.indexOf(".class")>=0){
                    className = className.substring(0,className.length()-6).replace("/", ".");
                    log.info("class:{}", className);
                    loadClass(className);
                }
            }
        } catch (IOException e) {
            log.error("load jar file error!", e);
        }
    }

添加了initJar方法,从jar包中遍历初始化Bean。

其它的一些联动修改,可以在源码中查看。

05、测试项目

新创建一个square-demo项目,pom.xml文件中引入我的们框架及打包工具(需要先在框架及打包工具目录下执行mvn clean install)

    <dependencies>
        <dependency>
            <groupId>com.jisuye</groupId>
            <artifactId>square</artifactId>
            <version>0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>com.jisuye</groupId>
                <artifactId>square-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

创建一个测试的HelloController.java:

package com.jisuye.controller;
// import ...
@Controller("/")
public class HelloController {
    @GetMapping("/hello")
    public String hello(@RequestParam("name") String name){
        return "hello "+name;
    }
}

启动程序T.java:

package com.jisuye;
// import ...
@SquareApplication
public class T {
    public static void main(String args[]){
        run(T.class, args);
    }
}

添加application.yml:

server:
  port: 8765
  servlet:
    context-path: /square-demo

好的,现在我们试一下开发中启动程序,支持T.main()方法,查看控制台输出:

18:58:04.793 [main] INFO com.jisuye.core.SquareApplication - 
 __________  ____
|   ____   |/ ___|  __ _ _   _  __ _ _ __ ___
|  |    |  |\___ \ / _` | | | |/ _` | '__/ _ \
|  |____|  | ___) | (_| | |_| | (_| | | |  __/
|__________||____/ \__, |\__,_|\__,_|_|  \___|
======================|_|====================
 :: Square ::        (v0.1)

18:58:04.794 [main] INFO com.jisuye.util.BeansInitUtil - ===bean init path:/C:/Users/admin/idea/square-demo/target/classes/com/jisuye/
18:58:04.795 [main] INFO com.jisuye.util.BeansInitUtil - load bean class:com.jisuye.controller.HelloController
18:58:04.805 [main] INFO com.jisuye.util.BeansInitUtil - classPath:, methods.length:10
18:58:04.813 [main] INFO com.jisuye.util.BeansInitUtil - add controller key:get:/hello
18:58:04.813 [main] INFO com.jisuye.util.BeansInitUtil - load bean class:com.jisuye.T
18:58:04.813 [main] INFO com.jisuye.core.SquareApplication - beans size is:3
....
18:58:06.911 [main] INFO com.jisuye.core.SquareApplication - Tomcat started on port(s): 8765 with context path '/square-demo'
18:58:06.911 [main] INFO com.jisuye.core.SquareApplication - Started Application in 2233 ms.

程序启动成功,在浏览器访问:http://localhost:8765/square-demo/hello?name=ixx

返回:hello ixx

到目前为止都没有问题,然后我们来试一下打包,在square-demo项目目录下执行mvn clean package,查看输出:

....
[INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ square-demo ---
[INFO] Building jar: C:\Users\admin\idea\square-demo\target\square-demo-1.0-SNAPSHOT.jar
[INFO]
[INFO] --- square-maven-plugin:0.1-SNAPSHOT:repackage (default) @ square-demo ---
[INFO] square maven plugin exe...
[INFO] outputDir:C:\Users\admin\idea\square-demo\target
[INFO] file name name ======square-demo-1.0-SNAPSHOT.jar
[INFO] build source dir:C:\Users\admin\idea\square-demo\src\main\java
[INFO] mainclass:com.jisuye.T
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.737 s
[INFO] Finished at: 2019-08-14T19:00:23+08:00
[INFO] ------------------------------------------------------------------------

build success! 我们来看一下target目录:

C:\Users\admin\idea\square-demo>ls target
classes  maven-archiver  maven-status  square-demo-1.0-SNAPSHOT.jar  square-demo-1.0-SNAPSHOT.jar.old

有一个square-demo-1.0-SNAPSHOT.jar 和一个square-demo-1.0-SNAPSHOT.jar.old,跟我们插件实现效果一样,.old是package打出来的而.jar是我们repackage过的,我们来启动看一下,执行java -jar target/square-demo-1.0-SNAPSHOT.jar

.....
19:04:17.672 [main] INFO com.jisuye.core.SquareApplication - Tomcat started on port(s): 8765 with context path '/square-demo'
19:04:17.673 [main] INFO com.jisuye.core.SquareApplication - Started Application in 944 ms.

可以看到也启动成功了,现在访问一下

在浏览器访问:http://localhost:8765/square-demo/hello?name=ixx

返回:hello ixx

06、遗留问题

编译打包基本完成了,但还有很多问题:

1、没有实现静态文件目录,public 的复制

2、没有处Square理框架的依赖关系

3、现有方式加载Bean有可能加载多余的类

问题留给下一篇....

这一篇写出来感觉没多少东西但在做的过程中确实是出现太多跟预想的不一样的结果,往往是为了解决一个问题,中间要走错很多路,找时间得好好看看Maven的实现原理。

本篇代码地址: https://github.com/iuv/square/tree/square9
打包插件地址: https://github.com/iuv/square-maven-plugin
演示项目地址: https://github.com/iuv/square-demo

本文作者: ixx
本文链接: http://jianpage.com/2019/08/15/square9
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容