【解析SQL模板-2】calcite解析SQL

背景

获取到完整SQL后,需要解析SQL判断SQL里面的tables、是否存在select *

实现

引入依赖:

<dependency>
    <groupId>org.apache.calcite</groupId>
    <artifactId>calcite-core</artifactId>
    <version>1.30.0</version>
</dependency>

SQL经过calcite解析之后,得到一棵抽象语法树,也就是我们说的AST,这棵语法树是由不同的节点组成,节点称之为SqlNode,根据不同类型的dml、ddl得到不同的类型的SqlNode,例如select语句转换为SqlSelect,delete语句转换为SqlDelete,join语句转换为SqlJoin。

一个select语句包含from部分、where部分、select部分等,每一部分都表示一个SqlNode。SqlKind是一个枚举类型,包含了各种SqlNode类型:SqlSelect、SqlIdentifier、SqlLiteral等。SqlIdentifier表示标识符,例如表名称、字段名;SqlLiteral表示字面常量,一些具体的数字、字符。


SqlNode.png

SQL构建成SqlNode节点:

public enum ApiDatasourceType {
    CLICKHOUSE,
    MYSQL
    ;
}
@Slf4j
public class SqlUtils {
    public static SqlNode parseSql(ApiDatasourceType type, String sql) {
        SqlParser parser;
        switch (type) {
            case MYSQL:
                parser = SqlParser.create(sql, SqlParserConfig.mysqlConfig());
                break;
            case CLICKHOUSE:
            default:
                parser = SqlParser.create(sql, SqlParserConfig.defaultConfig());
        }
        try {
            return parser.parseQuery();
        } catch (SqlParseException e) {
            throw e;
        }
    }
}

用法一:获取表名、判断是否存在select *

public class SqlUtils {

    public static boolean hasSelectAll(SqlNode node) {
        if (Objects.isNull(node)) {
            throw new IllegalStateException("SqlNode is null");
        }
        if (node.getKind() == SqlKind.IDENTIFIER) {
            return Objects.equals(node.toString(), "*");
        } else if (node.getKind() == SqlKind.AS) {
            return hasSelectAll(((SqlBasicCall) node).getOperandList().get(0));
        } else if (node.getKind() == SqlKind.SELECT) {
            SqlSelect select = (SqlSelect) node;
            return select.getSelectList().stream()
                    .anyMatch(SqlUtils::hasSelectAll)
                    || hasSelectAll(select.getFrom())
                    || (Objects.nonNull(select.getWhere()) && hasSelectAllInWhere(select.getWhere()));
        } else if (node.getKind() == SqlKind.ORDER_BY) {
            SqlOrderBy orderByNode = (SqlOrderBy) node;
            SqlSelect select = (SqlSelect) orderByNode.getOperandList().get(0);
            return select.getSelectList().stream()
                    .anyMatch(SqlUtils::hasSelectAll) || hasSelectAll(select.getFrom());
        } else if (node.getKind() == SqlKind.JOIN) {
            SqlJoin joinNode = (SqlJoin) node;
            return hasSelectAll(joinNode.getLeft()) || hasSelectAll(joinNode.getRight());
        } else if (node.getKind() == SqlKind.UNION) {
            SqlBasicCall unionNode = (SqlBasicCall) node;
            return unionNode.getOperandList()
                    .stream()
                    .anyMatch(SqlUtils::hasSelectAll);
        }
        return false;
    }

    public static Set<String> findTables(SqlNode node) {
        Set<String> tables = new HashSet<>();
        findTables(tables, node);
        return tables;
    }

    private static void findTables(Set<String> foundTables, SqlNode node) {
        if (Objects.isNull(node)) {
            throw new IllegalStateException("SqlNode is null");
        }
        //判断是否是标识符
        if (node.getKind() == SqlKind.IDENTIFIER) {
            foundTables.add(node.toString());
        } else if (node.getKind() == SqlKind.AS) {
            findTables(foundTables, ((SqlBasicCall) node).getOperandList().get(0));
        } else if (node.getKind() == SqlKind.SELECT) {
            SqlSelect select = (SqlSelect) node;
            findTables(foundTables, select.getFrom());
            if (Objects.nonNull(select.getWhere())) {
                findTablesInWhere(foundTables, select.getWhere());
            }
        } else if (node.getKind() == SqlKind.ORDER_BY) {
            SqlOrderBy orderByNode = (SqlOrderBy) node;
            SqlSelect selectNode = (SqlSelect) orderByNode.getOperandList().get(0);
            findTables(foundTables, selectNode.getFrom());
        } else if (node.getKind() == SqlKind.JOIN) {
            SqlJoin joinNode = (SqlJoin) node;
            findTables(foundTables, joinNode.getLeft());
            findTables(foundTables, joinNode.getRight());
        } else if (node.getKind() == SqlKind.UNION) {
            SqlBasicCall unionNode = (SqlBasicCall) node;
            unionNode.getOperandList().forEach(n -> findTables(foundTables, n));
        } else {
            throw new IllegalStateException(String.format("Un support node type %s", node.getKind().toString()));
        }
    }

    private static boolean hasSelectAllInWhere(SqlNode node) {
        if (node.getKind() == SqlKind.SELECT || node.getKind() == SqlKind.ORDER_BY) {
            return hasSelectAll(node);
        }
        if (node instanceof SqlBasicCall) {
            SqlBasicCall call = (SqlBasicCall) node;
            return call.getOperandList()
                    .stream().anyMatch(SqlUtils::hasSelectAllInWhere);
        }
        return false;
    }

    private static void findTablesInWhere(Set<String> foundTables, SqlNode node) {
        if (node.getKind() == SqlKind.SELECT || node.getKind() == SqlKind.ORDER_BY) {
            findTables(foundTables, node);
        }
        if (node instanceof SqlBasicCall) {
            SqlBasicCall call = (SqlBasicCall) node;
            call.getOperandList().forEach(n -> findTablesInWhere(foundTables, n));
        }
    }
}

测试方法:

    public static void main(String[] args) {
        String sql = "select count(*) cnt from ( select * from tableA where event_time >= 1713369600 and event_time <= 1713369600 limit 1 ) a";
        SqlNode sqlNode = parseSql(ApiDatasourceType.MYSQL, sql);
        System.out.println(SqlUtils.findTables(sqlNode));
        System.out.println(SqlUtils.hasSelectAll(sqlNode));
    }

用法二:解析获取select count(*) 查询总数的SQL

@Slf4j
public class MybatisGenerator {
    private static final SqlNode COUNT_SQL =
            SqlSelectBuilder.as(SqlSelectBuilder.function("count", SqlSelectBuilder.star()), SqlSelectBuilder.identifier("count"));

    public static void main(String[] args) {
        String dsl =
                "SELECT * FROM tableA as ta LEFT JOIN tableB as tb ON ta.column_name=tb.column_name where ta.event_time >= 1713369600 and ta.event_time <= 1713369600 order by ta.id desc limit 100 ";
        System.out.println(dsl);
        //获取到SQL解析器
        SqlNode sqlNode = SqlUtils.parseSql(ApiDatasourceType.CLICKHOUSE, dsl);
        SqlNode node = sqlNode;
        SqlSelect totalSqlNode;
        if (node.getKind() == SqlKind.ORDER_BY) {
            totalSqlNode = SqlSelectBuilder.builder().select(COUNT_SQL).from(((SqlOrderBy) node).getOperandList().get(0)).buildSelect();
        } else if (node.getKind() == SqlKind.SELECT) {
            totalSqlNode = SqlSelectBuilder.builder().select(COUNT_SQL)
                    .from(SqlSelectBuilder.builder((SqlSelect) node)
                            .clearFetch()
                            .clearOffset()
                            .clearOrderBy()
                            .buildSelect())
                    .buildSelect();
        } else {
            throw new IllegalStateException("错误的SQL类型");
        }
    }
}

测试运行:

SELECT COUNT(*) AS `count` FROM (SELECT * FROM `tableA` AS `ta` LEFT JOIN `tableB` AS `tb` ON `ta`.`column_name` = `tb`.`column_name` WHERE `ta`.`event_time` >= 1713369600 AND `ta`.`event_time` <= 1713369600)

场景三:替换备用表名

@Slf4j
public class MybatisGenerator {

    public static Map<String, String> BAK_TABLES = new HashMap<>();

    //双表备份--替换表名
    static {
        BAK_TABLES.put("tableA", "tableA_bak");
        BAK_TABLES.put("tableB", "tableB_bak");
    }


    private static SqlNode rewriteTableName(SqlNode node, ProcessContext context) {
        if (Objects.isNull(node)) {
            return null;
        }
        return analysisSqlNode(node, context);
    }

    private static SqlNode analysisSqlNode(SqlNode node, ProcessContext context) {
        SqlNode resNode = node;
        if (SqlKind.SELECT == node.getKind()) {
            SqlSelect select = (SqlSelect) node;
            SqlNode resFrom;
            if (SqlKind.JOIN == select.getFrom().getKind()) {
                SqlJoin join = (SqlJoin) select.getFrom();
                SqlNode sqlNodeLeft = analysisSqlNode(join.getLeft(), context);
                SqlNode sqlNodeRight = analysisSqlNode(join.getRight(), context);
                join.setLeft(sqlNodeLeft);
                join.setRight(sqlNodeRight);
                resFrom = join;
            } else {
                resFrom = analysisSqlNode(select.getFrom(), context);
            }
            resNode = SqlSelectBuilder.builder(select)
                    .from(resFrom)
                    .buildSelect();

        } else if (SqlKind.ORDER_BY == node.getKind()) {
            SqlOrderBy orderBy = (SqlOrderBy) node;
            if (SqlKind.SELECT == orderBy.query.getKind()) {
                SqlSelect sqlSelect = SqlSelectBuilder.builder((SqlSelect) orderBy.query)
                        .from(analysisSqlNode(((SqlSelect) orderBy.query).getFrom(), context))
                        .buildSelect();
                resNode = SqlOrderByBuilder.builder(orderBy).query(sqlSelect).build();
            }
        } else if (SqlKind.JOIN == node.getKind()) {
            SqlJoin join = (SqlJoin) node;
            SqlNode sqlNodeLeft = analysisSqlNode(join.getLeft(), context);
            SqlNode sqlNodeRight = analysisSqlNode(join.getRight(), context);
            join.setLeft(sqlNodeLeft);
            join.setRight(sqlNodeRight);
            resNode = join;
        } else if (SqlKind.AS == node.getKind()) {
            SqlBasicCall as = (SqlBasicCall) node;
            SqlNode sqlNode = as.getOperandList().get(0);
            if (SqlKind.IDENTIFIER != sqlNode.getKind()) {
                SqlNode sqlNode1 = analysisSqlNode(sqlNode, context);
                resNode = new SqlBasicCall(as.getOperator(), Lists.newArrayList(sqlNode1, as.getOperandList().get(1)),
                        SqlParserPos.ZERO);
            } else {
                resNode = replaceTableNameConvert(node, context);
            }
        } else if (SqlKind.IDENTIFIER == node.getKind()) {
            resNode = replaceTableNameConvert(node, context);
        }
        return resNode;
    }

    private static SqlNode replaceTableNameConvert(SqlNode sourceNode, ProcessContext context) {
        if (Objects.isNull(sourceNode)) {
            return sourceNode;
        }
        SqlNode resNode = sourceNode;
        if (SqlKind.IDENTIFIER == sourceNode.getKind()) {
            //这里有两种情况:database为空或者有值
            String database = "";
            String oldTable = "";
            PhysicalTable physicalTable;
            String oldTableName = sourceNode.toString();
            String[] split = oldTableName.split("\\.");
            if (split.length == 2) {
                database = split[0] + ".";
                oldTable = split[1];
                physicalTable = context.getPhysicalTableMap().get(oldTable);
            } else {
                oldTable = split[0];
                physicalTable = context.getPhysicalTableMap().get(oldTable);
            }
            if (physicalTable != null && StringUtils.isNotBlank(physicalTable.getCurrentShardTableName())) {
                resNode = SqlSelectBuilder.as(SqlSelectBuilder.identifier(database + physicalTable.getCurrentShardTableName()),
                        SqlSelectBuilder.identifier(oldTableName));
            } else if (BAK_TABLES.containsKey(oldTable)) {
                resNode = SqlSelectBuilder.as(SqlSelectBuilder.identifier(database + BAK_TABLES.get(oldTable)),
                        SqlSelectBuilder.identifier(oldTableName));
            }

        } else if (SqlKind.AS == sourceNode.getKind()) {
            SqlBasicCall as = (SqlBasicCall) sourceNode;
            //这里有两种情况:database为空或者有值
            String database = "";
            String oldTable = "";
            PhysicalTable physicalTable;
            String oldAsTableName = as.getOperandList().get(1).toString();
            String[] split = as.getOperandList().get(0).toString().split("\\.");
            if (split.length == 2) {
                database = split[0] + ".";
                oldTable = split[1];
                physicalTable = context.getPhysicalTableMap().get(oldTable);
            } else {
                oldTable = split[0];
                physicalTable = context.getPhysicalTableMap().get(oldTable);
            }
            if (physicalTable != null && StringUtils.isNotBlank(physicalTable.getCurrentShardTableName())) {
                resNode = SqlSelectBuilder.as(SqlSelectBuilder.identifier(database
                                + physicalTable.getCurrentShardTableName()),
                        SqlSelectBuilder.identifier(oldAsTableName));
            } else if (BAK_TABLES.containsKey(oldTable)) {
                resNode = SqlSelectBuilder.as(SqlSelectBuilder.identifier(database
                                + BAK_TABLES.get(oldTable)),
                        SqlSelectBuilder.identifier(oldAsTableName));
            }
        }
        return resNode;
    }


    @Data
    public static class ProcessContext {
        private Map<String, PhysicalTable> physicalTableMap = new HashMap<>();
    }

    public static void main(String[] args) {
        String dsl =
                "SELECT * FROM tableA as ta LEFT JOIN tableB as tb ON ta.column_name=tb.column_name where ta.event_time >= 1713369600 and ta.event_time <= 1713369600 order by ta.id desc limit 100 ";
        System.out.println(dsl);
        //获取到SQL解析器
        SqlNode sqlNode = SqlUtils.parseSql(ApiDatasourceType.CLICKHOUSE, dsl);
        ProcessContext processContext = new ProcessContext();
        SqlNode sqlNode1 = rewriteTableName(sqlNode, processContext);
        String newSql1 = sqlNode1.toSqlString(ClickhouseSqlDialect.DEFAULT)
                .getSql()
                .replace('\n', ' ');
        System.out.println(newSql1);
    }  
}

场景四:解析SQL

解析结果:

@Getter
@Setter
@ToString
public class AnalysisResult {

    /**
     * fetch + limit 最大值
     */
    private long maxSum;

    /**
     * offset 最大
     */
    private long maxOffset;

    /**
     * fetch 最大
     */
    private long maxFetch;

    private boolean hasGroupBy;

    /**
     * 查询语句中是否有count(*)
     */
    private boolean hasCount;

    private Set<String> queryTables = new HashSet<>();

    private SqlNode sqlNode;

    // 真实访问的物理表信息
    private List<PhysicalTable> physicalTableLists = new ArrayList<>();

    public void updateLimit(long offset, long fetch) {
        long sum = fetch + offset;
        maxSum = Math.max(sum, maxSum);
        maxFetch = Math.max(fetch, maxFetch);
        maxOffset = Math.max(offset, maxOffset);
    }

    public void updateLimit(Pair<Long, Long> limit) {
        long offset = limit.getLeft();
        long fetch = limit.getRight();
        updateLimit(offset, fetch);
    }

    public void updateGroupby(boolean hasGroupBy) {
        this.hasGroupBy = hasGroupBy;
    }

    public void setSqlNode(SqlNode sqlNode) {
        this.sqlNode = sqlNode;
    }

    public void addTable(String table) {
        queryTables.add(table);
    }

    public Set<String> queryTables() {
        return new HashSet<>(queryTables);
    }

    public PhysicalTable queryTableByName(String tableName) {
        if (StringUtils.isEmpty(tableName) || CollectionUtils.isEmpty(physicalTableLists)) {
            return null;
        }
        return physicalTableLists.stream().collect(Collectors.toMap(PhysicalTable::getName, Function.identity())).get(tableName);
    }
}

处理流程:

@Slf4j
public class SqlAnalysis {

    private static String JDBC_URL_PATTERN =
            "jdbc:(?<type>[a-z]+)://(?<host>[a-zA-Z0-9-//.]+):(?<port>[0-9]+)/(?<database>[a-zA-Z0-9_]+)?";
    public static boolean hasLimit(SqlNode node) {
        return node.getKind() == SqlKind.ORDER_BY && getFetch((SqlOrderBy) node) != null;
    }

    public static Pair<Long, Long> getLimit(SqlOrderBy node) {
        return Optional.ofNullable(node)
                .map(n -> Pair.of(getOffsetValue(n), getFetchValue(n)))
                .orElseGet(() -> Pair.of(0L, 0L));
    }

    public static void process(AnalysisResult result, SqlNode node) {
        if (Objects.isNull(node)) {
            return;
        }
        if (node.getKind() == SqlKind.AS) {
            SqlNode sqlNode = ((SqlBasicCall) node).getOperandList().get(0);
            process(result, sqlNode);
            //获取到as前的字符
            if (sqlNode.getKind() == SqlKind.IDENTIFIER) {
                result.addTable(sqlNode.toString());
            }
        } else if (node.getKind() == SqlKind.SELECT) {
            SqlSelect select = (SqlSelect) node;
            select.getSelectList().forEach(n -> process(result, n));
            if (CollectionUtils.isNotEmpty(select.getGroup())) {
                result.updateGroupby(true);
            }
            processFromNode(result, select.getFrom());
            process(result, select.getWhere());
        } else if (node.getKind() == SqlKind.ORDER_BY) {
            SqlOrderBy orderByNode = (SqlOrderBy) node;
            result.updateLimit(SqlAnalysis.getLimit(orderByNode));
            SqlSelect selectNode = (SqlSelect) orderByNode.getOperandList().get(0);
            process(result, selectNode);
        } else if (node.getKind() == SqlKind.JOIN) {
            SqlJoin joinNode = (SqlJoin) node;
            process(result, joinNode.getLeft());
            process(result, joinNode.getRight());
        } else if (node.getKind() == SqlKind.UNION) {
            SqlBasicCall unionNode = (SqlBasicCall) node;
            unionNode.getOperandList().forEach(n -> process(result, n));
        } else if (node.getKind() == SqlKind.OTHER_FUNCTION) {
            if (((SqlBasicCall) node).getOperator().isName("count", true)) {
                result.setHasCount(true);
            }
        }
    }

    public static void processFromNode(AnalysisResult result, SqlNode node) {
        if (Objects.isNull(node)) {
            return;
        }
        SqlNode target = node;
        if (node.getKind() == SqlKind.AS) {
            target = ((SqlBasicCall) node).getOperandList().get(0);
        }
        if (target.getKind() == SqlKind.IDENTIFIER) {
            PhysicalTable physicalTable = result.queryTableByName(target.toString());
            if (physicalTable != null && physicalTable.getShardNum() > 0) {
                target = SqlSelectBuilder.identifier(target.toString());
            }
            result.addTable(target.toString());
        } else {
            process(result, node);
        }
    }

    private static SqlNode getFetch(SqlOrderBy orderBy) {
        return orderBy.getOperandList().get(3);
    }

    private static SqlNode getOffset(SqlOrderBy orderBy) {
        return orderBy.getOperandList().get(2);
    }

    private static SqlNodeList getOrderList(SqlOrderBy orderBy) {
        return (SqlNodeList) orderBy.getOperandList().get(1);
    }

    private static long getFetchValue(SqlOrderBy orderBy) {
        return Optional.ofNullable(orderBy.getOperandList().get(3))
                .filter(SqlNumericLiteral.class::isInstance)
                .map(SqlNumericLiteral.class::cast)
                .map(SqlLiteral::getValue)
                .filter(BigDecimal.class::isInstance)
                .map(BigDecimal.class::cast)
                .map(BigDecimal::longValue)
                .orElse(0L);

    }

    private static long getOffsetValue(SqlOrderBy orderBy) {
        return Optional.ofNullable(orderBy.getOperandList().get(2))
                .filter(SqlNumericLiteral.class::isInstance)
                .map(SqlNumericLiteral.class::cast)
                .map(SqlLiteral::getValue)
                .filter(BigDecimal.class::isInstance)
                .map(BigDecimal.class::cast)
                .map(BigDecimal::longValue)
                .orElse(0L);
    }

    public static IllegalStateException parseSqlParserException(SqlParseException ex, String sql) {
        String errorMessage = Optional.ofNullable(ex.getMessage())
                .map(m -> m.split("\n"))
                .map(m -> m[0])
                .orElse("");

        return new IllegalStateException(String.format("解析SQL「%s」失败,失败原因是「%s」", sql, errorMessage), ex);
    }
}

测试方法:

    public static void main(String[] args) {
        String dsl =
                "SELECT * FROM tableA as ta LEFT JOIN tableB as tb ON ta.column_name=tb.column_name where ta.event_time >= 1713369600 and ta.event_time <= 1713369600 order by ta.id desc limit 10,100 ";
        System.out.println(dsl);
        //获取到SQL解析器
        SqlNode sqlNode = SqlUtils.parseSql(ApiDatasourceType.CLICKHOUSE, dsl);
        AnalysisResult result = new AnalysisResult();
        SqlAnalysis.process(result, sqlNode);
        System.out.println(result);
    }

结论:

AnalysisResult(maxSum=110, maxOffset=10, maxFetch=100, hasGroupBy=false, hasCount=false, queryTables=[tableB, tableA], sqlNode=null, physicalTableLists=[])

参考文档

【Calcite源码学习】SqlNode方言转换

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

推荐阅读更多精彩内容