فصل ۱۷: Culture، AssemblyLoadContext، Loading، Resolution و Isolation
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
Culture و Subculture
کد Subculture مربوط به Australian English و Austrian German بهترتیب اینها هستند:
در .NET، Culture با کلاس System.Globalization.CultureInfo نمایش داده میشود. Culture فعلی Application را میتوانید چنین بررسی کنید:
Console.WriteLine (System.Threading.Thread.CurrentThread.CurrentCulture);
Console.WriteLine (System.Threading.Thread.CurrentThread.CurrentUICulture);
اجرای این کد روی Computerای که برای Australia Localize شده، تفاوت دو مقدار را نشان میدهد:
CurrentCulture تنظیمات منطقهای Windows Control Panel را منعکس میکند، درحالیکه CurrentUICulture Language مربوط به OS را منعکس میکند.
Regional Settingها شامل مواردی مانند Time Zone و Format کردن Currency و Date هستند. CurrentCulture رفتار پیشفرض Functionهایی مانند DateTime.Parse را تعیین میکند. Regional Settingها تا حدی قابل Customize هستند که ممکن است دیگر شباهتی به Culture مشخصی نداشته باشند.
CurrentUICulture Languageی را تعیین میکند که Computer با User از طریق آن ارتباط برقرار میکند. Australia برای این هدف به نسخهٔ جداگانهای از English نیاز ندارد، پس از نسخهٔ US استفاده میکند. اگر نویسنده چند ماه در Austria کار میکرد، CurrentCulture را در Control Panel به Austrian-German تغییر میداد؛ اما چون German بلد نیست، CurrentUICulture را US English نگه میداشت.
ResourceManager بهطور پیشفرض از CurrentUICulture مربوط به Thread فعلی برای تعیین Satellite Assembly صحیح استفاده میکند. هنگام Load کردن Resourceها مکانیزم Fallback دارد: اگر Assembly مربوط به Subculture تعریف شده باشد همان استفاده میشود؛ در غیر این صورت به Culture عمومی برمیگردد؛ و اگر Culture عمومی هم موجود نباشد، به Culture پیشفرض در Main Assembly بازمیگردد.
Loading، Resolving و Isolating Assemblyها
Load کردن Assembly از Location معلوم، فرایندی نسبتاً ساده است که آن را Assembly Loading مینامیم.
اما حالت رایجتر این است که شما یا CLR باید Assembly را فقط با دانستن Full Name یا Simple Name آن Load کنید. این Assembly Resolution نام دارد. Resolution با Loading فرق دارد چون ابتدا باید Assembly پیدا شود.
Assembly Resolution در دو حالت Trigger میشود:
- توسط CLR هنگامی که باید Dependency را Resolve کند.
- بهصورت صریح هنگامی که متدی مانند
Assembly.Load(AssemblyName) را فراخوانی میکنید.
برای حالت اول Applicationای را فرض کنید که یک Main Assembly بههمراه چند Library Assembly که بهصورت static Reference شدهاند دارد:
AdventureGame.dll // Main assembly
Terrain.dll // Referenced assembly
UIEngine.dll // Referenced assembly
منظور از Static Reference این است که AdventureGame.dll با Reference به Terrain.dll و UIEngine.dll Compile شده است. خود Compiler لازم نیست Assembly Resolution انجام دهد، چون صریحاً یا از طریق MSBuild میداند این Fileها کجا هستند. Compiler هنگام Compilation Full Name مربوط به Assemblyهای Terrain و UIEngine را در Metadata مربوط به AdventureGame مینویسد، اما هیچ اطلاعاتی دربارهٔ محل File آنها ثبت نمیکند. بنابراین در Runtime باید Terrain و UIEngine Resolve شوند.
Assembly Loading و Resolution توسط Assembly Load Context یا ALC مدیریت میشود؛ مشخصاً نمونهای از کلاس AssemblyLoadContext در System.Runtime.Loader. چون AdventureGame.dll Main Assembly برنامه است، CLR از Default ALC یعنی AssemblyLoadContext.Default برای Resolve کردن Dependencyهای آن استفاده میکند. Default ALC ابتدا File به نام AdventureGame.deps.json را بررسی میکند که Location Dependencyها را توصیف میکند؛ اگر وجود نداشته باشد به Application Base Folder نگاه میکند و در آنجا Terrain.dll و UIEngine.dll را پیدا میکند. Default ALC همچنین Assemblyهای Runtime خود .NET را Resolve میکند.
بهعنوان Developer میتوانید هنگام اجرای Program، Assemblyهای اضافی را بهصورت Dynamic Load کنید. مثلاً Featureهای Optional را در Assemblyهایی Package کنید که فقط پس از خرید Feature Deploy میشوند. در چنین حالتی وقتی Assemblyها موجود باشند میتوانید با Assembly.Load(AssemblyName) آنها را Load کنید.
مثال پیچیدهتر ساخت Plug-In System است که User بتواند Third-Party Assembly ارائه کند و Application در Runtime آنها را Detect و Load کند تا Functionality گسترش یابد. پیچیدگی از اینجا میآید که هر Plug-In Assembly ممکن است Dependencyهای خودش را داشته باشد که آنها هم باید Resolve شوند.
با Subclass کردن AssemblyLoadContext و Override کردن متد Resolution آن یعنی Load میتوانید کنترل کنید Plug-In چگونه Dependencyهایش را پیدا کند. برای مثال تصمیم بگیرید هر Plug-In در Folder خودش باشد و Dependencyهایش نیز همانجا قرار گیرند.
ALC هدف دیگری نیز دارد: با ساخت یک AssemblyLoadContext جدا برای هر مجموعهٔ Plug-In + Dependencyها میتوانید آنها را Isolate نگه دارید تا Dependencyهایشان موازی Load شوند و با یکدیگر یا Host Application تداخل نداشته باشند؛ مثلاً هرکدام Version خودش از JSON.NET را داشته باشد. پس ALC علاوه بر Loading و Resolution، مکانیزمی برای Isolation است. تحت شرایطی حتی میتوان ALC را Unload و Memory آن را آزاد کرد.
در این بخش اصول زیر بررسی میشوند:
- نحوهٔ Loading و Resolution توسط ALCها
- نقش Default ALC
Assembly.Load و Contextual ALCها- استفاده از
AssemblyDependencyResolver - Load و Resolve کردن Unmanaged Libraryها
- Unload کردن ALCها
- Legacy Assembly Loading Methodها
در پایان نیز Theory را در ساخت Plug-In System دارای ALC Isolation به کار میبریم.
Assembly Load Contextها
کلاس AssemblyLoadContext مسئول Loading و Resolution Assemblyها و همچنین فراهم کردن مکانیزم Isolation است.
هر Assembly Object در .NET دقیقاً متعلق به یک AssemblyLoadContext است. ALC مربوط به یک Assembly را چنین میگیرید:
Assembly assem = Assembly.GetExecutingAssembly();
AssemblyLoadContext context = AssemblyLoadContext.GetLoadContext (assem);
Console.WriteLine (context.Name);
برعکس، میتوانید ALC را Container یا Owner Assemblyها بدانید و از property به نام Assemblies آنها را دریافت کنید:
foreach (Assembly a in context.Assemblies)
Console.WriteLine (a.FullName);
کلاس AssemblyLoadContext همچنین property static به نام All دارد که همهٔ ALCها را Enumerate میکند.
برای ساخت ALC جدید کافی است AssemblyLoadContext را با یک Name نمونهسازی کنید؛ Name در Debugging مفید است. بااینحال معمولاً ابتدا از آن Subclass میسازید تا Logic مربوط به Resolve کردن Dependencyها، یعنی Load کردن Assembly از Name، را پیادهسازی کنید.
Load کردن Assemblyها
AssemblyLoadContext Methodهای زیر را برای Load صریح Assembly در Context خود ارائه میدهد:
public Assembly LoadFromAssemblyPath (string assemblyPath);
public Assembly LoadFromStream (Stream assembly, Stream assemblySymbols);
Method اول Assembly را از File Path و Method دوم از Stream که میتواند مستقیماً از Memory آمده باشد Load میکند. Parameter دوم Optional است و با محتوای Project Debug File یا .pdb متناظر است؛ این اطلاعات باعث میشود Stack Trace هنگام اجرای Code شامل Source Code Information باشد و برای Exception Reporting مفید است.
در هیچیک از این دو Method، Resolution انجام نمیشود.
کد زیر Assembly به نام c:\temp\foo.dll را در ALC خودش Load میکند:
var alc = new AssemblyLoadContext ("Test");
Assembly assem = alc.LoadFromAssemblyPath (@"c:\temp\foo.dll");
اگر Assembly معتبر باشد Loading همیشه موفق میشود، با یک Rule مهم: Simple Name یک Assembly درون ALC باید Unique باشد. بنابراین نمیتوانید چند Version از Assembly همنام را در یک ALC Load کنید؛ برای این کار باید ALCهای بیشتری بسازید:
var alc2 = new AssemblyLoadContext ("Test 2");
Assembly assem2 = alc2.LoadFromAssemblyPath (@"c:\temp\foo.dll");
توجه کنید Typeهایی که از Assembly Objectهای متفاوت میآیند با یکدیگر Incompatible هستند، حتی اگر Assemblyها از هر نظر دیگر یکسان باشند. در مثال ما Typeهای assem با Typeهای assem2 سازگار نیستند.
پس از Load شدن Assembly، جز با Unload کردن ALC نمیتوان آن را Unload کرد. CLR تا زمانی که Assembly Load است روی File Lock نگه میدارد.
byte[] bytes = File.ReadAllBytes (@"c:\temp\foo.dll");
var ms = new MemoryStream (bytes);
var assem = alc.LoadFromStream (ms);
این روش دو عیب دارد:
- property به نام
Location در Assembly خالی خواهد بود. گاهی دانستن اینکه Assembly از کجا Load شده مفید است و بعضی APIها به پر بودن آن وابستهاند. - Private Memory Consumption باید بلافاصله افزایش پیدا کند تا کل Size مربوط به Assembly جا شود. اگر از Filename Load کنید، CLR از Memory-Mapped File استفاده میکند که Lazy Loading و Process Sharing را ممکن میکند. اگر Memory کم شود، OS میتواند Memory را آزاد کند و هنگام نیاز بدون نوشتن در Page File دوباره آن را Load کند.
LoadFromAssemblyName
AssemblyLoadContext Method زیر را نیز دارد که Assembly را بر اساس Name Load میکند:
public Assembly LoadFromAssemblyName (AssemblyName assemblyName);
برخلاف دو Method قبلی، هیچ اطلاعاتی دربارهٔ Location Assembly نمیدهید؛ در واقع به ALC دستور میدهید Assembly را Resolve کند.
Resolve کردن Assemblyها
Method قبلی Assembly Resolution را Trigger میکند. CLR همچنین هنگام Load کردن Dependencyها Resolution را Trigger میکند. مثلاً اگر Assembly A بهصورت static Assembly B را Reference کند، CLR برای Resolve کردن B، Assembly Resolution را روی همان ALCای Trigger میکند که A در آن Load شده است.
فرایند به این ترتیب است:
- CLR ابتدا بررسی میکند آیا Resolution کاملاً یکسان با Full Assembly Name مطابق، قبلاً در همان ALC انجام شده است یا نه. اگر بله، همان
Assembly قبلی را برمیگرداند. - در غیر این صورت Method محافظتشده و Virtual به نام
Load در ALC را فراخوانی میکند که کار پیدا کردن و Load کردن Assembly را انجام میدهد. Default ALC Ruleهای خودش را اعمال میکند؛ در Custom ALC تصمیم کاملاً با شماست.
- اگر مرحلهٔ 2 مقدار null برگرداند، CLR Method به نام
Load را روی Default ALC فراخوانی میکند؛ این Fallback برای Resolve کردن .NET Runtime و Common Application Assemblyها مفید است. - اگر مرحلهٔ 3 هم null برگرداند، CLR Event به نام
Resolving را روی هر دو ALC Fire میکند: ابتدا Default ALC و سپس ALC اصلی. - برای Compatibility با .NET Framework، اگر هنوز Assembly Resolve نشده باشد، Event به نام
AppDomain.CurrentDomain.AssemblyResolve Fire میشود.
پس در Custom ALC دو راه برای پیادهسازی Resolution داریم:
- Override کردن Method به نام
Load. این کار به ALC شما «اولین حق تصمیم» میدهد که معمولاً مطلوب و برای Isolation ضروری است. - Handle کردن Event به نام
Resolving. این Event فقط وقتی Fire میشود که Default ALC نیز نتوانسته Assembly را Resolve کند.
فرض کنید میخواهیم Assemblyای را Load کنیم که Main Application هنگام Compile چیزی از آن نمیدانسته است: foo.dll در c:\temp. همچنین فرض کنید foo.dll Dependency خصوصی به bar.dll دارد. میخواهیم با Load کردن foo.dll، Dependency آن یعنی bar.dll هم از همان Folder درست Resolve شود و foo و bar با Main Application تداخل نکنند.
ابتدا Custom ALCای مینویسیم که Load را Override کند:
using System.IO;
using System.Runtime.Loader;
class FolderBasedALC : AssemblyLoadContext
{
readonly string _folder;
public FolderBasedALC (string folder) => _folder = folder;
protected override Assembly Load (AssemblyName assemblyName)
{
// Attempt to find the assembly:
string targetPath = Path.Combine (_folder, assemblyName.Name + ".dll");
if (File.Exists (targetPath))
return LoadFromAssemblyPath (targetPath); // Load the assembly
return null; // We can’t find it: it could be a .NET runtime assembly
}
}
در Method به نام Load اگر Assembly File وجود نداشته باشد null برمیگردانیم. این Check مهم است، چون foo.dll به Assemblyهای BCL مربوط به .NET نیز Dependency دارد و Load برای Assemblyهایی مانند System.Runtime هم فراخوانی میشود. با null، اجازه میدهیم CLR به Default ALC Fallback کند و آن Assemblyها را صحیح Resolve کند.
استفاده از Custom ALC:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyPath (@"c:\temp\foo.dll");
...
وقتی بعداً Code مربوط به foo را اجرا میکنیم، CLR در نقطهای باید Dependency یعنی bar.dll را Resolve کند. آن زمان Load در Custom ALC اجرا و bar.dll را در c:\temp پیدا میکند.
چون Load ما قادر به Resolve کردن خود foo.dll نیز هست، میتوانیم سادهتر بنویسیم:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
...
راه دیگر این است که بهجای Subclass کردن AssemblyLoadContext و Override کردن Load، یک ALC معمولی بسازیم و Event به نام Resolving را Handle کنیم:
var alc = new AssemblyLoadContext ("test");
alc.Resolving += (loadContext, assemblyName) =>
{
string targetPath = Path.Combine (@"c:\temp", assemblyName.Name + ".dll");
return alc.LoadFromAssemblyPath (targetPath); // Load the assembly
};
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
اکنون لازم نیست وجود Assembly را Check کنیم، چون Event به نام Resolving پس از آن Fire میشود که Default ALC فرصت Resolve کردن Assembly را داشته و شکست خورده باشد؛ بنابراین Handler ما برای Assemblyهای BCL اجرا نمیشود. این Solution سادهتر است، اما عیبی دارد. Main Application هنگام Compile چیزی از foo.dll یا bar.dll نمیدانسته، اما ممکن است خود Main Application به Assemblyهایی با همین Name وابسته باشد. اگر چنین شود، Event به نام Resolving هرگز Fire نمیشود و foo/bar مربوط به Application Load میشوند؛ در نتیجه Isolation از بین میرود.
Default ALC
هنگام Start شدن Application، CLR یک ALC ویژه را به property static به نام AssemblyLoadContext.Default اختصاص میدهد. Startup Assembly، Dependencyهای Static آن و Assemblyهای BCL مربوط به .NET Runtime در Default ALC Load میشوند.
Default ALC ابتدا در Default Probing Pathها میگردد؛ معمولاً Locationهای مشخصشده در Fileهای .deps.json و .runtimeconfig.json. اگر Assembly را پیدا نکند، Event به نام Resolving Fire میشود. با Handle کردن آن میتوانید Assembly را از Locationهای دیگر مانند Subfolder، Shared Folder یا حتی Binary Resource داخل Host Assembly Load کنید:
AssemblyLoadContext.Default.Resolving += (loadContext, assemblyName) =>
{
// Try to locate assemblyName, returning an Assembly object or null.
// Typically you’d call LoadFromAssemblyPath after finding the file.
// ...
};
Event به نام Resolving در Default ALC همچنین وقتی Fire میشود که Custom ALC نتواند Resolve کند، یعنی Load آن null برگرداند، و Default ALC هم Assembly را پیدا نکند.
میتوانید خارج از Event به نام Resolving نیز Assemblyها را داخل Default ALC Load کنید. ولی پیش از آن بهتر است بررسی کنید آیا مسئله را با ALC جدا یا روشهای Executing/Contextual ALC بهتر حل میکنید. Hard-Code کردن Default ALC Code شما را Brittle میکند، چون دیگر کل آن بهسادگی قابل Isolation نیست؛ برای مثال توسط Unit Test Framework یا LINQPad.
اگر همچنان میخواهید ادامه دهید، بهتر است یک Resolution Method مانند LoadFromAssemblyName را بهجای Loading Method مانند LoadFromAssemblyPath فراخوانی کنید، خصوصاً اگر Assembly بهصورت static Reference شده است. ممکن است Assembly قبلاً Load شده باشد؛ در آن صورت LoadFromAssemblyName همان Assembly موجود را برمیگرداند، اما LoadFromAssemblyPath Exception میدهد. همچنین با LoadFromAssemblyPath خطر Load کردن از Locationی ناسازگار با Default Resolution Mechanism وجود دارد.
اگر Assembly در مکانی است که ALC خودکار پیدا نمیکند، همچنان میتوانید همین روال را دنبال کنید و Event به نام Resolving را نیز Handle کنید.
هنگام فراخوانی LoadFromAssemblyName لازم نیست Full Name را بدهید؛ Simple Name کافی است، حتی اگر Assembly Strongly Named باشد:
AssemblyLoadContext.Default.LoadFromAssemblyName (new AssemblyName ("System.Xml"));
اما اگر Public Key Token را در Name قرار دهید، باید با Assembly Loadشده Match باشد.
Default Probing
Default Probing Pathها معمولاً شامل اینها هستند:
- Pathهای مشخصشده در
AppName.deps.json، که AppName نام Main Assembly است. اگر File وجود نداشته باشد، Application Base Folder استفاده میشود. - Folderهای حاوی .NET Runtime System Assemblyها، اگر Application Framework-Dependent باشد.
MSBuild بهصورت خودکار AppName.deps.json را میسازد و Location همهٔ Dependencyها را شرح میدهد؛ شامل Platform-Agnostic Assemblyها که در Application Base Folder قرار میگیرند و Platform-Specific Assemblyها که در Subdirectory به نام runtimes\ زیر Folderهایی مانند win یا unix قرار میگیرند.
Pathهای File تولیدشدهٔ .deps.json نسبت به Application Base Folder یا Folderهای اضافی در بخش additionalProbingPaths از Fileهای AppName.runtimeconfig.json و/یا AppName.runtimeconfig.dev.json Relative هستند؛ File دوم فقط برای Development Environment طراحی شده است.
ALC «فعلی»
پیشتر نسبت به Load صریح در Default ALC هشدار دادیم. معمولاً چیزی که میخواهید Load/Resolve کردن در ALC «فعلی» است.
در بیشتر موارد، ALC فعلی همان Contextی است که Assembly در حال اجرای فعلی را در خود دارد:
var executingAssem = Assembly.GetExecutingAssembly();
var alc = AssemblyLoadContext.GetLoadContext (executingAssem);
Assembly assem = alc.LoadFromAssemblyName (...); // to resolve by name
// OR: = alc.LoadFromAssemblyPath (...); // to load by path
راه منعطفتر و صریحتر:
var myAssem = typeof (SomeTypeInMyAssembly).Assembly;
var alc = AssemblyLoadContext.GetLoadContext (myAssem);
...
گاهی Infer کردن ALC فعلی ممکن نیست. فرض کنید Binary Serializer مربوط به .NET را مینویسید. Serializer Full Name مربوط به Typeهای Serializeشده، شامل Assembly Name، را مینویسد و این Nameها هنگام Deserialization باید Resolve شوند. کدام ALC را باید استفاده کرد؟ اگر به Executing Assembly تکیه کنیم، Assembly حاوی Deserializer را میگیرد، نه Assemblyای که Deserializer را فراخوانی کرده است.
بهترین Solution حدس زدن نیست، بلکه سؤال کردن از Caller است:
public object Deserialize (Stream stream, AssemblyLoadContext alc)
{
...
}
صریح بودن Flexibility را حداکثر و احتمال Error را حداقل میکند. Caller اکنون تصمیم میگیرد چه چیزی ALC فعلی محسوب شود:
var assem = typeof (SomeTypeThatIWillBeDeserializing).Assembly;
var alc = AssemblyLoadContext.GetLoadContext (assem);
var object = Deserialize (someStream, alc);
Assembly.Load و Contextual ALC
برای حالت رایج Load کردن Assembly در ALC مربوط به Executing Assembly، Microsoft Method زیر را در کلاس Assembly تعریف کرده است:
public static Assembly Load (string assemblyString);
نسخهٔ Functionally Identical دیگری نیز وجود دارد که AssemblyName میگیرد:
public static Assembly Load (AssemblyName assemblyRef);
اینها را با Legacy Method به نام Load(byte[]) اشتباه نگیرید که رفتار کاملاً متفاوتی دارد.
مانند LoadFromAssemblyName میتوانید Simple، Partial یا Full Name را بدهید:
Assembly a = Assembly.Load ("System.Private.Xml");
این کد System.Private.Xml را در همان ALCای Load میکند که Assembly مربوط به Executing Code در آن Load شده است. در مثال Simple Name داده شده، اما Stringهای زیر نیز در .NET معتبرند و نتیجهٔ یکسان دارند:
"System.Private.Xml, PublicKeyToken=cc7b13ffcd2ddd51"
"System.Private.Xml, Version=4.0.1.0"
"System.Private.Xml, Version=4.0.1.0, PublicKeyToken=cc7b13ffcd2ddd51"
اگر Public Key Token را مشخص کنید باید با آنچه Load میشود Match باشد.
هر دو Method صرفاً برای Resolution هستند، پس نمیتوانید File Path مشخص کنید. حتی اگر property به نام CodeBase را در AssemblyName پر کنید Ignore میشود.
Assembly a = typeof (System.Xml.Formatting).Assembly;
// یا حتی:
Assembly a = System.Xml.Formatting.Indented.GetType().Assembly;
این کار Hard-Code کردن Assembly Name را حذف میکند و در عین حال Assembly Resolution را روی ALC مربوط به Executing Code Trigger میکند.
اگر Assembly.Load را خودمان بنویسیم، تقریباً چنین خواهد بود:
[MethodImpl(MethodImplOptions.NoInlining)]
Assembly Load (string name)
{
Assembly callingAssembly = Assembly.GetCallingAssembly();
var callingAlc = AssemblyLoadContext.GetLoadContext (callingAssembly);
return callingAlc.LoadFromAssemblyName (new AssemblyName (name));
}
EnterContextualReflection
Strategy مربوط به Assembly.Load که از ALC مربوط به Calling Assembly استفاده میکند وقتی Assembly.Load از طریق واسطهای مانند Deserializer یا Unit Test Runner فراخوانی شود شکست میخورد. اگر واسطه در Assembly دیگری تعریف شده باشد، Load Context مربوط به واسطه بهجای Caller استفاده میشود.
این همان Scenarioی است که در مثال Deserializer گفتیم. Solution ایدهآل این است که Caller مجبور باشد ALC را مشخص کند نه اینکه با Assembly.Load(string) حدس زده شود. اما چون .NET 5+ و .NET Core از .NET Framework تکامل یافتهاند، جایی که Isolation با Application Domain انجام میشد نه ALC، این Solution ایدهآل همهجا رایج نیست و Assembly.Load(string) گاهی در Scenarioهایی استفاده شده که ALC قابل Infer نیست؛ نمونهاش Binary Serializer در .NET.
برای اینکه Assembly.Load در چنین حالتهایی همچنان کار کند، Microsoft متدی به نام EnterContextualReflection به AssemblyLoadContext اضافه کرده است. این Method یک ALC را به AssemblyLoadContext.CurrentContextualReflectionContext اختصاص میدهد. با اینکه این property static است، مقدارش در AsyncLocal نگهداری میشود؛ پس روی Threadهای مختلف میتواند مقدار متفاوت داشته باشد و در عملیات asynchronous حفظ میشود.
اگر این property non-null باشد، Assembly.Load آن را بر Calling ALC ترجیح میدهد:
Method1();
var myALC = new AssemblyLoadContext ("test");
using (myALC.EnterContextualReflection())
{
Console.WriteLine (
AssemblyLoadContext.CurrentContextualReflectionContext.Name); // test
Method2();
}
// Once disposed, EnterContextualReflection() no longer has an effect.
Method3();
void Method1() => Assembly.Load ("..."); // Will use calling ALC
void Method2() => Assembly.Load ("..."); // Will use myALC
void Method3() => Assembly.Load ("..."); // Will use calling ALC
نسخهٔ دقیقتر Method شبیه Assembly.Load که Contextual Reflection را نیز در نظر میگیرد:
[MethodImpl(MethodImplOptions.NoInlining)]
Assembly Load (string name)
{
var alc = AssemblyLoadContext.CurrentContextualReflectionContext
?? AssemblyLoadContext.GetLoadContext (Assembly.GetCallingAssembly());
return alc.LoadFromAssemblyName (new AssemblyName (name));
}
با وجود مفید بودن Contextual Reflection برای اجرای Legacy Code، Solution Robustتر این است که Code فراخوانندهٔ Assembly.Load را تغییر دهید تا بهجای آن LoadFromAssemblyName را روی ALCای که Caller پاس میدهد فراخوانی کند.
Load و Resolve کردن Unmanaged Libraryها
ALCها میتوانند Native Libraryها را نیز Load و Resolve کنند. Native Resolution زمانی Trigger میشود که External Method دارای Attribute به نام [DllImport] را فراخوانی کنید:
[DllImport ("SomeNativeLibrary.dll")]
static extern int SomeNativeMethod (string text);
چون در [DllImport] Full Path مشخص نکردهایم، فراخوانی SomeNativeMethod در همان ALCای Resolution را Trigger میکند که Assembly تعریفکنندهٔ این Method در آن قرار دارد.
Virtual Resolving Method در ALC برای Native Libraryها LoadUnmanagedDll است و Loading Method آن LoadUnmanagedDllFromPath:
protected override IntPtr LoadUnmanagedDll (string unmanagedDllName)
{
// Locate the full path of unmanagedDllName...
string fullPath = ...
return LoadUnmanagedDllFromPath (fullPath); // Load the DLL
}
اگر File را پیدا نکردید IntPtr.Zero برگردانید؛ CLR سپس Event به نام ResolvingUnmanagedDll را Fire میکند.
جالب اینکه LoadUnmanagedDllFromPath protected است و معمولاً از Handler مربوط به ResolvingUnmanagedDll قابل فراخوانی نیست. اما همان نتیجه را با NativeLibrary.Load static میتوانید بگیرید:
someALC.ResolvingUnmanagedDll += (requestingAssembly, unmanagedDllName) =>
{
return NativeLibrary.Load ("(full path to unmanaged DLL)");
};
با اینکه Native Libraryها معمولاً توسط ALC Resolve و Load میشوند، به ALC «تعلق» ندارند. پس از Load شدن، Native Library مستقل میایستد و مسئول Resolve کردن Transitive Dependencyهای خودش است. علاوه بر این Native Libraryها در سطح Process Global هستند؛ بنابراین اگر Filename یکسان باشد نمیتوان دو Version متفاوت از Native Library را Load کرد.
AssemblyDependencyResolver
Default ALC برای یافتن Dependencyهای Platform-Specific و Development-Time NuGet، در صورت وجود Fileهای .deps.json و .runtimeconfig.json را میخواند.
اگر بخواهید Assemblyای را در Custom ALC Load کنید که چنین Dependencyهایی دارد، باید این Logic را بازتولید کنید. Parse کردن دستی Configuration Fileها و دنبال کردن Ruleهای Platform Moniker هم دشوار است و هم با تغییر Ruleها در Version آیندهٔ .NET میشکند.
کلاس AssemblyDependencyResolver این مشکل را حل میکند. آن را با Path مربوط به Assemblyای که میخواهید Dependencyهایش Probe شوند میسازید:
var resolver = new AssemblyDependencyResolver (@"c:\temp\foo.dll");
برای یافتن Path یک Dependency، ResolveAssemblyToPath را فراخوانی میکنید:
string path = resolver.ResolveAssemblyToPath (new AssemblyName ("bar"));
اگر File به نام .deps.json وجود نداشته باشد یا چیزی دربارهٔ bar.dll نداشته باشد، نتیجه c:\temp\bar.dll خواهد بود. برای Unmanaged Dependency نیز ResolveUnmanagedDllToPath وجود دارد.
برای مثال پیچیدهتر، Console Projectی به نام ClientApp بسازید و NuGet Reference به Microsoft.Data.SqlClient اضافه کنید. سپس:
using Microsoft.Data.SqlClient;
namespace ClientApp
{
public class Program
{
public static SqlConnection GetConnection() => new SqlConnection();
static void Main() => GetConnection(); // Test that it resolves
}
}
Application را Build کنید و Output Folder را ببینید؛ Fileای به نام Microsoft.Data.SqlClient.dll وجود دارد. بااینحال این File در اجرا Load نمیشود و تلاش برای Load صریح آن Exception میدهد. Assembly واقعی در Subfolder به نام runtimes\win یا runtimes/unix است؛ Default ALC بهخاطر Parse کردن ClientApp.deps.json میداند آن را Load کند.
اگر بخواهید ClientApp.dll را از Application دیگری Load کنید، به ALCای نیاز دارید که Dependency آن یعنی Microsoft.Data.SqlClient.dll را Resolve کند. فقط نگاه کردن در Folder مربوط به ClientApp.dll کافی نیست؛ باید با AssemblyDependencyResolver Location صحیح برای Platform فعلی را پیدا کنید:
string path = @"C:\source\ClientApp\bin\Debug\netcoreapp3.0\ClientApp.dll";
var resolver = new AssemblyDependencyResolver (path);
var sqlClient = new AssemblyName ("Microsoft.Data.SqlClient");
Console.WriteLine (resolver.ResolveAssemblyToPath (sqlClient));
روی Windows خروجی:
C:\source\ClientApp\bin\Debug\netcoreapp3.0\runtimes\win\lib\netcoreapp2.1
\Microsoft.Data.SqlClient.dll
مثال کامل در بخش Plug-In System ادامه مییابد.
Unload کردن ALCها
در حالتهای ساده میتوان AssemblyLoadContext غیرپیشفرض را Unload کرد و Memory و File Lockهای Assemblyهای Loadشده را آزاد کرد. برای این کار ALC باید با Parameter به نام isCollectible برابر true ساخته شده باشد:
var alc = new AssemblyLoadContext ("test", isCollectible:true);
سپس Unload را فراخوانی میکنید تا فرایند Unload آغاز شود.
مدل Unload Cooperative است نه Preemptive. اگر Methodی در هر Assembly مربوط به ALC در حال اجرا باشد، Unload تا پایان آن Method به تعویق میافتد.
Unload واقعی هنگام Garbage Collection رخ میدهد و اگر چیزی بیرون ALC Reference غیرضعیف به چیزی داخل ALC داشته باشد، شامل Object، Type یا Assembly، Unload انجام نمیشود. APIها، حتی APIهای BCL، اغلب Objectها را در static field یا Dictionary Cache میکنند یا Event Subscription ایجاد میکنند و در نتیجه بهراحتی Referenceهایی ساخته میشود که جلوی Unload را میگیرد؛ بهخصوص وقتی Code داخل ALC بهشکل جدی از APIهای خارج ALC استفاده کند. پیدا کردن علت Unload ناموفق دشوار است و Toolهایی مانند WinDbg لازم دارد.
Legacy Loading Methodها
اگر هنوز از .NET Framework استفاده میکنید، یا Libraryای برای .NET Standard مینویسید و میخواهید .NET Framework را هم پشتیبانی کنید، نمیتوانید از AssemblyLoadContext استفاده کنید. Loading با Methodهای زیر انجام میشود:
public static Assembly LoadFrom (string assemblyFile);
public static Assembly LoadFile (string path);
public static Assembly Load (byte[] rawAssembly);
LoadFile و Load(byte[]) Isolation فراهم میکنند، اما LoadFrom نمیکند.
Resolution با Handle کردن Event به نام AssemblyResolve روی Application Domain انجام میشود که مانند Event به نام Resolving در Default ALC کار میکند. Assembly.Load(string) نیز برای Trigger کردن Resolution موجود است و به شکل مشابه کار میکند.
LoadFrom
LoadFrom Assembly را از Path دادهشده داخل Default ALC Load میکند. تقریباً شبیه AssemblyLoadContext.Default.LoadFromAssemblyPath است، با این تفاوتها:
- اگر Assemblyای با همان Simple Name از قبل در Default ALC باشد،
LoadFrom بهجای Exception همان Assembly را برمیگرداند. - اگر Assembly همنام از قبل نباشد و Load انجام شود، Assembly یک Status ویژه به نام «LoadFrom» میگیرد. این Status روی Resolution Logic مربوط به Default ALC اثر میگذارد؛ اگر آن Assembly Dependencyهایی در همان Folder داشته باشد، آن Dependencyها خودکار Resolve میشوند.
توانایی LoadFrom در Resolve خودکار Dependencyهای Transitive همان Folder میتواند راحت باشد تا وقتی Assembly اشتباهی را Load کند. چون Debug کردن چنین Scenarioهایی سخت است، گاهی بهتر است Load(string) یا LoadFile را استفاده کنید و Dependencyهای Transitive را با Event به نام AssemblyResolve خودتان Resolve کنید. این کار به شما قدرت تصمیمگیری دربارهٔ هر Assembly و امکان Debugging با Breakpoint داخل Event Handler را میدهد.
LoadFile و Load(byte[])
LoadFile و Load(byte[]) Assembly را از File Path یا Byte Array دادهشده در ALC جدید Load میکنند. برخلاف LoadFrom، این Methodها Isolation فراهم میکنند و اجازه میدهند چند Version از Assembly همنام را Load کنید. بااینحال دو Caveat وجود دارد که در مقالهٔ بعدی ادامه پیدا میکند.