maartenba / MvcSiteMapProvider

An ASP.NET MVC SiteMapProvider implementation for the ASP.NET MVC framework.
Microsoft Public License
537 stars 220 forks source link

MvcSitemapProvider v4 #119

Closed maartenba closed 10 years ago

maartenba commented 11 years ago

As it's been a while since ASP.NET MVC 4 is out and seeing that ASP.NET Web API dropped dependencies on System.Web, I'm kind of looking at evolving MvcSitemapProvider in that direction too...

vNext would:

That's the technical side. What about features? I would love to hear what you are using today, what you would not want to miss, what's currently missing, anything!

NightOwl888 commented 11 years ago

I started to refactor the AuthorizeAttributeAclModule primarily because it is just way to complicated to understand and fix bugs/add new features in there, and there is an open issue #130 about a new Attribute for MVC 4 that the module is ignoring that I would like to address. I started by breaking it into smaller methods without changing the logic.

I went back to the very beginning of the history on codeplex and it seems it hasn't been touched much since then, so I am hoping you might be able to shed some light on some of the logic that is in there.

Specifically, there is an inconsistency that seems really odd. Consider the following blocks:


64              // Time to delve into the AuthorizeAttribute defined on the node.
65              // Let's start by getting all metadata for the controller...
66              var controllerType = controllerTypeResolver.ResolveControllerType(mvcNode.Area, mvcNode.Controller);
67              if (controllerType == null)
68              {
69                  return false;
70              } 

/// Code omitted for clarity

111                 ControllerDescriptor controllerDescriptor = null;
112                 if (typeof(IController).IsAssignableFrom(controllerType))
113                 {
114                     controllerDescriptor = new ReflectedControllerDescriptor(controllerType);
115                 }
116                 else if (typeof(IAsyncController).IsAssignableFrom(controllerType))
117                 {
118                     controllerDescriptor = new ReflectedAsyncControllerDescriptor(controllerType);
119                 } 

Basically, we're saying by exiting out of the first block "If we can't get a controller descriptor (because we can't get it without the type), return not authorized."

The only other place the type is used is for a fallback effort to create a controller using Reflection when the ControllerFactory fails.

However, consider this bit:


121                 ActionDescriptor actionDescriptor = null;
122                 try
123                 {
124                     actionDescriptor = controllerDescriptor.FindAction(controllerContext, mvcNode.Action);
125                 }
126                 catch
127                 {
128                 }
129                 if (actionDescriptor == null)
130                 {
131                     actionDescriptor = controllerDescriptor.GetCanonicalActions().Where(a => a.ActionName == mvcNode.Action).FirstOrDefault();
132                 }
133      
134                 // Verify security
135                 try
136                 {
137                     if (actionDescriptor != null)
138                     {

// Block omitted for clarity

180                     }
181      
182                     // No objection.
183                     return true;
184                 }
185                 finally
186                 {
187                     // Restore HttpContext
188                     httpContext.RewritePath(originalPath, true);
189                 } 

Basically what we are saying here is "If we can't get an action descriptor (in which case it would be null), return authorized".

So why the different result? It seems like if either of them can't be retrieved we can't reach a conclusion whether authorized or not, so we should probably return the same decision. But which should be the default?

Note also this comment:


/// From DefaultAclModule

                try
                {
                    result &= module.IsAccessibleToUser(controllerTypeResolver, provider, context, node);
                }
                catch (AclModuleNotSupportedException)
                {
                    result &= true; // Convention throughout the provider: if the IAclModule can not authenticate a user, true is returned.
                }

According to this, the default should always be true...? Also seems odd.

NightOwl888 commented 11 years ago

Well, after chopping up the code a bit more, it is now clear that the only inconsistency is the fact we return false in this one case. The only other place that returns false is when checking the AuthorizeAttributes themselves. Here is the broken down method:


        public bool IsAccessibleToUser(ISiteMap siteMap, ISiteMapNode node)
        {
            // Is security trimming enabled?
            if (!siteMap.SecurityTrimmingEnabled)
            {
                return true;
            }

            var httpContext = httpContextFactory.Create();

            // Is it an external Url?
            if (node.HasExternalUrl(httpContext))
            {
                return true;
            }

            // Clickable? Always accessible.
            if (node.Clickable == false)
            {
                return true;
            }

            // Time to delve into the AuthorizeAttribute defined on the node.
            // Let's start by getting all metadata for the controller...
            var controllerType = controllerTypeResolver.ResolveControllerType(node.Area, node.Controller);
            if (controllerType == null)
            {
                return false; \\ <-- Sticks out like a sore thumb, now.
            }

            var originalPath = httpContext.Request.Path;

            // Find routes for the sitemap node's url
            var routes = this.FindRoutesForNode(node, originalPath, httpContext);
            if (routes == null)
            {
                return true; // Static URL's will have no route data, therefore return true.
            }

            // Create controller context
            bool factoryBuiltController = false;
            var controllerContext = this.CreateControllerContext(routes, controllerType, out factoryBuiltController);

            // Get controller descriptor
            var controllerDescriptor = this.GetControllerDescriptor(controllerType);

            // Get action descriptor
            var actionDescriptor = this.GetActionDescriptor(node.Action, controllerDescriptor, controllerContext);

            if (actionDescriptor == null)
            {
                // No objection.
                return true;
            }

            // Verify security
            try
            {
                var authorizeAttributes = this.GetAuthorizeAttributes(actionDescriptor, controllerContext);
                return this.VerifyAuthorizeAttributes(authorizeAttributes, controllerContext);
            }
            finally
            {
                // Restore HttpContext
                httpContext.RewritePath(originalPath, true);

                // Release controller
                if (factoryBuiltController)
                    ControllerBuilder.Current.GetControllerFactory().ReleaseController(controllerContext.Controller);
            }
        }

This certainly adds a lot more weight to the comment that says it should be true if verification couldn't be done. Thoughts?

maartenba commented 11 years ago

Let’s go with true. I picked false since I wanted to have security above all, but since the controller can not be found there’s actually no reason for us to say it’s unauthorized. It should be left to the ASP.NET MVC framework.

maartenba commented 11 years ago

@NightOwl888 just wanted to check your progress on this. Want to start integrating it in some sample projects to do some comparisons with vOld and get a first preview release out.

NightOwl888 commented 11 years ago

@maartenba

Sorry I have been quiet, I have been working on the e-commerce prototype I mentioned.

E-Commerce Prototype

I recently got it to the point where I could integrate the MvcSiteMapProvider, but ran into a snag. The menu doesn't seem to be functioning the way I expected. Although I took into consideration the "sourceMetaData" field in my custom visibility provider, it doesn't seem to work right when I navigate to a page that is not visible in the menu - it becomes visible in this case, which is not what I want. I haven't yet looked into why this is the case.

Actually, I have been thinking that the VisibilityProvider is not a good fit for my case because the visibility logic is technically supposed to be provided by the business layer, however I haven't been able to come up with a better way so far. I have been toying with the idea of making a separate sitemap for the menu than for the sitemap path, and I think that might work. Of course the downside is that there would be 2 cached XML structures instead of one, but de-coupling the behavior for the menu may be worth it.

Setting up the current version of this prototype on another workstation would be a PITA, but if you are curious, you can view the source here: https://github.com/NightOwl888/ComplexCommerce

Registration and setup for the SiteMapProvider: https://github.com/NightOwl888/ComplexCommerce/blob/master/src/ComplexCommerce/ComplexCommerce/DI/Registries/MvcSiteMapProviderRegistry.cs https://github.com/NightOwl888/ComplexCommerce/blob/master/src/ComplexCommerce/ComplexCommerce/App_Start/MvcSiteMapProviderConfig.cs

Custom implementations of SiteMapProvider: https://github.com/NightOwl888/ComplexCommerce/tree/master/src/ComplexCommerce/ComplexCommerce.Web.Mvc/SiteMapProvider

Unfinished Parts of MvcSiteMapProvider

Although you are welcome to begin some trial testing, you should be aware that there are certain areas that are not yet complete. The current branch is still at this location: https://github.com/NightOwl888/MvcSiteMapProvider/tree/v4

DI Configuration

I noticed some shortcomings in the provider when putting together a production-quality DI registration. Basically, there needs to be a few more strategy patterns put in to make it easy to register more than one configuration for cache timeouts, cache dependencies, and XML sources (I created a new interface so the XML can be provided from other sources than files, such as a database or string). There are also several factory classes that need to be removed as the strategy pattern would completely eliminate the need for them.

I also haven't yet created the "default" DI container that would be used in the case the provider is used in a project that doesn't employ a DI container. I have been holding off primarily because this will reference most of the classes and interfaces in the project and keeping up with the changes when the project is still in a state of flux would be quite a bit of effort.

XmlSiteMapController

I also discovered that there is a flaw in this design - the XmlSiteMapController only works with the current sitemap. In the ComplexCommerce prototype, the current culture is transmitted in the URL, which determines which SiteMap to use. The xml generated at http://www.mydomain.com/sitemap.xml would not contain any of the localized pages. In my case this is acceptable, as I can use http://www.mydomain.com/fr-ca/sitemap.xml for each localized version, but I will need to create custom routes to handle this scenario.

However, it is possible that separate sitemap files may be used for purposes other than localization, in which case this behavior might not be acceptable. That said, I haven't thought of a good way to get it to merge several registered sitemaps together into 1 result.

Separate Files

At first I thought this was a bad idea, then I started to think that it might make sense to be able to put several of the interfaces and base classes into a separate library so custom implementations can be made in the business layer. However, after putting this together and seeing it work, there doesn't seem to be any drawbacks to creating a presentation area (that has a reference to System.Web.Mvc) where these classes reside. MVC itself requires that any MVC customizations be made in this layer, so it will always be required to make one in any project (whether it is the same assembly as the views and controllers or not).

The bottom line is, I don't see any real benefits from making the provider span 2 different files (unless I have missed something). There are several examples of open source projects with a tremendous amount of functionality that only require 1 DLL dependency.

Currently there is still a MvcSiteMapProvider.Web assembly in the project, but I plan to remove it.

Internal View Engine

I am not sure exactly what you had in mind for dropping the internal view engine, so I haven't touched that part except for the tweaks that were needed to make it compatible with the new sitemap classes. In fact, the whole UI layer is primarily unaffected.

That said, I don't disagree with your sentiment of eliminating the internal view engine. So if you would like to tackle this, I would appreciate it.

Conditional Compilation

The pull request I sent for the AllowAnonymous attribute was broken for anyone still using .NET 3.5 because of conditional constants in the view engine. Of course, this issue could automatically go away if you eliminate the view engine.

If not, I suggest that we add the MSBuildTasks library to the project so we can setup the build configuration to work with multiple constants using the RegEx functionality that it has. Note the provider itself won't need a dependency on this DLL, only the MSBuild (project) file.

Nuget Packages and Build Files

I started a new project when I set out to build the provider rather than using the v4 version, primarily because I needed to ensure I had a functional starting point. During the transition, the latest Nuget Packages and build files didn't make it into the project. You are welcome to tackle this if you like.

maartenba commented 11 years ago

No sorries needed man, you're doing awesome work here!

E-Commerce Prototype

The VisibilityProvider should be used together with authorization. So if you navigate to a page that shouldn't be visible, authentication should kick in.

I'll skip setting up the sample but it would be great if we can ship it along with v4 of the provider later on.

Unfinished parts

DI configuration

Looking forward to it. The default container should be in the project but completely agree on skipping it for teh time being.

XmlSiteMapController

This one indeed only works with SiteMap.CurrentProvider. Perhaps we should remove this one and only ship the XmlSiteMapResult. This leaves the decision on merging multiple sitemaps, culture-specific sitemaps and so on with the user.

Separate files

Agreed. We should probably even ship these as two different packages.

Internal view engine

The idea was to have all views shipped in the NuGet package so the user always has all of the views in their project. We can keep it as well but I think it's better to just ship the .cshtml files instead of embedding them as a resource in the DLL.

Conditional compilation

Let's just drop the view engine.

Packages and build

Let's tackle this one once we're a bit further down the road. Ideally a simple build.bat should produce everything we need.

NightOwl888 commented 11 years ago

E-Commerce Prototype

I wasn't implying that the visibility provider functionality shouldn't be included in MvcSiteMapProvider. I only meant it isn't working in this specific case. But I haven't yet determined if this is a bug in the new provider, there is a pre-existing bug in the old provider that was carried forth, or there is a problem with my current setup.

Basically, I have a multi-level menu that I would only like to include categories in, and exclude any products. However, since categories can be nested within categories I cannot set the menu to exclude the products based on node level.

I tried doing this by making a custom visibility provider that sets the visibility (for the menu only) depending on the value of a custom attribute which is set in the business layer. This works when I am on the home page or any category page, but when I go to a product page the product menu item becomes visible on the menu.

There is also an issue with my custom visibility provider not getting called for the sitemap path when the menu precedes the sitemap path in the layout page. It is using the value it retrieves for the visibility of the menu for the sitemap path as well. For some reason, when I reverse them it works (but then my page isn't laid out the way I want).

XmlSiteMapController

I think we can leave the default controller as it will work for 80-90% of the cases, which will only have 1 sitemap for the entire app.

However, I think another factory class is required in order to work with the XmlSiteMapResult. Basically, XmlSiteMapResult's overloaded constructor is a problem for many DI containers. However, if the overloaded functionality were moved to an abstract factory, its dependencies could be injected transparently into a single constructor, while still providing multiple options. 1 of those options could be to pass in an array of sitemap keys that could be used to merge the sitemaps together. Then the factory could be injected into the controller (or a custom controller) and the configured XmlSiteMapResult could be returned from a method of the factory.

This pattern exists in several places in the project already, I just didn't think about using it in this case until now.

Separate Files

I am not sure if you understood, but my conclusion was that it DID NOT make sense to use 2 DLL files. And if we drop the custom view engine and load the view files into the project via Nuget, it really doesn't make sense to have 2 Nuget packages, either - when would we ever have a case where the views are not necessary?

I see one downside of deploying the views this way - if someone were to add a reference to the DLL without using Nuget, they wouldn't have the views. Perhaps it would make sense to put them somewhere where they can be easily obtained in this case...or maybe put the download location into the error message that the view could not be found (assuming that is possible)?

Packages and build

Agreed, but there should probably be multiple build.bat files - 1 for each version of .NET and version of MVC combination - I think that realistically only means 4 configurations. Of course, the .bat files should delegate most of their work to an MSBuild file because it is much easier to specify build options in that environment.

On a side note, I was unable to get the build to work with powershell on my workstation as powershell itself requires some kind of configuration or setup. A .bat file would be better.

maartenba commented 11 years ago

Separate Files

With the two packages I meant putting the views separately to support non-web projects referencing MvcSiteMapProvider. We can probably even apply some NuGet magic to install them when we're in an MVC project.

Packages and build

Just one which creates everything would be ideal :-)

NightOwl888 commented 11 years ago

Separate Files

Ahh, okay. That makes sense.

Packages and Build

Actually, I prefer the 'double-click to get exactly what you want' approach rather than trying to figure out what parameters to pass to a custom .bat file. The .bat files would all most likely be 1 line of code anyway that call MSBuild with specifically configured sets of parameters. The MSBuild (project) file would do all of the heavy lifting, which would be the single piece to maintain for all configurations.

maartenba commented 11 years ago

If that build just creates a new folder and contains subfolders named “mvc4-net40” would be okay imho.

waynebrantley commented 11 years ago

Guys - the views as cshtml is a big pain in the current solution. When you add a nuget package it adds the views to the solution - and that is an issue. For example, if I add a nuget reference to it from a 'library project', where I have sitemap helpers and providers and such - it adds those views! I then have to go through and delete them.

If I am not using half of the views and delete them, next time I update, it puts them back. The views are really EXAMPLE of what someone might use to use those menu helpers. For example, I might put those in when I first add the nuget reference, but once I have my menu working and am using my own versions of those, I would NOT want you to mess with them.

Given all the above, I think the views would be a separate 'example' project of what the menu views should look like. Or perhaps it is not even in a package, just is in the sample.

Just my 2 cents!

waynebrantley commented 11 years ago

Also as far as the 'batch build'....what should be done is to apply for a free teamcity account. Then let the builds be completely automated from the github source.

http://codebetter.com/codebetter-ci/

NightOwl888 commented 11 years ago

@waynebrantley

I agree that the Nuget package should not be able to overwrite custom changes, although I don't know if that is possible. The views aren't technically just an example, they are also logical defaults.

Perhaps it would make more sense to make some visual studio project item templates instead of using a Nuget package. You would then, for example, add a "SiteMapPath View" to the project and customize it to meet your needs. From that point on it is totally under your control, but the default could be pre-filled with the most logical behavior. As each control is optional, it really doesn't make sense to pre-load all of the templates as Nuget would.

maartenba commented 11 years ago

Will give this a couple more iterations in my head.

NightOwl888 commented 11 years ago

@maartenba

BTW - If you are going to start experimenting, I recommend you choose a different DI container than StructureMap so we have proof that the provider can be registered with other containers.

I am working on the strategy pattern for using named instances of ICacheDetails. Basically, you need to inject the instanceName into the constructor of the CacheDetails in its configuration, then inject the same name into the cacheDetailsName constructor argument of a BuilderSet. It seems hokey, but in some DI containers there is no such thing as a named instance, so this should be more universal.

maartenba commented 11 years ago

I’ll plug it in using Autofac.

NightOwl888 commented 11 years ago

After giving it some more thought, I realized that the configuration can be done without using the strategy pattern. I am starting to think that putting in the strategy pattern only confuses things in this case.

Here is an example of what the configuration of 2 separate sitemaps would look like without the strategy pattern:

container.Configure(x => x
    .For<MvcSiteMapProvider.Builder.ISiteMapBuilderSetStrategy>()
    .Use<MvcSiteMapProvider.Builder.SiteMapBuilderSetStrategy>()
    .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilderSet>().Contains(y =>
    {
        y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("default")
            .Ctor<MvcSiteMapProvider.Caching.ICacheDetails>().Is<MvcSiteMapProvider.Caching.CacheDetails>()
                .Ctor<MvcSiteMapProvider.Caching.ICacheDependency>().Is<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
                    .Ctor<string>("fileName").Is(System.Web.Hosting.HostingEnvironment.MapPath("~/Mvc.sitemap"))
                .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(5))
                .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue);
        y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("super-duper")
            .Ctor<MvcSiteMapProvider.Caching.ICacheDetails>().Is<MvcSiteMapProvider.Caching.CacheDetails>()
                .Ctor<MvcSiteMapProvider.Caching.ICacheDependency>().Is<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
                    .Ctor<string>("fileName").Is(System.Web.Hosting.HostingEnvironment.MapPath("~/AnotherFile.sitemap"))
                .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(30))
                .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue);
    })
);

And this is the same configuration with the strategy pattern for both ICacheDetails and ICacheDependency:


// Configure named instances of ICacheDependency
container.Configure(x => x
    .For<MvcSiteMapProvider.Caching.ICacheDependencyStrategy>().Use<MvcSiteMapProvider.Caching.CacheDependencyStrategy>()
    .EnumerableOf<MvcSiteMapProvider.Caching.ICacheDependency>().Contains(y =>
    {
        y.Type<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
            .Ctor<string>("instanceName").Is("default")
            .Ctor<string>("fileName").Is(System.Web.Hosting.HostingEnvironment.MapPath("~/Mvc.sitemap"));
        y.Type<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
            .Ctor<string>("instanceName").Is("super-duper")
            .Ctor<string>("fileName").Is(System.Web.Hosting.HostingEnvironment.MapPath("~/AnotherFile.sitemap"));
    }
));

// Configure named instances of ICacheDetails
container.Configure(x => x
    .For<MvcSiteMapProvider.Caching.ICacheDetailsStrategy>().Use<MvcSiteMapProvider.Caching.CacheDetailsStrategy>()
    .EnumerableOf<MvcSiteMapProvider.Caching.ICacheDetails>().Contains(y =>
    {
        y.Type<MvcSiteMapProvider.Caching.CacheDetails>()
            .Ctor<string>("instanceName").Is("default")
            .Ctor<string>("cacheDependencyName").Is("default")
            .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(5))
            .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue);
        y.Type<MvcSiteMapProvider.Caching.CacheDetails>()
            .Ctor<string>("instanceName").Is("super-duper")
            .Ctor<string>("cacheDependencyName").Is("super-duper")
            .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(30))
            .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue);
    }
));

// Configure builder sets
container.Configure(x => x
    .For<MvcSiteMapProvider.Builder.ISiteMapBuilderSetStrategy>()
    .Use<MvcSiteMapProvider.Builder.SiteMapBuilderSetStrategy>()
    .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilderSet>().Contains(y =>
    {
        y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("default")
            .Ctor<string>("cacheDetailsName").Is("default");
        y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("super-duper")
            .Ctor<string>("cacheDetailsName").Is("super-duper");
    })
);

It seems that the first method is much more clear. The only advantage of the second method is that cache timeouts and cache dependencies can be reused across builder sets, but since that is an edge case anyway, I don't foresee any problem without the strategy.

Thoughts?

maartenba commented 11 years ago

The first approach is much more readable imho.

NightOwl888 commented 11 years ago

Oops, I was wrong - here is what the configuration would look like for builder sets. I forgot about the builders:

container.Configure(x => x
    .For<MvcSiteMapProvider.Builder.ISiteMapBuilderSetStrategy>()
    .Use<MvcSiteMapProvider.Builder.SiteMapBuilderSetStrategy>()
    .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilderSet>().Contains(y =>
    {
        y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("default")
            .Ctor<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Is<MvcSiteMapProvider.Builder.CompositeSiteMapBuilder>()
                .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Contains(z =>
                {
                    z.Type<MvcSiteMapProvider.Builder.XmlSiteMapBuilder>()
                        .Ctor<IEnumerable<string>>("attributesToIgnore").Is(new string[0])
                        .Ctor<MvcSiteMapProvider.Xml.IXmlSource>().Is<MvcSiteMapProvider.Xml.FileXmlSource>()
                            .Ctor<string>("xmlFileName").Is("~/Mvc.sitemap");
                    z.Type<MvcSiteMapProvider.Builder.ReflectionSiteMapBuilder>()
                        .Ctor<IEnumerable<string>>("includeAssemblies").Is(new string[0])
                        .Ctor<IEnumerable<string>>("excludeAssemblies").Is(new string[0]);
                    z.Type<MvcSiteMapProvider.Builder.VisitingSiteMapBuilder>();
                })
            .Ctor<MvcSiteMapProvider.Caching.ICacheDetails>().Is<MvcSiteMapProvider.Caching.CacheDetails>()
                .Ctor<MvcSiteMapProvider.Caching.ICacheDependency>().Is<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
                    .Ctor<string>("fileName").Is(System.Web.Hosting.HostingEnvironment.MapPath("~/Mvc.sitemap"))
                .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(5))
                .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue);
        y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("super-duper")
            .Ctor<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Is<MvcSiteMapProvider.Builder.CompositeSiteMapBuilder>()
                .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Contains(z =>
                {
                    z.Type<MvcSiteMapProvider.Builder.XmlSiteMapBuilder>()
                        .Ctor<IEnumerable<string>>("attributesToIgnore").Is(new string[0])
                        .Ctor<MvcSiteMapProvider.Xml.IXmlSource>().Is<MvcSiteMapProvider.Xml.FileXmlSource>()
                            .Ctor<string>("xmlFileName").Is("~/AnotherFile.sitemap");
                    z.Type<MvcSiteMapProvider.Builder.ReflectionSiteMapBuilder>()
                        .Ctor<IEnumerable<string>>("includeAssemblies").Is(new string[0])
                        .Ctor<IEnumerable<string>>("excludeAssemblies").Is(new string[0]);
                    z.Type<MvcSiteMapProvider.Builder.VisitingSiteMapBuilder>();
                })
            .Ctor<MvcSiteMapProvider.Caching.ICacheDetails>().Is<MvcSiteMapProvider.Caching.CacheDetails>()
                .Ctor<MvcSiteMapProvider.Caching.ICacheDependency>().Is<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
                    .Ctor<string>("fileName").Is(System.Web.Hosting.HostingEnvironment.MapPath("~/AnotherFile.sitemap"))
                .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(30))
                .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue);
    })
);

Still I think it is fairly easy to read.

NightOwl888 commented 11 years ago

I goofed again. The last code did not work because StructureMap couldn't tell what level the .Ctor() method was intended for. After several hours of research, I finally found an alternative way of doing it without resorting to naming (below). However, I did some additional research and it seems that Autofac, Unity, Ninject, Castle Windsor, and StructureMap all support named instances - and there are probably others. So, if someone were to need the ability to register more than 1 instance of CacheDetails, CacheDependency, or XmlSource, they can use named instances, or in some cases there are other ways to pass the instances around - in StructureMap, you can just pass variable references, like this:

container.Configure(x =>
    {

        var xmlSource1 = x.For<MvcSiteMapProvider.Xml.IXmlSource>().Use<MvcSiteMapProvider.Xml.FileXmlSource>()
            .Ctor<string>("xmlFileName").Is("~/Mvc.sitemap");

        var builder1 =  x.For<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Use<MvcSiteMapProvider.Builder.CompositeSiteMapBuilder>()
            .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Contains(y =>
            {
                y.Type<MvcSiteMapProvider.Builder.XmlSiteMapBuilder>()
                    .Ctor<IEnumerable<string>>("attributesToIgnore").Is(new string[0])
                    .Ctor<MvcSiteMapProvider.Xml.IXmlSource>().Is(xmlSource1);
                y.Type<MvcSiteMapProvider.Builder.ReflectionSiteMapBuilder>()
                    .Ctor<IEnumerable<string>>("includeAssemblies").Is(new string[0])
                    .Ctor<IEnumerable<string>>("excludeAssemblies").Is(new string[0]);
                y.Type<MvcSiteMapProvider.Builder.VisitingSiteMapBuilder>();
            });

        var cacheDependency1 = x.For<MvcSiteMapProvider.Caching.ICacheDependency>()
            .Use<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
            .Ctor<string>("fileName")
            .Is(System.Web.Hosting.HostingEnvironment.MapPath("~/Mvc.sitemap"));

        var cacheDetails1 = x.For<MvcSiteMapProvider.Caching.ICacheDetails>().Use<MvcSiteMapProvider.Caching.CacheDetails>()
            .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(5))
            .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
            .Ctor<MvcSiteMapProvider.Caching.ICacheDependency>().Is(cacheDependency1);

        var xmlSource2 = x.For<MvcSiteMapProvider.Xml.IXmlSource>().Use<MvcSiteMapProvider.Xml.FileXmlSource>()
            .Ctor<string>("xmlFileName").Is("~/AnotherFile.sitemap");

        var builder2 = x.For<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Use<MvcSiteMapProvider.Builder.CompositeSiteMapBuilder>()
            .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Contains(y =>
            {
                y.Type<MvcSiteMapProvider.Builder.XmlSiteMapBuilder>()
                    .Ctor<IEnumerable<string>>("attributesToIgnore").Is(new string[0])
                    .Ctor<MvcSiteMapProvider.Xml.IXmlSource>().Is(xmlSource2);
                y.Type<MvcSiteMapProvider.Builder.ReflectionSiteMapBuilder>()
                    .Ctor<IEnumerable<string>>("includeAssemblies").Is(new string[0])
                    .Ctor<IEnumerable<string>>("excludeAssemblies").Is(new string[0]);
                y.Type<MvcSiteMapProvider.Builder.VisitingSiteMapBuilder>();
            });

        var cacheDependency2 = x.For<MvcSiteMapProvider.Caching.ICacheDependency>()
            .Use<MvcSiteMapProvider.Caching.AspNetFileCacheDependency>()
            .Ctor<string>("fileName")
            .Is(System.Web.Hosting.HostingEnvironment.MapPath("~/AnotherFile.sitemap"));

        var cacheDetails2 = x.For<MvcSiteMapProvider.Caching.ICacheDetails>().Use<MvcSiteMapProvider.Caching.CacheDetails>()
            .Ctor<TimeSpan>("absoluteCacheExpiration").Is(TimeSpan.FromMinutes(30))
            .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
            .Ctor<MvcSiteMapProvider.Caching.ICacheDependency>().Is(cacheDependency2);

        x.For<MvcSiteMapProvider.Builder.ISiteMapBuilderSetStrategy>().Use<MvcSiteMapProvider.Builder.SiteMapBuilderSetStrategy>()
            .EnumerableOf<MvcSiteMapProvider.Builder.ISiteMapBuilderSet>().Contains(y =>
            {
                y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
                    .Ctor<string>("instanceName").Is("default")
                    .Ctor<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Is(builder1)
                    .Ctor<MvcSiteMapProvider.Caching.ICacheDetails>().Is(cacheDetails1);
                y.Type<MvcSiteMapProvider.Builder.SiteMapBuilderSet>()
                    .Ctor<string>("instanceName").Is("super-duper")
                    .Ctor<MvcSiteMapProvider.Builder.ISiteMapBuilder>().Is(builder2)
                    .Ctor<MvcSiteMapProvider.Caching.ICacheDetails>().Is(cacheDetails2);
            });
    }
);

Anyway, suffice to say we don't need the strategy pattern for many DI containers, and there is little point in complicating things just to ensure this compatible with the rest as most people will probably only want 1 instance of these interfaces anyway. If they need more instances, then they should use a container that allows them to specify which one to inject.

NightOwl888 commented 11 years ago

Update

In the past couple of days, I have buttoned this up quite a bit from where it was. Here is a summary of the major stuff that changed:

  1. Created Poor Man's DI container. The container contains most of the options of v3.x which can be configured in the AppSettings section of the config file. To use an external DI container, set the "MvcSiteMapProvider_UseExternalDIContainer" setting to "true", and then it will ignore any of the other settings and assume you will handle configuration yourself.
  2. Added reference to David Ebbo's WebActivator to start the built-in DI container automatically. Now the DLL just works simply by dropping it into an MVC project with ZERO configuration. Unfortunately, this feature doesn't work in .NET 3.5. I put in compiler constants so it can still compile, but the user must manually add MvcSiteMapProvider.DI.Composer.Compose() to their Global.asax file in that case.
  3. Removed several unneeded factory classes.
  4. Removed unneeded namespace imports.
  5. Refactored cache so it handles thread safety internally, drawing inspiration from Micro Caching in ASP.NET.
  6. Added a second cache option - using the caching from System.Runtime.Caching namespace. So now there is an AspNetSiteMapCache and a RuntimeSiteMapCache. It is required to use the corresponding cache dependency classes.
  7. Reworked the cache dependency classes so they can be chained together (meaning multiple dependencies can be specified as multiple classes). Created a NullCacheDependency that can be configured for either cache to indicate no dependencies.
  8. Fixed the project file so there are constants for both MVC and .NET versions. The .NET constant will change automatically when the version of .NET is changed. It now compiles under MVC2;NET35, MVC3;NET40, and MVC4;NET40.
  9. Added the ability to unload sitemaps from the cache manually though the static SiteMaps class (the same one that is used to load the sitemaps).
  10. Refactored SiteMapLoader into 2 classes, SiteMapLoader and SiteMapCreator to eliminate some constructor parameters.
  11. Finished everything on the TODO list (except one that was carried over from the old project).
  12. Setup the strong naming.
  13. Re-added the FilteredSiteMapNodeVisibilityProvider because it was missing.
  14. Created a factory to deal with the constructor of the XmlSiteMapResult class and fixed it so multiple cache keys can be passed so it will combine the results of multiple sitemaps. It still supports partial sitemaps by supplying a node other than the root, but these 2 features don't work simultaneously.
  15. Created a decorator IControllerFactory that wraps whatever IControllerFactory that is configured in Application_Start that only knows about the internal XmlSiteMapController so it can pass the dependencies to it. All other requests go to the original IControllerFactory.
  16. Removed unused error messages from Resources and ensured the ones that are in there are configured correctly to display passed in arguments.

WebActivator

You are probably wondering why I didn't use the built-in System.Web.PreApplicationStartMethodAttribute. There are 2 reasons:

  1. The built-in attribute can only be applied one time per application, meaning if someone added MvcSiteMapProvider they wouldn't be able to use this attribute again.
  2. The built-in attribute runs way too early - before the code in the MVC project is even compiled. If there are any custom providers in that layer, then there is no way to instantiate them. The WebActivator has an event that runs after Application_Start, which was better suited for this case.

Custom Visual Studio Templates

I threw the idea of Custom Visual Studio Templates out there before hoping it would spark a better idea to anyone keeping up with this thread.

However, I did a little research and discovered that this indeed may be the right solution for providing the partial views for MvcSiteMapProvider because they are easy to create and can be deployed with Nuget easily. Have a look at the following article:

http://visualstudiomagazine.com/articles/2012/08/20/visual-studio-custom-templates.aspx

NightOwl888 commented 11 years ago

I have tracked down that issue with my custom visibility provider. As it turns out, the issue was due to bug in the RequestCacheableSiteMapNode where the value of sourceMetadata was not being incorporated into the cache key.

So in short, this was a bug that I caused, not a pre-existing issue. It is fixed in the latest version here: https://github.com/NightOwl888/MvcSiteMapProvider/tree/v4

I recommend you pull down the latest version, as this could cause other visibility issues. There may be a few DI configuration changes needed with the caching if you haven't pulled in a couple of days, primarily to the classes that deal with caching. Just check the internal DI container if you need a guide to set them up.

NightOwl888 commented 11 years ago

Multiple Menus with Multiple Tenants

I ran into another snag. Consider the case where an application has multiple e-commerce tenants and each tenant has 2 menus - a main menu and a footer menu, all database driven. Ideally, these menus would be able to be configured as separate units. However, considering technical constraints it would also be ideal to build both of these menus (for each tenant) from the same SiteMap so they share the same cache.

This could be done by using the SiteMapBuilder to store metadata for each menu in each node in the Attributes field. For example, we could populate the value "visibleOnMainMenu" = "true", "visibleOnFooterMenu" = "false" into the Attributes property for a node that we want to show on the main menu, but hide on the footer menu. Then a visibility provider could be used to actually respond to this property under the right conditions.

Unfortunately, there doesn't seem to be a way to tell which menu instance is calling the code in the visibility provider. The SourceMetadata field for the menu is hard-coded to only provide the type name of control, but there is no identifier for which instance of the menu is calling it. To make matters worse, the SourceMetadata is itself static so we couldn't make a new instance if we wanted to.

I have searched the entire issues list to see if I could come up with some alternatives, but all I found was #64. This could work, but as was pointed out in the last post, it seems redundant. Not to mention, if we wanted 2 menus on some tenants and 3 menus on others, we would need to jump through a lot of hoops to get there.

The Menu isn't the only control I want to have more than one instance of. For example, I would like to display a list of all of the categories that a product belongs to using the SiteMapPath like at Folding Chair Depot.

Admittedly, the solution I came up with above feels a bit wrong. It would be better if the menu definitions could be specified through some kind of provider than to use the attributes for this purpose. However, it still doesn't address the fact that there can only be 1 menu structure per page (unless it is pulling from a different SiteMap). For that, there really needs to be some kind of name or ID put onto the menu and then made available in the visibility provider's SourceMetadata.

Thoughts?

maartenba commented 11 years ago

Wow, a lot of info to parse :-)

  1. WebActivator - great option! I use it all the time.
  2. Compilation: awesome that it's compiling under all these versions, great work.
  3. Caching: great work as well!
  4. On Visual Studio templates, I feel we really should be distributing this as a NuGet package. We can have the main package pull it in as a dependency when other .cshtml, .vbhtml or .aspx files are found in the project.

Multiple controls / multiple tenants

We can make this a play between visibility providers and HtmlHelpers. For example, here's what we can use to render a sitemappath:

@Html.SiteMapPath(new { tenant = "foo", someOtherVar = true }) where the default sitemap is used and the options are passed in as context to both the VisibilityProvider as well as the views that render this thing. In there, the users of MvcSiteMap can decide for themselves if they want to do something with this "calling context".

I still have to wire up everything in another project, a bit time constrained at the moment :-(

NightOwl888 commented 11 years ago

WebActivator

In retrospect, it would probably make sense to break off the "internal DI container" along with this extra dependency into a separate DLL that can be downloaded as an option from another Nuget package that depends on the MvcSiteMapProvider Nuget package.

Visual Studio Templates/Nuget

Well, I am not going to push the issue, because VS templates don't seem like the ideal solution. Splitting into separate Nuget packages would help, I guess. But if more helpers are added to this package, what then?

It seems this whole problem should be able to be avoided altogether. After giving it some thought and analyzing the MVC framework itself, could this whole problem exist because the MVC helpers generate default HTML code in C# and MvcSiteMapProvider uses external templates to provide HTML, when it shouldn't? Just thinking out loud...

Multiple controls / multiple tenants

Ok, I am having some trouble trying to picture the other side of the equation, meaning how the SiteMapPath() static method would actually process the "calling context". Some pseudo-code or a link to a similar solution would be helpful.

I was able to work out what needed to be done for my particular case with the SiteMapPath control - I added an overload that allows the starting node key to be passed as a parameter. Then I can simply construct all of these category sitemaps using a foreach loop that iterates a collection of categories on my Model, passing in the category ID as the key. Of course, an overload that accepts an ISiteMapNode rather than a key would probably also be helpful.

maartenba commented 11 years ago

WebActivator

We can probably move the poor man’s DI container to the NuGet package and drop it in the user’s project, much like ASP.NET Web API does by default. Or just keep it, I’d love to have a zero-config version of the provider.

Templates

These were originally inside C# code but I moved them out because a lot of people wanted to customize them. We should keep them (or use the internal view engine again and ship defaults embedded in the assembly).

Multiple controls / tenants

Well there’s always HttpContext.Current (and similar) on which we base the tree, right? That can serve as the context.

NightOwl888 commented 11 years ago

WebActivator

My thought here is that the extra baggage (meaning dependencies on webactivator and Microsoft.Web.Infrastructure) won't be needed for anyone using an external DI container. Dropping it into the user's project sounds like it might be ideal, provided it won't be overwritten again when someone updates the Nuget package (back to square one, it seems).

But then, splitting it into 2 Nuget packages - one for "MvcSiteMapProvider" and one for "MvcSiteMapProvider Default Container" could make sense, provided the description of the container makes it clear they do not need it if they are using an external DI container. Then updates to the default container could simply be left out of the equation.

Actually, multiple Nuget packages are probably the right path. Then we could also provide "MvcSiteMapProvider StructureMap Container", "MvcSiteMapProvider Unity Container", "MvcSiteMapProvider AutoFac Container", etc. that provide a starting point for each container. This is similar to the implementations that are already available for IDependencyResolver for MVC for each container via Nuget.

Templates

I am totally on board with being able to customize the templates. I am just trying to work out whether there is a solution that was overlooked. What about using the template name to determine if there is an external custom template, like:

public static MvcHtmlString Menu(this MvcSiteMapHtmlHelper helper, string templateName, ISiteMapNode startingNode, bool startingNodeInChildLevel, bool showStartingNode, int maxDepth, bool drillDownToCurrent)
        {
            var model = BuildModel(helper, startingNode, startingNodeInChildLevel, showStartingNode, maxDepth, drillDownToCurrent);
            if (!string.IsNullOrEmpty(templateName))
            {
                return helper
                    .CreateHtmlHelperForModel(model)
                    .DisplayFor(m => model, templateName);
            }
            else
            {
                return /// Hard coded HTML
            }
        }

I realize this makes the default template name worthless, but once again I am just thinking out loud.

Multiple controls / tenants

Well, not exactly. The multi-tenancy is controlled by the ISiteMapCacheKeyGenerator interface. The default implementation looks like this:

public virtual string GenerateKey()
{
    var context = mvcContextFactory.CreateHttpContext();
    var builder = new StringBuilder();
    builder.Append("sitemap://");
    builder.Append(context.Request.Url.DnsSafeHost);
    builder.Append("/");
    return builder.ToString();
}

But as should be apparent, there is no guarantee that every implementation will rely on HttpContext.

Besides, that doesn't address the original problem - which is how to tell one instance of @Html.MvcSiteMap().Menu() from another when they are rendered to the same view.

Looking at the implementation in the MVC framework, here is how they are handling inline anonymous types like what you suggested before:

// From RouteValueDictionary

// values input: new { tenant = "foo", someOtherVar = true }
private void AddValues(object values)
        {
            if (values != null)
            {
                foreach (PropertyDescriptor descriptor in TypeDescriptor.GetProperties(values))
                {
                    object obj2 = descriptor.GetValue(values);
                    this.Add(descriptor.Name, obj2);
                }
            }
        }

So it looks like if this logic were applied to SourceMetadata before building the model in each helper, we could then add custom information to SourceMetadata on a per instance level (provided the SourceMetadata were made into an instance variable rather than static). This meta information could then be used to differentiate between 2 instances of the same control from within IVisibilityProvider.

Of course, we would also want to add the HtmlHelper = typeof(MenuHelper).FullName to the SourceMetadata to provide some measure of backward compatibility.

maartenba commented 11 years ago

WebActivator

I like that approach!

Templates

That may just work, however we need a third check: if a template named Menu.cshtml exists, default to that one. This enables 3 scenarios:

From: NightOwl888 [mailto:notifications@github.com] Sent: woensdag 20 maart 2013 9:08 To: maartenba/MvcSiteMapProvider Cc: Maarten Balliauw Subject: Re: [MvcSiteMapProvider] MvcSitemapProvider vNext (#119)

WebActivator

My thought here is that the extra baggage (meaning dependencies on webactivator and Microsoft.Web.Infrastructure) won't be needed for anyone using an external DI container. Dropping it into the user's project sounds like it might be ideal, provided it won't be overwritten again when someone updates the Nuget package (back to square one, it seems).

But then, splitting it into 2 Nuget packages - one for "MvcSiteMapProvider" and one for "MvcSiteMapProvider Default Container" could make sense, provided the description of the container makes it clear they do not need it if they are using an external DI container. Then updates to the default container could simply be left out of the equation.

Actually, multiple Nuget packages are probably the right path. Then we could also provide "MvcSiteMapProvider StructureMap Container", "MvcSiteMapProvider Unity Container", "MvcSiteMapProvider AutoFac Container", etc. that provide a starting point for each container. This is similar to the implementations that are already available for IDependencyResolver for MVC for each container via Nuget.

Templates

I am totally on board with being able to customize the templates. I am just trying to work out whether there is a solution that was overlooked. What about using the template name to determine if there is an external custom template, like:

public static MvcHtmlString Menu(this MvcSiteMapHtmlHelper helper, string templateName, ISiteMapNode startingNode, bool startingNodeInChildLevel, bool showStartingNode, int maxDepth, bool drillDownToCurrent) { var model = BuildModel(helper, startingNode, startingNodeInChildLevel, showStartingNode, maxDepth, drillDownToCurrent); if (!string.IsNullOrEmpty(templateName)) { return helper .CreateHtmlHelperForModel(model) .DisplayFor(m => model, templateName); } else { return /// Hard coded HTML } }

I realize this makes the default template name worthless, but once again I am just thinking out loud.

Multiple controls / tenants

Well, not exactly. The multi-tenancy is controlled by the ISiteMapCacheKeyGenerator interface. The default implementation looks like this:

public virtual string GenerateKey() { var context = mvcContextFactory.CreateHttpContext(); var builder = new StringBuilder(); builder.Append("sitemap://"); builder.Append(context.Request.Url.DnsSafeHost); builder.Append("/"); return builder.ToString(); }

But as should be apparent, there is no guarantee that every implementation will rely on HttpContext.

Besides, that doesn't address the original problem - which is how to tell one instance of @Html.MvcSiteMap().Menu() from another when they are rendered to the same view.

Looking at the implementation in the MVC framework, here is how they are handling inline anonymous types like what you suggested before:

// From RouteValueDictionary

// values input: new { tenant = "foo", someOtherVar = true } private void AddValues(object values) { if (values != null) { foreach (PropertyDescriptor descriptor in TypeDescriptor.GetProperties(values)) { object obj2 = descriptor.GetValue(values); this.Add(descriptor.Name, obj2); } } }

So it looks like if this logic were applied to SourceMetadata before building the model in each helper, we could then add custom information to SourceMetadata on a per instance level (provided the SourceMetadata were made into an instance variable rather than static). This meta information could then be used to differentiate between 2 instances of the same control from within IVisibilityProvider.

Of course, we would also want to add the HtmlHelper = typeof(MenuHelper).FullName to the SourceMetadata to provide some measure of backward compatibility.

— Reply to this email directly or view it on GitHub https://github.com/maartenba/MvcSiteMapProvider/issues/119#issuecomment-15162533 . https://github.com/notifications/beacon/OgHlEtub9rJzfLSOAmMnQC6zMK-AmnBKu9Yih-TA9jKIdFXe4jvkGClJ89j4V1cu.gif

NightOwl888 commented 11 years ago

Templates

Well, I was hoping it wouldn't come to that. I am reminded of a similar scenario of attempting to pull the height and width of an image dynamically from the file system, only to overwhelm the operating system.

Might I suggest that the value of whether the file exists be cached so we don't end up in the same boat. Storing the value of whether a file exists in memory would be really cheap by comparison to checking whether the file exists on every request.

Even if this idea were implemented, there is still the matter of giving the end developer a starting point for which to work from for each template, and making the templates intuitively install into the correct place. Visual Studio item templates would only cover the first case, where a separate Nuget package would cover them both.

However, Visual Studio item templates have an advantage of being able to provide 1 template at a time using the correct view engine format (.cshtml vs .ascx) rather than installing all of them and having to delete the ones we don't need. The Visual Studio item templates also can be installed via Nuget (in the same package as the provider, not a separate one) so everything is ready to go, without any danger of overwriting any customizations to the templates. Updates (or additions) can be done to the templates themselves without having to worry about overwriting anyone's changes.

At this point, we could just flip a coin as both ideas have merit. In fact, there is really no reason why both ideas couldn't be implemented, provided the default templates are not in the same Nuget project as the provider itself.

Multiple controls / tenants

If there are no objections to going down this path, I will get started implementing this solution. But then, you were the one who suggested it :). I will add an overload that doesn't require reflection as well, similar to the way they did it in MVC. This will add several more overloads to all of the helpers, but I can see a potential use for this for any of the helpers. For example, there might be a valid case where someone would want to display a Title to the end user under certain conditions, in which case the visibility provider and SourceMetadata attributes could come in handy.

I will also add a site map node key and ISiteMapNode overloads on the SiteMapPath so paths other than the current node can be displayed in the current view.

waynebrantley commented 11 years ago

@NightOwl888 just want to say you are doing some great work...and ALOT of it.

NightOwl888 commented 11 years ago

@waynebrantley - Well, not all without a purpose :). Actually, although I mentioned unit tests early on, I am doubtful I will have the chance to put any together. I made most of the classes testable, but didn't actually make any tests. I really need to get working on the main project, though. I could use your help to build them, or at least set the new provider up in a new project to try out its features and find bugs ;).

NightOwl888 commented 11 years ago

Official Branch Name Change

I renamed the working branch (v4-2-new-ui) to "v4" and have synced up my master branch with yours. I just wanted to make sure you were aware of this in case you pulled something locally and have local changes.

So, now the primary branch is here: https://github.com/NightOwl888/MvcSiteMapProvider/tree/v4. Please update your remote locations.

Templates

I did some more research and determined that you were probably on the right track to begin with. Specifically, using the Nuget package itself to provide the templates. Based on this information, I suggest we do the following:

  1. Drop the view engine from the assembly.
  2. Create separate Nuget packages for MVC2, MVC3, and MVC4. Nuget is capable of installing different DLLs based on .NET version, not by using MVC version - so separate packages will be necessary.
  3. Create a single .bat file to handle building all 3 versions of the provider and drop them into versioned directories in the release folder.
  4. Use a Powershell script to install the templates from within Nuget, but only in the case where they don't already exist (on a file by file basis). Nuget executes a file named Install.ps1 automatically during install and Uninstall.ps1 during uninstall where this file copying logic can reside.
  5. Don't hard code the HTML into the provider, but rely on these templates.
  6. Add the templates as Solution Items so they can be maintained from within Visual Studio, and use a script to copy them into the Nuget package location. They must not be referenced from within the .Nuspec file so Nuget won't attempt to install them itself.
  7. Create separate Nuget packages for specific DI container configurations - or at the very least provide 1 or 2 as templates the community can use to provide implementations of the rest.

While this wouldn't provide the best experience possible, it would be the simplest method to implement and maintain, and addresses the updating problem that waynebrantley pointed out. That said, I am no expert on Nuget or Powershell - in fact, I have never worked with Powershell and have not yet used Nuget to create packages. Therefore, I think it would be better if you (@maartenba) handled these tasks.

I will however provide a default StructureMap repository and setup code for a StrctureMap DI container starting point Nuget package.

maartenba commented 11 years ago

Will do!

What would be the best way of pulling your repo back to the original one?

NightOwl888 commented 11 years ago

Assuming you haven't pulled anything locally yet, I would probably follow this workflow, or alternatively you can pull down my changes into a new location, submit your work to me as a pull request, and then do all of these steps later after I have merged everything.

1 - Navigate to your local repo root directory. 2 - Check which remote you have pointing to the authoritive repo (the one you have hosted at GitHub)

git remote -v

3 - Pull down the latest changes from the authoritive repo (replace origin with the remote from the last step if it is not actually origin)

git pull origin master
git pull origin v4

4 - Rename v4 to something else

git branch -m v4 v4-maarten

5 - Checkout your master branch

git checkout master

6 - Create a new working branch based on master to pull my changes into

git branch v4

7 - Add a remote to my repo

git remote add nightowl888 https://github.com/NightOwl888/MvcSiteMapProvider.git

8 - Switch to your working branch

git checkout v4

9 - Merge in the changes from my branch (http://stackoverflow.com/questions/7200614/how-to-merge-remote-master-to-local-branch)

git fetch nightowl888
git rebase nightowl888/master

10 - Push your changes back to GitHub (then let me know when you did this, so I can sync my repo again).

git push origin --all

11 - Do your changes and commit to the v4 branch 12 - When you are ready to go with v4, create a branch of master, in case there is anything left to commit on v3 (for maintenance)

git checkout master
git branch v3

13 - Merge v4 back to master and push to GitHub (the following assumes you still have master checked out)

git merge v4
git push origin --all
maartenba commented 11 years ago

Seems there are some conflicts in that flow. Perhaps we can use your repo as the master and repopulate mine?

NightOwl888 commented 11 years ago

I have added the starting point code for StructureMap so you can package it into a Nuget download. It is all in the v4 branch.

I structured the code so that Application_Start should always look like this for any external DI container:

    // MvcSiteMapProvider Configuration
#if NET35
    MvcSiteMapProvider.DI.Composer.Compose();
#endif
    XmlSiteMapController.RegisterRoutes(RouteTable.Routes);
    var container = DIConfig.Register();
    MvcSiteMapProviderConfig.Register(container);

Strictly speaking, the call to Composer.Compose() isn't necessary when using an external DI container.

This is what the code should look like when using the built-in DI container:

    // MvcSiteMapProvider Configuration
#if NET35
    MvcSiteMapProvider.DI.Composer.Compose();
#endif
    XmlSiteMapController.RegisterRoutes(RouteTable.Routes);

As you can see, it is the last 2 lines that wire us up for external DI. I have included an IDependencyInjectionContainer interface and an InjectableControllerFactory as an abstraction layer so any DI container can be used and the code will always look like this in Global.asax.

I also wired all of the classes up as Visual Studio linked files that point to a directory outside of the solution. This is so you can build the Nuget packages using the exact same files that are included in MvcMusicStore. I recommend that all of the DI container code be included this way so it can be updated and tested easily. You can change this directory structure if it makes sense for Nuget packages, but be sure to update the links in the project file. I am not sure how you would handle the shared dependencies, though.

I also recommend we start using linked files for the default helper templates so they are visible within the VS solution, but physically located in a place where they are easy to package with Nuget.

IMPORTANT: The DIConfig.cs file is in the global namespace. I did this so namespaces wouldn't have to be added to Global.asax, but it does present a problem for testing multiple DI container configurations in the same project. I solved it by making the linked file conditional within the MvcMusicStore.csproj file depending on a compilation constant. I verified it works, but there is no visual clue which copy of DIConfig.cs is the active one.

Also, the DIConfig.cs file technically belongs in the App_Start folder in the end application, but since it is not possible to have 2 files with the same name in the same folder, I put it in the DI/StructureMap folder in MvcMusicStore for testing purposes. Test configurations for other DI containers should follow the same convention.

Note also I included an Instructions.txt file inside the StructureMap folder. I set it up so a user can follow it to manually install the files in case you don't get around to making a Nuget package, but it is pretty easy to follow it to create the Nuget package as well.

maartenba commented 11 years ago

Not yet working on the build and template packaging, but I've done some more refactorings.

Sample app

DI

Open tasks

You've been doing awesome work! Thanks!

NightOwl888 commented 11 years ago

I was beginning to wonder what happened to you!

Sample App

Being that I was completely unable to run the demo app because the database is newer than the SQL Server 2005 instance I have installed on my machine, I consider that a good thing!

Unfortunately, MS gave me no choice - I am maintaining an application that uses Visio as a database modeling tool and MS dropped support for this in SQL Server 2008.

DI

The common service locator probably won't help. Service locator is an anti-pattern that takes away control of the lifetime of the class dependencies. Using a composition root in the top layer of the application is a much better design choice because it gives the top level of the application absolute control over all of the dependencies. Here is what Mark Seemann, author of "Dependency Injection in .NET" has to say about it:

Common Service Locator

There’s an open source project called the Common Service Locator (http://commonservicelocator. codeplex.com/) that aims to decouple application code from specific DI CONTAINERS by hiding each container behind a common IServiceLocator interface. I hope that this explanation of how a COMPOSITION ROOT effectively decouples the rest of the application code from DI CONTAINERS enables you now to understand why you don’t need the Common Service Locator. As I explain in section 5.4, because SERVICE LOCATOR is an anti-pattern, it’s best to stay away from it—and with a COMPOSITION ROOT, you don’t need it, either.

Since Mark Seeman claims to have created and maintained an open-source service locator project for several years before deciding it is an anti-pattern that should be avoided, his opinion carries a lot of weight with me.

In short, the MVC application (entry point) should have ultimate control over the DI configuration. Any abstraction layer that is put in will effectively take this control away and make the DI configuration more difficult to maintain.

Packaging

I wasn't quite sure how it would be best to handle the packaging - that is one of the reasons why I put compilation constants into the DI code - so you could potentially use them to build different packages depending on target framework and MVC version.

It would be best for maintainability to build a single package that uses a script to determine what version to install and have several different options in the same package. It would have to take care of all of the following checks:

Version of .NET on the target app Version of MVC on the target app Whether the target app uses Webforms or Razor views

Alternatively, there could be a different Nuget package for each combination of configurations, but that could be difficult to maintain. It could be made maintainable by making a command line script (possibly MSBuild) that knows how to build all of the individual packages from a single command.

NightOwl888 commented 11 years ago

@maartenba

BTW - When I was merging everything, I noticed a couple of loose ends.

  1. The ReflectionSiteMapBuilder has not been tested, and contains some commented code because I couldn't decide between the v4 version or the v3 version. It was refactored along with everything else, but the code hasn't actually been run, so there could be problems.
  2. The SiteMapProviderEventHandler was not migrated. If someone were to want to know when a node were added, it would not be too difficult to get this information by implementing ISiteMapBuilder directly or subclassing SiteMap and overriding the AddNodeInternal method, so I felt this was redundant. I have no objection if you want to put this back in, but you will need to be mindful of the fact that the SiteMap object is no longer static and that it is completely destroyed when the cache times out. It would probably be best to declare the user event on the SiteMaps static object and have another internal event on SiteMap that is registered and unregistered appropriately in the SiteMapLoader during the SiteMap lifecycle. However, that means SiteMapLoader has another dependency (SiteMaps) that should be injected into the constructor.
maartenba commented 11 years ago

DI

Not sure in our project it makes a lot of difference. Still using InjectableControllerFactory to do the wiring, except based on IServiceLocator instead of IDependencyInjectionContainer. Main reason why I did this is that all popular DI containers have a CSL implementation package out there making it easier to wire. I don't have a strong opinion on which approach we should take. Thoughts?

Packaging

Work in progress.

ReflectionSiteMapBuilder

Have tested it, seems to break. Can you have a look at my latest commit (run music store and you'll see the exception pop up)

Eventhandler

Not sure if wee need it still. The visitor pattern can be used for it.

NightOwl888 commented 11 years ago

DI

Well, I have declared the concrete implementation of IDependencyInjectionContainer internal primarily so it wouldn't be abused as a ServiceLocator outside of the context of the application's composition root. Implementing this interface is trivially easy, and it could be made even easier using a tip I found in the "Professional ASP.NET MVC 4" book. An extension method could be used to implement the strongly typed overload like this:

public static class DependencyInjectionContainerExtensions
{
    public static T Resolve<T>(this IDependencyInjectionContainer container)
    {
        return (T)container.Resolve(typeof(T));
    }
}

That would make the IDependencyInjectionContainer interface look like this:

namespace DI
{
    public interface IDependencyInjectionContainer
    {
        object Resolve(Type type);
        // T Resolve<T>(); <-- No longer needs to be implemented
    }
}

Anyway, I do have a strong opinion about this - primarily because of Mark Seemann who was shown the light by Martin Fowler about the Service Locator being an anti-pattern. I value both of their opinions. The pattern that makes up a DI container is exactly the same as a service locator - the only difference is in how they are used.

Technically speaking, the IDependencyInjectionContainer is a service locator also, but it is only meant to make the maintenance go easier for supporting multiple Nuget packages with different DI containers. It would be very bad to inject the IDependencyInjectionContainer into a service somewhere else in the application. This interface could be removed altogether by the end developer if so desired, but since it abstracts away the DI container it could make the switch from one DI container to another easier, so I consider it a plus to do it this way - as long as it is not done anywhere else but in the wire-up code.

Although technically any developer can implement the service locator (anti) pattern if they so choose after installing the Nuget package, it would be best to start them off on the right track. And given the choice between implementing 1 simple interface vs taking on another DLL dependency, I think the choice to implement the interface would be the path most developers would take. Besides, with the Nuget package they generally won't have to touch this code, but they can if they want to. Adding a DLL means more code that is out of their control.

Interestingly, even Microsoft is now on board with taking a stand against the Service Locator, despite it being the only answer for DI they came up with for MVC and Web API. Here is a quote from the "Professional ASP.NET MVC 4" book:

SHOULD YOU CONSUME DEPENDENCY RESOLVER IN YOUR APPLICATION?

You might be tempted to consume IDependencyResolver from within your own application. Resist that temptation.

The dependency resolver interface is exactly what MVC needs — and nothing more. It’s not intended to hide or replace the traditional API of your dependency injection container. Most containers have complex and interesting APIs; in fact, it’s likely that you will choose your container based on the APIs and features that it offers more than any other reason.

Packaging

Cool.

ReflectionSiteMapBuilder

I will take a look.

Eventhandler

Right, and it seems like a long way to go to get functionality that can already be achieved a multitude of ways. Not to mention, it would be somewhat challenging to implement the event handlers without causing memory leaks.

XmlSiteMapProvider

Another thing that comes to mind for unfinished business is to make an ISiteMapBuilder implementation that can be used to pull the data from the legacy XmlSiteMapProvider. It doesn't have to be wired up by default, but it would be nice if it were in the box.

maartenba commented 11 years ago

DI

Feel free to revert to that implementation then.

XmlSiteMapProvider

Doesn't it work with legacy XML currently? Have tested it on top of 2 older projects and had no issues loading them (apart from updating the XSD version)

NightOwl888 commented 11 years ago

XmlSiteMapProvider

I was referring to the ASP.NET classic provider, not the legacy MvcSiteMapProvider XML format. Actually, that is what I thought you were referring to early on in the process when you mentioned XmlSiteMapProvider because that is the name of the default provider class in ASP.NET classic.

I figured it wouldn't be too difficult to provide this as an ISiteMapBuilder as a way to provide interoperability with the menu and sitemap controls on classic ASP.NET pages, since the XML formats are similar, and we are no longer hard wired to the MvcSiteMapProvider's XML format. However, rather than reading the XML file directly, it might make more sense to read the nodes directly from the XmlSiteMapProvider class itself.

NightOwl888 commented 11 years ago

@maartenba - Should we update the readme to tell people to submit pull requests to v4? Since it is only loosely base on v3, merging the pull requests ourselves could get tiresome.

maartenba commented 11 years ago

Adding it to the readme

NightOwl888 commented 11 years ago

@maartenba

I tinkered with the AuthorizeAttributeAclModule today, trying to squeeze a little more performance out of it. Unfortunately, despite caching ControllerContext, AuthorizeAttributes collection, and individual AuthorizeAttributes, there was no perceivable improvement in performance so I reverted everything back to no caching.

However, I did notice a small impact when copying Telerik's logic which only used reflection when it was absolutely necessary, so I put those changes in.

After doing some testing, pulling up the site map takes approximately 4 seconds with security trimming disabled and around 5~6 seconds with security trimming enabled. The site map XML (which in MVC4 skips authorization altogether) takes around 2 seconds. So it would seem that the SiteMap helper takes 2 seconds to create the display code and there is roughly 1.5 seconds being added by the MVC framework when security trimming is enabled. The AuthorizeAttributeAclModule itself is not causing a huge amount of friction.

That said, the menu changes that midishero put in should help the majority of the real-world cases. If anyone were to actually put so many links on their site map, they would most likely want to also use the OutputCacheAttribute as well.

maartenba commented 11 years ago

That’s awesome news! I hope to have time to work on the packaging over the weekend but it seems my wife is planning my agenda already :)

NightOwl888 commented 11 years ago

@maartenba

DI

I just found a problem that I was unaware of until I tried to use someone else's framework that relies on IDependencyResolver - MVC doesn't allow both IControllerFactory and IDependencyResolver to work at the same time.

This basically means for the Nuget packages we need to provide an implementation of each to let the end developer choose the implementation path that most likely they have already gone down. It sounds like we need a common, IControllerFactory, and IDependencyResolver package (total of 3) for each DI container.

This also means that the internal DI container needs to check for a custom registered type of IControllerFactory and IDependencyResolver and apply the decorator pattern to whichever one is in use. Previously, I assumed it was safe to register a decorated IControllerFactory automatically, but if someone already is using IDependencyResolver there will be problems.

maartenba commented 11 years ago

I guess we'll have to. But it makes it quite confusing to end users imho. Nothing much we can do about it though.

NightOwl888 commented 11 years ago

DI

I have worked out the issue with DI in the internal container and added the generic IDependencyResolver implementation that can work with any container. I also eliminated some of the StructureMap registries and reverted back to the new keyword because there was simply too much to deal with to wire up StructureMap.

The bootstrapping code now looks like this:

    // MvcSiteMapProvider Configuration
#if NET35
    MvcSiteMapProvider.DI.Composer.Compose();
#endif
    XmlSiteMapController.RegisterRoutes(RouteTable.Routes);

    // NOTE: This check wouldn't have to be made in a real-world application - we do it
    // in the demo because we want to support both the internal and external DI containers.
    if (new MvcSiteMapProvider.DI.ConfigurationSettings().UseExternalDIContainer == true)
    {
        var container = DIConfig.Register();
#if DependencyResolver
        DependencyResolverConfig.Register(container);
#else
        ControllerFactoryConfig.Register(container);
#endif
        MvcSiteMapProviderConfig.Register(container);
    }

I also re-added the WebActivator reference to the Composer file - previously I had it in AssemblyInfo.cs, but you deleted it when you made changes to that file. I am not sure if that was your intention.

Anyway, when researching how to set up a DependencyResolver, I downloaded the Nuget package for StructureMap, and here is how they are bootstrapping the DI code:

[assembly: WebActivator.PreApplicationStartMethod(typeof(MvcDependencyResolver.App_Start.StructuremapMvc), "Start")]

namespace MvcDependencyResolver.App_Start {
    public static class StructuremapMvc {
        public static void Start() {
            IContainer container = IoC.Initialize();
            DependencyResolver.SetResolver(new StructureMapDependencyResolver(container));
            GlobalConfiguration.Configuration.DependencyResolver = new StructureMapDependencyResolver(container);
        }
    }
}

Clearly it makes more sense to do it this way (using WebActivator) than to try to merge some lines of code into Global.asax, but we still will need 2 different bootstrapping implementations for each container: one for IDependencyResolver and one for IControllerFactory.

It is also important that all of the DI composition code be kept as code in the UI layer instead of being ferried off to other DLLs. It is only configuration code if it can be configured!

Compilation Constants

I also added some documentation about what constants can be used in the MvcMusicStore project. I changed the logic in the project file to use the Choose conditional statement and now the files that don't match the current configuration will disappear completely from the project. Changing from one DI container to another can be done by changing the constant, but the changes aren't reflected in Visual Studio unless the project is unloaded and reloaded.