文档章节

谈谈.NET Core中基于Generic Host来实现后台任务

SEOwhywhy
 SEOwhywhy
发布于 2018/11/15 09:06
字数 3294
阅读 11
收藏 0

  目录
  
  前言
  
  什么是Generic Host
  
  后台任务示例
  
  控制台形式
  
  消费MQ消息的后台任务
  
  Web形式
  
  部署
  
  IHostedService和BackgroundService的区别
  
  IHostBuilder的扩展写法
  
  总结
  
  前言
  
  很多时候,后台任务对我们来说是一个利器,帮我们在后面处理了成千上万的事情。
  
  在.NET Framework时代,我们可能比较多的就是一个项目,会有一到多个对应的Windows服务,这些Windows服务就可以当作是我们所说的后台任务了。
  
  我喜欢将后台任务分为两大类,一类是不停的跑,好比MQ的消费者,RPC的服务端。另一类是定时的跑,好比定时任务。
  
  那么在.NET Core时代是不是有一些不同的解决方案呢?答案是肯定的。
  
  Generic Host就是其中一种方案,也是本文的主角。
  
  什么是Generic Host
  
  Generic Host是ASP.NET Core 2.1中的新增功能,它的目的是将HTTP管道从Web Host的API中分离出来,从而启用更多的Host方案。
  
  这样可以让基于Generic Host的一些特性延用一些基础的功能。如:如配置、依赖关系注入和日志等。
  
  Generic Host更倾向于通用性,换句话就是说,我们即可以在Web项目中使用,也可以在非Web项目中使用!
  
  虽然有时候后台任务混杂在Web项目中并不是一个太好的选择,但也并不失是一个解决方案。尤其是在资源并不充足的时候。
  
  比较好的做法还是让其独立出来,让它的职责更加单一。
  
  下面就先来看看如何创建后台任务吧。
  
  后台任务示例
  
  我们先来写两个后台任务(一个一直跑,一个定时跑),体验一下这些后台任务要怎么上手,同样也是我们后面要使用到的。
  
  这两个任务统一继承BackgroundService这个抽象类,而不是IHostedService这个接口。后面会说到两者的区别。
  
  一直跑的后台任务
  
  先上代码
  
  public class PrinterHostedService2 : BackgroundService
  
  {
  
  private readonly ILogger _logger;
  
  private readonly AppSettings _settings;
  
  public PrinterHostedService2(ILoggerFactory loggerFactory, IOptionsSnapshot<AppSettings> options)
  
  {
  
  this._logger = loggerFactory.CreateLogger<PrinterHostedService2>();
  
  this._settings = options.Value;
  
  }
  
  public override Task StopAsync(CancellationToken cancellationToken)
  
  {
  
  _logger.LogInformation("Printer2 is stopped");
  
  return Task.CompletedTask;
  
  }
  
  protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  
  {
  
  while (!stoppingToken.IsCancellationRequested)
  
  {
  
  _logger.LogInformation($"Printer2 is working. {_settings.PrinterDelaySecond}");
  
  await Task.Delay(TimeSpan.FromSeconds(_settings.PrinterDelaySecond), stoppingToken);
  
  }
  
  }
  
  }
  
  来看看里面的细节。
  
  我们的这个服务继承了BackgroundService,就一定要实现里面的ExecuteAsync,至于StartAsync和StopAsync等方法可以选择性的override。
  
  我们ExecuteAsync在里面就是输出了一下日志,然后休眠在配置文件中指定的秒数。
  
  这个任务可以说是最简单的例子了,其中还用到了依赖注入,如果想在任务中注入数据仓储之类的,应该就不需要再多说了。
  
  同样的方式再写一个定时的。
  
  定时跑的后台任务
  
  这里借助了Timer来完成定时跑的功能,同样的还可以结合Quartz来完成。
  
  public class TimerHostedService : BackgroundService
  
  {
  
  //other ...
  
  private Timer _timer;
  
  protected override Task ExecuteAsync(CancellationToken stoppingToken)
  
  {
  
  _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(_settings.TimerPeriod));
  
  return Task.CompletedTask;
  
  }
  
  private void DoWork(object state)
  
  {
  
  _logger.LogInformation("Timer is working");
  
  }
  
  public override Task StopAsync(CancellationToken cancellationToken)
  
  {
  
  _logger.LogInformation("Timer is stopping");
  
  _timer?.Change(Timeout.Infinite, 0);
  
  return base.StopAsync(cancellationToken);
  
  }
  
  public override void Dispose()
  
  {
  
  _timer?.Dispose();
  
  base.Dispose();
  
  }
  
  }
  
  和第一个后台任务相比,没有太大的差异。
  
  下面我们先来看看如何用控制台的形式来启动这两个任务。
  
  控制台形式
  
  这里会同时引入NLog来记录任务跑的日志,方便我们观察。
  
  Main函数的代码如下:
  
  class Program
  
  {
  
  static async Task Main(string[] args)
  
  {
  
  var builder = new HostBuilder()
  
  //logging
  
  .ConfigureLogging(factory =>
  
  {
  
  //use nlog
  
  factory.AddNLog(new NLogProviderOptions { CaptureMessageTemplates = true, CaptureMessageProperties = true });
  
  NLog.LogManager.LoadConfiguration("nlog.config");
  
  })
  
  //host config
  
  .ConfigureHostConfiguration(config =>
  
  {
  
  //command line
  
  if (args != null)
  
  {
  
  config.AddCommandLine(args);
  
  }
  
  })
  
  //app config
  
  .ConfigureAppConfiguration((hostContext, config) =>
  
  {
  
  var env = hostContext.HostingEnvironment;
  
  config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
  
  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
  
  config.AddEnvironmentVariables();
  
  if (args != null)
  
  {
  
  config.AddCommandLine(args);
  
  }
  
  })
  
  //service
  
  .ConfigureServices((hostContext, services) =>
  
  {
  
  services.AddOptions();
  
  services.Configure<AppSettings>(hostContext.Configuration.GetSection("AppSettings"));
  
  //basic usage
  
  services.AddHostedService<PrinterHostedService2>();
  
  services.AddHostedService<TimerHostedService>();
  
  }) ;
  
  //console
  
  await builder.RunConsoleAsync();
  
  ////start and wait for shutdown
  
  //var host = builder.Build();
  
  //using (host)
  
  //{
  
  //    await host.StartAsync();
  
  //    await host.WaitForShutdownAsync();
  
  //}
  
  }
  
  }
  
  对于控制台的方式,需要我们对HostBuilder有一定的了解,虽说它和WebHostBuild有相似的地方。可能大部分时候,我们是直接使用了WebHost.CreateDefaultBuilder(args)来构造的,如果对CreateDefaultBuilder里面的内容没有了解,那么对上面的代码可能就不会太清晰。
  
  上述代码的大致流程如下:
  
  new一个HostBuilder对象
  
  配置日志,主要是接入了NLog
  
  Host的配置,这里主要是引入了CommandLine,因为需要传递参数给程序
  
  应用的配置,指定了配置文件,和引入CommandLine
  
  Service的配置,这个就和我们在Startup里面写的差不多了,最主要的是我们的后台服务要在这里注入
  
  启动
  
  其中,
  
  2-5的顺序可以按个人习惯来写,里面的内容也和我们写Startup大同小异。
  
  第6步,启动的时候,有多种方式,这里列出了两种行为等价的方式。
  
  a. 通过RunConsoleAsync的方式来启动
  
  b. 先StartAsync然后再WaitForShutdownAsync
  
  RunConsoleAsync的奥秘,我觉得还是直接看下面的代码比较容易懂。
  
  /// <summary>
  
  /// Listens for Ctrl+C or SIGTERM and calls <see cref="IApplicationLifetime.StopApplication"/> to start the shutdown process.
  
  /// This will unblock extensions like RunAsync and WaitForShutdownAsync.
  
  /// </summary>
  
  /// <param name="hostBuilder">The <see cref="IHostBuilder" /> to configure.</param>
  
  /// <returns>The same instance of the <see cref="IHostBuilder"/> for chaining.</returns>
  
  public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder)
  
  {
  
  return hostBuilder.ConfigureServices((context, collection) => collection.AddSingleton<IHostLifetime, ConsoleLifetime>());
  
  }
  
  /// <summary>
  
  /// Enables console support, builds and starts the host, and waits for Ctrl+C or SIGTERM to shut down.
  
  /// </summary>
  
  /// <param name="hostBuilder">The <see cref="IHostBuilder" /> to configure.</param>
  
  /// <param name="cancellationToken"></param>
  
  /// <returns>www.hjpt521.com </returns>
  
  public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
  
  {
  
  return hostBuilder.UseConsoleLifetime().Build().RunAsync(cancellationToken);
  
  }
  
  这里涉及到了一个比较重要的IHostLifetime,Host的生命周期,ConsoleLifeTime是默认的一个,可以理解成当接收到ctrl+c这样的指令时,它就会触发停止。
  
  接下来,写一下nlog的配置文件
  
  <?xml version="1.0" encoding="utf-8" ?>
  
  <nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xsi:schemaLocation="NLog NLog.xsd"
  
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  
  autoReload="true"
  
  internalLogLevel="Info" >
  
  <targets>
  
  <target xsi:type=www.xycheng178.com"File"
  
  name="ghost"
  
  fileName="logs/ghost.log"
  
  layout="${date}|www.gcyl152.com${www.furong157.com level:uppercase=true}|${message}" />
  
  </targets>
  
  <rules>
  
  <logger name="GHost.*" minlevel="Info" writeTo="ghost" />
  
  <logger name="Microsoft.*" minlevel="Info" writeTo="ghost" />
  
  </rules>
  
  </nlog>
  
  这个时候已经可以通过命令启动我们的应用了。
  
  dotnet run -- --environment Staging
  
  这里指定了运行环境为Staging,而不是默认的Production。
  
  在构造HostBuilder的时候,可以通过UseEnvironment或ConfigureHostConfiguration直接指定运行环境,但是个人更加倾向于在启动命令中去指定,避免一些不可控因素。
  
  这个时候大致效果如下:
  
  虽然效果已经出来了,不过大家可能会觉得这个有点小打小闹,下面来个略微复杂一点的后台任务,用来监听并消费RabbitMQ的消息。
  
  消费MQ消息的后台任务
  
  public class ComsumeRabbitMQHostedService : BackgroundService
  
  {
  
  private readonly ILogger _logger;
  
  private readonly AppSettings www.mengzhidu178.com_settings;
  
  private IConnection _connection;
  
  private IModel _channel;
  
  public ComsumeRabbitMQHostedService(ILoggerFactory loggerFactory, IOptionsSnapshot<AppSettings> options)
  
  {
  
  this._logger = loggerFactory.CreateLogger<ComsumeRabbitMQHostedService>();
  
  this._settings = options.Value;
  
  InitRabbitMQ(this._settings);
  
  }
  
  private void InitRabbitMQ(AppSettings settings)
  
  {
  
  var factory = new ConnectionFactory { HostName = settings.HostName, };
  
  _connection = factory.CreateConnection();
  
  _channel = _connection.CreateModel(www.yongxinzaixian.cn);
  
  _channel.ExchangeDeclare(_settings.ExchangeName, ExchangeType.Topic);
  
  _channel.QueueDeclare(_settings.QueueName, false, false, false, null);
  
  _channel.QueueBind(_settings.QueueName, _settings.ExchangeName, _settings.RoutingKey, null);
  
  _channel.BasicQos(0, 1, false);
  
  _connection.ConnectionShutdown +www.dfgjyl.cn= RabbitMQ_ConnectionShutdown;
  
  }
  
  protected override Task ExecuteAsync(CancellationToken stoppingToken)
  
  {
  
  stoppingToken.ThrowIfCancellationRequested();
  
  var consumer = new EventingBasicConsumer(_channel);
  
  consumer.Received += (ch, ea) =>
  
  {
  
  var content = System.Text.Encoding.UTF8.GetString(ea.Body);
  
  HandleMessage(content);
  
  _channel.BasicAck(ea.DeliveryTag, false);
  
  };
  
  consumer.Shutdown += OnConsumerShutdown;
  
  consumer.Registered www.mcyllpt.com +www.ysyl157.com = OnConsumerRegistered;
  
  consumer.Unregistered += OnConsumerUnregistered;
  
  consumer.ConsumerCancelled www.michenggw.com+= OnConsumerConsumerCancelled;
  
  _channel.BasicConsume(_settings.QueueName, false, consumer);
  
  return Task.CompletedTask;
  
  }
  
  private void HandleMessage(string content)
  
  {
  
  _logger.LogInformation($"consumer received {content}");
  
  }
  
  private void OnConsumerConsumerCancelled(www.furggw.com object sender, ConsumerEventArgs e)  { ... }
  
  private void OnConsumerUnregistered(object sender, ConsumerEventArgs e) { ... }
  
  private void OnConsumerRegistered(object sender, ConsumerEventArgs e) { ... }
  
  private void OnConsumerShutdown(object sender, ShutdownEventArgs e) { ... }
  
  private void RabbitMQ_ConnectionShutdown(object sender, ShutdownEventArgs e)  { ... }
  
  public override void Dispose()
  
  {
  
  _channel.Close();
  
  _connection.Close();
  
  base.Dispose();
  
  }
  
  }
  
  代码细节就不需要多说了,下面就启动MQ发送程序来模拟消息的发送
  
  同时看我们任务的日志输出
  
  由启动到停止,效果都是符合我们预期的。
  
  下面再来看看Web形式的后台任务是怎么处理的。
  
  Web形式
  
  这种模式下的后台任务,其实就是十分简单的了。
  
  我们只要在Startup的ConfigureServices方法里面注册我们的几个后台任务就可以了。
  
  public void ConfigureServices(IServiceCollection services)
  
  {
  
  services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
  
  services.AddHostedService<PrinterHostedService2>();
  
  services.AddHostedService<TimerHostedService>();
  
  services.AddHostedService<ComsumeRabbitMQHostedService>();
  
  }
  
  启动Web站点后,我们发了20条MQ消息,再访问了一下Web站点的首页,最后是停止站点。
  
  下面是日志结果,都是符合我们的预期。
  
  可能大家会比较好奇,这三个后台任务是怎么混合在Web项目里面启动的。
  
  答案就在下面的两个链接里。
  
  https://github.com/aspnet/Hosting/blob/2.1.1/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs#L153
  
  https://github.com/aspnet/Hosting/blob/2.1.1/src/Microsoft.AspNetCore.Hosting/Internal/HostedServiceExecutor.cs
  
  上面说了那么多,都是在本地直接运行的,可能大家会比较关注这个要怎样部署,下面我们就不看看怎么部署。
  
  部署
  
  部署的话,针对不同的情形(web和非web)都有不同的选择。
  
  正常来说,如果本身就是web程序,那么平时我们怎么部署的,就和平时那样部署即可。
  
  花点时间讲讲部署非web的情形。
  
  其实这里的部署等价于让程序在后台运行。
  
  在Linux下面让程序在后台运行方式有好多好多,Supervisor、Screen、pm2、systemctl等。
  
  这里主要介绍一下systemctl,同时用上面的例子来进行部署,由于个人服务器没有MQ环境,所以没有启用消费MQ的后台任务。
  
  先创建一个 service 文件
  
  vim /etc/systemd/system/ghostdemo.service
  
  内容如下:
  
  [Unit]
  
  Description=Generic Host Demo
  
  [Service]
  
  WorkingDirectory=/var/www/ghost
  
  ExecStart=/usr/bin/dotnet /var/www/ghost/ConsoleGHost.dll --environment Staging
  
  KillSignal=SIGINT
  
  SyslogIdentifier=ghost-example
  
  [Install]
  
  WantedBy=multi-user.target
  
  其中,各项配置的含义可以自行查找,这里不作说明。
  
  然后可以通过下面的命令来启动和停止这个服务
  
  service ghostdemo start
  
  service ghostdemo stop
  
  测试无误之后,就可以设为自启动了。
  
  systemctl enable ghostdemo.service
  
  下面来看看运行的效果
  
  我们先启动服务,然后去查看实时日志,可以看到应用的日志不停的输出。
  
  当我们停了服务,再看实时日志,就会发现我们的两个后台任务已经停止了,也没有日志再进来了。
  
  再去看看服务系统日志
  
  sudo journalctl -fu ghostdemo.service
  
  发现它确实也是停了。
  
  在这里,我们还可以看到服务的当前环境和根路径。
  
  IHostedService和BackgroundService的区别
  
  前面的所有示例中,我们用的都是BackgroundService,而不是IHostedService。
  
  这两者有什么区别呢?
  
  可以这样简单的理解,IHostedService是原料,BackgroundService是一个用原料加工过一部分的半成品。
  
  这两个都是不能直接当成成品来用的,都需要进行加工才能做成一个可用的成品。
  
  同时也意味着,如果使用IHostedService可能会需要做比较多的控制。
  
  基于前面的打印后台任务,在这里使用IHostedService来实现。
  
  如果我们只是纯綷的把实现代码放到StartAsync方法中,那么可能就会有惊喜了。
  
  public class PrinterHostedService : IHostedService, IDisposable
  
  {
  
  //other ....
  
  public async Task StartAsync(CancellationToken cancellationToken)
  
  {
  
  while (!cancellationToken.IsCancellationRequested)
  
  {
  
  Console.WriteLine("Printer is working.");
  
  await Task.Delay(TimeSpan.FromSeconds(_settings.PrinterDelaySecond), cancellationToken);
  
  }
  
  }
  
  public Task StopAsync(CancellationToken cancellationToken)
  
  {
  
  Console.WriteLine("Printer is stopped");
  
  return Task.CompletedTask;
  
  }
  
  }
  
  运行之后,想用ctrl+c来停止,发现还是一直在跑。
  
  ps一看,这个进程还在,kill掉之后才不会继续输出。。
  
  问题出在那里呢?原因其实还是比较明显的,因为这个任务还没有启动成功,一直处于启动中的状态!
  
  换句话说,StartAsync方法还没有执行完。这个问题一定要小心再小心。
  
  要怎么处理这个问题呢?解决方法也比较简单,可以通过引用一个变量来记录要运行的任务,将其从StartAsync方法中解放出来。
  
  public class PrinterHostedService3 : IHostedService, IDisposable
  
  {
  
  //others .....
  
  private bool _stopping;
  
  private Task _backgroundTask;
  
  public Task StartAsync(CancellationToken cancellationToken)
  
  {
  
  Console.WriteLine("Printer3 is starting.");
  
  _backgroundTask = BackgroundTask(cancellationToken);
  
  return Task.CompletedTask;
  
  }
  
  private async Task BackgroundTask(CancellationToken cancellationToken)
  
  {
  
  while (!_stopping)
  
  {
  
  await Task.Delay(TimeSpan.FromSeconds(_settings.PrinterDelaySecond),cancellationToken);
  
  Console.WriteLine("Printer3 is doing background work.");
  
  }
  
  }
  
  public Task StopAsync(CancellationToken cancellationToken)
  
  {
  
  Console.WriteLine("Printer3 is stopping.");
  
  _stopping = true;
  
  return Task.CompletedTask;
  
  }
  
  public void Dispose()
  
  {
  
  Console.WriteLine("Printer3 is disposing.");
  
  }
  
  }
  
  这样就能让这个任务真正的启动成功了!效果就不放图了。
  
  相对来说,BackgroundService用起来会比较简单,实现核心的ExecuteAsync这个抽象方法就差不多了,出错的概率也会比较低。
  
  IHostBuilder的扩展写法
  
  在注册服务的时候,我们还可以通过编写IHostBuilder的扩展方法来完成。
  
  public static class Extensions
  
  {
  
  public static IHostBuilder UseHostedService<T>(this IHostBuilder hostBuilder)
  
  where T : class, IHostedService, IDisposable
  
  {
  
  return hostBuilder.ConfigureServices(services =>
  
  services.AddHostedService<T>());
  
  }
  
  public static IHostBuilder UseComsumeRabbitMQ(this IHostBuilder hostBuilder)
  
  {
  
  return hostBuilder.ConfigureServices(services =>
  
  services.AddHostedService<ComsumeRabbitMQHostedService>());
  
  }
  
  }
  
  使用的时候就可以像下面一样。
  
  var builder = new HostBuilder()
  
  //others ...
  
  .ConfigureServices((hostContext, services) =>
  
  {
  
  services.AddOptions();
  
  services.Configure<AppSettings>(hostContext.Configuration.GetSection("AppSettings"));
  
  //basic usage
  
  //services.AddHostedService<PrinterHostedService2>();
  
  //services.AddHostedService<TimerHostedService>();
  
  //services.AddHostedService<ComsumeRabbitMQHostedService>();
  
  })
  
  //extensions usage
  
  .UseComsumeRabbitMQ()
  
  .UseHostedService<TimerHostedService>()
  
  .UseHostedService<PrinterHostedService2>()
  
  //.UseHostedService<ComsumeRabbitMQHostedService>()
  
  ;
  
  总结
  
  Generic Host让我们可以用熟悉的方式来处理后台任务,不得不说这是一个很的特性。
  
  无论是将后台任务独立一个项目,还是将其混搭在Web项目中,都已经符合不少应用的情景了。
  
  最后放上本文用到的示例代码
  
  GenericHostDemo

© 著作权归作者所有

SEOwhywhy
粉丝 5
博文 109
码字总数 239543
作品 0
私信 提问
.Net Core小技巧 - Hosted Services + Quartz实现定时任务调度

背景   之前一直有朋友问,.Net Core + Linux环境有没有类似Windows服务的东西。其实是有的,我了解的方法有两种:   #1 创建一个ASP.Net Core的Web项目(如Web API),然后通过添加中间...

dotNET跨平台
2018/07/03
0
0
.Net Core 实践 - 如何在控制台应用(.Net Core)使用appsettings.json配置

新建控制台应用(.Net Core)程序 添加json文件,命名为appsettings.json,设置文件属性 。添加内容如下 nuget添加相关引用 依次添加以下引用 实现思路 在看到《.NET 通用主机》的文章之后,认...

repeatedly
02/18
0
0
.Net Core 简单定时任务框架封装

  有段日子没有更新,写点东西冒个泡 。这篇文章过来讲个小东西,也是大家在日常开发中也经常需要面临的问题:后台定时任务处理。估计大家看到这句就已经联想到 QuartZ 等类似第三方类库了...

KevinCC
2018/11/30
0
0
Asp.Net Core 轻松学-基于微服务的后台任务调度管理器

前言     在 Asp.Net Core 中,我们常常使用 System.Threading.Timer 这个定时器去做一些需要长期在后台运行的任务,但是这个定时器在某些场合却不太灵光,而且常常无法控制启动和停止,...

Ron.liang
2018/12/07
0
0
Asp.Net Core 轻松学-多线程之Task(补充)

前言     在上一章 Asp.Net Core 轻松学-多线程之Task快速上手 文章中,介绍了使用Task的各种常用场景,但是感觉有部分内容还没有完善,在这里补充一下。 1. 任务的等待 在使用 Task 进行...

Ron.liang
01/02
0
0

没有更多内容

加载失败,请刷新页面

加载更多

好程序员分享Python自动化运维开发实战 六、流程控制

好程序员分享Python自动化运维开发实战 六、流程控制 PYTHON 条件语句 Python条件语句是通过一条或多条语句的执行结果(True或者False)来决定执行的代码块。 Python程序语言指定任何非0和非...

好程序员IT
25分钟前
0
0
5年经验的Java工程师面试答不出反射和动态代理!怕是只会CRUD哦

分享阿里 P8 高级架构师吐血总结的 《BATJ大厂高级Java必问面试学习视频》,附送 100G 面试学习视频文档 阿里 P8 级高级架构师吐血总结的面试学习视频, 内容覆盖很广,分布式缓存、RPC 调用、...

戎码益深
45分钟前
1
0
加权查询

https://www.elastic.co/guide/cn/elasticsearch/guide/current/_cross_fields_queries.html 假设二维向量,如果一个维度设为无穷大,无论另一个维度为多大(具体值),向量都是与坐标轴平行...

Java搬砖工程师
47分钟前
3
0
GLIBC等相关文件升级更新

###参考资料:https://blog.csdn.net/glongljl/article/details/80156243###安装更新libstdc++.so文件https://blog.csdn.net/xdzhangzhenhao/article/details/80746403找一个更高版本的......

KYO4321
47分钟前
2
0
C# Dev XtraTabControl添加关闭页签功能

这个功能在界面操作上很常见,记录下,希望可以帮助您,请持续关注我。 一、添加关闭功能 若要tabpage显示关闭按钮,把xtraTabControl的ClosePageButtonShowMode属性设为InAllTabPageHeaders...

黑鹰客栈
53分钟前
1
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部