前面我们基础设施基本搭建完毕,后面可以做一些稍微复杂点的功能了,接下来就来实现一个设置管理。
设置管理一般用做一些系统设置之类的,如邮箱配置等,面向使用人员。而不需要修改我们的配置文件,修改配置文件的方式就偏向于技术人员了。
话不多说,开造。
设计结构
设置管理中需要2个表,一个是设置组表,比如什么邮箱设置是一个分组,公众号设置是一个分组。一个是设置的值的存储表,用作存储分组的设置。
using Wheel.Domain.Common;
namespace Wheel.Domain.Settings
{
public class SettingGroup : Entity
{
public string Name { get; set; }
public string NormalizedName { get; set; }
public virtual ICollection SettingValues { get; set; }
}
}
using Wheel.Domain.Common;
using Wheel.Enums;
namespace Wheel.Domain.Settings
{
public class SettingValue : Entity
{
public virtual long SettingGroupId { get; set; }
public virtual SettingGroup SettingGroup { get; set; }
public string Key { get; set; }
public string Value { get; set; }
public SettingValueType ValueType { get; set; }
public SettingScope SettingScope { get; set; }
public string? SettingScopeKey { get; set; }
}
}
这里有两个枚举值,分别是SettingValueType和SettingScope
SettingValueType是Value的类型,如字符串,布尔值,整型,浮点数,主要用于配合前端做页面展示格式以及修改配置时的数据校验。
SettingScope表示设置的生效范围,比如全局设置,用户设置等等,SettingScopeKey则用作存储范围关联的键值,比如用户范围的话,SettingScopeKey就约定存UserId作为键值,当然也可以自己约定别的唯一数用作关联。后续都可以扩展。
namespace Wheel.Enums
{
public enum SettingValueType
{
///
/// 布尔值
///
Bool,
///
/// 整型
///
Int,
///
/// 长整型
///
Long,
///
/// 64位双精度浮点型
///
Double,
///
/// 128位精确的十进制值
///
Decimal,
///
/// 字符串
///
String,
///
/// Json对象
///
JsonObject
}
}
namespace Wheel.Enums
{
public enum SettingScope
{
///
/// 全局设置
///
Global,
///
/// 用户设置
///
User,
}
}
修改DbContext
在DbContext中添加代码
#region Setting
public DbSet SettingGroups { get; set; }
public DbSet SettingValues { get; set; }
#endregion
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
ConfigureIdentity(builder);
ConfigureLocalization(builder);
ConfigurePermissionGrants(builder);
ConfigureMenus(builder);
ConfigureSettings(builder);
}
void ConfigureSettings(ModelBuilder builder)
{
builder.Entity(b =>
{
b.HasKey(o => o.Id);
b.Property(o => o.Name).HasMaxLength(128);
b.Property(o => o.NormalizedName).HasMaxLength(128);
b.HasMany(o => o.SettingValues).WithOne(o => o.SettingGroup);
b.HasIndex(o => o.Name);
});
builder.Entity(b =>
{
b.HasKey(o => o.Id);
b.Property(o => o.Key).HasMaxLength(128);
b.Property(o => o.SettingScopeKey).HasMaxLength(128);
b.Property(o => o.ValueType).HasMaxLength(2048);
b.HasOne(o => o.SettingGroup).WithMany(o => o.SettingValues);
b.HasIndex(o => o.Key);
});
}
然后执行数据库迁移命令修改数据库即可。
SettingManager
接下来实现一个SettingManager用于管理设置。
using HotChocolate.Types.Relay;
using System;
using System.Linq;
using Wheel.DependencyInjection;
using Wheel.Enums;
using Wheel.EventBus.Distributed;
using Wheel.EventBus.EventDatas;
using Wheel.Uow;
using Wheel.Utilities;
namespace Wheel.Domain.Settings
{
public class SettingManager : ITransientDependency
{
private readonly IBasicRepository _settingGroupRepository;
private readonly IBasicRepository _settingValueRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly SnowflakeIdGenerator _snowflakeIdGenerator;
private readonly IDistributedEventBus _distributedEventBus;
public SettingManager(IBasicRepository settingGroupRepository, IBasicRepository settingValueRepository, IUnitOfWork unitOfWork, SnowflakeIdGenerator snowflakeIdGenerator, IDistributedEventBus distributedEventBus)
{
_settingGroupRepository = settingGroupRepository;
_settingValueRepository = settingValueRepository;
_unitOfWork = unitOfWork;
_snowflakeIdGenerator = snowflakeIdGenerator;
_distributedEventBus = distributedEventBus;
}
public async Task GetSettingValue(string settingGroupName, string settingKey, SettingScope settingScope = SettingScope.Golbal, string? settingScopeKey = null, CancellationToken cancellationToken = default)
{
var settingGroup = await _settingGroupRepository.FindAsync(a => a.Name == settingGroupName, cancellationToken);
if (settingGroup is null)
{
throw new ArgumentException($"SettingGroup: {settingGroup} Not Exist.");
}
var settingValue = settingGroup?.SettingValues.FirstOrDefault(a => a.Key == settingKey && a.SettingScope == settingScope && a.SettingScopeKey == settingScopeKey);
if (settingValue is null)
return default;
if(settingValue.ValueType == SettingValueType.JsonObject)
return settingValue.Value.ToObject();
return (T)Convert.ChangeType(settingValue, typeof(T));
}
public async Task GetSettingValue(string settingGroupName, string settingKey, SettingScope settingScope = SettingScope.Golbal, string? settingScopeKey = null, CancellationToken cancellationToken = default)
{
var settingGroup = await _settingGroupRepository.FindAsync(a => a.Name == settingGroupName, cancellationToken);
if (settingGroup is null)
{
throw new ArgumentException($"SettingGroup: {settingGroup} Not Exist.");
}
var settingValue = settingGroup?.SettingValues.FirstOrDefault(a => a.Key == settingKey && a.SettingScope == settingScope && a.SettingScopeKey == settingScopeKey);
return settingValue;
}
public async Task?> GetSettingValues(string settingGroupName, SettingScope settingScope = SettingScope.Golbal, string? settingScopeKey = null, CancellationToken cancellationToken = default)
{
var settingGroup = await _settingGroupRepository.FindAsync(a => a.Name == settingGroupName, cancellationToken);
if (settingGroup is null)
{
throw new ArgumentException($"SettingGroup: {settingGroup} Not Exist.");
}
var settingValues = settingGroup?.SettingValues.Where(a => a.SettingScope == settingScope && a.SettingScopeKey == settingScopeKey).ToList();
return settingValues;
}
public async Task SetSettingValue(string settingGroupName, SettingValue settingValue, CancellationToken cancellationToken = default)
{
using (var uow = await _unitOfWork.BeginTransactionAsync(cancellationToken))
{
try
{
var settingGroup = await _settingGroupRepository.FindAsync(a => a.Name == settingGroupName, cancellationToken);
if (settingGroup is null)
settingGroup = await _settingGroupRepository.InsertAsync(new SettingGroup { Id = _snowflakeIdGenerator.Create(), Name = settingGroupName, NormalizedName = settingGroupName.ToUpper() }, cancellationToken: cancellationToken);
CheckSettingValueType(settingValue.Value, settingValue.ValueType);
var sv = await _settingValueRepository.FindAsync(a=> a.SettingGroupId == settingGroup.Id && a.Id == settingValue.Id, cancellationToken);
if(sv is null)
{
settingValue.Id = _snowflakeIdGenerator.Create();
settingValue.SettingGroupId = settingGroup.Id;
await _settingValueRepository.InsertAsync(settingValue, cancellationToken: cancellationToken);
}
else
await _settingValueRepository.UpdateAsync(settingValue, cancellationToken: cancellationToken);
await uow.CommitAsync(cancellationToken);
await _distributedEventBus.PublishAsync(new UpdateSettingEventData() { GroupName = settingGroupName, SettingScope = settingValue.SettingScope, SettingScopeKey = settingValue.SettingScopeKey });
}
catch(Exception ex)
{
await uow.RollbackAsync(cancellationToken);
ex.ReThrow();
}
}
}
public async Task SetSettingValues(string settingGroupName, List settingValues, CancellationToken cancellationToken = default)
{
using (var uow = await _unitOfWork.BeginTransactionAsync(cancellationToken))
{
try
{
var settingGroup = await _settingGroupRepository.FindAsync(a => a.Name == settingGroupName, cancellationToken);
if (settingGroup is null)
settingGroup = await _settingGroupRepository.InsertAsync(new SettingGroup { Id = _snowflakeIdGenerator.Create(), Name = settingGroupName, NormalizedName = settingGroupName.ToUpper() }, true, cancellationToken: cancellationToken);
foreach (var settingValue in settingValues)
{
CheckSettingValueType(settingValue.Value, settingValue.ValueType);
var sv = await _settingValueRepository.FindAsync(a => a.SettingGroupId == settingGroup.Id && a.Id == settingValue.Id, cancellationToken);
if (sv is null)
{
settingValue.Id = _snowflakeIdGenerator.Create();
settingValue.SettingGroupId = settingGroup.Id;
await _settingValueRepository.InsertAsync(settingValue, cancellationToken: cancellationToken);
}
else
await _settingValueRepository.UpdateAsync(settingValue, cancellationToken: cancellationToken);
}
await uow.CommitAsync(cancellationToken);
await _distributedEventBus.PublishAsync(new UpdateSettingEventData() { GroupName = settingGroupName, SettingScope = settingValues.First().SettingScope, SettingScopeKey = settingValues.First().SettingScopeKey });
}
catch (Exception ex)
{
await uow.RollbackAsync(cancellationToken);
ex.ReThrow();
}
}
}
private void CheckSettingValueType(string settingValue, SettingValueType settingValueType)
{
switch (settingValueType)
{
case SettingValueType.String:
case SettingValueType.JsonObject:
return;
case SettingValueType.Bool:
if(bool.TryParse(settingValue, out var _))
{
return;
}
else
{
throw new ArgumentException($"SettingValue: {settingValue} Can Not Parse To Bool Type");
}
case SettingValueType.Int:
if (int.TryParse(settingValue, out var _))
{
return;
}
else
{
throw new ArgumentException($"SettingValue: {settingValue} Can Not Parse To Int Type");
}
case SettingValueType.Long:
if (long.TryParse(settingValue, out var _))
{
return;
}
else
{
throw new ArgumentException($"SettingValue: {settingValue} Can Not Parse To Long Type");
}
case SettingValueType.Double:
if (double.TryParse(settingValue, out var _))
{
return;
}
else
{
throw new ArgumentException($"SettingValue: {settingValue} Can Not Parse To Double Type");
}
case SettingValueType.Decimal:
if (decimal.TryParse(settingValue, out var _))
{
return;
}
else
{
throw new ArgumentException($"SettingValue: {settingValue} Can Not Parse To Decimal Type");
}
}
}
}
}
这里CheckSettingValueType就是根据SettingValueType做数据校验,如果不符合条件的则拒绝修改。
就这样,数据库的设置管理操作基本完成。
SettingDefinition
数据库完成之后,接下来就是业务层面的事情了,这里我们定义一个ISettingDefinition接口,用作设置组结构的基本定义和作用范围,比如我们邮箱设置里面包含什么参数值,类型,默认值是什么。
ISettingDefinition:
using Wheel.DependencyInjection;
using Wheel.Enums;
namespace Wheel.Settings
{
public interface ISettingDefinition : ITransientDependency
{
string GroupName { get; }
SettingScope SettingScope { get; }
ValueTask> Define();
}
}
EmailSettingDefinition:
using Wheel.Enums;
namespace Wheel.Settings.Email
{
public class EmailSettingDefinition : ISettingDefinition
{
public string GroupName => "EmailSetting";
public SettingScope SettingScope => SettingScope.Golbal;
public ValueTask> Define()
{
return ValueTask.FromResult(new Dictionary
{
{ "SenderName", new(SettingValueType.String, "Wheel") },
{ "Host", new(SettingValueType.String, "smtp.exmail.qq.com") },
{ "Prot", new(SettingValueType.Int, "465") },
{ "UserName", new(SettingValueType.String) },
{ "Password", new(SettingValueType.String) },
{ "UseSsl", new(SettingValueType.Bool, "true") },
});
}
}
}
public record SettingValueParams(SettingValueType SettingValueType, string服务器托管网? DefalutValue = null, string? SettingScopeKey = null);
可以看到这里邮件的设置定义:
GroupName指定是EmailSetting这个分组。
SettingScope指定了是全局范围的设置。
SettingValueParams是一个record结构,包含设置值的类型,默认值以及范围的Key值。
Define里面是一个字典结构,里面定义的邮件发送设置里面所需要的所有参数以及默认值。
SettingDefinition的作用更多在于当数据库没有存储数据时,作为一个默认的结构以及默认值取用。
SettingManageAppService
接下来就需要提供API给客户端交互了,两个接口即可满足,一个用于获取设置,一个用于修改设置。
ISettingManageAppService:
using Wheel.Core.Dto;
using Wheel.DependencyInjection;
using Wheel.Enums;
using Wheel.Services.SettingManage.Dtos;
namespace Wheel.Services.SettingManage
{
public interface ISettingManageAppService : ITransientDependency
{
Task>> GetAllSettingGroup(SettingScope settingScope = SettingScope.Golbal);
Task UpdateSettings(SettingGroupDto settingGroupDto, SettingScope settingScope = SettingScope.Golbal);
}
}
SettingManageAppService:
using Wheel.Core.Dto;
using Wheel.Domain.Settings;
using Wheel.Domain;
using Wheel.Enums;
using Wheel.Services.SettingManage.Dtos;
using Wheel.Settings;
namespace Wheel.Services.SettingManage
{
public class SettingManageAppService : WheelServiceBase, ISettingManageAppService
{
private readonly IBasicRepository _settingGroupRepository;
private readonly IBasicRepository _settingValueRepository;
private readonly SettingManager _settingManager;
public SettingManageAppService(IBasicRepository settingGroupRepository, IBasicRepository settingValueRepository, SettingManager settingManager)
{
_settingGroupRepository = settingGroupRepository;
_settingValueRepository = settingValueRepository;
_settingManager = settingManager;
}
public async Task>> GetAllSettingGroup(SettingScope settingScope = SettingScope.Golbal)
{
var settingDefinitions = ServiceProvider.GetServices().Where(a => a.SettingScope == settingScope);
var settingGroups = await _settingGroupRepository.GetListAsync(a => a.SettingValues.Any(a => a.SettingScope == settingScope && (settingScope == SettingScope.User ? a.SettingScopeKey == CurrentUser.Id : a.SettingScopeKey == null)));
foreach (var settingDefinition in settingDefinitions)
{
if (settingGroups.Any(a => a.Name == settingDefinition.GroupName))
continue;
else
{
var group = new SettingGroup
{
Name = settingDefinition.GroupName,
NormalizedName = settingDefinition.GroupName.ToUpper(),
SettingValues = new List()
};
foreach (var settings in await settingDefinition.Define())
{
group.SettingValues.Add(new SettingValue
{
Key = settings.Key,
Value = settings.Value.DefalutValue,
ValueType = settings.Value.SettingValueType,
SettingScopeKey = settings.Value.SettingScopeKey,
SettingScope = settingScope
});
}
settingGroups.Add(group);
}
}
var settingGroupDtos = Mapper.Map>(settingGroups);
return new R>(settingGroupDtos);
}
public async Task UpdateSettings(SettingGroupDto settingGroupDto, SettingScope settingScope = SettingScope.Golbal)
{
var settings = Mapper.Map>(settingGroupDto.SettingValues);
settings.ForEach(a =>
{
a.SettingScope = settingScope;
a.SettingScopeKey = settingScope == SettingScope.User ? CurrentUser.Id : null;
});
await _settingManager.SetSettingValues(settingGroupDto.Name, settings);
return new R();
}
}
}
这里可以看到GetAllSettingGroup的实现,当数据库取值没有改设置组数据时,获取SettingDefinition的结构返回给客户端。
SettingManageController
SettingManageController很简单,就是包装ISettingManageAppService暴露API出去即可。
using Microsoft.AspNetCore.Mvc;
using Wheel.Core.Dto;
using Wheel.Enums;
using Wheel.Services.SettingManage;
using Wheel.Services.SettingManage.Dtos;
namespace Wheel.Controllers
{
///
/// 设置管理
///
[Route("api/[controller]")]
[ApiController]
public class SettingManageController : WheelControllerBase
{
private readonly ISettingManageAppService _settingManageAppService;
public SettingManageController(ISettingManageAppService settingManageAppService)
{
_settingManageAppService = settingManageAppService;
}
///
/// 获取所有设置
///
/// 设置范围
///
[HttpGet()]
public Task>> GetAllSettingGroup(SettingScope settingScope = SettingScope.Golbal)
{
return _settingManageAppService.GetAllSettingGroup(settingScope);
}
///
/// 更新设置
///
/// 设置组数据
/// 设置范围
///
[HttpPut("{settingScope}")]
public Task UpdateSettings(SettingGroupDto settingGroupDto, SettingScope settingScope)
{
return _settingManageAppService.UpdateSettings(settingGroupDto, settingScope);
}
}
}
就这样与客户端的交互API完成了。
SettingProvider
接下来则是需要实现一个给内部业务获取设置的工具。
SettingProvider用作程序内获取对应设置。直接封装获取全局设置或用户设置。
using Wheel.DependencyInjection;
namespace Wheel.Settings
{
public interface ISettingProvider : ITransientDependency
{
public Task> GetGolbalSettings(string groupKey, CancellationToken cancellationToken = default);
public Task GetGolbalSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default);
public Task GetGolbalSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default) where T : struct;
public Task> GetUserSettings(string groupKey, CancellationToken cancellationToken = default);
public Task GetUserSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default);
public Task GetUserSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default) where T : struct;
}
}
using Microsoft.Extensions.Caching.Distributed;
using Wheel.Core.Users;
using Wheel.Domain.Settings;
using Wheel.Enums;
namespace Wheel.Settings
{
public class DefaultSettingProvider : ISettingProvider
{
private readonly SettingManager _settingManager;
private readonly IDistributedCache _distributedCache;
private readonly ICurrentUser _currentUser;
private readonly IServiceProvider _serviceProvider;
public DefaultSettingProvider(SettingManager settingManager, IDistributedCache distributedCache, ICurrentUser currentUser, IServiceProvider serviceProvider)
{
_settingManager = settingManager;
_distributedCache = distributedCache;
_currentUser = currentUser;
_serviceProvider = serviceProvider;
}
public async Task GetGolbalSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default)
{
var settings = await GetGolbalSettings(groupKey, cancellationToken);
return settings[settingKey];
}
public async Task GetGolbalSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default) where T : struct
{
var settings = await GetGolbalSettings(groupKey, cancellationToken);
return settings[settingKey].To();
}
public async Task> GetGolbalSettings(string groupKey, CancellationToken cancellationToken = default)
{
var cacheSettings = await GetCacheItem(groupKey, SettingScope.Golbal, cancellationToken: cancellationToken);
if(cacheSettings is null)
{
var dbSettings = await _settingManager.GetSettingValues(groupKey, SettingScope.Golbal, cancellationToken: cancellationToken);
if(dbSettings is null)
{
var settingDefinition = _serviceProvider.GetServices().FirstOrDefault(a => a.GroupName == groupKey && a.SettingScope == SettingScope.Golbal);
if(settingDefinition is null)
return new();
else
{
var setting = await settingDefinition.Define();
return setting.ToDictionary(a => a.Key, a => a.Value.DefalutValue)!;
}
}
return dbSettings.ToDictionary(a => a.Key, a => a.Value);
}
else
{
return cacheSettings.ToDictionary(a => a.Key, a => a.Value);
}
}
public async Task GetUserSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default)
{
var settings = await GetUserSettings(groupKey, cancellationToken);
return settings[settingKey];
}
public async Task GetUserSetting(string groupKey, string settingKey, CancellationToken cancellationToken = default) where T : struct
{
var settings = await GetUserSettings(groupKey, cancellationToken);
return settings[settingKey].To();
}
public async Task> GetUserSettings(string groupKey, CancellationToken cancellationToken = default)
{
var cacheSettings = await GetCacheItem(groupKey, SettingScope.User, settingScopeKey: _currentUser.Id, cancellationToken: cancellationToken);
if (cacheSettings is null)
{
var dbSettings = await _settingManager.GetSettingValues(groupKey, SettingScope.User, settingScopeKey: _currentUser.Id, cancellationToken: cancellationToken);
if (dbSettings is null)
{
var settingDefinition = _serviceProvider.GetServices().FirstOrDefault(a => a.GroupName == groupKey && a.SettingScope == SettingScope.User);
if (settingDefinition is null)
return new();
else
{
var setting = await settingDefinition.Define();
return setting.ToDictionary(a => a.Key, a => a.Value.DefalutValue)!;
}
}
return dbSettings.ToDictionary(a => a.Key, a => a.Value);
}
else
{
return cacheSettings.ToDictionary(a => a.Key, a => a.Value);
}
}
private async Task> GetCacheItem(string groupKey, SettingScope settingScope, string settingScopeKey = null, CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(groupKey, settingScope, settingScopeKey);
return await _distributedCache.GetAsync>(cacheKey, cancellationToken);
}
private string BuildCacheKey(string groupKey, SettingScope settingScope, string settingScopeKey)
{
return $"{groupKey}:{settingScope}"+ (settingScope == SettingScope.Golbal ? "" : $":{settingScopeKey}");
}
}
}
using Wheel.Domain.Settings;
using Wheel.Enums;
namespace Wheel.Settings
{
public class SettingValueCacheItem
{
public string Key { get; set; }
public string Value { get; set; }
public SettingValueType ValueType { get; set; }
}
}
这里获取设置时优先从缓存读取,若缓存没有,则取数据库,若数据库再没有,则从SettingDefintion中获取默认值。
那么这里缓存数据从哪里来呢?细心的可以看到上面的SettingManager的修改设置的方法中有一行代码:
await _distributedEventBus.PublishAsync(new UpdateSettingEventData() { GroupName = settingGroupName, SettingScope = settingValues.First().SettingScope, SettingScopeKey = settingValues.First().SettingScopeKey });
这里通过消息队列,通知更新缓存。
UpdateSettingEvent
using Wheel.Enums;
namespace Wheel.EventBus.EventDatas
{
[EventName("UpdateSetting")]
public class UpdateSettingEventData
{
public string GroupName { get; set; }
public SettingScope SettingScope { get; set; }
public string? SettingScopeKey { get; set; }
}
}
using AutoMapper;
using Microsoft.Extensions.Caching.Distributed;
using Wheel.DependencyInjection;
using Wheel.Domain.Settings;
using Wheel.EventBus.Distributed;
using Wheel.EventBus.EventDatas;
using Wheel.Services.SettingManage.Dtos;
namespace Wheel.EventBus.Handlers
{
public class UpdateSettingEventHandler : IDistributedEventHandler, ITransientDependency
{
private readonly SettingManager _settingManager;
private readonly IDistributedCache _distributedCache;
private readonly IMapper _mapper;
public UpdateSettingEventHandler(SettingManager settingManage服务器托管网r, IDistributedCache distributedCache, IMapper mapper)
{
_settingManager = settingManager;
_distributedCache = distributedCache;
_mapper = mapper;
}
public async Task Handle(UpdateSettingEventData eventData, CancellationToken cancellationToken = default)
{
var settings = await _settingManager.GetSettingValues(eventData.GroupName, eventData.SettingScope, eventData.SettingScopeKey, cancellationToken);
await _distributedCache.SetAsync($"Setting:{eventData.GroupName}:{eventData.SettingScope}" + (eventData.SettingScope == Enums.SettingScope.Golbal ? "" : $":{eventData.SettingScopeKey}"), _mapper.Map>(settings));
}
}
}
UpdateSettingEventHandler负责在设置更新之后,获取最新的设置直接塞到缓存当中。
只需一行代码将SettingProvider加入到WheelServiceBase和WheelControllerBase中,后续就可以很方便的获取设置,不需要频繁在构造器注入:
public ISettingProvider SettingProvider => LazyGetService();
测试
启动程序,测试一下获取设置值,这里可以看到,我们通过SettingProvider成功读取了设置。
就这样,我们完成了我们的设置管理功能。
轮子仓库地址https://github.com/Wheel-Framework/Wheel
欢迎进群催更。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net
最近正再使用Wox,这个软件还挺高效的,而且还能自己编写一些插件,这里打算自己写点插件用用. Wox官网 Plugin (wox.one)插件,此外官方也提供了编写文档,编写插件 · GitBook (wox.one)提供Python和C#两种优秀的语言编写方…