Tài liệu hạn chế xem trước, để xem đầy đủ mời bạn chọn Tải xuống
1
/ 47 trang
THÔNG TIN TÀI LIỆU
Thông tin cơ bản
Định dạng
Số trang
47
Dung lượng
16,2 MB
Nội dung
<configSections> <section name="castle" type="Castle.Windsor.Configuration.AppDomain.CastleSectionHandler, Castle.Windsor" /> <! leave all the other section nodes as before > </configSections> Then, directly inside the <configuration> node, add a <castle> node: <configuration> <! etc > <castle> <components> </components> </castle> <system.web> <! etc > You can put the <castle> node immediately before <system.web>. Finally, instruct ASP.NET MVC to use your new controller factory by calling SetControllerFactory() inside the Application_Start handler in Global.asax.cs: protected void Application_Start() { RegisterRoutes(RouteTable.Routes); ControllerBuilder.Current.SetControllerFactory(new WindsorControllerFactory()); } At this point, it’s a good idea to check that everything still works as before when you run your application. Your new IoC container should be able to resolve ProductsController when ASP.NET MVC requests it, so the application should behave as if nothing’s different. Using Your IoC Container The whole point of bringing in an IoC container is that you can use it to eliminate hard-coded dependencies between components. Right now, you’re going to eliminate ProductsController’s current hard-coded dependency on SqlProductsRepository (which, in turn, means y ou ’ ll eliminate the hard-coded connection string, soon to be configured else- where). The advantages will soon become clear. When an IoC container instantiates an object (e.g., a controller class), it inspects that type’s list of constructor par ameters (a.k.a. dependencies) and tr ies to supply a suitable object for each one. So, if you edit ProductsController, adding a new constructor parameter as follows: public class ProductsController : Controller { private IProductsRepository productsRepository; public ProductsController(IProductsRepository productsRepository) { this.productsRepository = productsRepository; } CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION 99 10078ch04.qxd 3/26/09 12:52 PM Page 99 public ViewResult List() { return View(productsRepository.Products.ToList()); } } then the IoC container will see that ProductsController depends on an IProductsRepository. When instantiating a ProductsController, Windsor will supply some IProductsRepository instance. (Exactly which implementation of IProductsRepository will depend on your web.config file.) This is a great step forward: ProductsController no longer has any fixed coupling to any particular concrete repository. Why is that so advantageous? • It’s the starting point for unit testing (here, that means automated tests that have their own simulated database, not a real one, which is faster and more flexible). • It’s the moment at which you can approach separation of concerns with real mental clarity. The interface between the two application pieces ( ProductsController and the repository) is now an explicit fact, no longer just your imagination. • You protect your code base against the possible future confusion or laziness of yourself or other developers. It’s now much less likely that anyone will misunderstand how the controller is supposed to be distinct from the repository and then mangle the two into a single intractable beast. • You can trivially hook it up to any other IProductsController (e.g., for a different data- base or ORM technology) without even having to change the compiled assembly. This is most useful if you’re sharing application components across different software projects in your company. OK, that’s enough cheerleading. But does it actually work? Try running it, and you’ll get an error message like that shown in Figure 4-11. Figure 4-11. Windsor’s error message when you haven’t registered a component Whoops, you haven’t yet registered any IProductsRepository with the IoC container. Go back to y our web.config file and update the <castle> section: CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION100 10078ch04.qxd 3/26/09 12:52 PM Page 100 <castle> <components> <component id="ProdsRepository" service="DomainModel.Abstract.IProductsRepository, DomainModel" type="DomainModel.Concrete.SqlProductsRepository, DomainModel"> <parameters> <connectionString> your connection string goes here</connectionString> </parameters> </component> </components> </castle> Try running it now, and you’ll find that things are working again. You’ve nominated SqlProductsRepository as the active implementation of IProductsRepository. Of course, you could change that to FakeProductsRepository if you wanted. Note that the connection string is now in your web.config file instead of being compiled into the binary DLL. 10 ■Tip If you have several repositories in your application, don’t copy and paste the same connection string value into each <component> node. Instead, you can use Windsor’ s properties feature to make them all share the same value. Inside the <castle> node, add <properties><myConnStr>XXX</myConnStr> </properties> (where XXX is your connection string), and then for each component, replace the connec- tion string value with the reference tag #{myConnStr}. Choosing a Component Lifestyle Castle Windsor lets you select a lifestyle for each IoC component—lifestyle options include Transient, Singleton, PerWebRequest, Pooled, and Custom. These determine exactly when the container should create a new instance of each IoC component object, and which threads share those instances. The default lifestyle is Singleton, which means that only a single instance of the component object exists, and it’s shared globally. Your SqlProductsRepository currently has this Singleton lifestyle, so you’re keeping a sin- gle LINQ to SQL DataContext alive as long as your application runs, sharing it across all r equests . That might seem fine at the moment, because so far all data access is r ead-only, but it would lead to problems when you start editing data. Uncommitted changes would start leaking across requests. A v oid this pr oblem b y changing SqlProductsRepository’ s lifestyle to PerWebRequest, b y updating its registration in web.config: <component id="ProdsRepository" service="DomainModel.Abstract.IProductsRepository, DomainModel" type="DomainModel.Concrete.SqlProductsRepository, DomainModel" lifestyle="PerWebRequest"> CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION 101 10. That’s not a record-breaking feat—ASP.NET has native support for configuring connection strings in the <connectionStrings> node of your web.config file anyway. What’s advantageous about IoC is that you can use it to configure any set of component constructor parameters without writing any extra code. 10078ch04.qxd 3/26/09 12:52 PM Page 101 Then register Windsor’s PerRequestLifestyle module in your <httpModules> node: 11 <httpModules> < add name="PerRequestLifestyle" type="Castle.MicroKernel.Lifestyle.PerWebRequestLifestyleModule, Castle.MicroKernel" /> <! Leave the other modules in place > </httpModules> If you’re later going to deploy to an IIS 7 web server, then be sure to add the following equivalent configuration to your web.config file’s <system.webServer>/<modules> node, too (you’ll learn more about configuring IIS 7 in Chapter 14): <remove name="PerRequestLifestyle"/> <add name="PerRequestLifestyle" preCondition="managedHandler" type="Castle.MicroKernel.Lifestyle.PerWebRequestLifestyleModule, Castle.MicroKernel" /> This is the great thing about IoC containers: the amount of work you can avoid doing. You’ve just accomplished the DataContext-per-HTTP-request pattern purely by tweaking your web.config file. So that’s it—you’ve set up a working IoC system. No matter how many IoC components and dependencies you need to add, the plumbing is already done. Creating Automated Tests Almost all the foundational pieces of infrastructure are now in place—a solution and project structure, a basic domain model and LINQ to SQL repository system, an IoC container—so now you can do the real job of writing application behavior and tests! ProductsController currently produces a list of every product in your entire catalog. Let’s improve on that: the first application behavior to test and code is producing a paged list of products. In this section, you’ll see how to combine NUnit, Moq, and your component- oriented architecture to design new application behaviors using unit tests, starting with that paged list. ■Note TDD is not about testing, it’ s about design (although it also takes care of some aspects of testing). With TDD, you describe intended beha viors in the form of unit tests, so you can la ter run those tests and verify that your implementation correctly satisfies the design. It allows you to decouple a design from its implementation, crea ting a permanent record of design decisions tha t you can ra pidly recheck a gainst an y future version of your code base. “Test-driven development” is an unfortunate choice of name that misleads by putting the emphasis on the word test. You might prefer the more up-to-date buzzphrase “Behavior-Driven Design (BDD)” instead, though how that differs from TDD (if indeed it differs at all) is a topic for another debate. CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION102 11. Windsor uses this IHttpModule to support PerWebRequestLifestyleModule, so that it can intercept the Application_EndRequest event and dispose of anything it created during the request. 10078ch04.qxd 3/26/09 12:52 PM Page 102 Each time you create a test that fails or won’t compile (because the application doesn’t yet satisfy that test), that drives the requirement to alter your application code to satisfy the test. TDD enthusiasts prefer never to alter their application code except in response to a failing test, thereby ensuring that the test suite represents a complete (within practical limits) description of all design decisions. If you don’t want to be this formal about design, you can skip the TDD in these chapters by ignoring the shaded sidebars. It isn’t compulsory for ASP.NET MVC. However, it’s worth giving it a try to see how well it would fit into your development process. You can follow it as strictly or as loosely as you wish. TESTING: GETTING STARTED You’ve already made a Tests project, but you’ll also need a couple of open source unit testing tools. If you don’t already have them, download and install the latest versions of NUnit (a framework for defining unit tests and running them in a GUI), available from www.nunit.org/, 12 and Moq (a mocking framework designed especially for C# 3.5 syntax), from http://code.google.com/p/moq/. 13 Add references from your Tests project to all these assemblies: • nunit.framework (from the Add Reference pop-up window’s .NET tab) • System.Web (again, from the .NET tab) • System.Web.Abstractions (again, from the .NET tab) • System.Web.Routing (again, from the .NET tab) • System.Web.Mvc.dll (again, from the .NET tab) • Moq.dll (from the Browse tab, because when you download Moq, you just get this assembly file—it’s not registered in your GAC) • Your DomainModel project (from the Projects tab) • Your WebUI project (from the Projects tab) Adding the First Unit Test To hold the first unit test, create a new class in your Tests project called ProductsControllerTests. The first test will demand the ability to call the List action with a pa ge number as a parameter (e.g., List(2)), resulting in it putting only the relevant page of products into Model: [TestFixture] public class ProductsControllerTests { [Test] public void List_Presents_Correct_Page_Of_Products() Continued CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION 103 12. I’m using version 2.5 Beta 2 13. I’m using v ersion 3.0 10078ch04.qxd 3/26/09 12:52 PM Page 103 { / / Arrange: 5 products in the repository IProductsRepository repository = MockProductsRepository( new Product { Name = "P1" }, new Product { Name = "P2" }, new Product { Name = "P3" }, new Product { Name = "P4" }, new Product { Name = "P5" } ); ProductsController controller = new ProductsController(repository); controller.PageSize = 3; // This property doesn't yet exist, but by // accessing it, you're implicitly forming // a requirement for it to exist // Act: Request the second page (page size = 3) var result = controller.List(2); // Assert: Check the results Assert.IsNotNull(result, "Didn't render view"); var products = result.ViewData.Model as IList<Product>; Assert.AreEqual(2, products.Count, "Got wrong number of products"); // Make sure the correct objects were selected Assert.AreEqual("P4", products[0].Name); Assert.AreEqual("P5", products[1].Name); } static IProductsRepository MockProductsRepository(params Product[] prods) { // Generate an implementor of IProductsRepository at runtime using Moq var mockProductsRepos = new Moq.Mock<IProductsRepository>(); mockProductsRepos.Setup(x => x.Products).Returns(prods.AsQueryable()); return mockProductsRepos.Object; } } As you can see, this unit test simulates a particular repository condition that makes for a meaningful test. Moq uses runtime code generation to create an implementor of IProductsRepository that is set up to behave in a certain way (i.e., it returns the specified set of Product objects). It’s far easier, tidier, and faster to do this than to actually load real rows into a SQL Server database for testing, and it’s only possible because ProductsController accesses its repository only through an abstract interface. Check That You Have a Red Light First Try to compile your solution. At first, you’ll get a compiler error, because List() doesn’t yet take any param- eters (and you tried to call List(2)), and there’s no such thing as ProductsController.PageSize (see Figure 4-12). CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION104 10078ch04.qxd 3/26/09 12:52 PM Page 104 Figure 4-12. Tests drive the need to implement methods and properties. It may feel strange to deliberately write test code that can’t compile (and of course, IntelliSense starts to break down at this point), but this is one of the techniques of TDD. The compiler error is in effect the first failed test, driving the requirement to go and create some new methods or properties (in this case, the com- piler error forces you to add a new page parameter to List()). It’s not that we want compiler errors, it’s just that we want to write the tests first, even if they do cause compiler errors. Personally, I don’t like this very much, so I usually create method or property stubs at the same time as I write tests that require them, keep- ing the compiler and IDE happy. You can make your own judgment. Throughout the SportsStore chapters, we’ll do “authentic TDD” and write test code first, even when it causes compiler errors at first. Get the code to compile by adding PageSize as a public int member field on ProductsController, and page as an int parameter on the List() method (details are shown after this sidebar). Load NUnit GUI (it was installed with NUnit, and is probably on your Start menu), go to File ➤ Open Project, and then browse to find your compiled Tests.dll (it will be in yoursolution\Tests\bin\Debug\). NUnit GUI will inspect the assembly to find any [TestFixture] classes, and will display them and their [Test] methods in a graphical hierarchy. Click Run (see Figure 4-13). Figure 4-13. A red light in NUnit GUI Unsurprisingly, the test still fails, because your current ProductsController returns all records from the repository, instead of just the requested page. As discussed in Chapter 2, that’s a good thing: in red-green development, you need to see a failing test before you code the behavior that makes the test pass. It confirms that the test actually responds to the code you’ve just written. CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION 105 10078ch04.qxd 3/26/09 12:52 PM Page 105 If you haven’t already done so, update ProductsController’s List() method to add a page parameter and define PageSize as a public class member: public class ProductsController : Controller { p ublic int PageSize = 4; // Will change this later private IProductsRepository productsRepository; public ProductsController(IProductsRepository productsRepository) { this.productsRepository = productsRepository; } public ViewResult List(int page) { return View(productsRepository.Products.ToList()); } } Now you can add the paging behavior for real. This used to be a tricky task before LINQ (yes, SQL Server 2005 can return paged data sets, but it’s hardly obvious how to do it), but now it all goes into a single, elegant C# code statement. Update the List() method once again: public ViewResult List(int page) { return View(productsRepository.Products .Skip((page - 1) * PageSize) .Take(PageSize) .ToList() ); } Now, if you’re doing unit tests, recompile and rerun the test in NUnit GUI. Behold . . . a green light! Configuring a Custom URL Schema Adding a page parameter to the List() action was great for unit testing, but it causes a little pr oblem if y ou try to run the application for real (see Figure 4-14). How is the MVC Framework supposed to invoke your List() method when it doesn’t kno w what v alue to supply for page? I f the par ameter wer e of a r efer ence or nullable type , 14 it would just pass null, but int isn’t one of those, so it has to throw an error and give up. CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION106 14. A nullable type is a type for which null is a valid value. Examples include object, string, System.Nullable<int>, and any class y ou define. These are held on the heap and referenced via a pointer (which can be set to null). That’s not the case with int, DateTime, or any struct, which are held as a block of memory in the stack, so it isn’t meaningful to set them to null (there has to be something in that memor y space). 10078ch04.qxd 3/26/09 12:52 PM Page 106 Figure 4-14. Error due to having specified no value for the page parameter As an experiment, try changing the URL in your browser to http://localhost:xxxxx/ ?page=1 or http://localhost:xxxxx/?page=2 (replacing xxxxx with whatever port number was already there). You’ll find that it works, and your application will select and display the rele- vant page of results. That’s because when ASP.NET MVC can’t find a routing parameter to match an action method parameter (in this case, page), it will try to use a query string parame- ter instead. This is the framework’s parameter binding mechanism, which is explained in detail in Chapters 9 and 11. But of course, those are ugly URLs, and you need it to work even when there’s no query string parameter, so it’s time to edit your routing configuration. Adding a RouteTable Entry You can solve the problem of the missing page number by changing your routing configura- tion, setting a default value. Go back to Global.asax.cs, remove the existing call to MapRoute, and replace it with this: routes.MapRoute( null, // Don't bother giving this route entry a name "", // Matches the root URL, i.e. ~/ new { controller = "Products", action = "List", page = 1 } // Defaults ); routes.MapRoute ( null, // Don't bother giving this route entry a name "Page{page}", // URL pattern, e.g. ~/Page683 new { controller = "Products", action = "List"}, // Defaults new { page = @"\d+" } // Constraints: page must be numerical ); CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION 107 10078ch04.qxd 3/26/09 12:52 PM Page 107 What does this do? It says there are two acceptable URL formats: • An empty URL (the root URL, e.g., http://yoursite/), which goes to the List() action on ProductsController, passing a default page value of 1. • URLs of the form Page{page} (e.g., http://yoursite/Page41), where page must match the regular expression "\d+", 15 meaning that it consists purely of digits. Such requests also go to List() on ProductsController, passing the page value extracted from the URL. Now try launching the application, and you should see something like that shown in Figure 4-15. Figure 4-15. The paging logic selects and displays only the first four products. Perfect—now it displays just the first page of products, and you can add a page number to the URL (e .g., http://localhost:port/Page2) to get the other pages. Displaying Page Links It’s great that you can type in URLs like /Page2 and /Page59, but you’re the only person who will realize this. Visitors aren’t going to guess these URLs and type them in. Obviously, you CHAPTER 4 ■ SPORTSSTORE: A REAL APPLICATION108 15. In the code, it’s preceded by an @ symbol to tell the C# compiler not to interpret the backslash as the start of an escape sequence. 10078ch04.qxd 3/26/09 12:52 PM Page 108 [...]... new [] { new Product { Name = "A", Category = "Animal" }, new Product { Name = "B", Category = "Vegetable" }, new Product { Name = "C", Category = "Mineral" }, new Product { Name = "D", Category = "Vegetable" }, new Product { Name = "E", Category = "Animal" } }.AsQueryable(); var mockProductsRepos = new Moq.Mock(); mockProductsRepos.Setup(x => x.Products).Returns(products); var... implement the filtering behavior, update ProductsController’s List() method: public ViewResult List(string category, int page) { var productsInCategory = (category == null) ? productsRepository.Products : productsRepository.Products.Where(x => x.Category == category); int numProducts = productsInCategory.Count(); ViewData["TotalPages"] = (int)Math.Ceiling((double) numProducts / PageSize); ViewData["CurrentPage"]... List_Filters_By_Category_When_Requested() { // Set up scenario with two categories: Cats and Dogs IProductsRepository repository = MockProductsRepository( new Product { Name = "Snowball", Category = "Cats" }, 10078ch05.qxd 3/ 11/09 9:09 PM Page 1 23 CHAPTER 5 I SPORTSSTORE: NAVIGATION AND SHOPPING CART new new new new Product Product Product Product { { { { Name Name Name Name = = = = "Rex", Category = "Dogs" }, "Catface",... effect by abusing ASP NET MVC view templates 131 10078ch05.qxd 132 3/ 11/09 9:09 PM Page 132 CHAPTER 5 I SPORTSSTORE: NAVIGATION AND SHOPPING CART would be untestable But this widget must involve some application logic, because it has to get a list of categories from the products repository, and it has to know which one to highlight as “current.” In addition to the core ASP.NET MVC package, Microsoft has... the Response stream When you run the project now, you’ll see the output from NavController’s Menu() action injected into every generated page, as shown in Figure 5 -3 Figure 5 -3 NavController’s message being injected into the page So, what’s left is to enhance NavController so that it actually renders a set of category links 133 10078ch05.qxd 134 3/ 11/09 9:09 PM Page 134 CHAPTER 5 I SPORTSSTORE: NAVIGATION... routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); 129 10078ch05.qxd 130 3/ 11/09 9:09 PM Page 130 CHAPTER 5 I SPORTSSTORE: NAVIGATION AND SHOPPING CART routes.MapRoute(null, "", // Only matches the empty URL (i.e ~/) new { controller = "Products", action = "List", category = (string)null, page = 1 } ); routes.MapRoute(null, "Page{page}", // Matches ~/Page2, ~/Page1 23, but not ~/PageXYZ new { controller = "Products", action = "List", category... Category = "Dogs" } ); ProductsController controller = new ProductsController(repository); controller.PageSize = 10; // Request only the dogs var result = controller.List("Dogs", 1); // Check the results Assert.IsNotNull(result, "Didn't render view"); var products = (IList)result.ViewData.Model; Assert.AreEqual (3, products.Count, "Got wrong number of items"); Assert.AreEqual("Rex", products[0].Name);... Tests project: [TestFixture] public class NavControllerTests { [Test] public void Takes_IProductsRepository_As_Constructor_Param() { // This test "passes" if it compiles, so no Asserts are needed new NavController((IProductsRepository)null); } [Test] public void Produces_Home_Plus_NavLink_Object_For_Each_Distinct_Category() { // Arrange: Product repository with a few categories IQueryable products... should display all products • When category equals any other string, List() should display only products in that category Make a test for the first behavior by adding a new [Test] method to ProductsControllerTests: [Test] public void List_Includes_All_Products_When_Category_Is_Null() { // Set up scenario with two categories IProductsRepository repository = MockProductsRepository( new Product { Name =... Football_Goes_To_Football_Page_1() { TestRoute("~/Football", new { controller = "Products", action = "List", category = "Football", page = 1 }); } [Test] public void Football_Slash_Page 43_ Goes_To_Football_Page_ 43( ) { TestRoute("~/Football/Page 43" , new { controller = "Products", action = "List", category = "Football", page = 43 }); } [Test] public void Anything_Slash_Else_Goes_To_Else_On_AnythingController() . 2.5 Beta 2 13. I’m using v ersion 3. 0 10078ch04.qxd 3/ 26/09 12:52 PM Page 1 03 { / / Arrange: 5 products in the repository IProductsRepository repository = MockProductsRepository( new Product {. Product { Name = "P2" }, new Product { Name = "P3" }, new Product { Name = "P4" }, new Product { Name = "P5" } ); ProductsController controller = new ProductsController(repository); controller.PageSize. selected Assert.AreEqual("P4", products[0].Name); Assert.AreEqual("P5", products[1].Name); } static IProductsRepository MockProductsRepository(params Product[] prods) { // Generate an implementor of IProductsRepository