目标
通过.net gRPC实现对数据库的增删改查。
解决方案结构
- Protos 文件夹 存放 proto 文件,分为两组,一组定义message,一组定义rpc;
- Models .net standard 2.1 项目,导入message.proto,并通过 public partial class 扩展实体;
- Contract .net standard 2.1 项目,导入rpc.proto,引用Models项目,提供服务端和客户端调用;
- Service gRPC asp.net core3.1项目,引用Contract项目,实现rpc.proto的具体方法;
- Client .net core3.1 console项目,引用Contract,调用rpc.proto的方法,完成测试。
Node 实体的message和rpc的proto文件
Node是一个树形结构的对象,数字 Id 为主键,ParentId 为自耦的外键,实体的proto定义如下,CUDType为修改类型,用于记录并批量向后台提交修改。
message Node {
int32 id = 1;
string name = 2;
string code = 3;
int32 type_id = 4;
int32 parent_id = 5;
int32 sequence=6;
CUDType change=7;
}
enum CUDType{
No_Change = 0;
Create = 1;
Update = 2;
Delete = 3;
}
Node的 增删改查和批量提交rpc如下,其中 CreateNewId 用于为新对象分配 Id,不依赖数据库的自增长id。NodeCondition 可以提供多个查询条件,避免函数重载(proto不支持同名函数重载)。
service NodeRPC {
rpc CreateNewId(Empty) returns (IntResponse);
rpc GetAll (NodeCondition) returns (NodeList);
rpc Get (RequestId) returns (Node);
rpc Insert (Node) returns (Node);
rpc Update (Node) returns (Node);
rpc Remove (RequestId) returns (BoolResponse);
rpc SaveChanges(NodeList) returns (SaveResult);
}
message NodeCondition{
oneof value{
int32 id= 1;
string name = 2;
int32 parent_id = 3;
Empty all=4;
}
}
通过接口统一和扩展message生成的实体类
由于proto生成的cs代码是partial class,可以扩展,比较好的方法是定义接口,然后继承接口,在接口上扩展行为,这样Model就可以与原来的业务体系融合了。实体对象接口分为四个层级IEntity->IIdEntity->IIdNameEntity->ITreeNode。IEntity只需要记录修改状态,IIdEntity增加了Id主键属性,IIdNameEntity增加了Name,ITreeNode增加了ParentId等。
public interface IEntity
{
CUDType Change { get; set; }
}
public interface IIdEntity : IEntity
{
int Id { get; set; }
}
public interface IIdNameEntity : IIdEntity
{
string Name { get; set; }
}
public interface ITreeNode : IIdNameEntity
{
int Sequence { get; set; }
int ParentId { get; set; }
ITreeNode Parent { get; set; }
TreeNodeCollection Children { get; }
}
例子一:通过 ITreeNode 为 Node 扩展 Parent 和 Children 两个属性。
public sealed partial class Node : ITreeNode
{
public ITreeNode Parent { get; set; }//对应Proto文件中定义的Node的ParentId属性.
private TreeNodeCollection _Children = null;
public TreeNodeCollection Children
{
get
{
if (_Children == null)
{
_Children = new TreeNodeCollection(this);
}
return _Children;
}
}
}
例子二:通过扩展 ITreeNode 增加共性行为。
public static class TreeNodeExtension
{
public static int GetDepth(this ITreeNode node)
{
if (node.Parent == null)
return 0;
else
return node.Parent.GetDepth() + 1;
}
public static string GetFullPath(this ITreeNode node)
{
if (node.Parent == null)
return node.Name;
else
{
return $"{node.Parent.GetFullPath()}.{node.Name}";
}
}
}
CRUD 实体服务接口定义
我把数据-实体放到了Entity Services里,这样在服务层内部调用就没必要通过gRPC了,毕竟gRPC的参数有额外封装,调用起来麻烦且要多消耗内存。
public interface IEntityService<T> where T:IEntity
{
void Reconfigure();
IEnumerable<T> GetMany();
IEnumerable<T> GetMany(Func<T, bool> filter);
T GetOne(Func<T, bool> filter);
T Insert(T item);
T Update(T item);
bool Delete(T item);
SaveResult SaveChanges(IList<T> changes);
}
public interface IIdEntityService<T> : IEntityService<T> where T : IIdEntity
{
T GetOne(int id);
int CreateNewId();
int CreateNewId(int count);
bool Delete(int id);
IEnumerable<T> GetMany(ICollection<int> ids);
}
public interface IIdNameEntityService<T> : IIdEntityService<T> where T : IIdNameEntity
{
T GetOne(string name);
IDictionary<string, int> GetIds(ICollection<string> names);
}
CRUD的gRPC服务实现
Entity Service的实现可以用EF Core,也可以自己写,无外乎是sql语句。
然后在Services中再调用Entity Services实现RPC。
注意:Entity Service和 gRPC Service 用的是同一套 Models,否则要转来转去,这也是扩展Model的原因所在:面向接口(ITreeNode)的Entity Service实现可以子类共享。
public class NodeService : NodeRPC.NodeRPCBase
{
EntityServices.NodeService entityService;
public NodeService()
{
entityService = EntityServices.NodeService.GetInstance();
}
public override Task<Node> Get(RequestId request, ServerCallContext context)
{
if (request == null) return Task.FromResult((Node)null);
return Task.FromResult(entityService.GetOne(request.Id));
}
//...
}
测试验证
我写了客户端循环调用服务器GetOne 58次(=数据库记录数),平均单次调用耗时越6、7ms,速度很快。
ps: google把对象的ToString做成了JSON结构,如图Client。
后记
熟练使用gRPC后,感觉 proto 还是分成 ***messages.proto和***rpcs.proto 比较清晰,如果按照 每个实体/每张表 一个message.proto 和 一个rpc.proto,很快就一大堆了,不如按照服务合并好管理,也可以减少相互的import。
另外,gRPC的生产率要高于自己写实体和接口代码,因为 属性{get;set;} 自动生成完了,大部分时候models, contract工程没几行代码,估计慢慢的不用还不习惯了。