Go GraphQL 教程

Go GraphQL 教程

大家好,我叫谢伟,是一名程序员。

今天的主题:Go GraphQL 教程。

RESTful API 设计

一般的 Web 开发都是使用 RESTful 风格进行API的开发,这种 RESTful 风格的 API 开发的一般流程是:

  • 需求分析
  • 模型设计
  • 编码实现
    • 路由设计:
    • 参数操作:校验、请求
    • 响应:JSON 格式、状态码

一种资源一般都可以抽象出 4 类路由,比如投票接口:

# 获取所有投票信息
GET /v1/api/votes

# 获取单个投票信息
GET /v1/api/vote/{vote_id}

# 创建投票
POST /v1/api/vote

# 更新投票
PATCH  /v1/api/vote/{vote_id}

# 删除投票
DELETE /v1/api/vote/{vote_id}

分别对应资源的获取、创建、更新、删除。

对于后端开发人员而言,重要的是在满足需求的前提下设计这类 API。

设计这类 API 一般需要处理这些具体的问题:

  • 根据需求进行模型设计:即 model 层,模型设计核心对应数据库表,所以又需要根据需求,设计字段、字段类型、表的多对多等关系
  • 抽象出资源实体,进行资源的增删改查操作
  • 返回JSON 格式的响应、状态码、或者错误信息

前端或者客户端,根据具体的需求,调用接口,对接口返回的字段进行处理。尽管有时候需求并不需要所有字段,又或者有时候需求需要
调用多个接口,组装成一个大的格式,以完成需求。

后端抽象出多少实体,对应就会设计各种资源实体的接口。后续需求变更,为了兼容,需要维护越来越多的接口。

看到没,这类的接口设计:

  • 需要维护多类接口,需求不断变更,维护的接口越来越多
  • 字段的获取,前端或者客户端不能决定,而是一股脑的返回,再由相应开发人员处理
  • 需要考虑接口版本
    ...

GraphQL API

GraphQL 是一种专门用于API 的查询语言,由大厂 Facebook 推出,但是至今 GraphQL 并没有引起广泛的使用,
绝大多少还是采用 RESTful API 风格的形式开发。

GraphQL 尝试解决这些问题:

  • 查询语法和查询结果高度相似
  • 根据需求获取字段
  • 一个路由能获取多个请求的结果
  • 无需接口版本管理

1

既然是一种专门用于 API 的查询语言,其必定有一些规范或者语法约束。具体 GraphQL 包含哪些知识呢?

  • Schema 是类型语言的合集,定义了具体的操作(比如:请求、更改),和对象信息(比如:响应的字段)

schema.graphql

type Query {
    ping(data: String): Pong
}

type Mutation {
    createVote(name: String!): Vote
}

type Pong{
    data: String
    code: Int
}

type Vote {
    id: ID!
    name: String!
}

具体定义了请求合集:Query, 更改或者创建合集:Mutation,定义了两个对象类型:Pong, Vote , 对象内包含字段和类型。

这个schema 文件,是后端开发人员的开发文档,也是前端或者客户端人员的 API 文档。

假设,后端开发人员依据 schema 文件,已经开发完毕,那么如何调用 API 呢?

推荐使用:PostMan

# ping 请求动作
query {
    ping{
        data
        code
    }
}
# mutation 更改动作
mutation {
    createVote(name:"have a lunch") {
        id
        name 
    }
}

能发现一些规律么?

  • schema 文件几乎决定了请求的具体形式,请求什么格式,响应什么格式
  • API 请求动作包括:操作类型(query, mutation, subscription)、操作名称、请求名称、请求字段
query HeartBeat {
    ping{
        data
        code
    }
}
  • 操作类型: query
  • 操作名称: HeartBeat (操作名称一般省略)
  • 请求名称: ping
  • 响应字段:Pong 对象的字段 data、code

GraphQL 是一种专门用于 API 的查询语言,有语法约束。

具体包括:

  • 别名:字段或者对象重命名、主要为解决冲突问题
  • 片段:简单来说,就是提取公共字段,方便复用
  • 变量:请求参数以变量的形式
  • 指令:根据条件动态显示字段:@include 是否包含该字段、@skip 是否不包含该字段、@deprecate 是否废弃该字段
  • 内联片段:接口类型或者联合类型中获取下层字段
  • 元字段
  • 类型定义、对象定义
  • 内置的类型:ID、Int、Float、String、Boolean, 其他类型使用基本类型构造对象类型即可
  • 枚举:可选值的集合
  • 修饰符: ! 表示非空
  • 接口:interface
  • 联合类型: | 通过对象类型组合而成
  • 输入类型: 为解决传递复杂参数的问题

讲了这么些,其实最好的方式还是亲自调用下接口,参照着官方文档,按个调用尝试下,熟悉这套语法规范。

最佳的当然是:Github 的 GraphQL API4 (https://developer.github.com/v4/)

  • 熟络 GraphQL 语法规范
  • 学习 GraphQL 设计规范

登入自己的账号:访问:https://developer.github.com/v4/explorer/

仅举几个示例:

0. viewer: User!

  • 请求名称:viewer
  • 响应对象:User 非空,即一定会返回一个 User 对象,User 对象由一系列字段、对象组成

1. 基本请求动作

{
  viewer {
    __typename
    ... on User {
      name
    }
  }
}

// 结果

{
  "data": {
    "viewer": {
      "__typename": "User",
      "name": "XieWei"
    }
  }
}

2. 别名

{
  AliasForViewer:viewer {
    __typename
    ... on User {
      name
    }
  }
}


# 结果
{
  "data": {
    "AliasForViewer": {
      "__typename": "User",
      "name": "XieWei"
    }
  }
}

3.操作名称,变量,指令

query PrintViewer($Repository: String!,$Has: Boolean!){
  AliasForViewer:viewer{
    __typename
    ... on User {
      name
    }
    url
    status{
      createdAt
      emoji
      id
    }
    repository(name: $Repository) {
      name
      createdAt
      description @include(if:$Has)
      
    }
  
  }
}

# 变量
{
  "Repository": "2019-daily",
  "Has": false
}

# 结果

{
  "data": {
    "AliasForViewer": {
      "__typename": "User",
      "name": "XieWei",
      "url": "https://github.com/wuxiaoxiaoshen",
      "status": null,
      "repository": {
        "name": "2019-daily",
        "createdAt": "2019-01-11T15:17:43Z"
      }
    }
  }
}

# 如果变量为:

{
  "Repository": "2019-daily",
  "Has": true
}

# 则结果为

{
  "data": {
    "AliasForViewer": {
      "__typename": "User",
      "name": "XieWei",
      "url": "https://github.com/wuxiaoxiaoshen",
      "status": null,
      "repository": {
        "name": "2019-daily",
        "createdAt": "2019-01-11T15:17:43Z",
        "description": "把2019年的生活过成一本书"
      }
    }
  }
}

对照着文档多尝试。

上文多是讲述使用 GraphQL 进行查询操作时的语法。

2

schema 是所有请求、响应、对象声明的集合,对后端而言,是开发依据,对前端而言,是 API 文档。

如何定义 schema ?

你只需要知道这些内容即可:

  • 内置的标量类型:ID(实质是字符串,唯一标识符)、Boolean、String、Float
  • 修饰符 ! 表示非空
  • 对象类型:type 关键字
  • 枚举类型:enum 关键字
  • 输入类型:input 关键字

举一个具体的示例:小程序: 腾讯投票

首页

image

详情

image

Step1: 定义类型对象的字段

定义的类型对象和响应的字段设计几乎保持一致。

# 类似于 map, 左边表示字段名称,右边表示类型
# [] 表示列表
# ! 修饰符表示非空
type Vote {
    id: ID!
    createdAt: Time
    updatedAt: Time
    deletedAt: Time
    title: String
    description: String
    options: [Options!]!
    deadline: Time
    class: VoteClass
}

type Options {
    name: String
}

# 输入类型: 一般用户更改资源中的输入是列表对象,完成复杂任务

input optionsInput {
    name:String!
}

# 枚举类型:投票区分:单选、多选两个选项值
enum VoteClass {
    SINGLE
    MULTIPLE
}

# 自定义类型,默认类型(ID、String、Boolean、Float)不包含 Time 类型
scalar Time

# 对象类型,用于检查服务是否完好
type Ping {
    data: String
    code: Int

}

Step2: 定义操作类型:Query 用于查询,Mutation 用于创建、更改、删除资源


# Query、Mutation 关键字固定
# 左边表示操作名称,右边表示返回的值的类型
# Query 一般完成查询操作
# Mutation 一般完成资源的创建、更改、删除操作

type Query {
    ping: Ping
    pinWithData(data: String): Ping
    vote(id:ID!): Vote
}

type Mutation {
    createVote(title:String!, options:[optionsInput],deadline:Time, description:String, class:VoteClass!): Vote
    updateVote(title:String!, description:String!): Vote
}

schema 完成了对对象类型的定义和一些操作,是后端开发者的开发文档,是前端开发者的API文档。

3

客户端如何使用:Go : (graphql-go)

主题: 小程序腾讯投票

Step0: 项目结构


├── Makefile
├── README.md
├── cmd
│   ├── root_cmd.go
│   └── sync_cmd.go
├── main.go
├── model
│   └── vote.go
├── pkg
│   ├── database
│   │   └── database.go
│   └── router
│       └── router.go
├── schema.graphql
├── script
│   └── db.sh
└── web
    ├── mutation
    │   └── mutation_type.go
    ├── ping
    │   └── ping_query.go
    ├── query
    │   └── query_type.go
    └── vote
        ├── vote_curd.go
        ├── vote_params.go
        └── vote_type.go
  • cmd: 命令行文件:主要用于同步数据库表结构
  • main.go 函数主入口
  • model 模型定义,每种资源单独一个文件 比如 vote.go
  • pkg 基础设施:数据库连接、路由设计
  • web 核心业务路径,总体上按资源划分文件夹
    • vote
      • vote_curd.go 资源的增删改查
      • vote_params.go 请求参数
      • vote_type.go schema 中资源,即类型对象的定义
    • query
      • query.go
    • mutation
      • mutation.go

和之前的 RESTful API 的设计项目的结构基本保持一致。

Step1: 依据Schema 的定义:完成数据库模型定义

type base struct {
    Id        int64      `xorm:"pk autoincr notnull" json:"id"`
    CreatedAt time.Time  `xorm:"created" json:"created_at"`
    UpdatedAt time.Time  `xorm:"updated" json:"updated_at"`
    DeletedAt *time.Time `xorm:"deleted" json:"deleted_at"`
}

const (
    SINGLE = iota
    MULTIPLE
)

var ClassMap = map[int]string{}

func init() {
    ClassMap = make(map[int]string)
    ClassMap[SINGLE] = "SINGLE"
    ClassMap[MULTIPLE] = "MULTIPLE"
}

type Vote struct {
    base        `xorm:"extends"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    OptionIds   []int64   `json:"option_ids"`
    Deadline    time.Time `json:"deadline"`
    Class       int       `json:"class"`
}

type VoteSerializer struct {
    Id          int64              `json:"id"`
    CreatedAt   time.Time          `json:"created_at"`
    UpdatedAt   time.Time          `json:"updated_at"`
    Title       string             `json:"title"`
    Description string             `json:"description"`
    Options     []OptionSerializer `json:"options"`
    Deadline    time.Time          `json:"deadline"`
    Class       int                `json:"class"`
    ClassString string             `json:"class_string"`
}

func (V Vote) TableName() string {
    return "votes"
}

func (V Vote) Serializer() VoteSerializer {
    var optionSerializer []OptionSerializer
    var options []Option
    database.Engine.In("id", V.OptionIds).Find(&options)
    for _, i := range options {
        optionSerializer = append(optionSerializer, i.Serializer())
    }
    classString := func(value int) string {
        if V.Class == SINGLE {
            return "单选"
        }
        if V.Class == MULTIPLE {
            return "多选"
        }
        return ""
    }
    return VoteSerializer{
        Id:          V.Id,
        CreatedAt:   V.CreatedAt.Truncate(time.Second),
        UpdatedAt:   V.UpdatedAt.Truncate(time.Second),
        Title:       V.Title,
        Description: V.Description,
        Options:     optionSerializer,
        Deadline:    V.Deadline,
        Class:       V.Class,
        ClassString: classString(V.Class),
    }
}

type Option struct {
    base `xorm:"extends"`
    Name string `json:"name"`
}

type OptionSerializer struct {
    Id        int64     `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Name      string    `json:"name"`
}

func (O Option) TableName() string {
    return "options"
}

func (O Option) Serializer() OptionSerializer {
    return OptionSerializer{
        Id:        O.Id,
        CreatedAt: O.CreatedAt.Truncate(time.Second),
        UpdatedAt: O.UpdatedAt.Truncate(time.Second),
        Name:      O.Name,
    }
}

依然保持了个人的模型设计风格:

  • 定义一个结构体,对应数据库表
  • 定义个序列化结构体,对应模型的响应
  • 单选、多选项,实质在数据库中用0,1 表示,响应显示中文:单选、多选

Step2: query.go 文件描述


var Query = graphql.NewObject(graphql.ObjectConfig{
    Name: "Query",
    Fields: graphql.Fields{
        "ping": &graphql.Field{
            Type: ping.Ping,
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                return ping.Default, nil
            },
        },
    },
})

func init() {
    Query.AddFieldConfig("pingWithData", &graphql.Field{
        Type: ping.Ping,
        Args: graphql.FieldConfigArgument{
            "data": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.String),
            },
        },
        Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
            if p.Args["data"] == nil {
                return ping.Default, nil
            }
            return ping.MakeResponseForPing(p.Args["data"].(string)), nil
        },
    })
}

func init() {
    Query.AddFieldConfig("vote", &graphql.Field{
        Type: vote.Vote,
        Args: graphql.FieldConfigArgument{
            "id": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.ID),
            },
        },
        Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
            id := p.Args["id"]
            ID, _ := strconv.Atoi(id.(string))
            return vote.GetOneVote(int64(ID))
        },
    })
}

基本和 schema 文件中 Query 定义一致:

type Query {
    ping: Ping
    pinWithData(data: String): Ping
    vote(id:ID!): Vote
}
  • Fields 表示对象字段
  • Type 表示返回类型
  • Args 表示参数
  • Resolve 表示具体的处理函数

内置类型:(ID, String, Boolean, Float)

- graphql.ID
- graphql.String
- graphql.Boolean
- graphql.Float
...

简单的说:所有的对象、字段都需要有处理函数。

var Query = graphql.NewObject(graphql.ObjectConfig{
    Name: "Query",
    Fields: graphql.Fields{
        "ping": &graphql.Field{
            Type: ping.Ping,
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                return ping.Default, nil
            },
        },
    },
})

func init() {
    Query.AddFieldConfig("pingWithData", &graphql.Field{
        Type: ping.Ping,
        Args: graphql.FieldConfigArgument{
            "data": &graphql.ArgumentConfig{
                Type: graphql.NewNonNull(graphql.String),
            },
        },
        Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
            if p.Args["data"] == nil {
                return ping.Default, nil
            }
            return ping.MakeResponseForPing(p.Args["data"].(string)), nil
        },
    })
}

var Ping = graphql.NewObject(graphql.ObjectConfig{
    Name: "ping",
    Fields: graphql.Fields{
        "data": &graphql.Field{
            Type: graphql.String,
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                if response, ok := p.Source.(ResponseForPing); ok {
                    return response.Data, nil
                }
                return nil, fmt.Errorf("field not found")
            },
        },
        "code": &graphql.Field{
            Type: graphql.String,
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                if response, ok := p.Source.(ResponseForPing); ok {
                    return response.Code, nil
                }
                return nil, fmt.Errorf("field not found")
            },
        },
    },
})

type ResponseForPing struct {
    Data string `json:"data"`
    Code int    `json:"code"`
}

var Default = ResponseForPing{
    Data: "pong",
    Code: http.StatusOK,
}

func MakeResponseForPing(data string) ResponseForPing {
    return ResponseForPing{
        Data: data,
        Code: http.StatusOK,
    }
}

使用 Go Graphql-go 客户端,绝大多数工作都在定义对象、定义字段类型、定义字段的处理函数等。

  • graphql.Object
  • graphql.InputObject
  • graphql.Enum

Step3: mutation.go 文件描述

var Mutation = graphql.NewObject(graphql.ObjectConfig{
    Name: "Mutation",
    Fields: graphql.Fields{
        "createVote": &graphql.Field{
            Type: vote.Vote,
            Args: graphql.FieldConfigArgument{
                "title": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.String),
                },
                "options": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.NewList(vote.OptionInput)),
                },
                "description": &graphql.ArgumentConfig{
                    Type: graphql.String,
                },
                "deadline": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.String),
                },
                "class": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(vote.Class),
                },
            },
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                log.Println(p.Args)
                var params vote.CreateVoteParams
                params.Title = p.Args["title"].(string)
                if p.Args["description"] != nil {
                    params.Description = p.Args["description"].(string)
                }
                params.Deadline = p.Args["deadline"].(string)
                params.Class = p.Args["class"].(int)
                var options []vote.OptionParams
                for _, i := range p.Args["options"].([]interface{}) {
                    var one vote.OptionParams
                    k := i.(map[string]interface{})
                    one.Name = k["name"].(string)
                    options = append(options, one)
                }
                params.Options = options
                log.Println(params)
                result, err := vote.CreateVote(params)
                if err != nil {
                    return nil, err
                }
                return result, nil

            },
        },
        "updateVote": &graphql.Field{
            Type: vote.Vote,
            Args: graphql.FieldConfigArgument{
                "title": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.String),
                },
                "description": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.String),
                },
                "id": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.ID),
                },
            },
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                var params vote.UpdateVoteParams
                id := p.Args["id"]
                ID, _ := strconv.Atoi(id.(string))
                params.Id = int64(ID)
                params.Title = p.Args["title"].(string)
                params.Description = p.Args["description"].(string)
                return vote.UpdateOneVote(params)
            },
        },
    },
})

Step4: 构建 schema 启动服务


func RegisterSchema() *graphql.Schema {
    schema, err := graphql.NewSchema(
        graphql.SchemaConfig{
            Query:    query.Query,
            Mutation: mutation.Mutation,
        })
    if err != nil {
        panic(fmt.Sprintf("schema init fail %s", err.Error()))
    }
    return &schema

}

func Register() *handler.Handler {
    return handler.New(&handler.Config{
        Schema:   RegisterSchema(),
        Pretty:   true,
        GraphiQL: true,
    })
}
func StartWebServer() {
    log.Println("Start Web Server...")
    http.Handle("/graphql", Register())
    log.Fatal(http.ListenAndServe(":7878", nil))
}

Step5: 运行,接口调用

  • 只有一个路由:/graphql
  • 无需版本管理
  • 所有的请求方法都是:POST(query 动作当然也可以使用 GET,遇到请求参数较多时,不够方便)

接口调用示例:(根据查询文档,可以根据调用者的需求,自主选择响应的字段)

mutation {
    createVote(
        title: "去哪玩?",
        description:"本次团建去哪玩?",
        options:[
            {
                name: "杭州西湖"
            },{
                name:"安徽黄山"
            },{
                name:"香港九龙"
            }
            ],
        deadline: "2019-08-01 00:00:00",
        class: SINGLE
        ) {
            id
            title
            deadline
            description
            createdAt
            updatedAt
            options{
                name
            }
            class
            classString
        }
}

# 结果

{
    "data": {
        "vote": {
            "class": "SINGLE",
            "classString": "单选",
            "createdAt": "2019-07-30T19:33:27+08:00",
            "deadline": "2019-08-01T00:00:00+08:00",
            "description": "本次团建去哪玩?",
            "id": "1",
            "options": [
                {
                    "name": "杭州西湖"
                },
                {
                    "name": "安徽黄山"
                },
                {
                    "name": "香港九龙"
                }
            ],
            "title": "去哪玩?",
            "updatedAt": "2019-07-30T19:33:27+08:00"
        }
    }
}


query{
    vote(id:1){
            id
            title
            deadline
            description
            createdAt
            updatedAt
            options{
                name
            }
            class
            classString
    }
}

# 结果

{
    "data": {
        "createVote": {
            "class": "SINGLE",
            "classString": "SINGLE",
            "createdAt": "2019-07-30T19:33:27+08:00",
            "deadline": "2019-08-01T00:00:00+08:00",
            "description": "本次团建去哪玩?",
            "id": "1",
            "options": {
                {
                    "name": "杭州西湖"
                },
                {
                    "name": "安徽黄山"
                },
                {
                    "name": "香港九龙"
                }
            },
            "title": "去哪玩?",
            "updatedAt": "2019-07-30T19:33:27+08:00"
        }
    }

}

4

建议:

  • 优先设计:Schema, 指导着开发者
  • 如果请求或者更改动作过多,按功能或者资源划分(项目结构按功能划分,一定程度上有助于减轻思维负担)
var Query = graphql.NewObject(graphql.ObjectConfig{}

func init(){
    // 资源一
    Query.AddFieldConfig("filedsName", &graphql.Field{})
}

func init(){
    // 资源二
}
  • 如何处理复杂请求参数:
var Mutation = graphql.NewObject(graphql.ObjectConfig{
    Name: "Mutation",
    Fields: graphql.Fields{
        "createVote": &graphql.Field{
            Type: vote.Vote,
            Args: graphql.FieldConfigArgument{
                "title": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.String),
                },
                "options": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.NewList(vote.OptionInput)),
                },
                "description": &graphql.ArgumentConfig{
                    Type: graphql.String,
                },
                "deadline": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(graphql.String),
                },
                "class": &graphql.ArgumentConfig{
                    Type: graphql.NewNonNull(vote.Class),
                },
            },
            Resolve: func(p graphql.ResolveParams) (i interface{}, e error) {
                log.Println(p.Args)
                var params vote.CreateVoteParams
                params.Title = p.Args["title"].(string)
                if p.Args["description"] != nil {
                    params.Description = p.Args["description"].(string)
                }
                params.Deadline = p.Args["deadline"].(string)
                params.Class = p.Args["class"].(int)
                var options []vote.OptionParams
                for _, i := range p.Args["options"].([]interface{}) {
                    var one vote.OptionParams
                    k := i.(map[string]interface{})
                    one.Name = k["name"].(string)
                    options = append(options, one)
                }
                params.Options = options
                log.Println(params)
                result, err := vote.CreateVote(params)
                if err != nil {
                    return nil, err
                }
                return result, nil

            },
        },
    },
})

Args 定义所有该请求的字段和类型。
p.Args 类型(map[string]interface),可以获取到请求参数。返回是个 interface, 根据 Args 内定义的类型,类型转化

5

总结:本文简单讲解 GraphQL的语法和 Go 编程实现 GraphQL 操作。

建议如何学习?

<完>

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

推荐阅读更多精彩内容