多语言翻译组件的升级之路

国际化的定义各不相同。这是用于 W3C 国际化活动材料的高级工作定义。有些人使用其他术语(例如全球化)来指代同一概念。
国际化是产品、应用程序或文档内容的设计和开发,它可以为不同文化、地区或语言的目标受众轻松本地化。
国际化(Internationalization)通常用英文写成i18n,其中 18 是英文单词中i和n之间的字母数。[1]

本文主要基于java针对语言国际化进行阐述。

任何一个面向全世界的软件都会面临多语言国际化的问题,对于java web应用,要实现国际化功能,就是在数据展示给用户之前,替换成对应的语言。

1.使用spring自带的i18n(国际化)

这个比较简单,在网上找一找到处都是教程,这里将会手动配置一次spring i18n国际化来介绍一下。
以下是笔者使用的spring boot版本号.

<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starters</artifactId>
<version>2.2.5.RELEASE</version>
1-1.在properties或yml资源文件里面配置i18n

笔者用的是yml文件,可以自行转换成properties文件格式。

  • basename:以逗号分隔的基名列表(本质上是一个完全限定的类路径位置),每个基名都遵循ResourceBundle约定,对基于斜杠的位置提供宽松的支持。 如果它不包含包限定符(例如“org.mypackage”),它将从类路径根目录解析。
  • cache-duration:加载的资源包文件缓存持续时间。 如果没有设置,捆绑包将被永久缓存。 如果没有指定持续时间后缀,将使用秒。
# i18n
spring:
  messages:
    basename: i18n/messages
    cache-duration: 60
1-2.在resources文件夹下新增i18n文件夹,并新建相应的国际化文件

spring容器启动的时候,会根据配置的basename去对应的路径加载资源文件到MessageSource里,至于是怎么加载到MessageSource里的,在这里就不展开阐述了。

文件配置如图 1-1所示。

注意:红色框 里的就是i18n资源文件的配置,绿色框 是idea自动生成的文件夹,实际并不存在,无视就行。

图 1-1 i18n资源文件.png
# messages.properties
test.name=test rookie0peng
test.string=test string {0}
test.date=test date {0, date}
test.time=test time {0, time}
# messages_en.properties
test.name=test rookie0peng
test.string=test string {0}
test.date=test date {0, date}
test.time=test time {0, time}
# messages_zh.properties
test.name=测试阿鹏
test.string=测试字符串{0}
test.date=测试日期{0, date}
test.time=测试时间{0, time}
1-3.在代码中使用i18n进行国际化

这里演示的是比较简单的手动参数替换,还有更好一些的方法,比如说在响应数据写入流的时候进行参数替换。

@RestController
@RequestMapping("test")
public class TestController {

    @Autowired
    private MessageSource messageSource;

    @ApiOperation(value = "随便打印数据")
    @RequestMapping(path = "console", method = RequestMethod.POST)
    public Map<String, String> console() {
        Map<String, String> map = new LinkedHashMap<>();
        //1.测试无参替换
        map.put("test.name", messageSource.getMessage("test.name", null, LocaleContextHolder.getLocale()));
        //2.测试字符串参数替换
        Object[] stringParam = new Object[] {"1"};
        map.put("test.string", messageSource.getMessage("test.string", stringParam, LocaleContextHolder.getLocale()));
        //3.测试日期参数替换
        Object[] dateParam = new Object[] {new Date()};
        map.put("test.date", messageSource.getMessage("test.date", dateParam, LocaleContextHolder.getLocale()));
        //3.测试时间参数替换
        Object[] timeParam = new Object[] {new Date()};
        map.put("test.time", messageSource.getMessage("test.time", timeParam, LocaleContextHolder.getLocale()));
        return map;
    }
}
1-4.测试spring i18n

启动项目后,查看调用/test/console接口返回的数据,调用三次,分别设置header中的语言:默认、中文、英文。
笔者用的是idea的HTTP Client,以下是请求参数:

### 默认——测试spring i18n
POST {{baseUrl}}/test/console
Content-Type: application/json

{

}

### 中文——测试spring i18n
POST {{baseUrl}}/test/console
Content-Type: application/json
Accept-Language: zh-CN,zh;q=0.9

{

}

### 英文——测试spring i18n
POST {{baseUrl}}/test/console
Content-Type: application/json
Accept-Language: en;q=0.9

{

}

以下是响应参数。

{
  "test.name": "测试阿鹏",
  "test.string": "测试字符串1",
  "test.date": "测试日期2021年7月3日",
  "test.time": "测试时间下午4:41:27"
}

{
  "test.name": "测试阿鹏",
  "test.string": "测试字符串1",
  "test.date": "测试日期2021年7月3日",
  "test.time": "测试时间下午4:41:27"
}

{
  "test.name": "test rookie0peng",
  "test.string": "test string 1",
  "test.date": "test date Jul 3, 2021",
  "test.time": "test time 4:41:32 PM"
}
1-5.分析执行结果
  • 1-5-1.测试默认测试中文的响应数据是一样的,可以确定系统默认使用的中文环境。
  • 1-5-2.调用getMessage()方法时,不传第2个参数就是无参替换;否则,反之。
  • 1-5-3.使用有参替换时,还可以在properties文件里加入date、time等参数,spring可以自动格式化成对应的日期和时间。
1-6.结论

这里只是简单的演示了spring i18n的功能,对于一些简单的场景已然满足需要,如果需要进行扩展的话,有几种思路。

  • 1-6-1.如果使用了nacos等配置中心,则需要去注册中心手动拉取i18n的properties文件内容,并加载到应用程序的内存里,也可以在本地用户文件夹存放一份。
  • 1-6-2.如果需要一些正则翻译的话,则需要自己动手写正则替换的表达式。
  • 1-6-3.该例子展示的是在controller里进行替换,更好一点的方式是在filter,甚至是在响应数据写入流的时候进行替换,比如说指定某个响应对象的某个属性的序列化类(@JsonSerialize(using = TestJsonSerializer.class)),则该字段序列化的时候就会使用TestJsonSerializer.class进行序列化。在这个类里面就可以针对性的做我们想要的替换了。

2.自定义i18n

spring i18n是挺好用的,但是面对复杂的业务需求,还不够强大。比如,用户想添加一种语言;递归替换;布局可以自定义,用户添加布局字段时,针对该项目或组织的不同地区的人员,设置不同的翻译内容等等。

基于各种各样的原因,扩展i18n已是必须要做的事。

那么怎么扩展呢?国际化的本质就是将key替换成不同语言的value,这句话中有几个关键点:key、替换、语言、value。其中key语言value都是名词,代表着具体的数据;替换是动词,代表具体的翻译逻辑。那我们就需要针对这几个点进行设计与实现。

2-1.设计数据表

思路:通过一种语言和键找到对应的值。表结构设计比较简单,key、value、语言各建一个表,如图 2-1所示。

图 2-1 国际化表结构设计.png

其中每个表只展示了主键编号字段,其实还有一些字段没展示出来,比如code、name,这些可以根据自己的风格去设计。如果是多租户的系统,在每张表后面加入对应的租户id,即可进行数据隔离。

  • 2-1-1.如果用户新增的字段需要翻译,往语言键里增加一条数据,以及往语言值里增加与语言定义相同数量的记录即可。
  • 2-1-2.如果用户新增语言定义,则往语言值里面增加与语言键相同数量的记录即可。
  • 2-1-3.更新、删除同理。
2-2.数据缓存设计

在一个面向世界的应用里面,翻译的频率是很高的,而且随着时间的流逝,翻译的数据肯定会越来越多,如果每次响应数据的翻译都去查询数据库的话,那势必会造成数据库性能以及应用本身性能的浪费。对于这种修改频率不算高的数据,咱们可以缓存起来,用空间换时间。

这里打算用两级缓存的设计来适应该翻译场景,一级是redis,二级是应用内存。

  • 2-2-1.将用到的数据从数据库缓存在redis里面,并且生成一个更新标志放入redis。
  • 2-2-2.应用获取翻译数据的时候先判断redis更新标志是否为空。
    • 2-2-2-1.为空,则代表redis尚未缓存翻译数据,将翻译数据从数据库拉取到内存,且推送到redis。
    • 2-2-2-2.不为空,则代表redis已缓存翻译数据,然后再比对redis的更新标志和应用内存的更新标志是否一致。
      • 2-2-2-2-1.不一致,则说明翻译数据已经改变,需要从redis重新拉取一次翻译数据,缓存在应用内存中。
      • 2-2-2-2-2.一致,则说明翻译数据尚未改变,可以直接使用应用内存中的翻译数据。
  • 2-2-3.将最后拿到的翻译数据(key-value)返回给实现翻译逻辑的组件。

如图 2-2所示。

图 2-2 国际化两级缓存设计.png

代码如下所示。

/**
 * <pre>
 *  @description: 语言数据组件
 *  @author: rookie0peng
 *  @date: 2021/7/4 21:28
 *  </pre>
 */
public interface LanguageDataComponent {

    /**
     * 通过语言定义和键获取值
     * @param definition 语言定义
     * @param key 键
     * @return optional -> 值
     */
    Optional<String> getValue(String definition, String key);

    /**
     * 更新语言键值对映射
     * @param definition 语言定义
     */
    void updateKeyValueMap(String definition);
}
@Service("languageDataComponent")
public class LanguageDataComponentImpl implements LanguageDataComponent {

    /**
     * 语言定义 -> (key, value)
     */
    private static final Map<String, Map<String, String>> DEFINITION_TO_KEY_VALUE_MAP = new ConcurrentHashMap<>();

    /**
     * 一天
     */
    private static final long ONE_DAY = 86400;

    /**
     * 更新标志
     */
    private static final String CHANGE_FLAG = "CHANGE_FLAG";

    @Autowired
    private RedisTemplate<String, ?> redisTemplate;

    @Override
    public Optional<String> getValue(String definition, String key) {
        if (isEmpty(definition) || isEmpty(key))
            return Optional.empty();
        Map<String, String> key2ValueMap = DEFINITION_TO_KEY_VALUE_MAP.get(definition);
        return isNull(key2ValueMap) ? Optional.empty() : Optional.ofNullable(key2ValueMap.get(key));
    }

    @Override
    public void updateKeyValueMap(String definition) {
        if (isEmpty(definition))
            return;
        //1.获取redis更新标志和本地更新标志
        String redisChangeFlag = this.getRedisChangeFlag(definition);
        String localChangeFlag = this.getLocalChangeFlag(definition);
        //2.redis更新标志为空,则从数据库获取数据并分别推送到redis和应用内存,注意缓存击穿、缓存穿透和缓存雪崩的情况
        if (isEmpty(redisChangeFlag)) {
            this.deleteByKey(definition);
            this.getAndPutAllData(definition);
            return;
        }
        //3.如果不相等,则从redis拉取数据并保存到应用内存
        if (!redisChangeFlag.equals(localChangeFlag)) {
            Map<String, String> key2ValueMap = redisTemplate.opsForHash()
                    .entries(definition).entrySet().stream()
                    .collect(Collectors.toMap(entry -> objectToString(entry.getKey()),
                            entry -> objectToString(entry.getValue())));
            DEFINITION_TO_KEY_VALUE_MAP.put(definition, key2ValueMap);
        }
    }

    /**
     * 获取redis的更新标志
     * @param definition 语言定义
     * @return redis的更新标志
     */
    private String getRedisChangeFlag(String definition) {
        Object o = redisTemplate.opsForHash().get(definition, CHANGE_FLAG);
        return StringUtil.objectToString(o);
    }

    /**
     * 获取本地更新标志
     * @param definition 语言定义
     * @return 本地的更新标志
     */
    private String getLocalChangeFlag(String definition) {
        Map<String, String> key2ValueMap = DEFINITION_TO_KEY_VALUE_MAP.get(definition);
        return isNull(key2ValueMap) ? null : key2ValueMap.get(CHANGE_FLAG);
    }

    /**
     * 通过语言定义删除数据
     * @param definition 语言定义
     */
    private void deleteByKey(String definition) {
        redisTemplate.delete(definition);
    }

    /**
     * 获取与推送翻译数据
     * @param definition 语言定义
     * @return 翻译数据
     */
    private Map<String, String> getAndPutAllData(String definition) {
        //1.从数据库查询翻译数据
        Map<String, String> databaseKeyValueMap = this.getLanguageDataFromDatabase(definition);
        Map<String, String> key2ValueMap = isEmpty(databaseKeyValueMap) ? new ConcurrentHashMap<>()
                : new ConcurrentHashMap<>(databaseKeyValueMap);
        //2.设置更新标志
        key2ValueMap.put(CHANGE_FLAG, UUIDUtil.randomUUID());
        //3.将数据推送到redis
        byte[] byteKey = definition.getBytes(StandardCharsets.UTF_8);
        Map<byte[], byte[]> byteMap = key2ValueMap.entrySet().stream()
                .collect(Collectors.toMap(
                        entry -> entry.getKey().getBytes(StandardCharsets.UTF_8),
                        entry -> entry.getValue().getBytes(StandardCharsets.UTF_8)));
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            connection.hMSet(byteKey, byteMap);
            return null;
        });
        Long expire = redisTemplate.getExpire(definition);
        if (expire == null || expire < 300L)
            redisTemplate.expire(definition, ONE_DAY, TimeUnit.SECONDS);
        //4.将数据放到应用内存
        DEFINITION_TO_KEY_VALUE_MAP.put(definition, key2ValueMap);
        return key2ValueMap;
    }

    /**
     * 从数据库获取数据
     * @param definition 语言定义
     * @return 数据
     */
    private Map<String, String> getLanguageDataFromDatabase(String definition) {
        //TODO 补充从数据库查询的代码
        Map<String, String> map = new ConcurrentHashMap<>();
        if (Locale.SIMPLIFIED_CHINESE.toLanguageTag().equals(definition)) {
            map.put("test.key1", "这是第一个值");
            map.put("test.key2", "这是第二个值");
        } else {
            map.put("test.key1", "the first value");
            map.put("test.key2", "the second value");
        }
        return map;
    }
}
2-3.将替换逻辑嵌入spring的filter或者序列化

笔者在这里只演示简单的key->value替换,至于递归替换、正则替换可以自行考虑加上。

  • 2-3-1.当一个请求进来的时候,首先需要做一些前置处理。
    • 2-3-1-1.根据请求的语言设置当前线程的语言环境。
    • 2-3-1-2.更新一次当前应用内存的语言缓存数据。
  • 2-3-2.当返回响应的时候,通过序列化对响应数据进行替换。

代码如下所示。

/**
 * <pre>
 *  @description: 语言对象
 *  @author: rookie0peng
 *  @date: 2021/1/18 17:17
 *  </pre>
 */
public class Language {

    /**
     * 语言编码, 默认中文简体
     */
    private String localeTag;

    public Language() {

    }

    public Language(String localeTag) {
        this.localeTag = localeTag;
    }

    public String getLocaleTag() {
        return localeTag;
    }

    public void setLocaleTag(String localeTag) {
        this.localeTag = localeTag;
    }

    @Override
    public String toString() {
        return "Language{" +
                "localeTag='" + localeTag + '\'' +
                '}';
    }
}
/**
 * <pre>
 *  @description: 基于线程上下文的语言工具类
 *  @author: rookie0peng
 *  @date: 2021/1/18 17:16
 *  </pre>
 */
public class LanguageContextUtil {

    private static final ThreadLocal<Language> CONTEXT = new ThreadLocal<>();

    /**
     * 设置语言信息
     * @param language 语言信息
     */
    public static void set(Language language) {
        CONTEXT.set(language);
    }

    /**
     * 获取语言信息
     * @return 语言信息
     */
    public static Language get() {
        return CONTEXT.get();
    }

    /**
     * 移除语言信息
     */
    public static void remove() {
        CONTEXT.remove();
    }
}
/**
 * <pre>
 *  @description: 语言过滤器
 *  @author: rookie0peng
 *  @date: 2021/1/18 17:16
 *  </pre>
 */
@Component
public class LanguageFilter implements Filter {

    @Autowired
    private LanguageDataComponent languageDataComponent;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        try {
            //1.设置当前线程的语言环境
            String localeTag;
            Locale locale = servletRequest.getLocale();
            LanguageContextUtil.set(isNull(locale) || isNull(localeTag = locale.toLanguageTag())
                    ? new Language(Locale.SIMPLIFIED_CHINESE.toLanguageTag()) : new Language(localeTag));
            //2.更新语言数据
            languageDataComponent.updateKeyValueMap(LanguageContextUtil.get().getLocaleTag());
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            LanguageContextUtil.remove();
        }
    }
}
/**
 * <pre>
 *  @description: i18n序列化
 *  @author: rookie0peng
 *  @date: 2021/7/4 21:28
 *  </pre>
 */
public class I18nJsonSerializer<T> extends JsonSerializer<T> {

    @Override
    public void serialize(T t, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        //1.获取语言组件
        LanguageDataComponent languageDataComponent = SpringUtil.getBean(LanguageDataComponent.class);
        //2.通过 语言标志 和 键 获取对应的 值
        Optional<String> valueOptional = languageDataComponent.getValue(LanguageContextUtil.get().getLocaleTag(),
                StringUtil.objectToString(t));
        //3.写入流
        if (valueOptional.isPresent())
            jsonGenerator.writeString(valueOptional.get());
        else
            jsonGenerator.writeString(StringUtil.objectToString(t));
    }
}
public class TestTranslateVO implements Serializable {

    private static final long serialVersionUID = -273426001439788094L;

    private String code;

    /**
     * 指定i18n序列化类
     */
    @JsonSerialize(using = I18nJsonSerializer.class)
    private String name;

    public TestTranslateVO() {

    }

    public TestTranslateVO(String code, String name) {
        this.code = code;
        this.name = name;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "TestTranslateVO{" +
                "code='" + code + '\'' +
                ", name='" + name + '\'' +
                '}';
    }
}
/**
 * <pre>
 *  @description: 通过接口测试i18n
 *  @author: rookie0peng
 *  @date: 2021/1/20 13:07
 *  </pre>
 */
@Api(tags = "测试接口-作者:rookie0peng")
@RestController
@RequestMapping("test")
public class TestController {

    @Autowired
    private MessageSource messageSource;

    @ApiOperation(value = "随便打印数据")
    @RequestMapping(path = "console", method = RequestMethod.POST)
    public Map<String, String> console() {
        Map<String, String> map = new LinkedHashMap<>();
        //1.测试无参替换
        map.put("test.name", messageSource.getMessage("test.name", null, LocaleContextHolder.getLocale()));
        //2.测试字符串参数替换
        Object[] stringParam = new Object[] {"1"};
        map.put("test.string", messageSource.getMessage("test.string", stringParam, LocaleContextHolder.getLocale()));
        //3.测试日期参数替换
        Object[] dateParam = new Object[] {new Date()};
        map.put("test.date", messageSource.getMessage("test.date", dateParam, LocaleContextHolder.getLocale()));
        //3.测试时间参数替换
        Object[] timeParam = new Object[] {new Date()};
        map.put("test.time", messageSource.getMessage("test.time", timeParam, LocaleContextHolder.getLocale()));
        return map;
    }

    @ApiOperation(value = "测试自定义i18n")
    @RequestMapping(path = "test/custom-i18n", method = RequestMethod.POST)
    public List<TestTranslateVO> testCustomI18n() {
        //test.key1和test.key2在LanguageDataComponentImpl.getLanguageDataFromDatabase方法中定义了
        //test.key3用来测试不翻译的场景
        return ListUtil.newArrayList(
                new TestTranslateVO("test.key1", "test.key1"),
                new TestTranslateVO("test.key2", "test.key2"),
                new TestTranslateVO("test.key3", "test.key3"));
    }
}
2-4.测试自定义i18n

启动项目后,查看调用test/custom-i18n接口返回的数据,调用三次,分别设置header中的语言:默认、中文、英文。
笔者用的是idea的HTTP Client,以下是请求参数:

### 默认——测试自定义i18n
POST {{baseUrl}}/test/test/custom-i18n
Content-Type: application/json

{

}

### 中文——测试自定义i18n
POST {{baseUrl}}/test/test/custom-i18n
Content-Type: application/json
Accept-Language: zh-CN,zh;q=0.9

{

}

### 英文——测试自定义i18n
POST {{baseUrl}}/test/test/custom-i18n
Content-Type: application/json
Accept-Language: en;q=0.9

{

}

以下是返回参数。

[
  {
    "code": "test.key1",
    "name": "这是第一个值"
  },
  {
    "code": "test.key2",
    "name": "这是第二个值"
  },
  {
    "code": "test.key3",
    "name": "test.key3"
  }
]

[
  {
    "code": "test.key1",
    "name": "这是第一个值"
  },
  {
    "code": "test.key2",
    "name": "这是第二个值"
  },
  {
    "code": "test.key3",
    "name": "test.key3"
  }
]

[
  {
    "code": "test.key1",
    "name": "the first value"
  },
  {
    "code": "test.key2",
    "name": "the second value"
  },
  {
    "code": "test.key3",
    "name": "test.key3"
  }
]
2-5.分析执行结果
  • 2-5-1.测试默认测试中文的响应数据是一样的,可以确定系统默认使用的中文环境。
  • 2-5-2.对于使用了@JsonSerialize(using = I18nJsonSerializer.class)注解的属性,会根据key自动替换成对应的值。
  • 2-5-3.根据key没找到值时,还是会使用原本的key。
2-6.结论

这里只是简单的演示了自定义i18n的功能,但是已然支持用户新增语言、自定义翻译后的值、多机部署等。如果想要支持正则替换、递归翻译也可以自行扩展。

3.总结

这里演示了两种i18n的实现方案,具体想用哪种就见仁见智了。图方便,开箱即用,那就选spring i18n;图灵活,可扩展性强,那就选自定义i18n。自然,肯定还有很多我没想到的方案,如果可以的话,欢迎大家在评论区指点。

参考

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

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

推荐阅读更多精彩内容