ASPNET Core TagHelper's - A Better @addTagHelper type resolver

Published on 25 July 2016

What's this about?

This is about TagHelper's in ASP.NET Core, and how to get more flexible @addTagHelper directives.

Suppose your application loads some assemblies dynamically - for example, from a plugins folder, and those assemblies contain TagHelper's.

In startup.cs you would have something like this to register your assemblies with the MVC parts system:


 var assy = Assembly.LoadFile("C:\\SomePath\Plugin.Authentication.dll");
 mvcBuilder.AddApplicationPart(assy);

 var assy = Assembly.LoadFile("C:\\SomePath\Plugin.Markdown.dll");
 mvcBuilder.AddApplicationPart(assy);

Now suppose you have a Razor View with some markup that can be targeted by those tag helpers:


 <plugin-authentication />
 <plugin-markdown visible="true"/>

If you run your application, those TagHelper's won't work. This is because you don't have any @addTagHelper directive yet in your razor view, and so razor doesn't know it should be using them. This is where things get a bit interesting!

Let's add an addTagHelper directive

So we add the directive to our __ViewImports.cshtml file:

@addTagHelper "*, Plugin.Markdown"

Now when we start our application, BOOM:


An error occurred during the compilation of a resource required to process this request. Please review the following specific error details and modify your source code appropriately.

/Views/_ViewImports.cshtml

Cannot resolve TagHelper containing assembly 'Plugin.Markdown'. Error: Could not load file or assembly 'Plugin.Markdown' or one of its dependencies. The system cannot find the file specified.
@addTagHelper "*, Plugin.Markdown"

This is because by defualt MVC does not resolve TagHelper assemblies registered with the parts system (atleast this is true as of RTM 1.0.0) so it complains when it processes that directive, saying it can't find such an assembly - because it can only see assemblies that are in the bin folder by default. So it can't see your plugin assembly.

How do we solve?

Well if you add this line:


mvcBuilder.AddTagHelpersAsServices();

That will register some replacement services that will check the application parts system when trying to resolve the tag helper assemblies based on the name provided by the addTagHelper directive.

However - this now works but it's still not ideal because we still have to add a directive for each plugin before it will work on our page/s. So when someone develops a new plugin, it won't work until we modify our _ViewImports.cshtml file and add another line:

@addTagHelper "*, Plugin.Markdown"
@addTagHelper "*, Plugin.Another"
@addTagHelper "*, Plugin.YetAnother"

This can be incredibly frustrating because if you are wanting an extensibile system where plugins can be installed on the fly, then they should just work without constant modifications to source code.

So Can We Do Better?

Yup. So here is my solution to this issue, and that is to allow globbing to be supported in the addTagHelper directive for the assembly name, just like it is for the TypeName portion.

So this is how you do that.

ITagHelperTypeResolver

We need to create an ITagHelperTypeResolver and implement it's Resolve method. This method takes the string provided by in the addTagHelper directive and returns all TagHelper type's that are matches to that string. We will make our implementation support globbing on the assembly name so it can match TagHelper types accross multiple assemblies registered with the Application Parts system, instead of just from a single one.

Here is my quick and dirty implementation, where I took a lot of the code from the microsoft implementation, and just added a few tweaks for globbing:


    public class AssemblyNameGlobbingTagHelperTypeResolver : ITagHelperTypeResolver
    {
       
        private static readonly System.Reflection.TypeInfo ITagHelperTypeInfo = typeof(ITagHelper).GetTypeInfo();

        protected TagHelperFeature Feature { get; }

        public AssemblyNameGlobbingTagHelperTypeResolver(ApplicationPartManager manager)
        {
            if (manager == null)
            {
                throw new ArgumentNullException(nameof(manager));
            }

            Feature = new TagHelperFeature();
            manager.PopulateFeature(Feature);

            // _manager = manager;

        }

        /// <inheritdoc />
        public IEnumerable<Type> Resolve(
            string name,
            SourceLocation documentLocation,
            ErrorSink errorSink)
        {
            if (errorSink == null)
            {
                throw new ArgumentNullException(nameof(errorSink));
            }

            if (string.IsNullOrEmpty(name))
            {
                var errorLength = name == null ? 1 : Math.Max(name.Length, 1);
                errorSink.OnError(
                    documentLocation,
                    "Tag Helper Assembly Name Cannot Be Empty Or Null",
                    errorLength);

                return Type.EmptyTypes;
            }


            IEnumerable<TypeInfo> libraryTypes;
            try
            {
                libraryTypes = GetExportedTypes(name);
            }
            catch (Exception ex)
            {
                errorSink.OnError(
                    documentLocation,
                    $"Cannot Resolve Tag Helper Assembly: {name}, {ex.Message}",
                    name.Length);

                return Type.EmptyTypes;
            }

            return libraryTypes;

        }


        /// <inheritdoc />
        protected IEnumerable<System.Reflection.TypeInfo> GetExportedTypes(string assemblyNamePattern)
        {
            if (assemblyNamePattern == null)
            {
                throw new ArgumentNullException(nameof(assemblyNamePattern));
            }

            var results = new List<System.Reflection.TypeInfo>();

            for (var i = 0; i < Feature.TagHelpers.Count; i++)
            {
                var tagHelperAssemblyName = Feature.TagHelpers[i].Assembly.GetName();

                if (assemblyNamePattern.Contains("*")) // is it actually a pattern?
                {
                    if (tagHelperAssemblyName.Name.Like(assemblyNamePattern))
                    {
                        results.Add(Feature.TagHelpers[i]);
                        continue;
                    }
                }

                // not a pattern so treat as normal assembly name.
                var assyName = new AssemblyName(assemblyNamePattern);
                if (AssemblyNameComparer.OrdinalIgnoreCase.Equals(tagHelperAssemblyName, assyName))
                {
                    results.Add(Feature.TagHelpers[i]);
                    continue;
                }
            }

            return results;
        }

        private class AssemblyNameComparer : IEqualityComparer<AssemblyName>
        {
            public static readonly IEqualityComparer<AssemblyName> OrdinalIgnoreCase = new AssemblyNameComparer();

            private AssemblyNameComparer()
            {
            }

            public bool Equals(AssemblyName x, AssemblyName y)
            {
                // Ignore case because that's what Assembly.Load does.
                return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase) &&
                       string.Equals(x.CultureName ?? string.Empty, y.CultureName ?? string.Empty, StringComparison.Ordinal);
            }

            public int GetHashCode(AssemblyName obj)
            {
                var hashCode = 0;
                if (obj.Name != null)
                {
                    hashCode ^= obj.Name.GetHashCode();
                }

                hashCode ^= (obj.CultureName ?? string.Empty).GetHashCode();
                return hashCode;
            }
        }


    }


Now we just register this on startup, after we have registered MVC:


  services.AddSingleton<ITagHelperTypeResolver, AssemblyNameGlobbingTagHelperTypeResolver>();

Now we can just add one directive to our __ViewImports.cshtml file, like this:

@addTagHelper "*, Plugin.*"

Now that will include all TagHelpers that live in assemblies matching that glob. We can drop new plugins in and their tag helpers will light up automatically.

You are welcome.

comments powered by Disqus