主要参考文章微软官方文档: https://docs.microsoft.com/zh-cn/aspnet/core/grpc/client?view=aspnetcore-3.1

此外还参考了文章 https://www.cnblogs.com/stulzq/p/11581967.html并写了一个demo: https://files.cnblogs.com/files/hudean/GrpcDemo.zip

一、简介

gRPC 是一种与语言无关的高性能远程过程调用 (RPC) 框架。

gRPC 的主要优点是:

  • 现代高性能轻量级 RPC 框架。
  • 协定优先 API 开发,默认使用协议缓冲区,允许与语言无关的实现。
  • 可用于多种语言的工具,以生成强类型服务器和客户端。
  • 支持客户端、服务器和双向流式处理调用。
  • 使用 Protobuf 二进制序列化减少对网络的使用。

这些优点使 gRPC 适用于:

  • 效率至关重要的轻量级微服务。
  • 需要多种语言用于开发的 Polyglot 系统。
  • 需要处理流式处理请求或响应的点对点实时服务。

二、创建 gRPC 服务

  • 启动 Visual Studio 并选择“创建新项目”。 或者,从 Visual Studio“文件”菜单中选择“新建” > “项目” 。

  • 在“创建新项目”对话框中,选择“gRPC 服务”,然后选择“下一步” :

  • 将项目命名为 GrpcGreeter。 将项目命名为“GrpcGreeter”非常重要,这样在复制和粘贴代码时命名空间就会匹配。

  • 选择“创建”。

  • 在“创建新 gRPC 服务”对话框中:

    • 选择“gRPC 服务”模板。
    • 选择“创建”。

运行服务

  • 按 Ctrl+F5 以在不使用调试程序的情况下运行。

    Visual Studio 会显示以下对话框:

    如果信任 IIS Express SSL 证书,请选择“是” 。

    将显示以下对话框:

    如果你同意信任开发证书,请选择“是”。

日志显示该服务正在侦听 https://localhost:5001

控制台显示如下:

info: Microsoft.Hosting.Lifetime[0]
Now listening on: https://localhost:5001
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development

备注

gRPC 模板配置为使用传输层安全性 (TLS)。 gRPC 客户端需要使用 HTTPS 调用服务器。

macOS 不支持 ASP.NET Core gRPC 及 TLS。 在 macOS 上成功运行 gRPC 服务需要其他配置。

检查项目文件

GrpcGreeter 项目文件:

  • greet.proto : Protos/greet.proto 文件定义 Greeter gRPC,且用于生成 gRPC 服务器资产。
  • Services 文件夹:包含 Greeter 服务的实现。
  • appSettings.json :包含配置数据,例如 Kestrel 使用的协议。
  • Program.cs:包含 gRPC 服务的入口点。

Startup.cs :包含配置应用行为的代码。

上述准备工作完成,开始写gRPC服务端代码!

example.proto文件内容如下

syntax = "proto3";

option csharp_namespace = "GrpcGreeter";

package example;

service exampler {
// Unarys
rpc UnaryCall (ExampleRequest) returns (ExampleResponse); // Server streaming
rpc StreamingFromServer (ExampleRequest) returns (stream ExampleResponse); // Client streaming
rpc StreamingFromClient (stream ExampleRequest) returns (ExampleResponse); // Bi-directional streaming
rpc StreamingBothWays (stream ExampleRequest) returns (stream ExampleResponse);
}
message ExampleRequest {
int32 id = 1;
string name = 2;
} message ExampleResponse {
string msg = 1;
}

example.proto

其中:

syntax = "proto3";是使用 proto3 语法,protocol buffer 编译器默认使用的是 proto2 。 这必须是文件的非空、非注释的第一行。

对于 C#语言,编译器会为每一个.proto 文件创建一个.cs 文件,为每一个消息类型都创建一个类来操作。

option csharp_namespace = "GrpcGreeter";是c#代码的命名空间

package example;包的命名空间

service exampler 是服务的名字

rpc UnaryCall (ExampleRequest) returns (ExampleResponse); 意思是rpc调用方法 UnaryCall 方法参数是ExampleRequest类型 返回值是ExampleResponse 类型

message ExampleRequest {
int32 id = 1;
string name = 2;
}

指定字段类型 在上面的例子中,所有字段都是标量类型:一个整型(id),一个string类型(name)。当然,你也可以为字段指定其他的合成类型,包括枚举(enumerations)或其他消息类型。
分配标识号 我们可以看到在上面定义的消息中,给每个字段都定义了唯一的数字值。这些数字是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留
[1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。 最小的标识号可以从1开始,最大到2^29 - 1, or 536,870,911。不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。类似地,你不能使用之前保留的任何标识符。 指定字段规则 消息的字段可以是一下情况之一: singular(默认):一个格式良好的消息可以包含该段可以出现 0 或 1 次(不能大于 1 次)。
repeated:在一个格式良好的消息中,这种字段可以重复任意多次(包括0次)。重复的值的顺序会被保留。
默认情况下,标量数值类型的repeated字段使用packed的编码方式。

  

在GrpcGreeter.csproj文件添加:

<ItemGroup>
<Protobuf Include="Protos\example.proto" GrpcServices="Server" />
</ItemGroup>

点击保存

在Services文件夹下添加ExampleService类,代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Core; namespace GrpcGreeter
{
public class ExampleService :exampler.examplerBase
{
/// <summary>
/// 一元方法以参数的形式获取请求消息,并返回响应。 返回响应时,一元调用完成。
/// </summary>
/// <param name="request"></param>
/// <param name="context"></param>
/// <returns></returns>
public override Task<ExampleResponse> UnaryCall(ExampleRequest request, ServerCallContext context)
{
// return base.UnaryCall(request, context);
return Task.FromResult(new ExampleResponse
{
Msg = "id :" + request.Id + "name : " + request.Name + " hello" }) ;
} /// <summary>
/// 服务器流式处理方法
/// 服务器流式处理方法以参数的形式获取请求消息。 由于可以将多个消息流式传输回调用方,因此可使用 responseStream.WriteAsync 发送响应
/// 消息。 当方法返回时,服务器流式处理调用完成。
/// </summary>
/// <param name="request"></param>
/// <param name="responseStream"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task StreamingFromServer(ExampleRequest request, IServerStreamWriter<ExampleResponse> responseStream, ServerCallContext context)
{ /**
* 服务器流式处理方法启动后,客户端无法发送其他消息或数据。 某些流式处理方法设计为永久运行。 对于连续流式处理方法,客户端可以在*再需要调用时将其取消。 当发生取消时,客户端会将信号发送到服务器,并引发 ServerCallContext.CancellationToken。 应在服务器上通过异步方法使用 CancellationToken 标记,以实现以下目的:
所有异步工作都与流式处理调用一起取消。
该方法快速退出。
**/
//return base.StreamingFromServer(request, responseStream, context); //for (int i = 0; i < 5; i++)
//{
// await responseStream.WriteAsync(new ExampleResponse { Msg = "我是服务端流for:" + i });
// await Task.Delay(TimeSpan.FromSeconds(1));
//}
int index = 0;
while (!context.CancellationToken.IsCancellationRequested)
{
index++;
await responseStream.WriteAsync(new ExampleResponse { Msg = "我是服务端流while" + index+" "+request.Id+" "+request.Name });
await Task.Delay(TimeSpan.FromSeconds(1), context.CancellationToken); } } /// <summary>
/// 客户端流式处理方法
/// 客户端流式处理方法在该方法没有接收消息的情况下启动。 requestStream 参数用于从客户端读取消息。 返回响应消息时,客户端流式处理调用
/// 完成:
/// </summary>
/// <param name="requestStream"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task<ExampleResponse> StreamingFromClient(IAsyncStreamReader<ExampleRequest> requestStream, ServerCallContext context)
{
// return base.StreamingFromClient(requestStream, context);
List<string> list = new List<string>();
while (await requestStream.MoveNext())
{
//var message = requestStream.Current;
var id = requestStream.Current.Id;
var name = requestStream.Current.Name; list.Add($"{id}-{name}");
// ...
}
return new ExampleResponse() { Msg = "我是客户端流while"+string.Join(',',list) }; //await foreach (var message in requestStream.ReadAllAsync())
//{
// // ...
//}
// return new ExampleResponse() { Msg= "我是客户端流foreach" };
} /// <summary>
/// 双向流式处理方法
/// 双向流式处理方法在该方法没有接收到消息的情况下启动。 requestStream 参数用于从客户端读取消息。
/// 该方法可选择使用 responseStream.WriteAsync 发送消息。 当方法返回时,双向流式处理调用完成:
/// </summary>
/// <param name="requestStream"></param>
/// <param name="responseStream"></param>
/// <param name="context"></param>
/// <returns></returns>
public override async Task StreamingBothWays(IAsyncStreamReader<ExampleRequest> requestStream, IServerStreamWriter<ExampleResponse> responseStream, ServerCallContext context)
{
//return base.StreamingBothWays(requestStream, responseStream, context);
await foreach (var message in requestStream.ReadAllAsync())
{
string str= message.Id + " " + message.Name;
await responseStream.WriteAsync(new ExampleResponse() { Msg="我是双向流:"+ str }); } //// Read requests in a background task.
//var readTask = Task.Run(async () =>
//{
// await foreach (var message in requestStream.ReadAllAsync())
// {
// // Process request.
// string str = message.Id + " " + message.Name;
// }
//}); //// Send responses until the client signals that it is complete.
//while (!readTask.IsCompleted)
//{
// await responseStream.WriteAsync(new ExampleResponse());
// await Task.Delay(TimeSpan.FromSeconds(1), context.CancellationToken);
//}
} }
}

ExampleService

在Startup类里Configure中加入一个这个 endpoints.MapGrpcService<ExampleService>();

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; namespace GrpcGreeter
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddGrpc();
} // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
} app.UseRouting(); app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<GreeterService>();
endpoints.MapGrpcService<ExampleService>(); endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
});
});
}
}
}

Startup

就此 gRPC服务端代码完成了

  

在 .NET 控制台应用中创建 gRPC 客户端

  • 打开 Visual Studio 的第二个实例并选择“创建新项目”。
  • 在“创建新项目”对话框中,选择“控制台应用(.NET Core)”,然后选择“下一步” 。
  • 在“项目名称”文本框中,输入“GrpcGreeterClient”,然后选择“创建” 。

添加所需的包

gRPC 客户端项目需要以下包:

  • Grpc.Net.Client,其中包含 .NET Core 客户端。
  • Google.Protobuf 包含适用于 C# 的 Protobuf 消息。
  • Grpc.Tools 包含适用于 Protobuf 文件的 C# 工具支持。 运行时不需要工具包,因此依赖项标记为 PrivateAssets="All"

通过包管理器控制台 (PMC) 或管理 NuGet 包来安装包。

用于安装包的 PMC 选项

  • 从 Visual Studio 中,依次选择“工具” > “NuGet 包管理器” > “包管理器控制台”

  • 从“包管理器控制台”窗口中,运行 cd GrpcGreeterClient 以将目录更改为包含 GrpcGreeterClient.csproj 文件的文件夹。

  运行以下命令:
  PowerShell

Install-Package Grpc.Net.Client
Install-Package Google.Protobuf
Install-Package Grpc.Tools

  

管理 NuGet 包选项以安装包

  • 右键单击“解决方案资源管理器” > “管理 NuGet 包”中的项目 。
  • 选择“浏览”选项卡。
  • 在搜索框中输入 Grpc.Net.Client。
  • 从“浏览”选项卡中选择“Grpc.Net.Client”包,然后选择“安装” 。
  • 为 Google.Protobuf 和 Grpc.Tools 重复这些步骤。

添加 greet.proto

  • 在 gRPC 客户端项目中创建 Protos 文件夹。

  • 从 gRPC Greeter 服务将 Protos\greet.proto 文件复制到 gRPC 客户端项目。

  • 将 greet.proto 文件中的命名空间更新为项目的命名空间:

    option csharp_namespace = "GrpcGreeterClient";
  • 编辑 GrpcGreeterClient.csproj 项目文件:

    右键单击项目,并选择“编辑项目文件”。


添加具有引用 greet.proto 文件的 <Protobuf> 元素的项组:

XML

<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
</ItemGroup>

  

创建 Greeter 客户端

构建客户端项目,以在 GrpcGreeter 命名空间中创建类型。 GrpcGreeter 类型是由生成进程自动生成的。

使用以下代码更新 gRPC 客户端的 Program.cs 文件:

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Grpc.Net.Client; namespace GrpcGreeterClient
{
class Program
{
static async Task Main(string[] args)
{
// The port number(5001) must match the port of the gRPC server.
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(
new HelloRequest { Name = "GreeterClient" });
Console.WriteLine("Greeting: " + reply.Message);
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
}
}

添加内容如下:

<ItemGroup>
<Protobuf Include="Protos\example.proto" GrpcServices="Server" />
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\example.proto" GrpcServices="Client" />
</ItemGroup>

在gRPC客户端写调用服务端代码,代码如下:

using Grpc.Core;
using Grpc.Net.Client;
using GrpcGreeter;
using System;
using System.Threading;
using System.Threading.Tasks; namespace GrpcGreeterClient
{
class Program
{
//static void Main(string[] args)
//{
// Console.WriteLine("Hello World!");
//}
//static async Task Main(string[] args)
//{
// // The port number(5001) must match the port of the gRPC server.
// using var channel = GrpcChannel.ForAddress("https://localhost:5001");
// var client = new Greeter.GreeterClient(channel);
// var reply = await client.SayHelloAsync(
// new HelloRequest { Name = "GreeterClient" });
// Console.WriteLine("Greeting: " + reply.Message);
// Console.WriteLine("Press any key to exit...");
// Console.ReadKey();
//} static async Task Main(string[] args)
{
// The port number(5001) must match the port of the gRPC server.
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new exampler.examplerClient(channel); #region 一元调用 //var reply = await client.UnaryCallAsync(new ExampleRequest { Id = 1, Name = "hda" });
//Console.WriteLine("Greeting: " + reply.Msg); #endregion 一元调用 #region 服务器流式处理调用 //using var call = client.StreamingFromServer(new ExampleRequest { Id = 1, Name = "hda" }); //while (await call.ResponseStream.MoveNext(CancellationToken.None))
//{
// Console.WriteLine("Greeting: " + call.ResponseStream.Current.Msg); //}
//如果使用 C# 8 或更高版本,则可使用 await foreach 语法来读取消息。 IAsyncStreamReader<T>.ReadAllAsync() 扩展方法读取响应数据流中的所有消息:
//await foreach (var response in call.ResponseStream.ReadAllAsync())
//{
// Console.WriteLine("Greeting: " + response.Msg);
// // "Greeting: Hello World" is written multiple times
//} #endregion 服务器流式处理调用 #region 客户端流式处理调用
//using var call = client.StreamingFromClient();
//for (int i = 0; i < 5; i++)
//{
// await call.RequestStream.WriteAsync(new ExampleRequest { Id = i, Name = "hda" + i });
//}
//await call.RequestStream.CompleteAsync();
//var response = await call;
//Console.WriteLine($"Count: {response.Msg}");
#endregion 客户端流式处理调用 #region 双向流式处理调用 //通过调用 EchoClient.Echo 启动新的双向流式调用。
//使用 ResponseStream.ReadAllAsync() 创建用于从服务中读取消息的后台任务。
//使用 RequestStream.WriteAsync 将消息发送到服务器。
//使用 RequestStream.CompleteAsync() 通知服务器它已发送消息。
//等待直到后台任务已读取所有传入消息。
//双向流式处理调用期间,客户端和服务可在任何时间互相发送消息。 与双向调用交互的最佳客户端逻辑因服务逻辑而异。
using var call = client.StreamingBothWays();
Console.WriteLine("Starting background task to receive messages");
var readTask = Task.Run(async () =>
{
await foreach (var response in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine(response.Msg);
// Echo messages sent to the service
}
});
Console.WriteLine("Starting to send messages");
Console.WriteLine("Type a message to echo then press enter.");
while (true)
{
var result = Console.ReadLine();
if (string.IsNullOrEmpty(result))
{
break;
} await call.RequestStream.WriteAsync(new ExampleRequest { Id=1,Name= result });
} Console.WriteLine("Disconnecting");
await call.RequestStream.CompleteAsync();
await readTask;
#endregion 双向流式处理调用 Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
}
}

代码链接地址: https://files.cnblogs.com/files/hudean/GrpcGreeter.zip

ASP.Net Core 3.1 使用gRPC入门指南的更多相关文章

  1. ASP.NET Core 3.0 使用gRPC

    一.简介 gRPC 是一个由Google开源的,跨语言的,高性能的远程过程调用(RPC)框架. gRPC使客户端和服务端应用程序可以透明地进行通信,并简化了连接系统的构建.它使用HTTP/2作为通信协 ...

  2. ASP.NET Core消息队列RabbitMQ基础入门实战演练

    一.课程介绍 人生苦短,我用.NET Core!消息队列RabbitMQ大家相比都不陌生,本次分享课程阿笨将给大家分享一下在一般项目中99%都会用到的消息队列MQ的一个实战业务运用场景.本次分享课程不 ...

  3. ASP.NET Core 中文文档 第二章 指南(3)用 Visual Studio 发布一个 Azure 云 Web 应用程序

    原文:Getting Started 作者:Rick Anderson 翻译:谢炀(Kiler) 校对:孟帅洋(书缘).刘怡(AlexLEWIS).何镇汐 设置开发环境 安装最新版本的 Azure S ...

  4. 基于 Vue.js 之 iView UI 框架非工程化实践记要 使用 Newtonsoft.Json 操作 JSON 字符串 基于.net core实现项目自动编译、并生成nuget包 webpack + vue 在dev和production模式下的小小区别 这样入门asp.net core 之 静态文件 这样入门asp.net core,如何

    基于 Vue.js 之 iView UI 框架非工程化实践记要   像我们平日里做惯了 Java 或者 .NET 这种后端程序员,对于前端的认识还常常停留在 jQuery 时代,包括其插件在需要时就引 ...

  5. ASP.NET Core 3.0 使用 gRPC无法编译问题

    一.问题 创建了gRPC项目后,编译发现报错: 二.解决 1.检查项目路径是否存在中文 2.检查当前Windows用户目录是否为非英文字符,如果是则必须改为英文 修改方法: https://jingy ...

  6. ASP.NET Core 中文文档 第二章 指南(4.1)ASP.NET Core MVC 与 Visual Studio 入门

    原文:Getting started with ASP.NET Core MVC and Visual Studio 作者:Rick Anderson 翻译:娄宇(Lyrics) 校对:刘怡(Alex ...

  7. ASP.NET Core MVC和Visual Studio入门

    本教程将教你使用Visual Studio 2017创建 ASP.NET Core MVC web应用程序的基础知识. 安装Visual Studio 2017 和.Net Core 安装Visual ...

  8. ASP.NET Core 中文文档 第二章 指南(8) 使用 dotnet watch 开发 ASP.NET Core 应用程序

    原文:Developing ASP.NET Core applications using dotnet watch 作者:Victor Hurdugaci 翻译:谢炀(Kiler) 校对:刘怡(Al ...

  9. ASP.NET Core 中文文档 第二章 指南 (09) 使用 Swagger 生成 ASP.NET Web API 在线帮助测试文档

    原文:ASP.NET Web API Help Pages using Swagger 作者:Shayne Boyer 翻译:谢炀(kiler) 翻译:许登洋(Seay) 对于开发人员来说,构建一个消 ...

随机推荐

  1. monkey及其的日志管理和分析

    1.   monkey 1.1.  介绍 通过monkey程序模拟用户触摸屏幕,滑动Trackball.按键等操作来对设备上的程序进行压力测试,检测程序多久的时间会发生异常,检查和评估被测程序的稳定性 ...

  2. CF1295E Permutation Separation

    线段树 难得把E想出来,写出来,但却没有调出来(再给我5分钟),我的紫名啊,我一场上紫的大好机会啊 首先考虑是否能将$k$在$1$--$n-1$的每一个的最小代价都求出来 因为$k$从$i$到$i-1 ...

  3. 汉诺塔问题实验--一个简洁的JAVA程序

    思路: 这里使用递归法 n==1的时候,直接把它从x移到z位置即可. 如果是n层,我们首先把上面的n- 1层移到y位置,然后把最 下面的那个最大的盘子,移到z位置,然后把y上面放的上面n-1层移到z位 ...

  4. 一个 Task 不够,又来一个 ValueTask ,真的学懵了!

    一:背景 1. 讲故事 前几天在项目中用 MemoryStream 的时候意外发现 ReadAsync 方法多了一个返回 ValueTask 的重载,真是日了狗了,一个 Task 已经够学了,又来一个 ...

  5. 什么是麒麟(kylin)?查数据贼快的哟

    前言 微信搜[Java3y]关注这个有梦想的男人,点赞关注是对我最大的支持! 文本已收录至我的GitHub:https://github.com/ZhongFuCheng3y/3y,有300多篇原创文 ...

  6. windows鼠标右键文件太多

    1.网上找的(已经验证太难找,并没有多大用) 单击Windows的"开始"菜单,单击"运行",在"打开"框中键入"regedit& ...

  7. ubuntu12.04管理文件系统工具

    ubuntu12.04管理文件系统工具 以前可以自动管理系统盘和移动硬盘,刚重新安装了UBUNTU12.04LTS之后不行了,原来是这个工具: "PCMANFM" 群星_-_偏偏喜 ...

  8. JavaScript 读取CSS3 transform

    某些场景需要读取 css3 transform的属性 例如 transform:translate(10px,10px) rotate(-45deg); 这该怎么读取呢,正则表达式?毫无疑问这很坑爹 ...

  9. 【应用服务 App Service】App Service 新手资料包

    问题描述 云计算的趋势已成定局,作为一个开发者,如果想对PaaS服务中的应用服务有一个初步的了解,从那些资料入手呢? 以Azure的官方文档作为基础库,从中选择出部分内容,分为:本地开发工具,App ...

  10. TP5 RCE

    Thinkphp5 RCE 复现 环境: win10+wamp+thinkphp5.1.29 下载地址 源码分析 程序首先跳转到 public目录下的index.php,然后执行 thinkphp/l ...