ASP.NET Core 3.x RESTful API 学习笔记

ASP.NET Core 3.x RESTful API 学习笔记

什么是 REST


  1. Representional State Transfer(状态表述转换)
  2. 它描述了 Web 应用到底怎么样设计才算是优良的。这里定义了以下三点:
    • 一组网页的网络(一个虚拟状态机)
    • 在这些网页上,用户可以通过点击链接来前进(状态转换)
    • 点击链接的结果就是下一个网页(表示程序的下一个状态)被传输到用户那里,并渲染好给用户使用。

REST 是一种架构风格


  • REST 是一种架构风格,而不是规范或标准;
  • REST 需要使用一些规范、协议或标准来实现这种架构风格;
  • REST 与协议无关。JSON 并不是 REST 强制的,甚至 HTTP 都不是 REST 强制使用的,但这也仅仅是从理论上来看。

REST 的优点


  • 性能
  • 组件交互的可扩展性
  • 组件的可修改性
  • 可移植性
  • 可靠性
  • 可视性

REST 的约束


  1. 客户端-服务器
  2. 无状态
  3. 统一的资源接口/界面
    • 资源的标识
    • 通过表述来对资源进行操纵
    • 带有自我描述的信息
    • 超媒体作为应用程序状态的引擎(HATEOAS)
  4. 多层系统
  5. 可缓存
  6. 按需编码(可选约束)

Richardson 成熟度模型


  • Level 0,POX(Plain old xml)沼泽

POST(查询数据信息)
http ://host/myapi
POST (创建数据)
http ://host/myapi

URI路径混用,GET/POST混用,不区分查询还是创建

  • Level 1,资源

POST
http ://host/api/authors
POST
http ://host/api/authors/{id}

URI已经做了区分,但是方法并没有全用对
也没有返回状态码

  • Level 2,动词。

GET
http ://host/api/authors
200 Ok (authors)
POST (author representation)
http ://host/api/authors
201 Created (author)

各动词全都用对了,且状态码均正确返回了。
Level 2 虽然理论上还不能称为 RESTful,但其实已经够用了。

  • Level 3,超媒体。

GET
http ://host/api/authors
200 Ok (返回了 authors 和 驱动应用程序的超链接)

Level 3 表示实现了HATEOAS。

大部分 Web API 都不是 RESTful API

  • 根据 Roy Fielding 博士的描述,达到 Level 3 也仅仅是 RESTful API 的一个前提。

[ApiController] 注解


[ApiController] 这个注解是应用于 Controller 的,它其实并不是强制的。
它会启用以下行为:

  • 要求使用属性路由(Attribute Routing)
  • 自动 HTTP 400 响应
  • 推断参数的绑定源
  • Multipart / form-data 请求推断
  • 错误状态代码的问题详细信息

HTTP 动词


  • GET 获取资源
  • POST 创建 / 添加资源
  • DELETE 删除资源
  • PATCH 局部更新
  • PUT 替换 / 完全更新
    (PUT 可选:资源不存在即创建,类似 POST)

以上 HTTP 动作对于 CRUD 完全足够了,但是由于现实情况不只是增删改查,所以我们还是需要做出一定妥协。

HTTP 状态码


· 1xx

1xx 属于信息性的状态码,Web API 并不使用 1xx 的状态码。

· 2xx

2xx 意味着请求执行的很成功。

  • 200 - Ok,表示请求成功;
  • 201 - Created,请求成功并创建了资源;
  • 204 - No Content,请求成功,但是不应该返回任何东西,例如删除操作。

· 3xx

3xx 用于跳转。绝大多数 Web API 都不需要使用 3xx 状态码。

· 4xx

4xx 表示客户端错误。

  • 400 - Bad Request,表示 API 消费者发送到服务器的请求是有错误的
  • 401 - Unauthorized,表示没有提供授权或者授权信息不正确
  • 403 - Forbidden,表示身份认证已经成功,但是已认证用户却无法访问请求的资源
  • 404 - Not Found,表示请求的资源不存在
  • 405 - Method not allowed,表示请求的方法不被支持
  • 406 - Not Acceptable,表示 API 消费者请求的表述格式不被 Web API 所支持,并且 API 不会提供默认的表述格式。例如请求application/xml,而服务器只提供application/json
  • 409 - Conflict,表示请求与服务器当前状态冲突。例如,当你编辑某个资源的时候,该资源在服务器上又进行了更新,所以你编辑的资源版本和服务器的不一致。当然有时候也用来表示你想要创建的资源在服务器上已经存在了。它就是用来处理并发问题的状态码。
  • 415 - Unsupported media type,与406正好相反,有一些请求必须带着数据发往服务器,这些数据都属于特定的媒体类型,如果 API 不支持该媒体类型格式,415就会被返回。
  • 422 - Unprocessable entity,它是 HTTP 扩展协议的一部分。它说明服务器已经懂得了实体的 Content Type,也就是说 415 肯定不合适;此外,实体的语法也没有问题,所以 400 也不合适。但是服务器仍然无法处理这个实体数据,这时就可以返回 422。所以它通常是用来表示语义上有错误,通常就表示实体验证的错误。

· 5xx

5xx 表示服务器端错误。

  • 500 - Internal server error,表示服务器出现了错误,客户端无能为力,只能以后再试试了。

内容协商 Content Negotiation


  • 内容协商是这样一个过程,针对一个响应,当有多种表述格式可用的时候,选取最佳的一个表述。
  • 消费者在请求的时候,在 [Accept] Header 里设置媒体类型为 application/json 或者 application/xml等。
  • 若请求的媒体类型不被服务器所接受,则应返回 406 Not Acceptable
  • 服务器输出格式在 ASP.NET Core 里对应的是 Input Formatters
services.AddControllers(setup =>
{
    //如果服务器不能接受Accept Header,则返回 406 Not Acceptable
    setup.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters();

Entity Model 与 面向外部的 Model


  • Entity Model
  • Entity Framework Core 使用的 Entity Model 是用来表示数据库里面的记录的。

  • 面向外部的 Model
  • 面向外部的 Model 则表示了要传输的东西。有时候叫做 dto,有时候叫做 ViewModel

Entity Model 和 面向外部的 Model 应该分开,这样可以加强程序的 Robust 性。

  • IActionResult 与 ActionResult<T>
  • 尽量使用 ActionResult<T>,这有助于书写 API 文档。

  • 使用 对象映射器 (AutoMapper)
  • 创建 Profiles 文件夹,添加 CompanyProfile 类继承于 Profile 类;

  • CompanyProfile 构造函数里写

CreateMap<Company, CompanyDto>()
    .ForMember(
        dest => dest.CompanyName,
        opt => opt.MapFrom(src => src.Name));
  • 调用的时候只需要调用 Map 函数即可。
var companyDtos = _mapper.Map<IEnumerable<CompanyDto>>(companies);
  • 下面是 EmployeeProfile 的构造函数代码
CreateMap<Employee, EmployeeDto>()
    .ForMember(dest => dest.Name,
        opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"))
    .ForMember(dest => dest.GenderDisplay,
        opt => opt.MapFrom(src => src.Gender.ToString()))
    .ForMember(dest => dest.Age,
        opt => opt.MapFrom(src => DateTime.Now.Year - src.DateOfBirth.Year));

处理故障(异常)


Startup.csConfigure 方法里写

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler(appBuilder =>
    {
        appBuilder.Run(async context =>
        {
            context.Response.StatusCode = 500;
            await context.Response.WriteAsync("Unexpected Error!");
        });
    });
}

HTTP HEAD


  • HEAD 和 GET 几乎是一样的
  • 只是有一点重要的不同:HEAD 的 API 不应该返回响应的 Body
  • HEAD 可以用来在资源上获取一些信息

只需要在 [HttpGet] 同方法加上 [HttpHead] 即可。

虽然 HttpHead 只需返回 Head 不需要返回 Body,但是 HttpHeadHttpGet 一样,需要执行完整个方法体,才能返回。

过滤和搜索


如何给 API 传递数据
  • 数据可以通过多种方式来传递给 API。
  • Binding source Attributes 会告诉 Model 的绑定引擎从哪里找到绑定源。
Binding source Attributes
Attributes Binding Source
[FromBody] 请求的 Body
[FromForm] 请求的 Body 中的 form 数据
[FromHeader] 请求的 Header
[FromQuery] Query string 参数
[FromRoute] 当前请求中的路由数据
[FromService] 作为 Action 参数而注入的服务
[ApiController]
  • 默认情况下 ASP.NET Core 会使用 Complex Object Model Binder,它会把数据从 Value Providers 那里提取出来,而 Value Providers 的顺序是定义好的。
  • 但是我们构建 API 时通常会使用 [ApiController] 这个属性,为了更好地适应 API 它改变了上面的规则。
[ApiController] 更改后的规则
  • [FromBody] 通常是用来推断复杂类型参数的。
  • [FromForm] 通常是用来推断 IFormFile 和 IFormFileCollection 类型的 Action 参数。
  • [FromRoute] 用来推断 Action 的参数名和路由模板中的参数名一致的情况。
  • [FromQuery] 用来推断其它的 Action 参数。
过滤
  • 过滤集合的意思就是根据条件限定返回的集合。
  • 例如我想返回所有类型为国有企业的欧洲公司。则 URI 为:
    GET /api/companies?type=State-owned&region=Europe
  • 所以过滤就是指:我们把某个字段的名字以及想要让该字段匹配的值一起传递给 API,并将这些作为返回的集合的一部分。
搜索
  • 针对集合进行搜索是指根据预定义的一些规则,把符合条件的数据添加到集合里面。
  • 搜索实际上超出了过滤的范围。针对搜索,通常不会把要匹配的字段名传递过去,通常会把要搜索的值传递给 API,然后 API 自行决定应该对哪些字段来查找该值。经常会是全文搜索
  • 例如: GET /api/companies?q=xxx
过滤 vs 搜索
  • 过滤: 首先是一个完整的集合,然后根据条件把匹配/不匹配的数据项移除。
  • 搜索: 首先是一个空的集合,然后根据条件把匹配/不匹配的数据项往里面添加。

注意:过滤和搜索这些参数并不是资源的一部分。只允许针对资源的字段进行过滤。

安全性 和 幂等性


  • 安全性 是指方法执行后并不会改变资源的表述。
  • 幂等性 是指方法无论执行多少次都会得到同样的结果。
HTTP 方法 安全? 幂等?
GET
OPTIONS
HEAD
POST
DELETE
PUT
PATCH

(PATCH 不幂等,主要原因是:假设有一个 PATCH,提交是向一个数组类型插入数据,则多次运行就会多次插入,自然就不幂等了)

创建资源


虽然偶尔输出用的 Dto 和 输入用的 Dto 是一样的,但是最好不要共用一个 Dto 类。因为不知道以后需求更改之后,两个 Dto 还是否一致。

在使用 [ApiController] 修饰的 Controller 类里,不需要手动判定输入参数是否为 null 了。

心得:EF Core 会将所有名为 Id || {entityName}Id 的字段自动标记为主键,将声明为主键和外键均自动加上索引。如果主键是 Guid 类型,插入数据的时候,如果未提供主键,EF Core 会自动 new 一个Guid 作为主键。

自定义 ModelBinder

在 Helpers 文件夹下建立 ArrayModelBinder.cs:

public class ArrayModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (!bindingContext.ModelMetadata.IsEnumerableType)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString();
        if (string.IsNullOrWhiteSpace(value))
        {
            bindingContext.Result = ModelBindingResult.Success(null);
            return Task.CompletedTask;
        }
            
        var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];
        var converter = TypeDescriptor.GetConverter(elementType);

        var values = value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
            .Select(x => converter.ConvertFromString(x.Trim())).ToArray();
        var typedValues = Array.CreateInstance(elementType, values.Length);
        values.CopyTo(typedValues, 0);
        bindingContext.Model = typedValues;
        bindingContext.Result = ModelBindingResult.Success(bindingContext.Model);
        return Task.CompletedTask;
    }
}

然后就可以这样用:

[FromRoute]
[ModelBinder(BinderType = typeof(ArrayModelBinder))]

HTTP OPTIONS


API 消费者如何知道某个 API 是否允许被访问?

答案是 HTTP OPTIONS

  • OPTION 请求,可以获取针对某个 Web API 的通信选项的信息。
[HttpOptions]
public IActionResult GetCompaniesOptions()
{
    Response.Headers.Add("Allow", "GET,POST,OPTIONS");
    return Ok();
}

输入验证 Data Annotations


验证三部曲

  • 定义验证规则
  • 按验证规则进行检查
  • 报告验证的错误。

定义验证规则

  • Data Annotations。例如 [Required][MaxLength] 等。
  • 自定义 Attribute。
  • 实现 IValidatableObject 接口。
验证什么?
  • 验证的是输入数据,而不是输出数据。

按验证规则进行检查

  • ModelState 对象是一个 Dictionary 字典,它既包含 model 的状态,又包含 model 的绑定验证信息。
  • 它也包含针对每个提交的属性值的错误信息的集合。每当有请求进来的时候,定义好的验证规则就会被检查。
  • 验证不通过 | 类型不正确:ModelState.IsValid 就会是 false

返回 422 Unprocessable Entity

然后在响应的 body 里面包含验证错误信息。
查看对应的标准 Validation Problem Details RFC,ASP.NET Core 内置了对这个标准的支持。

实现 IValidatableObject 接口

AddDto类 或者 ViewModel类 继承 IValidatableObject接口,并实现 Validate 方法,在里面进行验证。

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
    if (FirstName == LastName)
    {
        yield return new ValidationResult("姓和名不能一样",
            new[] { nameof(FirstName), nameof(LastName) });
    }
}

自定义 Attribute

自定义 Attribute 可以针对类这个级别,也可以针对类的属性。

public class EmployeeNoMustDifferentFromFirstNameAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var addDto = (EmployeeAddDto)validationContext.ObjectInstance;
        if (addDto.EmployeeNo == addDto.FirstName)
        {
            return new ValidationResult("员工编号和姓名不能一样", new[] { nameof(EmployeeAddDto) });
        }

        return ValidationResult.Success;
    }
}

自定义 Attribute 的 错误信息

要使用传入的 ErrorMessage,将上面改为:

return new ValidationResult(ErrorMessage, new[] ....

即可。

错误信息的报告

以下是 ASP.NET Core 自带的错误报告样式:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-218d293052d0aa4bb1e15ad93b892aac-5c8faea2430ec942-00",
    "errors": {
        "EmployeeAddDto": [
            "The field employee is invalid."
        ]
    }
}

其中 traceId 是可以在后台日志中查询到的一个标志。

有一个标准 RFC (7807)

  • Problem details for HTTP APIs RFC (7807)
    • 为所需错误信息的应用,定义了通用的错误格式
    • 可以识别出问题属于哪个 API

想要自定义错误信息报告,需要在 StartupConfigureService
service.AddControllers 后面加上 ConfigureApiBehaviorOptions

services.AddControllers(setup =>
{
    setup.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters()
.ConfigureApiBehaviorOptions(setup =>
{
    setup.InvalidModelStateResponseFactory = context =>
    {
        var problemDetails = new ValidationProblemDetails(context.ModelState)
        {
            Type = "http://www.baidu.com",
            Title = "有错误!!!",
            Status = StatusCodes.Status422UnprocessableEntity,
            Detail = "请看详细信息",
            Instance = context.HttpContext.Request.Path
        };
        problemDetails.Extensions.Add("traceId", context.HttpContext.TraceIdentifier);
        return new UnprocessableEntityObjectResult(problemDetails)
        {
            ContentTypes = { "application/problem+json" }
        };
    };
});

其他验证方式

  • 还可以使用第三方的验证库 FluentValidation
    • 很容易创建复杂的验证规则
    • 验证规则与 Model 分离
    • 容易进行单元测试

整体更新/替换:PUT


更新分为两种:PUT vs PATCH

  • PUT 整体更新/替换
    资源所有的字段都被重写了,或者是设置为该字段的默认值。
  • PATCH 局部更新
    使用 JsonPatchDocument 发送变更的数据,对资源指定的字段进行更新
更新或新增
  • PUT 也可以这样用:在资源不存在的时候新增。

局部更新 PATCH


  • HTTP PATCH 是用来做局部更新的
  • PATCH 请求 Body 里面的数据格式为 JSON PATCH(RFC 6902)
  • PATCH 请求的 media type 是 application/json-patch+json
JSON PATCH Operations
  • Add
[{
  "op": "add",
  "path": "/biscuits/1",
  "value": {
    "name": "Ginger Nut"
  }
}]
  • Replace
[{
  "op": "replace",
  "path": "/biscuits/0/name",
  "value": {
    "name": "Chocolate Digestive"
  }
}]
  • Remove
[{
  "op": "remove",
  "path": "/biscuits"
},
{
  "op": "remove",
  "path": "/biscuits/0"
}]
  • Copy
[{
  "op": "copy",
  "from": "/biscuits/0",
  "path": "/best_biscuit"
}]
  • Move
[{
  "op": "move",
  "from": "/biscuits",
  "path": "/cookies"
}]
  • Test
[{
  "op": "test",
  "path": "/best_biscuit/name",
  "value": "Choco Leibniz"
}]

处理 Patch 的 Action

[HttpPatch("{employeeId}")]
public async Task<IActionResult> PartiallyUpdateEmployeeForCompany(
    Guid companyId,
    Guid employeeId,
    JsonPatchDocument<EmployeeUpdateDto> patchDocument)
{
    if (!await _companyRepository.CompanyExistsAsync(companyId))
    {
        return NotFound();
    }
    var employeeEntity = await _companyRepository.GetEmployeeAsync(companyId, employeeId);
    if (employeeEntity == null)
    {
        return NotFound();
    }
    var dtoToPatch = _mapper.Map<EmployeeUpdateDto>(employeeEntity);

    patchDocument.ApplyTo(dtoToPatch, ModelState);
    if (!TryValidateModel(dtoToPatch))
    {
        return ValidationProblem(ModelState);
    }

    _mapper.Map(dtoToPatch, employeeEntity);
    _companyRepository.UpdateEmployee(employeeEntity);
    await _companyRepository.SaveAsync();
    return NoContent();
}

注意这里的 ValidationProblem 调用的是 ControllerBase 里写的默认方法,默认返回 400 Bad Request,而且也没有走上面 Startup.cs 里自定义的错误信息报告ConfigureApiBehaviorOptions 里的 InvalidModelStateResponseFactory

要返回自定义的 422,需要 Override 一下 ValidationProblem 方法

public override ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary)
{
    var options = HttpContext.RequestServices.GetRequiredService<IOptions<ApiBehaviorOptions>>();
    return (ActionResult)options.Value.InvalidModelStateResponseFactory(ControllerContext);
}

删除资源 DELETE


正常写即可。

翻页


针对集合资源翻页
  • 集合资源的数量通常比较大

    • 需要对它们进行翻页查询
  • 能避免性能问题

  • 参数通过 QueryString 进行传递

    • api/companies?pageNumber=1&pageSize=5
  • 每页的笔数需要进行控制

  • 默认就应该进行分页

  • 应该对底层的数据存储进行分页

返回翻页信息
  • 应该包含前一页和后一页的链接
  • 其他信息:PageNumber,PageSize,总记录数,总页数…

以下是某些人的实现,

{
  "items": [{company}, {company}...],
  "pagination": {"pageNumber": 1, "pageSize": 5, "previous": ...}
}

这样做是没有问题的,但是:

  • 响应的 body 不符合请求的 Accept Header,这不是 application/json,它应该是一个新的 media type
  • 破坏了自我描述性信息这个约束:API 消费者不知道如何使用 application/json 这个 media type 来解释这个响应

所以,

  • 当使用 application/json 请求的时候,翻页的信息元数据并不应该是资源表述的一部分
  • 通常情况下,应该放在自定义的 Header里,通常叫 X-Pagination
实现自定义类
PagedList<T>
  • CurrentPage, TotalPages, HasPrevious, HasNext
  • 可以复用
  • 再使用它来创建翻页信息
public class PagedList<T> : List<T>
{
    public int CurrentPage { get; private set; }
    public int TotalPages { get; private set; }
    public int PageSize { get; private set; }
    public int TotalCount { get; private set; }
    public bool HasPrevious => CurrentPage > 1;
    public bool HasNext => CurrentPage < TotalPages;

    public PagedList(List<T> items, int count, int pageNumber, int pageSize)
    {
        TotalCount = count;
        PageSize = pageSize;
        CurrentPage = pageNumber;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);
        AddRange(items);
    }

    public static async Task<PagedList<T>> Create(IQueryable<T> source, int pageNumber, int pageSize)
    {
        var count = await source.CountAsync();
        var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
        return new PagedList<T>(items, count, pageNumber, pageSize);
    }
}

为资源排序


通常是这样写的:

  • api/companies?orderBy=companyName
  • api/companies?orderBy=companyName desc
  • api/companies?orderBy=companyName desc, id
问题:针对谁来排序?

应该是针对面向外部的 Model 来进行排序。

排序遇到的问题
  • 映射问题

    • 需要从Name → FirstName + LastName
  • 应用排序问题

    • 传入的是字符串,而 OrderBy() 参数是 lambda 表达式;
    • 需要写一堆 switch 进行判断;
    • 幸好有 System.Linq.Dynamic.Core 这个 Linq 扩展库。
  • 复用性

    • 我们不想针对每一个资源都写一堆排序的代码;
    • 所以我们考虑写一个针对 IQueryable<T> 的一个扩展方法。
属性映射服务
  • 一个资源(DTO)的属性可以映射到 Entity 上面多个属性
    • Name → FirstName + LastName
  • 映射可能需要反转顺序
    • Age asc → DateOfBirth desc

思路:

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