ASP.NET Web API 单元测试 - 单元测试
今天来到了最后的压轴章节:单元测试
我们已经有了完整的程序结构,现在是时候来对我们的组件做单元测试了。
在UnitTestingWebAPI.Tests类库上添加UnitTestingWebAPI.Domain, UnitTestingWebAPI.Data, UnitTestingWebAPI.Service和UnitTestingWebAPI.API.Core 同样要安装下列的Nuget 包:
Entity Framework
Microsoft.AspNet.WebApi.Core
Microsoft.AspNet.WebApi.Client
Microsoft.AspNet.WebApi.Owin
Microsoft.AspNet.WebApi.SelfHost
Micoroft.Owin
Owin
Micoroft.Owin.Hosting
Micoroft.Owin.Host.HttpListener
Autofac.WebApi2
NUnit
NUnitTestAdapter
从清单中可知,我们将用NUnit 来写单元测试
Services 单元测试
写单元测试的第一件事是需要去设置或初始化一些单元测试中要用到的变量,NUnit框架则给要测试的方法添加Setup特性,在任何其他的NUnit测试开始之前,这一方法会先执行,把Services层注入到Controller的构造函数之后的第一件事就是进行单元测试。因此在对WebAPI进行单元测试之前需要仿造Repositories和Service。
在这个例子中会看到如何仿造ArticleService, 并在这个Service的构造函数中注入IArticleRepository和IUnitOfWork,所以我们需要创建两个"特别的"实例来注入。
ArticleService Constructor
privatereadonlyIArticleRepositoryarticlesRepository;privatereadonlyIUnitOfWorkunitOfWork;publicArticleService(IArticleRepositoryarticlesRepository,IUnitOfWorkunitOfWork){this.articlesRepository=articlesRepository;this.unitOfWork=unitOfWork;}
这里的"特别的",是因为这些实例不是真正访问数据库的实例.
注意
单元测试必须运行在内存中并且不应该访问数据库. 所有核心的方法必须通过像我们的例子中用Mock这样的框架仿造。这个方式自动的测试会更快些。单元测试最基本的目的是更多的测试组件的行为,而不是真正的结果.
开始测试ArticleService,创建一个ServiceTests的文件并添加下列代码:
[TestFixture]publicclassServicesTests{#regionVariablesIArticleService_articleService;IArticleRepository_articleRepository;IUnitOfWork_unitOfWork;List<Article>_randomArticles;#endregion#regionSetup[SetUp]publicvoidSetup(){_randomArticles=SetupArticles();_articleRepository=SetupArticleRepository();_unitOfWork=newMock<IUnitOfWork>().Object;_articleService=newArticleService(_articleRepository,_unitOfWork);}publicList<Article>SetupArticles(){int_counter=newint();List<Article>_articles=BloggerInitializer.GetAllArticles();foreach(Article_articlein_articles)_article.ID=++_counter;return_articles;}publicIArticleRepositorySetupArticleRepository(){//Initrepositoryvarrepo=newMock<IArticleRepository>();//Setupmockingbehaviorrepo.Setup(r=>r.GetAll()).Returns(_randomArticles);repo.Setup(r=>r.GetById(It.IsAny<int>())).Returns(newFunc<int,Article>(id=>_randomArticles.Find(a=>a.ID.Equals(id))));repo.Setup(r=>r.Add(It.IsAny<Article>())).Callback(newAction<Article>(newArticle=>{dynamicmaxArticleID=_randomArticles.Last().ID;newArticle.ID=maxArticleID+1;newArticle.DateCreated=DateTime.Now;_randomArticles.Add(newArticle);}));repo.Setup(r=>r.Update(It.IsAny<Article>())).Callback(newAction<Article>(x=>{varoldArticle=_randomArticles.Find(a=>a.ID==x.ID);oldArticle.DateEdited=DateTime.Now;oldArticle=x;}));repo.Setup(r=>r.Delete(It.IsAny<Article>())).Callback(newAction<Article>(x=>{var_articleToRemove=_randomArticles.Find(a=>a.ID==x.ID);if(_articleToRemove!=null)_randomArticles.Remove(_articleToRemove);}));//Returnmockimplementationreturnrepo.Object;}#endregion}
如果你直接copy代码可能会报错:
One or more types required to compile a dynaic expression ....
解决办法:
在Assembiles中添加Microsoft.CSharp.dll
在SetupArticleRepository()方法中我们模仿了_articleRepository的行为,换句话说,当一个特定的方法使用了这个Reporistory的实例,就会得到我们所期待的结果。然后我们在_articleService的构造函数中注入这个实例。我们用下面代码测试_articleService.GetArticles()的行为是否是我们所期待的.
ServiceShouldReturnAllArticles Test
[Test]publicvoidServiceShouldReturnAllArticles(){vararticles=_articleService.GetArticles();NUnit.Framework.Assert.That(articles,Is.EqualTo(_randomArticles));}
编译项目,运行测试,要确保这个测试变为绿色通过状态,用同样的方式创建下面的测试:
Services Test
[Test]publicvoidServiceShouldReturnRightArticle(){varwcfSecurityArticle=_articleService.GetArticle(2);NUnit.Framework.Assert.That(wcfSecurityArticle,Is.EqualTo(_randomArticles.Find(a=>a.Title.Contains("SecureWCFServices"))));}[Test]publicvoidServiceShouldAddNewArticle(){var_newArticle=newArticle(){Author="ChrisSakellarios",Contents="IfyouareanASP.NETMVCdeveloper,youwillcertainly..",Title="URLRootinginASP.NET(WebForms)",URL="https://chsakell.com/2013/12/15/url-rooting-in-asp-net-web-forms/"};int_maxArticleIDBeforeAdd=_randomArticles.Max(a=>a.ID);_articleService.CreateArticle(_newArticle);NUnit.Framework.Assert.That(_newArticle,Is.EqualTo(_randomArticles.Last()));NUnit.Framework.Assert.That(_maxArticleIDBeforeAdd+1,Is.EqualTo(_randomArticles.Last().ID));}[Test]publicvoidServiceShouldUpdateArticle(){var_firstArticle=_randomArticles.First();_firstArticle.Title="ODatafeat.ASP.NETWebAPI";//reversed<imgdraggable="false"class="emoji"alt=""src="https://s.w.org/p_w_picpaths/core/emoji/2/svg/1f642.svg">_firstArticle.URL="http://t.co/fuIbNoc7Zh";//shortlink_articleService.UpdateArticle(_firstArticle);NUnit.Framework.Assert.That(_firstArticle.DateEdited,Is.Not.EqualTo(DateTime.MinValue));NUnit.Framework.Assert.That(_firstArticle.URL,Is.EqualTo("http://t.co/fuIbNoc7Zh"));NUnit.Framework.Assert.That(_firstArticle.ID,Is.EqualTo(1));//hasn'tchanged}[Test]publicvoidServiceShouldDeleteArticle(){intmaxID=_randomArticles.Max(a=>a.ID);//Beforeremovalvar_lastArticle=_randomArticles.Last();//Removelastarticle_articleService.DeleteArticle(_lastArticle);NUnit.Framework.Assert.That(maxID,Is.GreaterThan(_randomArticles.Max(a=>a.ID)));//Maxreducedby1}
WebAPI 控制器单元测试
在熟悉了伪造Services行为测试的基础上,来进行WebAPI控制器的单元测试。
第一件事:设置在测试中需要的变量。
用下面的代码创建用于测试的控制器:
[TestFixture]publicclassControllerTests{#regionVariablesIArticleService_articleService;IArticleRepository_articleRepository;IUnitOfWork_unitOfWork;List<Article>_randomArticles;#endregion#regionSetup[SetUp]publicvoidSetup(){_randomArticles=SetupArticles();_articleRepository=SetupArticleRepository();_unitOfWork=newMock<IUnitOfWork>().Object;_articleService=newArticleService(_articleRepository,_unitOfWork);}///<summary>///SetupArticles///</summary>///<returns></returns>publicList<Article>SetupArticles(){int_counter=newint();List<Article>_articles=BloggerInitializer.GetAllArticles();foreach(Article_articlein_articles)_article.ID=++_counter;return_articles;}///<summary>///Emulate_articleRepositorybehavior///</summary>///<returns></returns>publicIArticleRepositorySetupArticleRepository(){//Initrepositoryvarrepo=newMock<IArticleRepository>();//Getallarticlesrepo.Setup(r=>r.GetAll()).Returns(_randomArticles);//GetArticlebyidrepo.Setup(r=>r.GetById(It.IsAny<int>())).Returns(newFunc<int,Article>(id=>_randomArticles.Find(a=>a.ID.Equals(id))));//AddArticlerepo.Setup(r=>r.Add(It.IsAny<Article>())).Callback(newAction<Article>(newArticle=>{dynamicmaxArticleID=_randomArticles.Last().ID;newArticle.ID=maxArticleID+1;newArticle.DateCreated=DateTime.Now;_randomArticles.Add(newArticle);}));//UpdateArticlerepo.Setup(r=>r.Update(It.IsAny<Article>())).Callback(newAction<Article>(x=>{varoldArticle=_randomArticles.Find(a=>a.ID==x.ID);oldArticle.DateEdited=DateTime.Now;oldArticle.URL=x.URL;oldArticle.Title=x.Title;oldArticle.Contents=x.Contents;oldArticle.BlogID=x.BlogID;}));//DeleteArticlerepo.Setup(r=>r.Delete(It.IsAny<Article>())).Callback(newAction<Article>(x=>{var_articleToRemove=_randomArticles.Find(a=>a.ID==x.ID);if(_articleToRemove!=null)_randomArticles.Remove(_articleToRemove);}));//Returnmockimplementationreturnrepo.Object;}#endregion}
控制器的类和其它的类一样,所以我们可以分开各自测试。下面测试_articlesController.GetArticles(),看看是否能返回所有的文章。
[Test]publicvoidControlerShouldReturnAllArticles(){var_articlesController=newArticlesController(_articleService);varresult=_articlesController.GetArticles();CollectionAssert.AreEqual(result,_randomArticles);}
请确保测试已绿色通过,我们初始化了3条数据,用_articlesController.GetArticle(3)测试看看能否返回最后一条。
[Test]publicvoidControlerShouldReturnLastArticle(){var_articlesController=newArticlesController(_articleService);varresult=_articlesController.GetArticle(3)asOkNegotiatedContentResult<Article>;Assert.IsNotNull(result);Assert.AreEqual(result.Content.Title,_randomArticles.Last().Title);}
测试一个无效的Update操作,必须失败并且返回一个BadRequestResult, 重新调用设置在_articleRepository上的Update操作。
repo.Setup(r=>r.Update(It.IsAny<Article>())).Callback(newAction<Article>(x=>{varoldArticle=_randomArticles.Find(a=>a.ID==x.ID);oldArticle.DateEdited=DateTime.Now;oldArticle.URL=x.URL;oldArticle.Title=x.Title;oldArticle.Contents=x.Contents;oldArticle.BlogID=x.BlogID;}));
所以,当我们测试一个不存在的文章就应该返回失败信息。
[Test]publicvoidControlerShouldPutReturnBadRequestResult(){var_articlesController=newArticlesController(_articleService){Configuration=newHttpConfiguration(),Request=newHttpRequestMessage{Method=HttpMethod.Put,RequestUri=newUri("http://localhost/api/articles/-1")}};varbadresult=_articlesController.PutArticle(-1,newArticle(){Title="UnknownArticle"});Assert.That(badresult,Is.TypeOf<BadRequestResult>());}
通过分别成功更新第一篇文章、发表一篇新文章、发布失败一篇文章来完成我们的单元测试。
Controller 单元测试
[Test]publicvoidControlerShouldPutUpdateFirstArticle(){var_articlesController=newArticlesController(_articleService){Configuration=newHttpConfiguration(),Request=newHttpRequestMessage{Method=HttpMethod.Put,RequestUri=newUri("http://localhost/api/articles/1")}};IHttpActionResultupdateResult=_articlesController.PutArticle(1,newArticle(){ID=1,Title="ASP.NETWebAPIfeat.OData",URL="http://t.co/fuIbNoc7Zh",Contents=@"ODataisanopenstandardprotocol.."})asIHttpActionResult;Assert.That(updateResult,Is.TypeOf<StatusCodeResult>());StatusCodeResultstatusCodeResult=updateResultasStatusCodeResult;Assert.That(statusCodeResult.StatusCode,Is.EqualTo(HttpStatusCode.NoContent));Assert.That(_randomArticles.First().URL,Is.EqualTo("http://t.co/fuIbNoc7Zh"));}[Test]publicvoidControlerShouldPostNewArticle(){vararticle=newArticle{Title="WebAPIUnitTesting",URL="https://chsakell.com/web-api-unit-testing",Author="ChrisSakellarios",DateCreated=DateTime.Now,Contents="UnittestingWebAPI.."};var_articlesController=newArticlesController(_articleService){Configuration=newHttpConfiguration(),Request=newHttpRequestMessage{Method=HttpMethod.Post,RequestUri=newUri("http://localhost/api/articles")}};_articlesController.Configuration.MapHttpAttributeRoutes();_articlesController.Configuration.EnsureInitialized();_articlesController.RequestContext.RouteData=newHttpRouteData(newHttpRoute(),newHttpRouteValueDictionary{{"_articlesController","Articles"}});varresult=_articlesController.PostArticle(article)asCreatedAtRouteNegotiatedContentResult<Article>;Assert.That(result.RouteName,Is.EqualTo("DefaultApi"));Assert.That(result.Content.ID,Is.EqualTo(result.RouteValues["id"]));Assert.That(result.Content.ID,Is.EqualTo(_randomArticles.Max(a=>a.ID)));}[Test]publicvoidControlerShouldNotPostNewArticle(){vararticle=newArticle{Title="WebAPIUnitTesting",URL="https://chsakell.com/web-api-unit-testing",Author="ChrisSakellarios",DateCreated=DateTime.Now,Contents=null};var_articlesController=newArticlesController(_articleService){Configuration=newHttpConfiguration(),Request=newHttpRequestMessage{Method=HttpMethod.Post,RequestUri=newUri("http://localhost/api/articles")}};_articlesController.Configuration.MapHttpAttributeRoutes();_articlesController.Configuration.EnsureInitialized();_articlesController.RequestContext.RouteData=newHttpRouteData(newHttpRoute(),newHttpRouteValueDictionary{{"Controller","Articles"}});_articlesController.ModelState.AddModelError("Contents","Contentsisrequiredfield");varresult=_articlesController.PostArticle(article)asInvalidModelStateResult;Assert.That(result.ModelState.Count,Is.EqualTo(1));Assert.That(result.ModelState.IsValid,Is.EqualTo(false));}
上面测试的重点,我们请求的几个方面:返回码或路由属性。
管理 Handler单元测试
你可以通过创建HttpMessageInvoker的实例来测试Message Handler, 解析你要测试的Handler实例并调用SendAsync 方法。创建一个MessageHandlerTest.cs文件,并贴上下面的启动设置代码
#regionVariablesprivateEndRequestHandler_endRequestHandler;privateHeaderAppenderHandler_headerAppenderHandler;#endregion#regionSetup[SetUp]publicvoidSetup(){//DirectMessageHandlertest_endRequestHandler=newEndRequestHandler();_headerAppenderHandler=newHeaderAppenderHandler(){InnerHandler=_endRequestHandler};}#endregion
我们在HeaderAppenderHandler的内部设置另外一个可以终止请求的Hanlder.只要Uri中包含一个测试字符,从新调用EndRequestHandler将会终止请求.现在来测试.
[Test]publicasyncvoidShouldAppendCustomHeader(){varinvoker=newHttpMessageInvoker(_headerAppenderHandler);varresult=awaitinvoker.SendAsync(newHttpRequestMessage(HttpMethod.Get,newUri("http://localhost/api/test/")),CancellationToken.None);Assert.That(result.Headers.Contains("X-WebAPI-Header"),Is.True);Assert.That(result.Content.ReadAsStringAsync().Result,Is.EqualTo("Unittestingmessagehandlers!"));}
假如要做一个集成测试:当一个请求被消息管道分配到Controller的Action的真实behavior。
这将需要运行WebApi,然后运行单元测试。怎么做呢?必须是 通过Self host的模式运行API,然后设置恰当的配置。
在UnitTestingWebAPI.Tests的项目中添加Startup.cs文件:
Hosting/Startup.cs
publicclassStartup{publicvoidConfiguration(IAppBuilderappBuilder){varconfig=newHttpConfiguration();config.MessageHandlers.Add(newHeaderAppenderHandler());config.MessageHandlers.Add(newEndRequestHandler());config.Filters.Add(newArticlesReversedFilter());config.Services.Replace(typeof(IAssembliesResolver),newCustomAssembliesResolver());config.Routes.MapHttpRoute(name:"DefaultApi",routeTemplate:"api/{controller}/{id}",defaults:new{id=RouteParameter.Optional});config.MapHttpAttributeRoutes();//Autofacconfigurationvarbuilder=newContainerBuilder();builder.RegisterApiControllers(typeof(ArticlesController).Assembly);//UnitofWorkvar_unitOfWork=newMock<IUnitOfWork>();builder.RegisterInstance(_unitOfWork.Object).As<IUnitOfWork>();//Repositoriesvar_articlesRepository=newMock<IArticleRepository>();_articlesRepository.Setup(x=>x.GetAll()).Returns(BloggerInitializer.GetAllArticles());builder.RegisterInstance(_articlesRepository.Object).As<IArticleRepository>();var_blogsRepository=newMock<IBlogRepository>();_blogsRepository.Setup(x=>x.GetAll()).Returns(BloggerInitializer.GetBlogs);builder.RegisterInstance(_blogsRepository.Object).As<IBlogRepository>();//Servicesbuilder.RegisterAssemblyTypes(typeof(ArticleService).Assembly).Where(t=>t.Name.EndsWith("Service")).AsImplementedInterfaces().InstancePerRequest();builder.RegisterInstance(newArticleService(_articlesRepository.Object,_unitOfWork.Object));builder.RegisterInstance(newBlogService(_blogsRepository.Object,_unitOfWork.Object));IContainercontainer=builder.Build();config.DependencyResolver=newAutofacWebApiDependencyResolver(container);appBuilder.UseWebApi(config);}}
可能注意到和UnitTestingWebAPI.API里的WebSetup类的不同之处在与,这里我们用了假的Repositories和Services。
返回到ControllerTests.cs中。
[Test]publicvoidShouldCallToControllerActionAppendCustomHeader(){//Arrangevaraddress="http://localhost:9000/";using(WebApp.Start<Startup>(address)){HttpClient_client=newHttpClient();varresponse=_client.GetAsync(address+"api/articles").Result;Assert.That(response.Headers.Contains("X-WebAPI-Header"),Is.True);var_returnedArticles=response.Content.ReadAsAsync<List<Article>>().Result;Assert.That(_returnedArticles.Count,Is.EqualTo(BloggerInitializer.GetAllArticles().Count));}}
媒体类型格式化器 测试
我们在UnitTestingWebAPI.API.Core中创建了ArticleFormatter,现在测试一下,应该返回用逗号分割的文章字符串。它只能是写文章的实例,但不能读或者明白其它类型的类。为了应用这个格式化器需要设置请求头信息的Accept为application/article
[TestFixture]publicclassMediaTypeFormatterTests{#regionVariablesBlog_blog;Article_article;ArticleFormatter_formatter;#endregion#regionSetup[SetUp]publicvoidSetup(){_blog=BloggerInitializer.GetBlogs().First();_article=BloggerInitializer.GetChsakellsArticles().First();_formatter=newArticleFormatter();}#endregion}
我们可以创建一个ObjectContent来测试MediaTypeFormatter,传递一个对象来检查是否能被被格式化,如果格式化器不能读和写传递过去的对象则会抛出异常,例如,文章的格式化器不能识别Blog对象:
[Test]publicvoidFormatterShouldThrowExceptionWhenUnsupportedType(){Assert.Throws<InvalidOperationException>(()=>newObjectContent<Blog>(_blog,_formatter));}
换句话说,传一个Article对象就一定会通过测试
[Test]publicvoidFormatterShouldNotThrowExceptionWhenArticle(){Assert.DoesNotThrow(()=>newObjectContent<Article>(_article,_formatter));}
用下面的代码测试不符合MediaType formatter的Media type
Media Type Formatters Unit tests
[Test]publicvoidFormatterShouldHeaderBeSetCorrectly(){varcontent=newObjectContent<Article>(_article,newArticleFormatter());Assert.That(content.Headers.ContentType.MediaType,Is.EqualTo("application/article"));}[Test]publicasyncvoidFormatterShouldBeAbleToDeserializeArticle(){varcontent=newObjectContent<Article>(_article,_formatter);vardeserializedItem=awaitcontent.ReadAsAsync<Article>(new[]{_formatter});Assert.That(_article,Is.SameAs(deserializedItem));}[Test]publicvoidFormatterShouldNotBeAbleToWriteUnsupportedType(){varcanWriteBlog=_formatter.CanWriteType(typeof(Blog));Assert.That(canWriteBlog,Is.False);}[Test]publicvoidFormatterShouldBeAbleToWriteArticle(){varcanWriteArticle=_formatter.CanWriteType(typeof(Article));Assert.That(canWriteArticle,Is.True);}
路由测试
在不Host Web API的情况下,测试路由配置。为了这个目的,需要一个可以从HttpControllerContext的实例中返回Controllerl类型或Controller中Action的帮助类,在测试之前,先创建一个路由配置的HttpConfiguration
Helpers/ControllerActionSelector.cs
publicclassControllerActionSelector{#regionVariablesHttpConfigurationconfig;HttpRequestMessagerequest;IHttpRouteDatarouteData;IHttpControllerSelectorcontrollerSelector;HttpControllerContextcontrollerContext;#endregion#regionConstructorpublicControllerActionSelector(HttpConfigurationconf,HttpRequestMessagereq){config=conf;request=req;routeData=config.Routes.GetRouteData(request);request.Properties[HttpPropertyKeys.HttpRouteDataKey]=routeData;controllerSelector=newDefaultHttpControllerSelector(config);controllerContext=newHttpControllerContext(config,routeData,request);}#endregion#regionMethodspublicstringGetActionName(){if(controllerContext.ControllerDescriptor==null)GetControllerType();varactionSelector=newApiControllerActionSelector();vardescriptor=actionSelector.SelectAction(controllerContext);returndescriptor.ActionName;}publicTypeGetControllerType(){vardescriptor=controllerSelector.SelectController(request);controllerContext.ControllerDescriptor=descriptor;returndescriptor.ControllerType;}#endregion}
下面是路由测试:
[TestFixture]publicclassRouteTests{#regionVariablesHttpConfiguration_config;#endregion#regionSetup[SetUp]publicvoidSetup(){_config=newHttpConfiguration();_config.Routes.MapHttpRoute(name:"DefaultWebAPI",routeTemplate:"api/{controller}/{id}",defaults:new{id=RouteParameter.Optional});}#endregion#regionHelpermethodspublicstaticstringGetMethodName<T,U>(Expression<Func<T,U>>expression){varmethod=expression.BodyasMethodCallExpression;if(method!=null)returnmethod.Method.Name;thrownewArgumentException("Expressioniswrong");}#endregion}
测试一个请求api/articles/5到ArticleController的action GetArticle(int id)
[Test]publicvoidRouteShouldControllerGetArticleIsInvoked(){varrequest=newHttpRequestMessage(HttpMethod.Get,"http://www.chsakell.com/api/articles/5");var_actionSelector=newControllerActionSelector(_config,request);Assert.That(typeof(ArticlesController),Is.EqualTo(_actionSelector.GetControllerType()));Assert.That(GetMethodName((ArticlesControllerc)=>c.GetArticle(5)),Is.EqualTo(_actionSelector.GetActionName()));}
我们用反射得到controller的action名称,用同样的方法来测试post提交的action
[Test]publicvoidRouteShouldPostArticleActionIsInvoked(){varrequest=newHttpRequestMessage(HttpMethod.Post,"http://www.chsakell.com/api/articles/");var_actionSelector=newControllerActionSelector(_config,request);Assert.That(GetMethodName((ArticlesControllerc)=>c.PostArticle(newArticle())),Is.EqualTo(_actionSelector.GetActionName()));}
下面这个测试,路由会发生异常.
[Test]publicvoidRouteShouldInvalidRouteThrowException(){varrequest=newHttpRequestMessage(HttpMethod.Post,"http://www.chsakell.com/api/InvalidController/");var_actionSelector=newControllerActionSelector(_config,request);Assert.Throws<HttpResponseException>(()=>_actionSelector.GetActionName());}
结论
我们看到了Web API栈很多方面的单元测试,例如: mocking 服务层,单元测试控制器,消息管道,过滤器,定制媒体类型和路由配置。
尝试在你的程序中总是写单元测试,你不会后悔的。从里面会得到很多的好处,例如:在repository中一个简单的修改可能破坏很多方面,如果写一个合适的测试,则可能破坏你程序的问题会立即出现.
原文:chsakell's Blog
声明:本站所有文章资源内容,如无特殊说明或标注,均为采集网络资源。如若本站内容侵犯了原著者的合法权益,可联系本站删除。