.NET Core基于Generic Host實(shí)現(xiàn)后臺(tái)任務(wù)方法教程
前言
很多時(shí)候,后臺(tái)任務(wù)對(duì)我們來說是一個(gè)利器,幫我們?cè)诤竺嫣幚砹顺汕先f的事情。
在.NET Framework時(shí)代,我們可能比較多的就是一個(gè)項(xiàng)目,會(huì)有一到多個(gè)對(duì)應(yīng)的Windows服務(wù),這些Windows服務(wù)就可以當(dāng)作是我們所說的后臺(tái)任務(wù)了。
我喜歡將后臺(tái)任務(wù)分為兩大類,一類是不停的跑,好比MQ的消費(fèi)者,RPC的服務(wù)端。另一類是定時(shí)的跑,好比定時(shí)任務(wù)。
那么在.NET Core時(shí)代是不是有一些不同的解決方案呢?答案是肯定的。
Generic Host就是其中一種方案,也是本文的主角。
什么是Generic Host
Generic Host是ASP.NET Core 2.1中的新增功能,它的目的是將HTTP管道從Web Host的API中分離出來,從而啟用更多的Host方案。
現(xiàn)在2.1版本的Asp.Net Core中,有了兩種可用的Host。
Web Host –適用于托管Web程序的Host,就是我們所熟悉的在Asp.Net Core應(yīng)用程序的Mai函數(shù)中用CreateWebHostBuilder創(chuàng)建出來的常用的WebHost。

Generic Host (ASP.NET Core 2.1版本才有) – 適用于托管非 Web 應(yīng)用(例如,運(yùn)行后臺(tái)任務(wù)的應(yīng)用)。 在未來的版本中,通用主機(jī)將適用于托管任何類型的應(yīng)用,包括 Web 應(yīng)用。 通用主機(jī)最終將取代 Web 主機(jī),這大概也是這種類型的主機(jī)叫做通用主機(jī)的原因。
這樣可以讓基于Generic Host的一些特性延用一些基礎(chǔ)的功能。如:如配置、依賴關(guān)系注入和日志等。
Generic Host更傾向于通用性,換句話就是說,我們即可以在Web項(xiàng)目中使用,也可以在非Web項(xiàng)目中使用!
雖然有時(shí)候后臺(tái)任務(wù)混雜在Web項(xiàng)目中并不是一個(gè)太好的選擇,但也并不失是一個(gè)解決方案。尤其是在資源并不充足的時(shí)候。
比較好的做法還是讓其獨(dú)立出來,讓它的職責(zé)更加單一。
下面就先來看看如何創(chuàng)建后臺(tái)任務(wù)吧。
后臺(tái)任務(wù)示例
我們先來寫兩個(gè)后臺(tái)任務(wù)(一個(gè)一直跑,一個(gè)定時(shí)跑),體驗(yàn)一下這些后臺(tái)任務(wù)要怎么上手,同樣也是我們后面要使用到的。
這兩個(gè)任務(wù)統(tǒng)一繼承BackgroundService這個(gè)抽象類,而不是IHostedService這個(gè)接口。后面會(huì)說到兩者的區(qū)別。
1、一直跑的后臺(tái)任務(wù)
先上代碼
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);
}
}
}
來看看里面的細(xì)節(jié)。
我們的這個(gè)服務(wù)繼承了BackgroundService,就一定要實(shí)現(xiàn)里面的ExecuteAsync,至于StartAsync和StopAsync等方法可以選擇性的override。
我們ExecuteAsync在里面就是輸出了一下日志,然后休眠在配置文件中指定的秒數(shù)。
這個(gè)任務(wù)可以說是最簡(jiǎn)單的例子了,其中還用到了依賴注入,如果想在任務(wù)中注入數(shù)據(jù)倉儲(chǔ)之類的,應(yīng)該就不需要再多說了。
同樣的方式再寫一個(gè)定時(shí)的。
定時(shí)跑的后臺(tái)任務(wù)
這里借助了Timer來完成定時(shí)跑的功能,同樣的還可以結(jié)合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();
}
}
和第一個(gè)后臺(tái)任務(wù)相比,沒有太大的差異。
下面我們先來看看如何用控制臺(tái)的形式來啟動(dòng)這兩個(gè)任務(wù)。
控制臺(tái)形式
這里會(huì)同時(shí)引入NLog來記錄任務(wù)跑的日志,方便我們觀察。
Main函數(shù)的代碼如下:
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();
//}
}
}
對(duì)于控制臺(tái)的方式,需要我們對(duì)HostBuilder有一定的了解,雖說它和WebHostBuild有相似的地方??赡艽蟛糠謺r(shí)候,我們是直接使用了WebHost.CreateDefaultBuilder(args)來構(gòu)造的,如果對(duì)CreateDefaultBuilder里面的內(nèi)容沒有了解,那么對(duì)上面的代碼可能就不會(huì)太清晰。
上述代碼的大致流程如下:
- new一個(gè)HostBuilder對(duì)象
- 配置日志,主要是接入了NLog
- Host的配置,這里主要是引入了CommandLine,因?yàn)樾枰獋鬟f參數(shù)給程序
- 應(yīng)用的配置,指定了配置文件,和引入CommandLine
- Service的配置,這個(gè)就和我們?cè)赟tartup里面寫的差不多了,最主要的是我們的后臺(tái)服務(wù)要在這里注入
- 啟動(dòng)
其中,
2-5的順序可以按個(gè)人習(xí)慣來寫,里面的內(nèi)容也和我們寫Startup大同小異。
第6步,啟動(dòng)的時(shí)候,有多種方式,這里列出了兩種行為等價(jià)的方式。
a. 通過RunConsoleAsync的方式來啟動(dòng)
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></returns>
public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
{
return hostBuilder.UseConsoleLifetime().Build().RunAsync(cancellationToken);
}
這里涉及到了一個(gè)比較重要的IHostLifetime,Host的生命周期,ConsoleLifeTime是默認(rèn)的一個(gè),可以理解成當(dāng)接收到ctrl+c這樣的指令時(shí),它就會(huì)觸發(fā)停止。
接下來,寫一下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="File"
name="ghost"
fileName="logs/ghost.log"
layout="${date}|${level:uppercase=true}|${message}" />
</targets>
<rules>
<logger name="GHost.*" minlevel="Info" writeTo="ghost" />
<logger name="Microsoft.*" minlevel="Info" writeTo="ghost" />
</rules>
</nlog>
這個(gè)時(shí)候已經(jīng)可以通過命令啟動(dòng)我們的應(yīng)用了。
dotnet run -- --environment Staging
這里指定了運(yùn)行環(huán)境為Staging,而不是默認(rèn)的Production。
在構(gòu)造HostBuilder的時(shí)候,可以通過UseEnvironment或ConfigureHostConfiguration直接指定運(yùn)行環(huán)境,但是個(gè)人更加傾向于在啟動(dòng)命令中去指定,避免一些不可控因素。
這個(gè)時(shí)候大致效果如下:

雖然效果已經(jīng)出來了,不過大家可能會(huì)覺得這個(gè)有點(diǎn)小打小鬧,下面來個(gè)略微復(fù)雜一點(diǎn)的后臺(tái)任務(wù),用來監(jiān)聽并消費(fèi)RabbitMQ的消息。
消費(fèi)MQ消息的后臺(tái)任務(wù)
public class ComsumeRabbitMQHostedService : BackgroundService
{
private readonly ILogger _logger;
private readonly AppSettings _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();
_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 += 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 += OnConsumerRegistered;
consumer.Unregistered += OnConsumerUnregistered;
consumer.ConsumerCancelled += OnConsumerConsumerCancelled;
_channel.BasicConsume(_settings.QueueName, false, consumer);
return Task.CompletedTask;
}
private void HandleMessage(string content)
{
_logger.LogInformation($"consumer received {content}");
}
private void OnConsumerConsumerCancelled(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();
}
}
代碼細(xì)節(jié)就不需要多說了,下面就啟動(dòng)MQ發(fā)送程序來模擬消息的發(fā)送

同時(shí)看我們?nèi)蝿?wù)的日志輸出

由啟動(dòng)到停止,效果都是符合我們預(yù)期的。
下面再來看看Web形式的后臺(tái)任務(wù)是怎么處理的。
Web形式
這種模式下的后臺(tái)任務(wù),其實(shí)就是十分簡(jiǎn)單的了。
我們只要在Startup的ConfigureServices方法里面注冊(cè)我們的幾個(gè)后臺(tái)任務(wù)就可以了。
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddHostedService<PrinterHostedService2>();
services.AddHostedService<TimerHostedService>();
services.AddHostedService<ComsumeRabbitMQHostedService>();
}
啟動(dòng)Web站點(diǎn)后,我們發(fā)了20條MQ消息,再訪問了一下Web站點(diǎn)的首頁,最后是停止站點(diǎn)。
下面是日志結(jié)果,都是符合我們的預(yù)期。

可能大家會(huì)比較好奇,這三個(gè)后臺(tái)任務(wù)是怎么混合在Web項(xiàng)目里面啟動(dòng)的。
答案就在下面的兩個(gè)鏈接里。
https://github.com/aspnet/Hosting/blob/2.1.1/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs
https://github.com/aspnet/Hosting/blob/2.1.1/src/Microsoft.AspNetCore.Hosting/Internal/HostedServiceExecutor.cs
上面說了那么多,都是在本地直接運(yùn)行的,可能大家會(huì)比較關(guān)注這個(gè)要怎樣部署,下面我們就不看看怎么部署。
部署
部署的話,針對(duì)不同的情形(web和非web)都有不同的選擇。
正常來說,如果本身就是web程序,那么平時(shí)我們?cè)趺床渴鸬?,就和平時(shí)那樣部署即可。
花點(diǎn)時(shí)間講講部署非web的情形。
其實(shí)這里的部署等價(jià)于讓程序在后臺(tái)運(yùn)行。
在Linux下面讓程序在后臺(tái)運(yùn)行方式有好多好多,Supervisor、Screen、pm2、systemctl等。
這里主要介紹一下systemctl,同時(shí)用上面的例子來進(jìn)行部署,由于個(gè)人服務(wù)器沒有MQ環(huán)境,所以沒有啟用消費(fèi)MQ的后臺(tái)任務(wù)。
先創(chuàng)建一個(gè) service 文件
vim /etc/systemd/system/ghostdemo.service
內(nèi)容如下:
[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
其中,各項(xiàng)配置的含義可以自行查找,這里不作說明。
然后可以通過下面的命令來啟動(dòng)和停止這個(gè)服務(wù)
service ghostdemo start service ghostdemo stop
測(cè)試無誤之后,就可以設(shè)為自啟動(dòng)了。
systemctl enable ghostdemo.service
下面來看看運(yùn)行的效果

我們先啟動(dòng)服務(wù),然后去查看實(shí)時(shí)日志,可以看到應(yīng)用的日志不停的輸出。
當(dāng)我們停了服務(wù),再看實(shí)時(shí)日志,就會(huì)發(fā)現(xiàn)我們的兩個(gè)后臺(tái)任務(wù)已經(jīng)停止了,也沒有日志再進(jìn)來了。
再去看看服務(wù)系統(tǒng)日志
sudo journalctl -fu ghostdemo.service

發(fā)現(xiàn)它確實(shí)也是停了。
在這里,我們還可以看到服務(wù)的當(dāng)前環(huán)境和根路徑。
IHostedService和BackgroundService的區(qū)別
前面的所有示例中,我們用的都是BackgroundService,而不是IHostedService。
這兩者有什么區(qū)別呢?
可以這樣簡(jiǎn)單的理解,IHostedService是原料,BackgroundService是一個(gè)用原料加工過一部分的半成品。
這兩個(gè)都是不能直接當(dāng)成成品來用的,都需要進(jìn)行加工才能做成一個(gè)可用的成品。
同時(shí)也意味著,如果使用IHostedService可能會(huì)需要做比較多的控制。
基于前面的打印后臺(tái)任務(wù),在這里使用IHostedService來實(shí)現(xiàn)。
如果我們只是純綷的把實(shí)現(xiàn)代碼放到StartAsync方法中,那么可能就會(huì)有驚喜了。
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;
}
}
運(yùn)行之后,想用ctrl+c來停止,發(fā)現(xiàn)還是一直在跑。

ps一看,這個(gè)進(jìn)程還在,kill掉之后才不會(huì)繼續(xù)輸出。。

問題出在那里呢?原因其實(shí)還是比較明顯的,因?yàn)檫@個(gè)任務(wù)還沒有啟動(dòng)成功,一直處于啟動(dòng)中的狀態(tài)!
換句話說,StartAsync方法還沒有執(zhí)行完。這個(gè)問題一定要小心再小心。
要怎么處理這個(gè)問題呢?解決方法也比較簡(jiǎn)單,可以通過引用一個(gè)變量來記錄要運(yùn)行的任務(wù),將其從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.");
}
}
這樣就能讓這個(gè)任務(wù)真正的啟動(dòng)成功了!效果就不放圖了。
相對(duì)來說,BackgroundService用起來會(huì)比較簡(jiǎn)單,實(shí)現(xiàn)核心的ExecuteAsync這個(gè)抽象方法就差不多了,出錯(cuò)的概率也會(huì)比較低。
IHostBuilder的擴(kuò)展寫法
在注冊(cè)服務(wù)的時(shí)候,我們還可以通過編寫IHostBuilder的擴(kuò)展方法來完成。
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>());
}
}
使用的時(shí)候就可以像下面一樣。
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>()
;
總結(jié)
Generic Host讓我們可以用熟悉的方式來處理后臺(tái)任務(wù),不得不說這是一個(gè)很👍的特性。
無論是將后臺(tái)任務(wù)獨(dú)立一個(gè)項(xiàng)目,還是將其混搭在Web項(xiàng)目中,都已經(jīng)符合不少應(yīng)用的情景了。
最后放上本文用到的示例代碼
好了,以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,如果有疑問大家可以留言交流,謝謝大家對(duì)腳本之家的支持。
相關(guān)文章
Debian 8或Debian 9(64 位)安裝 .NET Core
這篇文章主要為大家詳細(xì)介紹了Debian 8或Debian 9(64 位)安裝 .NET Core,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-03-03
ASP.NET Core實(shí)現(xiàn)AES-GCM加密算法
這篇文章介紹了ASP.NET Core實(shí)現(xiàn)AES-GCM加密的方法,文中通過示例代碼介紹的非常詳細(xì)。對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-07-07
ASP.NET 在下載文件時(shí)對(duì)其重命名的思路及實(shí)現(xiàn)方法
ASP.NET 在下載文件時(shí)對(duì)其重命名的思路及實(shí)現(xiàn)方法,需要的朋友可以參考一下2013-06-06
ADO.NET中的五個(gè)主要對(duì)象的詳細(xì)介紹與應(yīng)用
ADO.NET中的五個(gè)主要對(duì)象:Connection、Command、DataAdapter DataSet、DataReader詳細(xì)介紹與應(yīng)用,感興趣的朋友可以參考下2012-12-12
ASP.NET?使用?Dispose?釋放資源的四種方法詳細(xì)介紹
本篇文章主要介紹了ASP.NET?使用?Dispose?釋放資源的四種方法,有興趣的同學(xué)可以來看看,喜歡的話記得收藏一下哦,方便下次瀏覽觀看2021-11-11
ASP.NET將文件寫到另一服務(wù)器(圖文教程)及注意事項(xiàng)
有時(shí)我們需要將來自于客戶端的文件上傳到WEB服務(wù)器端,并在服務(wù)端將文件存儲(chǔ)到第三方文件服務(wù)器中存儲(chǔ),既然有需求,那就有實(shí)現(xiàn)了,感興趣的你可以了解此文,或許對(duì)你學(xué)習(xí)asp.net 起到很好的作用哦2013-01-01
asp.net動(dòng)態(tài)加載自定義控件的方法
這篇文章主要介紹了asp.net動(dòng)態(tài)加載自定義控件的方法,涉及asp.net動(dòng)態(tài)加載控件的相關(guān)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-04-04

