.net core实践系列之短信服务-架构优化
<h1>前言</h1><p>通过前面的几篇文章,讲解了一个短信服务的架构设计与实现。然而初始方案并非100%完美的,我们仍可以对该架构做一些优化与调整。</p>
<p>同时我也希望通过这篇文章与大家分享一下,我的架构设计理念。</p>
<p>源码地址:https://github.com/SkyChenSky/Sikiro.SMS/tree/optimize (与之前的是另外的分支)</p>
<h1>架构是设计的还是演变的?</h1>
<h2>架构</h2>
<p>该词出自于建筑学。软件架构定义是指软件系统的基础结构,是系统中的实体及实体(服务)之间的关系所进行的抽象描述。而架构设计的<strong>目的</strong>是为了解决软件<strong>系统复杂度</strong>带来的问题。</p>
<h2>复杂度</h2>
<p>系统复杂度主要有下面几点:</p>
<ul>
<li>高可用</li>
<li>高性能</li>
<li>可扩展</li>
<li>安全性</li>
<li>维护成本</li>
<li>用户规模</li>
</ul>
<h2>业务规模</h2>
<p>系统的复杂度导致的直接原因是业务规模。为了用户流畅放心的使用产品,不得不提高系统性能与安全。当系统成为人们生活不可缺一部分时,避免机房停电、挖掘机挖断电缆导致的系统不可用,不得不去思考同城跨机房同步、异地多活的高可用方案。</p>
<h2>答案并非二选一</h2>
<p>我认为架构,需要在已知可见的业务复杂度与用户规模的基础上进行<strong>架构设计</strong>;伴随着技术积累与成长而对系统进行<strong>架构优化</strong>;用户的日益增长,业务的不断扩充,迫使了系统的复杂度增加,为了解决系统带来新的复杂度而进行<strong>架构演变。</strong></p>
<p>因此,架构方案是在已有的业务复杂度、用户规模、技术积累度、人力时间成本等几个方面的<strong>取舍</strong><strong>决策</strong>后的结果体现。</p>
<h1>原架构</h1>
<p><img src="https://images2018.cnblogs.com/blog/488722/201809/488722-20180910094322846-1200124774.png" alt="" width="1103" height="506"></p>
<h2>缺点分析</h2>
<ul>
<li>一般情况下,调度任务轮询数据库,90%的动作是无用功,频繁的数据库访问会对数据库增加不少压力。</li>
<li>为了让调度任务服务进行轮循数据,需要在API优先进行数据持久化,这无疑是降低了API的性能。</li>
<li>MongoDB的Update操作相比于Insert操作时低效的,对于日志类数据应增量添加。</li>
</ul>
<p>因此从上述可见,调度任务服务这块是优化关键点所在。</p>
<h1>新架构图</h1>
<p><img src="https://images2018.cnblogs.com/blog/488722/201809/488722-20180910095608175-1702670026.png" alt="" width="1206" height="577"></p>
<ul>
<li>使用了RabbitMQ的队列定时任务代替调度任务来实现定时发送。</li>
<li>抛弃了调度任务,减少了调用链,同时也减少了应用服务数据量。</li>
<li>对SMS集合在MongoDB里进行按年月的时间划分,对于日志类数据可以在有效的时间范围外进行方便的归档、删除。同时也避免了同集合的数据量过大导致的查询效率缓慢。</li>
</ul>
<h1>队列定时任务</h1>
<p>RabbitMQ自身并没有定时任务,然而可以通过消息的Time-To-Live(过期时间)与Dead Letter Exchange(死信交换机)的结合模拟定时发布的功能。具体原理如下:</p>
<ul>
<li>生产者发布消息,并发布到已申明消息过期时间(TTL)的缓存队列(非真正业务消费队列)</li>
<li>消息在缓存队列等待消息过期,然后由Dead Letter Exchange将消息重新分配到实际消费队列</li>
<li>消费者再从实际消费队列消费并完成业务</li>
</ul>
<p> </p>
<p> <img src="https://images2018.cnblogs.com/blog/488722/201809/488722-20180910101404020-251360017.png" alt="" width="856" height="197"></p>
<h2>Dead Letter Exchange</h2>
<p>Dead Letter Exchange与平常的Exchange无异,主要用于消息死亡后通过Dead Letter Exchange与x-dead-letter-routing-key重新分配到新的队列进行消费处理。</p>
<p>消息死亡的方式有三种:</p>
<ul>
<li>消息进入了一条已经达到最大长度的队列</li>
<li>消息因为设置了Time-To-Live的导致过期</li>
<li>消息因basic.reject或者basic.nack动作而拒绝</li>
</ul>
<h2 id="time-to-live-extensions">Time-To-Live</h2>
<p>两种消息过期的方式:</p>
<p>队列申明<span class="hljs-string">x-message-ttl参数</span></p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">var</span> args = <span style="color: rgba(0, 0, 255, 1)">new</span> Dictionary<<span style="color: rgba(0, 0, 255, 1)">string</span>, <span style="color: rgba(0, 0, 255, 1)">object</span>><span style="color: rgba(0, 0, 0, 1)">();
args.Add(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">x-message-ttl</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(128, 0, 128, 1)">60000</span><span style="color: rgba(0, 0, 0, 1)">);
model.QueueDeclare(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">myqueue</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">false</span>, <span style="color: rgba(0, 0, 255, 1)">false</span>, <span style="color: rgba(0, 0, 255, 1)">false</span>, args);</pre>
</div>
<p><span class="hljs-string">每条消息发布声明Expiration参数</span></p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">byte</span>[] messageBodyBytes = System.Text.Encoding.UTF8.GetBytes(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Hello, world!</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);
IBasicProperties props </span>=<span style="color: rgba(0, 0, 0, 1)"> model.CreateBasicProperties();
props.ContentType </span>= <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">text/plain</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
props.DeliveryMode </span>= <span style="color: rgba(128, 0, 128, 1)">2</span><span style="color: rgba(0, 0, 0, 1)">;
props.Expiration </span>= <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">36000000</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">
model.BasicPublish(exchangeName,
routingKey, props,
messageBodyBytes);</span></pre>
</div>
<h2>RabbitMQ.Client队列定时任务Demo</h2>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> Program
{
</span><span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">void</span> Main(<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)">[] args)
{
</span><span style="color: rgba(0, 0, 255, 1)">var</span> factory = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ConnectionFactory
{
HostName </span>= <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">10.1.20.140</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">,
UserName </span>= <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">admin</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">,
Password </span>= <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">admin@ucsmy</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">
};
</span><span style="color: rgba(0, 0, 255, 1)">using</span> (<span style="color: rgba(0, 0, 255, 1)">var</span> connection =<span style="color: rgba(0, 0, 0, 1)"> factory.CreateConnection())
</span><span style="color: rgba(0, 0, 255, 1)">using</span> (<span style="color: rgba(0, 0, 255, 1)">var</span> channel =<span style="color: rgba(0, 0, 0, 1)"> connection.CreateModel())
{
</span><span style="color: rgba(0, 0, 255, 1)">var</span> queueName = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Queue.SMS.Test</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
</span><span style="color: rgba(0, 0, 255, 1)">var</span> exchangeName = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Exchange.SMS.Test</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
</span><span style="color: rgba(0, 0, 255, 1)">var</span> key = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Route.SMS.Test</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
DeclareDelayQueue(channel, exchangeName, queueName, key);
DeclareReallyConsumeQueue(channel, exchangeName, queueName, key);
</span><span style="color: rgba(0, 0, 255, 1)">var</span> body = Encoding.UTF8.GetBytes(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">info: test dely publish!</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);
channel.BasicPublish(exchangeName </span>+ <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">.Delay</span><span style="color: rgba(128, 0, 0, 1)">"</span>, key, <span style="color: rgba(0, 0, 255, 1)">null</span><span style="color: rgba(0, 0, 0, 1)">, body);
}
}
</span><span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">void</span> DeclareDelayQueue(IModel channel, <span style="color: rgba(0, 0, 255, 1)">string</span> exchangeName, <span style="color: rgba(0, 0, 255, 1)">string</span> queueName, <span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> key)
{
</span><span style="color: rgba(0, 0, 255, 1)">var</span> retryDic = <span style="color: rgba(0, 0, 255, 1)">new</span> Dictionary<<span style="color: rgba(0, 0, 255, 1)">string</span>, <span style="color: rgba(0, 0, 255, 1)">object</span>><span style="color: rgba(0, 0, 0, 1)">
{
{</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">x-dead-letter-exchange</span><span style="color: rgba(128, 0, 0, 1)">"</span>, exchangeName+<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">.dl</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">},
{</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">x-dead-letter-routing-key</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">, key},
{</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">x-message-ttl</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(128, 0, 128, 1)">30000</span><span style="color: rgba(0, 0, 0, 1)">}
};
</span><span style="color: rgba(0, 0, 255, 1)">var</span> ex = exchangeName + <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">.Delay</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
</span><span style="color: rgba(0, 0, 255, 1)">var</span> qu = queueName + <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">.Delay</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
channel.ExchangeDeclare(ex, </span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">topic</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);
channel.QueueDeclare(qu, </span><span style="color: rgba(0, 0, 255, 1)">false</span>, <span style="color: rgba(0, 0, 255, 1)">false</span>, <span style="color: rgba(0, 0, 255, 1)">false</span><span style="color: rgba(0, 0, 0, 1)">, retryDic);
channel.QueueBind(qu, ex, key);
}
</span><span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">void</span> DeclareReallyConsumeQueue(IModel channel, <span style="color: rgba(0, 0, 255, 1)">string</span> exchangeName, <span style="color: rgba(0, 0, 255, 1)">string</span> queueName, <span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> key)
{
</span><span style="color: rgba(0, 0, 255, 1)">var</span> ex = exchangeName + <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">.dl</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
channel.ExchangeDeclare(ex, </span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">topic</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);
channel.QueueDeclare(queueName, </span><span style="color: rgba(0, 0, 255, 1)">false</span>, <span style="color: rgba(0, 0, 255, 1)">false</span>, <span style="color: rgba(0, 0, 255, 1)">false</span><span style="color: rgba(0, 0, 0, 1)">);
channel.QueueBind(queueName, ex, key);
}
}</span></pre>
</div>
<h1>Sikiro.SMS实现优化</h1>
<p>上面介绍了队列定时任务基本原理,然而我们需要自己的项目进行修改优化。</p>
<h2>API消息发布</h2>
<p>EasyNetQ是一款非常良好使用性的RabbitMQ.Client封装。对队列定时任务他也已经提供了相应的方法<span style="color: rgba(0, 0, 0, 1)">FuturePublish</span>给我们使用。</p>
<p>然而他的<span style="color: rgba(0, 0, 0, 1)">FuturePublish</span>由有三种调度方式:</p>
<ul>
<li>DeadLetterExchangeAndMessageTtlScheduler</li>
<li>DelayedExchangeScheduler</li>
<li>ExternalScheduler</li>
</ul>
<p>DelayedExchangeScheduler是需要EasyNetQ项目提供的调度程序,本质上也是轮询</p>
<p>ExternalScheduler是通过使用MQ的插件。</p>
<p>DeadLetterExchangeAndMessageTtlScheduler才是我们之前通过DEMO实现的方式,在EasyNetQ组件上通过下面代码进行启用。</p>
<div class="cnblogs_code">
<pre>services.RegisterEasyNetQ(_infrastructureConfig.Infrastructure.RabbitMQ, a =><span style="color: rgba(0, 0, 0, 1)">
{
a.EnableDeadLetterExchangeAndMessageTtlScheduler();
});</span></pre>
</div>
<p>下面代码是Sikiro.SMS.Api的优化改造:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><summary></span>
<span style="color: rgba(128, 128, 128, 1)">///</span><span style="color: rgba(0, 128, 0, 1)"> 添加短信记录
</span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"></summary></span>
<span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><param name="model"></param></span>
<span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><returns></returns></span>
<span style="color: rgba(0, 0, 0, 1)">
</span><span style="color: rgba(0, 0, 255, 1)">public</span> ActionResult Post( List<PostModel><span style="color: rgba(0, 0, 0, 1)"> model)
{
_smsService.Page(model.MapTo</span><List<PostModel>, List<AddSmsModel>><span style="color: rgba(0, 0, 0, 1)">());
ImmediatelyPublish();
TimingPublish();
</span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> Ok();
}
</span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><summary></span>
<span style="color: rgba(128, 128, 128, 1)">///</span><span style="color: rgba(0, 128, 0, 1)"> 及时发送
</span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"></summary></span>
<span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> ImmediatelyPublish()
{
_smsService.SmsList.Where(a </span>=> a.TimeSendDateTime == <span style="color: rgba(0, 0, 255, 1)">null</span>).ToList().MapTo<List<SmsModel>, List<SmsQueueModel>><span style="color: rgba(0, 0, 0, 1)">()
.ForEach(
item </span>=><span style="color: rgba(0, 0, 0, 1)">
{
_bus.Publish(item, SmsQueueModelKey.Topic);
});
}
</span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"><summary></span>
<span style="color: rgba(128, 128, 128, 1)">///</span><span style="color: rgba(0, 128, 0, 1)"> 定时发送
</span><span style="color: rgba(128, 128, 128, 1)">///</span> <span style="color: rgba(128, 128, 128, 1)"></summary></span>
<span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> TimingPublish()
{
_smsService.SmsList.Where(a </span>=> a.TimeSendDateTime != <span style="color: rgba(0, 0, 255, 1)">null</span><span style="color: rgba(0, 0, 0, 1)">).ToList()
.ForEach(
item </span>=><span style="color: rgba(0, 0, 0, 1)">
{
_bus.FuturePublish(item.TimeSendDateTime.Value.ToUniversalTime(), item.MapTo</span><SmsModel, SmsQueueModel><span style="color: rgba(0, 0, 0, 1)">(),
SmsQueueModelKey.Topic);
});
}</span></pre>
</div>
<h2>重发机制</h2>
<p>重发一般是请求服务超时的情况下使用。而导致这种原因的主要几点是网络波动、服务压力过大。因为前面任意一种原因都无法在短时间恢复,因此对于简单的重试 类似while(i<3)ReSend() 是没有什么意义的。</p>
<p>因此我们需要借助队列定时任务+发送次数*延迟时间来完成有效的非频繁的重发。</p>
<div class="cnblogs_code">
<pre> <span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> Start()
{
Console.WriteLine(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">I started</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);
_bus.Subscribe</span><SmsQueueModel>(<span style="color: rgba(128, 0, 0, 1)">""</span>, msg =><span style="color: rgba(0, 0, 0, 1)">
{
</span><span style="color: rgba(0, 0, 255, 1)">try</span><span style="color: rgba(0, 0, 0, 1)">
{
_smsService.Send(msg.MapTo</span><SmsQueueModel, SmsModel><span style="color: rgba(0, 0, 0, 1)">());
}
</span><span style="color: rgba(0, 0, 255, 1)">catch</span><span style="color: rgba(0, 0, 0, 1)"> (WebException e)
{
e.WriteToFile();
ReSend();
}
</span><span style="color: rgba(0, 0, 255, 1)">catch</span><span style="color: rgba(0, 0, 0, 1)"> (Exception e)
{
e.WriteToFile();
}
}, a </span>=><span style="color: rgba(0, 0, 0, 1)">
{
a.WithTopic(SmsQueueModelKey.Topic);
});
}
</span><span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> ReSend()
{
</span><span style="color: rgba(0, 0, 255, 1)">var</span> model = _smsService.Sms.MapTo<SmsModel, SmsQueueModel><span style="color: rgba(0, 0, 0, 1)">();
model.SendCount</span>++<span style="color: rgba(0, 0, 0, 1)">;
_bus.FuturePublish(TimeSpan.FromSeconds(</span><span style="color: rgba(128, 0, 128, 1)">30</span> *<span style="color: rgba(0, 0, 0, 1)"> model.SendCount), model, SmsQueueModelKey.Topic);
}</span></pre>
</div>
<h2>SMS日志集合维度</h2>
<p>SMS日志作为非必要业务的运维型监控数据,在需要的时候随时可以对此进行删除或者归档处理。因此以时间(年月)作为集合维度,可以很好的对日志数据进行管理。</p>
<div class="cnblogs_code">
<pre>mongoProxy.Add(MongoKey.SmsDataBase, MongoKey.SmsCollection + <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">_</span><span style="color: rgba(128, 0, 0, 1)">"</span> + DateTime.Now.ToString(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">yyyyMM</span><span style="color: rgba(128, 0, 0, 1)">"</span>), model);</pre>
</div>
<h1>结束</h1>
<p>经过本系列6篇的文章,介绍了以短信服务为业务场景,基于.net core平台的一个简单架构设计、架构优化与服务实现的实践例子。希望我的分享能帮助有需要的朋友。如果有任何好的建议请到下方给我留言。</p>
<p> </p>
</div>
<div id="MySignature" role="contentinfo">
<div style="display: block; background: #406CA4;" id="my_signature">
<p style="padding-right: 10px; padding-bottom: 10px; padding-left: 20px; font-family: 微软雅黑; font-size: 12px; border: #e0e0e0 1px dashed; color: white;">
<br>作 者:<strong><span style="font-size: 12px">
陈珙 </span></strong> <br>
出 处:http://www.cnblogs.com/skychen1218/
<br>
关于作者:专注于微软平台的项目开发。如有问题或建议,请多多赐教!
<br>
版权声明:本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。
<br>
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角<strong style="color: red">推荐</strong>一下。您的鼓励是作者坚持原创和持续写作的最大动力!
<br>
</p>
</div><br><br>
来源:https://www.cnblogs.com/skychen1218/p/9565198.html
頁:
[1]