学会自己编写Mybatis插件(拦截器)实现自定义需求

前言

你有了解过它是如何实现的吗?你有没有自己编写 Mybatis 插件去实现一些自定义需求呢?

插件是一种常见的扩展方式,大多数开源框架也都支持用户通过添加自定义插件的方式来扩展或改变框架原有的功能。

Mybatis 中也提供了插件的功能,虽然叫插件,但是实际上是通过拦截器( Interceptor )实现的,通过拦截某些方法的调用,在执行目标逻辑之前插入我们自己的逻辑实现。另外在 MyBatis 的插件模块中还涉及责任链模式和 JDK 动态代理~

文章大纲:

一、应用场景

一些字段的自动填充
SQL语句监控、打印、数据权限等
数据加解密操作、数据脱敏操作
分页插件
参数、结果集的类型转换
这些都是一些可以使用Mybatis插件实现的场景,当然也可以使用其他的方式来实现,只不过拦截的地方不一样罢了,有早有晚。

二、Mybatis实现自定义拦截器

我们用自定义拦截器实现一个相对简单的需求,在大多数表设计中,都会有create_time和update_time等字段,在创建或更新时需要更新相关字段。

如果是使用过MybatisPlus的小伙伴,可能知道在MybatisPlus中有一个自动填充功能,通过实现MetaObjectHandler接口中的方法来进行实现(主要的实现代码在
com.baomidou.mybatisplus.core.MybatisParameterHandler中).

但使用Mybatis,并没有相关的方法或 API 可以直接来实现。所以我们这次就用以此处作为切入点,自定义拦截器来实现类似的自动填充功能。

编写步骤

编写一个拦截器类实现 Interceptor 接口
添加拦截注解 @Intercepts
在xml文件中配置拦截器或者添加到Configuration中
基础的环境我就不再贴出来啦哈,直接上三个步骤的代码

2.1、编写拦截器

package com.nzc.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.springframework.beans.factory.annotation.Value;

import java.lang.reflect.Field;
import java.util.*;

/**
 * @author 宁在春
 * @version 1.0
 * @description: 通过实现拦截器来实现部分字段的自动填充功能
 * @date 2023/4/6 21:49
 */
@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
@Slf4j
public class MybatisMetaInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        String sqlId = mappedStatement.getId();
        log.info("------sqlId------" + sqlId);
        SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
        Object parameter = invocation.getArgs()[1];
        log.info("------sqlCommandType------" + sqlCommandType);
        log.info("拦截查询请求 Executor#update 方法" + invocation.getMethod());
        if (parameter == null) {
            return invocation.proceed();
        }
        if (SqlCommandType.INSERT == sqlCommandType) {

            Field[] fields = getAllFields(parameter);
            for (Field field : fields) {
                log.info("------field.name------" + field.getName());
                try {
                    // 注入创建时间
                    if ("createTime".equals(field.getName())) {
                        field.setAccessible(true);
                        Object local_createDate = field.get(parameter);
                        field.setAccessible(false);
                        if (local_createDate == null || local_createDate.equals("")) {
                            field.setAccessible(true);
                            field.set(parameter, new Date());
                            field.setAccessible(false);
                        }
                    }
                } catch (Exception e) {
                }
            }
        }
        if (SqlCommandType.UPDATE == sqlCommandType) {
            Field[] fields = getAllFields(parameter);
            for (Field field : fields) {
                log.info("------field.name------" + field.getName());
                try {
                    if ("updateTime".equals(field.getName())) {
                        field.setAccessible(true);
                        field.set(parameter, new Date());
                        field.setAccessible(false);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Interceptor.super.plugin(target);
    }
     
    // 稍后会展开说的
    @Override
    public void setProperties(Properties properties) {
        System.out.println("=======begin");
        System.out.println(properties.getProperty("param1"));
        System.out.println(properties.getProperty("param2"));
        Interceptor.super.setProperties(properties);
        System.out.println("=======end");
    }

    /**
     * 获取类的所有属性,包括父类
     *
     * @param object
     * @return
     */
    public static Field[] getAllFields(Object object) {
        Class<?> clazz = object.getClass();
        List<Field> fieldList = new ArrayList<>();
        while (clazz != null) {
            fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields())));
            clazz = clazz.getSuperclass();
        }
        Field[] fields = new Field[fieldList.size()];
        fieldList.toArray(fields);
        return fields;
    }
}

2.2、添加到Mybatis配置

我这里使用的JavaConfig的方式

package com.nzc.config;

import com.nzc.interceptor.*;
import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisConfig {

    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return new ConfigurationCustomizer() {
            @Override
            public void customize(org.apache.ibatis.session.Configuration configuration) {
                // 开启驼峰命名映射
                configuration.setMapUnderscoreToCamelCase(true);
                MybatisMetaInterceptor mybatisMetaInterceptor = new MybatisMetaInterceptor();
                Properties properties = new Properties();
                properties.setProperty("param1","javaconfig-value1");
                properties.setProperty("param2","javaconfig-value2");
                mybatisMetaInterceptor.setProperties(properties);
                configuration.addInterceptor(mybatisMetaInterceptor);
            }
        };
    }


}

如果是xml配置的话,则是如下: property 是设置 拦截器中需要用到的参数

<configuration>
    <plugins>
        <plugin interceptor="com.nzc.interceptor.MybatisMetaInterceptor"> 
            <property name="param1" value="value1"/>
            <property name="param2" value="value2"/>
        </plugin>
    </plugins>
</configuration>    

2.3、测试

测试代码:实现了一个SysMapper的增删改查

package com.nzc.mapper;


import com.nzc.entity.SysUser;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import java.util.List;

/**
 * @author 宁在春
 * @description 针对表【sys_user】的数据库操作Mapper
 */
@Mapper
public interface SysUserMapper {

    @Select("SELECT * FROM tb_sys_user")
    List<SysUser> list();

    @Insert("insert into tb_sys_user(id,username,realname,create_time,update_time) values (#{id}, #{username}, #{realname}, #{createTime}, #{updateTime})")
    Boolean insert(SysUser sysUser);

    @Update("update tb_sys_user set  username=#{username} , realname=#{realname},update_time=#{updateTime}  where id=#{id}")
    boolean update(SysUser sysUser);
}
/**
 * @author 宁在春
 * @version 1.0
 * @description: TODO
 * @date 2023/4/6 21:38
 */
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class SysUserMapperTest {

    @Autowired
    private SysUserMapper sysUserMapper;


    @Test
    public void test1(){
        System.out.println(sysUserMapper.list());
    }

    @Test
    public void test2(){
        SysUser sysUser = new SysUser();
        sysUser.setId("1235");
        sysUser.setUsername("nzc5");
        sysUser.setRealname("nzc5");
        System.out.println(sysUserMapper.insert(sysUser));
    }

    @Test
    public void test3(){
        SysUser sysUser = new SysUser();
        sysUser.setId("1235");
        sysUser.setUsername("nzc7");
        sysUser.setRealname("nzc5");
        System.out.println(sysUserMapper.update(sysUser));
    }
}

当然重点不在这里,而是在我们打印的日志上,一起来看看效果吧

此处相关日志对应Interceptor中的日志打印,想要了解的更为详细的可以debug查看一番。

2.4、小结

通过这个小小的案例,我想大伙对于Mybatis中的拦截器应当是没有那般陌生了吧,接下来再来仔细聊聊吧

如果你使用过MybatisPlus的话,在读完这篇博文后,可以思考思考下面这个问题,或去看一看源码,将知识串联起来,如果可以的话,记得把答案贴到评论区啦~~~

思考:还记得这一小节开始我们聊到的MybatisPlus实现的自动填充功能吗?它是怎么实现的呢?

三、拦截器接口介绍

MyBatis 插件可以用来实现拦截器接口 Interceptor ,在实现类中对拦截对象和方法进行处理

public interface Interceptor {
  // 执行拦截逻辑的方法
  Object intercept(Invocation invocation) throws Throwable;

    //这个方法的参数 target 就是拦截器要拦截的对象,该方法会在创建被拦截的接口实现类时被调用。
    //该方法的实现很简单 ,只需要调用 MyBatis 提供的 Plug 类的 wrap 静态方法就可以通过 Java 动态代理拦截目标对象。
  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }

  //这个方法用来传递插件的参数,可以通过参数来改变插件的行为
  default void setProperties(Properties properties) {
    // NOP
  }

}

有点懵没啥事,一个一个展开说:

intercept 方法

Object intercept(Invocation invocation) throws Throwable;

简单说就是执行拦截逻辑的方法,但不得不说这句话是个高度概括~

首先我们要明白参数Invocation是个什么东东:

public class Invocation {

  private final Object target; // 拦截的对象信息
  private final Method method; // 拦截的方法信息
  private final Object[] args; // 拦截的对象方法中的参数

  public Invocation(Object target, Method method, Object[] args) {
    this.target = target;
    this.method = method;
    this.args = args;
  }

 // get...
 // 利用反射来执行拦截对象的方法
  public Object proceed() throws InvocationTargetException, IllegalAccessException {
    return method.invoke(target, args);
  }

}

联系我们之前实现的自定义拦截器上的注解:

@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
  1. target对应我们拦截的Executor对象
  2. method对应Executor#update方法
  3. args对应Executor#update#args参数

plugin方法

这个方法其实也很好说:

那就是Mybatis在创建拦截器代理时候会判断一次,当前这个类 Interceptor 到底需不需要生成一个代理进行拦截,如果需要拦截,就生成一个代理对象,这个代理就是一个 {@link Plugin},它实现了jdk的动态代理接口 {@link InvocationHandler},如果不需要代理,则直接返回目标对象本身 加载时机:该方法在 mybatis 加载核心配置文件时被调用

default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
public class Plugin implements InvocationHandler {


    //  利用反射,获取这个拦截器 MyInterceptor 的注解 Intercepts和Signature,然后解析里面的值,
    //  1 先是判断要拦截的对象是哪一个
    //  2 然后根据方法名称和参数判断要对哪一个方法进行拦截
    //  3 根据结果做出决定,是返回一个对象呢还是代理对象
    public static Object wrap(Object target, Interceptor interceptor) {
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        Class<?> type = target.getClass();
        // 这边就是判断当前的interceptor是否包含在
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
        if (interfaces.length > 0) {
            return Proxy.newProxyInstance(
                type.getClassLoader(),
                interfaces,
                new Plugin(target, interceptor, signatureMap));
        }
        //如果不需要代理,则直接返回目标对象本身
        return target;
    }

    //....

}

setProperties方法

在拦截器中可能需要使用到一些变量参数,并且这个参数是可配置的,这个时候我们就可以使用这个方法啦,加载时机:该方法在 mybatis 加载核心配置文件时被调用

 default void setProperties(Properties properties) {
    // NOP
  }

关于如何使用:

javaConfig方式设置:

@Bean
public ConfigurationCustomizer configurationCustomizer() {
    return new ConfigurationCustomizer() {
        @Override
        public void customize(org.apache.ibatis.session.Configuration configuration) {
            // 开启驼峰命名映射
            configuration.setMapUnderscoreToCamelCase(true);
            MybatisMetaInterceptor mybatisMetaInterceptor = new MybatisMetaInterceptor();
            Properties properties = new Properties();
            properties.setProperty("param1","javaconfig-value1");
            properties.setProperty("param2","javaconfig-value2");
            mybatisMetaInterceptor.setProperties(properties);
            configuration.addInterceptor(mybatisMetaInterceptor);
        }
    };
}

通过mybatis-config.xml文件进行配置

<configuration>
    <plugins>
        <plugin interceptor="com.nzc.interceptor.MybatisMetaInterceptor">
            <property name="param1" value="value1"/>
            <property name="param2" value="value2"/>
        </plugin>
    </plugins>
</configuration>    

测试效果就是测试案例上那般,通过了解拦截器接口的信息,对于之前的案例不再是那般模糊啦

接下来再接着聊一聊拦截器上面那一坨注解信息是用来干嘛的吧,

注意

当配置多个拦截器时, MyBatis 会遍历所有拦截器,按顺序执行拦截器的 plugin 口方法, 被拦截的对象就会被层层代理。

在执行拦截对象的方法时,会一层层地调用拦截器,拦截器通 invocation proceed()调用下层的方法,直到真正的方法被执行。

方法执行的结果 从最里面开始向外 层层返回,所以如果存在按顺序配置的三个签名相同的拦截器, MyBaits 会按照 C>B>A>target.proceed()>A>B>C 的顺序执行。如果签名不同, 就会按照 MyBatis 拦截对象的逻辑执行.

这也是我们最开始谈到的Mybatis插件模块所使用的设计模式-责任链模式。

四、拦截器注解介绍

上一个章节,我们只说明如何实现Interceptor接口来实现拦截,却没有说明要拦截的对象是谁,在什么时候进行拦截.就关系到我们之前编写的注解信息啦.

@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})

这两个注解用来配置拦截器要拦截的接口的方法。

@Intercepts({})注解中是一个@Signature()数组,可以在一个拦截器中同时拦截不同的接口和方法。

MyBatis 允许在己映射语句执行过程中的某一点进行拦截调用。默认情况下, MyBatis 允许使用插件来拦截的接口包括以下几个。

  • Executor
  • ParameterHandler
  • ResultSetHandler
  • StatementHandler

@Signature 注解包含以下三个属性。

  1. type 设置拦截接口,可选值是前面提到的4个接口
  2. method 设置拦截接口中的方法名 可选值是前面4个接口中所对应的方法,需要和接口匹配
  3. args 设置拦截方法的参数类型数组 通过方法名和参数类型可以确定唯一一个方法

Executor 接口

下面就是Executor接口的类信息

public interface Executor {

  int update(MappedStatement ms, Object parameter) throws SQLException;

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;

  <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

  List<BatchResult> flushStatements() throws SQLException;

  void commit(boolean required) throws SQLException;

  void rollback(boolean required) throws SQLException;

  CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql);

  boolean isCached(MappedStatement ms, CacheKey key);

  void clearLocalCache();

  void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType);

  Transaction getTransaction();

  void close(boolean forceRollback);

  boolean isClosed();

  void setExecutorWrapper(Executor executor);

}

我只会简单说一些最常用的~

1、update

int update(MappedStatement ms, Object parameter) throws SQLException;

该方法会在所有的 INSERT、UPDATE、DELETE 执行时被调用,因此如果想要拦截这类操作,可以拦截该方法。接口方法对应的签名如下。

@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})

2、query

<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;

<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;

该方法会在所有 SELECT 查询方法执行时被调用 通过这个接口参数可以获取很多有用的信息,这也是最常被拦截的方法。

@Intercepts({@Signature(
    type = Executor.class,
    method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
), @Signature(
    type = Executor.class,
    method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
)})

3、queryCursor:

 <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

该方法只有在查询 的返回值类型为 Cursor 时被调用 。接口方法对应的签名类似于之前的。

//该方法只在通过 SqlSession 方法调用 commit 方法时才被调用  
void commit(boolean required) throws SQLException; 
//该方法只在通过 SqlSessio口方法调用 rollback 方法时才被调用
void rollback(boolean required) throws SQLException;
//该方法只在通过 SqlSession 方法获取数据库连接时才被调用,
Transaction getTransaction();
//该方法只在延迟加载获取新的 Executor 后才会被执行
void close(boolean forceRollback);
//该方法只在延迟加载执行查询方法前被执行
boolean isClosed();

注解的编写方法都是类似的。

ParameterHandler 接口

public interface ParameterHandler {

    //该方法只在执行存储过程处理出参的时候被调用
    Object getParameterObject();
    //该方法在所有数据库方法设置 SQL 参数时被调用。
    void setParameters(PreparedStatement ps) throws SQLException;
}

我都写一块啦,如果要拦截某一个的话只写一个即可

@Intercepts({
        @Signature(type = ParameterHandler.class, method = "getParameterObject", args = {}),
        @Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class})
})

ResultSetHandler 接口

public interface ResultSetHandler {
    //该方法会在除存储过程及返回值类型为 Cursor 以外的查询方法中被调用。
    <E> List<E> handleResultSets(Statement stmt) throws SQLException;
    //只会在返回值类型为 ursor 查询方法中被调用  
    <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;
    //只在使用存储过程处理出参时被调用 ,
    void handleOutputParameters(CallableStatement cs) throws SQLException;
}
@Intercepts({
        @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}),
        @Signature(type = ResultSetHandler.class, method = "handleCursorResultSets", args = {Statement.class}),
        @Signature(type = ResultSetHandler.class, method = "handleOutputParameters", args = {CallableStatement.class})
})

StatementHandler 接口

public interface StatementHandler {
    //该方法会在数据库执行前被调用 优先于当前接口中的其他方法而被执行
    Statement prepare(Connection connection, Integer transactionTimeout)
        throws SQLException;
    //该方法在 prepare 方法之后执行,用于处理参数信息 
    void parameterize(Statement statement)
        throws SQLException;
    //在全局设置配置 defaultExecutorType BATCH 时,执行数据操作才会调用该方法
    void batch(Statement statement)
        throws SQLException;
    //执行UPDATE、DELETE、INSERT方法时执行
    int update(Statement statement)
        throws SQLException;
    //执行 SELECT 方法时调用,接口方法对应的签名如下。
    <E> List<E> query(Statement statement, ResultHandler resultHandler)
        throws SQLException;

    <E> Cursor<E> queryCursor(Statement statement)
        throws SQLException;

    //获取实际的SQL字符串
    BoundSql getBoundSql();

    ParameterHandler getParameterHandler();

}
@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class,Integer.class}),
        @Signature(type = StatementHandler.class, method = "parameterize", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = "batch", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = "query", args = {Statement.class,ResultHandler.class}),
        @Signature(type = StatementHandler.class, method = "queryCursor", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = "getBoundSql", args = {}),
        @Signature(type = StatementHandler.class, method = "getParameterHandler", args = {})
}

如果有时间的话,我会更加建议看了的小伙伴,自己去实现接口做个测试,验证一番,也能了解的更彻底些。看会了,很多时候知识的记忆还是浅的。

五、进一步思考

看完这篇文章后,不知道你有没有什么收获。

再次看看这张文章大纲的图吧

试着思考思考下面几个问题:

  • Mybatis插件适用于哪些场景?回忆一下你做过的项目,是否有可以使用Mybatis插件来实现的呢?
  • 你可以编写一个Mybatis插件了吗?
  • 感兴趣的话,你可以试着去了解一下Mybatis分页插件的实现方式。

最后留下一个遇到的问题,也是下一篇文章可能会写的吧,同时也使用到了今天所谈到了的拦截器。

在项目中,你们都是如何针对表中某些字段进行加解密的呢?

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

推荐阅读更多精彩内容