程序员的福音 - Apache Commons VFS(下)

此文是系列文章第十二篇,前几篇请点击链接查看

程序猿的福音 - Apache Commons简介

程序员的福音 - Apache Commons Lang

程序员的福音 - Apache Commons IO

程序员的福音 - Apache Commons Codec

程序员的福音 - Apache Commons Compress

程序员的福音 - Apache Commons Exec

程序员的福音 - Apache Commons Email

程序员的福音 - Apache Commons Net

程序员的福音 - Apache Commons Collections

程序员的福音 - Apache Commons HttpClient

程序员的福音 - Apache Commons VFS(上)

Apache Commons VFS 为访问各种不同的文件系统提供了一个统一API。支持本地磁盘、HTTP服务器、FTP服务器、HDFS文件系统、ZIP压缩包等,支持自行扩展存储客户端。

上一篇我们主要介绍了 VFS 的整体结构和使用方法,本篇我将给大家介绍下在 VFS 接口的基础上扩展自己的文件系统客户端程序。

01. 扩展机制

Commons VFS 内部包含 providers.xml 配置文件,里面配置了若干现成的 provider,在类路径下创建"META-INF/vfs-providers.xml"文件,可以添加额外的配置。可以配置自己的实现,如亚马逊 S3 对象存储客户端。

配置文件是XML文件。配置文件的根元素是<providers>元素。<providers>元素可能包含:

零个或多个<provider>元素。

可选的<default provider>元素。

零个或多个<extension map>元素。

零个或多个<mime type map>元素。

下面是一个配置文件示例:

<providers>
    <provider class-name="org.apache.commons.vfs2.provider.zip.ZipFileProvider">
        <scheme name="zip"/>
    </provider>
    <extension-map extension="zip" scheme="zip"/>
    <mime-type-map mime-type="application/zip" scheme="zip"/>
    <provider class-name="org.apache.commons.vfs2.provider.ftp.FtpFileProvider">
        <scheme name="ftp"/>
        <if-available class-name="org.apache.commons.net.ftp.FTPFile"/>
    </provider>
    <default-provider class-name="org.apache.commons.vfs2.provider.url.UrlFileProvider"/>
</providers>

<provider>:元素定义了一个文件提供者。它必须具有 class name 属性,该属性指定提供程序类的完全限定名。provider 类必须是 public 的,并且必须有一个带有 FileSystemManager 参数的公共构造函数,该参数允许系统传递所使用的文件系统管理器。

<provider>元素可以包含零个或多个<scheme>元素,以及零个或多个<if-available>元素。<scheme>元素定义提供者将处理的 URI 协议。它必须有一个 name 属性,用于指定 URI 协议。<if-available>元素用于禁用提供程序。它必须具有 class name 属性,该属性指定要测试的类的完全限定名。如果找不到类,则不注册 provider 程序。

<default-provider>:元素定义默认提供者。它的格式与<provider>元素相同。

<extension-map>:元素定义了从文件扩展名到具有该扩展名的 provider 的映射。它必须有一个 extension 属性(指定扩展)和一个 scheme 属性(指定提供程序的 URI 方案)。

<mime-map>:元素定义了从文件的 mime 类型到应该处理该 mime 类型文件的提供程序的映射。它必须有一个 mime type 属性(指定 mime 类型)和一个 scheme 属性(指定提供程序的 URI 方案)。

StandardFileSystemManager 的 init 方法在加载自带的 providers.xml 后还会加载自定义配置。以下是他的源代码

@Override
public void init() throws FileSystemException {
    // Set the replicator and temporary file store (use the same component)
    final DefaultFileReplicator replicator = createDefaultFileReplicator();
    setReplicator(new PrivilegedFileReplicator(replicator));
    setTemporaryFileStore(replicator);

    if (configUri == null) {
        // Use default config
        final URL url = getClass().getResource(CONFIG_RESOURCE);
        FileSystemException.requireNonNull(url, "vfs.impl/find-config-file.error", CONFIG_RESOURCE);
        configUri = url;
    }
    // 配置自带的providers.xml
    configure(configUri);
    // 配置自定义的 vfs-providers.xml, 如果存在
    configurePlugins();

    // Initialize super-class
    super.init();
}
// 类路径如果存在多个vfs-providers.xml则依次解析加载
protected void configurePlugins() throws FileSystemException {
    final Enumeration<URL> enumResources;
    try {
        enumResources = enumerateResources(PLUGIN_CONFIG_RESOURCE);
    } catch (final IOException e) {
        throw new FileSystemException(e);
    }
    while (enumResources.hasMoreElements()) {
    // 解析xml配置并创建provider等对象
        configure(enumResources.nextElement());
    }
}

后面就是解析 xml 文件并根据配置情况将 provider 类放入一个 providers 属性中,providers 是一个HashMap

/**
 * Mapping from URI scheme to FileProvider.
 */
private final Map<String, FileProvider> providers = new HashMap<>();

02. 扩展步骤

下面我用一个例子介绍下扩展的步骤。

例子是实现一个 S3 存储服务的客户端,S3 是需要花钱去购买。我们可以使用 minio 来代替。minio 是一款开源的对象存储服务器,兼容亚马逊的S3协议。访问 http://www.minio.org.cn/download.shtml#/linux 下载 minio 服务器二进制程序,由于我们只是本机测试,所以直接用最简单的方式启动一个单机版的 minio,使用以下命令启动

export MINIO_ACCESS_KEY=minio
export MINIO_SECRET_KEY=123456
./minio server /mnt/data

这样我们就启动好了,浏览器访问 http://localhost:9000/ 用户名是 minio,密码 123456,能访问能登录成功则说明启动成功。

手动创建一个 bucket,名称为 bucket

由于我们使用的minio,需要引入 Java 的 minio-client 依赖。依赖如下:

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.3.0</version>
</dependency>

由于需要存在 minio-client 依赖才加载我们的 provider,所以需要配置 if-avalilble,自定义 vfs-providers.xml 如下:

<providers>
    <provider class-name="com.example.vfs.s3.S3FileProvider">
        <scheme name="minio"/>
        <scheme name="s3"/>
        <if-available class-name="io.minio.MinioClient"/>
        <if-available class-name="io.minio.ObjectArgs"/>
    </provider>
</providers>

VFS 获取文件大致流程如下:

FileSystemManager 解析文件名,通过文件名中的协议(如ftp://中的ftp)获取对应 FileProvider 对象,FileProvider 通过 FileNameParser 对象解析文件名获取对应的 FileSystem 对象,通过 FileSystem 对象的 resolveFile 方法获取文件对象 FileObject(默认先从缓存中查找,不存在再调用 createFile 方法创建 FileObject 对象,FileObject 就是实体文件的抽象,提供读取和修改等相关能力)

FileSystemManager fsMgr = VFS.getManager();
// s3是协议名
String path = "s3://[IP]:9000/bucket/path/a.txt";
FileObject fo = fsMgr.resolveFile(path);

我们主要关注 resolveFile 方法,以下是 VFS 的部分源码

@Override
public FileObject resolveFile(String uri) throws FileSystemException {
    return resolveFile(getBaseFile(), uri);
}
@Override
public FileObject resolveFile(FileObject baseFile, String uri) 
                throws FileSystemException {
    return resolveFile(baseFile, uri, 
    baseFile == null ? null : baseFile.getFileSystem().getFileSystemOptions());
}
public FileObject resolveFile(FileObject baseFile, String uri,
            FileSystemOptions fileSystemOptions)
    final FileObject realBaseFile;
    if (baseFile != null && VFS.isUriStyle() && baseFile.getName().isFile()) {
        realBaseFile = baseFile.getParent();
    } else {
        realBaseFile = baseFile;
    }
    // decode url, 如果不合法则抛出异常
    UriParser.checkUriEncoding(uri);
    if (uri == null) {
        throw new IllegalArgumentException();
    }
    // 提取scheme,本例就是"s3"
    String scheme = UriParser.extractScheme(getSchemes(), uri);
    if (scheme != null) {
        // 通过scheme获取注册的FileProvider
        // 此处获取的就是我们配置文件注册的 com.example.vfs.s3.S3FileProvider 
        FileProvider provider = providers.get(scheme);
        if (provider != null) {
        // provider 入口方法,下面主要看下这里的逻辑
            return provider.findFile(realBaseFile, uri, fileSystemOptions);
        }
        // ... ... 省略其他逻辑
    }
}

我们主要关注 32 行 provider.findFile() 方法,此处就是我们定义的 S3FileProvider 了,其中 findFile 是父类 AbstractOriginatingFileProvider 中的方法,以下是其部分源码

/**
 * Locates a file object, by absolute URI.
 *
 * @param baseFileObject The base file object.
 * @param uri The URI of the file to locate
 * @param fileSystemOptions The FileSystem options.
 * @return The located FileObject
 * @throws FileSystemException if an error occurs.
 */
@Override
public FileObject findFile(FileObject baseFileObject, String uri, FileSystemOptions fileSystemOptions)
        throws FileSystemException {
    // Parse the URI
    final FileName name;
    try {
    // 解析uri获取FileName
        name = parseUri(baseFileObject != null ? baseFileObject.getName() : null, uri);
    } catch (final FileSystemException exc) {
        throw new FileSystemException("vfs.provider/invalid-absolute-uri.error", uri, exc);
    }

    // Locate the file
    return findFile(name, fileSystemOptions);
}

/**
 * Locates a file from its parsed URI.
 *
 * @param fileName The file name.
 * @param fileSystemOptions FileSystem options.
 * @return A FileObject associated with the file.
 * @throws FileSystemException if an error occurs.
 */
protected FileObject findFile(FileName fileName, FileSystemOptions fileSystemOptions)
        throws FileSystemException {
    // Check in the cache for the file system
   FileName rootName = getContext().getFileSystemManager().resolveName(fileName, FileName.ROOT_PATH);
   FileSystem fs = getFileSystem(rootName, fileSystemOptions);
   // Locate the file
   // 此处会调用FileSystem的createFile方法
   return fs.resolveFile(fileName);
}
/**
 * Returns the FileSystem associated with the specified root.
 *
 * @param rootFileName The root path.
 * @param fileSystemOptions The FileSystem options.
 * @return The FileSystem.
 * @throws FileSystemException if an error occurs.
 * @since 2.0
 */
protected synchronized FileSystem getFileSystem(FileName rootFileName, final FileSystemOptions fileSystemOptions)
        throws FileSystemException {
    // 先从缓存中取
    FileSystem fs = findFileSystem(rootFileName, fileSystemOptions);
    if (fs == null) {
        // Need to create the file system, and cache it
        // doCreateFileSystem是抽象方法,需要我们去实现的
        fs = doCreateFileSystem(rootFileName, fileSystemOptions);
        addFileSystem(rootFileName, fs);
    }
    return fs;
}

上述源码 59 行的 doCreateFileSystem 方法是抽象方法,需要我们的 provider 去实现的,下面看看我们自己的 S3FileProvider 实现类

public class S3FileProvider extends AbstractOriginatingFileProvider {
    public static final UserAuthenticationData.Type[] AUTHENTICATOR_TYPES = new UserAuthenticationData.Type[] {
            UserAuthenticationData.USERNAME, UserAuthenticationData.PASSWORD };
    // 支持的能力
    static final Collection<Capability> CAPABILITIES = Collections
            .unmodifiableCollection(Arrays.asList(Capability.GET_TYPE, Capability.READ_CONTENT,
                    Capability.CREATE,
                    Capability.DELETE,
                    Capability.RENAME,
                    Capability.WRITE_CONTENT,
                    Capability.URI, Capability.GET_LAST_MODIFIED,
                    Capability.SET_LAST_MODIFIED_FILE,
                    Capability.LIST_CHILDREN));

    public S3FileProvider() {
    // 使用自己的FileNameParser
        this.setFileNameParser(S3FileNameParser.getInstance());
    }
    /**
     * S3FileSystem创建关键方法
     */
    @Override
    protected FileSystem doCreateFileSystem(FileName rootName, FileSystemOptions fileSystemOptions) throws FileSystemException {
        UserAuthenticationData authData = UserAuthenticatorUtils.authenticate(fileSystemOptions, AUTHENTICATOR_TYPES);
        MinioClient client = createClient((GenericFileName) rootName, authData);
        // S3FileSystem,依赖 MinioClient
        return new S3FileSystem((GenericFileName) rootName, client, fileSystemOptions);
    }

    private MinioClient createClient(GenericFileName rootName, UserAuthenticationData authData) {
        String accessKey = new String(UserAuthenticatorUtils.getData(authData, USERNAME, UserAuthenticatorUtils.toChar(rootName.getUserName())));
        String secretKey = new String(UserAuthenticatorUtils.getData(authData, PASSWORD, UserAuthenticatorUtils.toChar(rootName.getUserName())));
        MinioClient client = new MinioClient.Builder()
                .endpoint("http://" + rootName.getHostName() + ":" + rootName.getPort() + "/")
                .credentials(accessKey, secretKey)
                .build();
        return client;
    }

    @Override
    public Collection<Capability> getCapabilities() {
        return CAPABILITIES;
    }
}

上述代码 17 行设置我们自己的 S3FileNameParser 用于解析 S3 的 uri,23 行的 doCreateFileSystem 方法返回我们自己定义的 S3FileSystem,其中依赖 MinioClient。接下来就是 S3FileSystem 类了

public class S3FileSystem extends AbstractFileSystem {

    private MinioClient client;

    protected S3FileSystem(GenericFileName rootFileName, MinioClient client, FileSystemOptions fileSystemOptions) {
        super(rootFileName, (FileObject)null, fileSystemOptions);
        this.client = client;
    }
   /**
    * 创建 FileObject 关键方法
    * FileSystem.resolveFile() 方法会调用此函数
    * @return S3FileObject
    */
    @Override
    protected FileObject createFile(AbstractFileName name) throws Exception {
        return new S3FileObject((S3FileName) name, this);
    }

    @Override
    protected void addCapabilities(Collection<Capability> caps) {
        caps.addAll(S3FileProvider.CAPABILITIES);
    }

   /**
    * 获取MinioClient, 主要给 FileObject 类去调用
    * @return MinioClient
    */
    public MinioClient getClient() {
        return client;
    }
}

下面就是关键的 S3FileObject 类了,核心思想就是将所有读写请求,代理给 MinioClient

/**
 * 将文件读写请求代理给MinioClient
 */
public class S3FileObject extends AbstractFileObject<S3FileSystem> {

    private final S3FileName fileName;
    protected S3FileObject(S3FileName name, S3FileSystem fileSystem) {
        super(name, fileSystem);
        this.fileName = name;
    }

    /**
     * 写入文件
     * @param bAppend 是否追加
     * @return
     * @throws Exception
     */
    @Override
    protected OutputStream doGetOutputStream(boolean bAppend) throws Exception {
        if (bAppend) {
            throw new FileSystemException("vfs.provider/write-append-not-supported.error", this.getName().getURI());
        }
        return new S3OutputStream(new File(FileUtils.getTempDirectoryPath() + "/s3", RandomStringUtils.random(10)));
    }

    @Override
    protected InputStream doGetInputStream(int bufferSize) throws Exception {
        return getObject();
    }

    @Override
    protected long doGetContentSize() throws Exception {
        return getStat().size();
    }

    /**
     * 获取文件类型
     * 目录,文件 or 不存在
     */
    @Override
    protected FileType doGetType() throws Exception {
        try {
            if (getStat() == null) {
                return FileType.IMAGINARY;
            }
        } catch (Exception e) {
            return FileType.IMAGINARY;
        }
        return fileName.getType();
    }

    private StatObjectResponse getStat() throws Exception {
        try {
            StatObjectArgs args = StatObjectArgs.builder().bucket(fileName.getBucketName()).object(fileName.getS3path()).build();
            return getClient().statObject(args);
        } catch (ErrorResponseException e) {
            if (e.response().code() == 404) {
                return null;
            }
            throw e;
        }
    }

    private GetObjectResponse getObject() throws Exception {
        GetObjectArgs args = GetObjectArgs.builder().bucket(this.fileName.getBucketName()).object(fileName.getS3path()).build();
        try {
            GetObjectResponse res = getClient().getObject(args);
            Iterator<Pair<String, String>> ite =  res.headers().iterator();
            while (ite.hasNext()) {
                Pair<String, String> pair = ite.next();
                System.out.println(pair.toString());
            }
            return res;
        } catch (ErrorResponseException e) {
            if (e.response().code() == 404) {
                throw new FileNotFoundException(fileName.getURI());
            }
            throw e;
        }
    }
    /**
     * 获取子文件,url字符串形式
     */
    @Override
    protected String[] doListChildren() throws Exception {
        ListObjectsArgs args = ListObjectsArgs.builder().bucket(this.fileName.getBucketName()).prefix(fileName.getS3path()).build();
        Iterator<Result<Item>> ite = getClient().listObjects(args).iterator();
        List<String> result = new ArrayList<>();
        while (ite.hasNext()) {
            Result<Item> r = ite.next();
            result.add(r.get().objectName());
        }
        return result.toArray(new String[0]);
    }
    
    /**
     * 删除
     */
    @Override
    protected void doDelete() throws Exception {
        RemoveObjectArgs args = RemoveObjectArgs.builder().bucket(fileName.getBucketName()).object(fileName.getS3path()).build();
        getClient().removeObject(args);
    }

    @Override
    public Path getPath() {
        return null;
    }

    /**
     * 获取子文件,FileObject形式
     */
    @Override
    protected FileObject[] doListChildrenResolved() throws Exception {
        if (this.doGetType() != FileType.FOLDER) {
            return null;
        }
        final String[] children = doListChildren();
        final FileObject[] fo = new FileObject[children.length];
        for (int i = 0; i < children.length; i++) {
            String name = fileName.getRootURI() + fileName.getBucketName() + children[i];
            fo[i] = getFileSystem().getFileSystemManager().resolveFile(name, getFileSystem().getFileSystemOptions());
        }
        return fo;
    }

    private MinioClient getClient() {
        return getAbstractFileSystem().getClient();
    }
    // 自定义输出流,用于写入文件
    // 在写入完成后会调用MinioClient将文件写入Minio中
    private class S3OutputStream extends MonitorOutputStream {
        private File tempFile;
        public S3OutputStream(File tempFile) throws IOException {
            super(FileUtils.openOutputStream(tempFile));
            this.tempFile = tempFile;
        }

        @Override
        protected void onClose() throws IOException {
            try {
                InputStream is = new FileInputStream(tempFile);
                PutObjectArgs args = PutObjectArgs.builder().bucket(fileName.getBucketName()).object(fileName.getS3path()).stream(is, tempFile.length(), -1).build();
                // 调用MinioClient写入服务端
                ObjectWriteResponse owr = getClient().putObject(args);
                if (owr.versionId() == null) {
                    throw new FileSystemException("vfs.provider.ftp/finish-put.error", getName());
                }
            } catch (Exception e) {
                throw new FileSystemException("vfs.provider.ftp/finish-put.error", getName(), e);
            } finally {
                FileUtils.deleteQuietly(tempFile);
            }
        }
    }
}

篇幅原因,S3FileName 和 S3FileNameParser 类的源码比较简单就不贴出来了

03. 测试

编写测试代码,注意其中的用户名密码和 IP 按照第二节安装的实际情况修改。

@Test
public void s3Test() throws IOException {
    FileSystemManager fsMgr = VFS.getManager();
    StaticUserAuthenticator auth = new StaticUserAuthenticator("", "username", "password");
    FileSystemOptions opts = new FileSystemOptions();
    DefaultFileSystemConfigBuilder.getInstance().setUserAuthenticator(opts, auth);
    FileObject fo = fsMgr.resolveFile("s3://192.168.1.11:9000/bucket/files/test.txt", opts);
    if (!fo.exists()) {
        System.out.println("fo not exists");
        return;
    }
    System.out.println("parent:"+fo.getParent().toString());
    System.out.println("name:"+fo.getName());
    System.out.println("path:"+fo.getPath());
    System.out.println("pubURI:"+fo.getPublicURIString());
    System.out.println("URI:"+fo.getURI().toString());
    System.out.println("URL:"+fo.getURL());
    boolean isFile = fo.isFile();
    boolean isFolder = fo.isFolder();
    // 是否符号链接
    boolean isSymbolic = fo.isSymbolicLink();
    boolean executable = fo.isExecutable();
    boolean isHidden = fo.isHidden();
    boolean isReadable = fo.isReadable();
    boolean isWriteable = fo.isWriteable();
    System.out.println("type:"+fo.getType());
    if (fo.getType().hasChildren()) {
        System.out.println("child:"+fo.getChild("child"));
        System.out.println(fo.getChild("child").getContent().getSize());
        System.out.println("children:"+ Arrays.toString(fo.getChildren()));
    }
    if (fo.getType().hasContent()) {
        FileContent fc = fo.getContent();
        InputStream is = fc.getInputStream();
        FileUtils.copyInputStreamToFile(is, new File("/test/t.txt"));
        // byte[] bytes = fc.getByteArray();
        System.out.println(fc.getString("UTF-8"));
    }
    // if (fo.isWriteable()) {
    //     int suc = fo.delete(Selectors.EXCLUDE_SELF);
    //     System.out.println(suc);
    // }
    // 会同时关闭FileContent并释放FileObject
    fo.close();
    // 关闭文件系统,释放连接,清除缓存等
    fsMgr.close();
}

04. 总结

本章主要讲解 Commons VFS 的扩展机制。如果有其他存储客户端的需求 VFS 不支持的情况可以自行扩展。这样可以使用统一的 API 实现多个文件系统的读写操作,有对应需求可以考虑扩展。

后续章节我将继续给大家介绍commons中其他好用的工具类库,期待你的关注。

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

推荐阅读更多精彩内容