目錄
1.Aop介紹
2.Aop的基本概念
3.Aop的織入方式
4.Aop之靜態織入
5.Aop之動態織入
Aop介紹
我們先看一下wiki百科的介紹
Traditional software development focuses on decomposing systems into units of primary functionality, while recognizing that there are other issues of concern that do not fit well into the primary decomposition. The traditional development process leaves it to the programmers to code modules corresponding to the primary functionality and to make sure that all other issues of concern are addressed in the code wherever appropriate. Programmers need to keep in mind all the things that need to be done, how to deal with each issue, the problems associated with the possible interactions, and the execution of the right behavior at the right time. These concerns span multiple primary functional units within the application, and often result in serious problems faced during application development and maintenance. The distribution of the code for realizing a concern becomes especially critical as the requirements for that concern evolve – a system maintainer must find and correctly update a variety of situations.
Aspect-oriented software development focuses on the identification, specification and representation of cross-cutting concerns and their modularization into separate functional units as well as their automated composition into a working system.
傳統的軟件開發關注將系統分解成為一個主要的功能單元,然而卻發現一些問題並不適合分解方式。這種傳統的開發過程讓編碼人員合作編寫主要的功能模塊,以此保證那些主要的關注點能夠正確的被編碼。編碼人員需要記住所有需要被完成的事情,如何處理每個問題,問題可能的關聯關系,以此確定代碼在正確的時候以正確的方式被執行。這些關注點在應用里面跨域了多個主要供單元,這經常在開發和維護時引發一些嚴重的問題。這些分布式代碼導致的問題變得越來越迫切得需要得到解決-一個系統維護人員必須解決這種問題。
面向切面軟件開發需要關注這些識別的,詳細的,具有代表性的切面問題,將其模塊化到功能搗衣並且自動將這些代碼組合到一個工作中的系統。
英語比較蹩腳,翻譯比較澀,總結起來的意思就是,Aop是將一些已經識別的切面關注的功能封裝,並能自動將該功能組合到需要的地方。
我對Aop的理解就是,一些被封裝好的,通用的業務單元,真正的編程人員不需要關注的部分,並能動態或靜態的將這部分功能組合到業務中去。舉個簡單的例子,我們在代碼中,經常要判斷是否用戶登錄,如果未登錄,就需要跳轉到指定的頁面,偽代碼如下:
public string GetNews(){ /*判斷是否登錄,如果已經登錄,則執行后面的業務代碼 如果沒有登錄,則跳轉到登錄頁面*/ //業務代碼
}
我們可以來看一下簡單的流程圖
從圖中我們可以將代碼中的登錄的判斷業務分解成一個單獨的業務單元,在需要的地方打上一個標簽,告訴系統這里需要執行,那么其他編碼人員就不需要再寫重復類似的代碼了。這就是Aop解決的問題。
在介紹Aop的實現方式前,我們先了解一下Aop的幾個知識點,這有助於我們理解Aop的實際技術。
8)weaving(插入):是指應用aspects到一個target對象創建proxy對象的過程:complie time,classload time,runtime
目前在.NET平台中,支持的織入方式有倆中,一種是靜態織入,即編譯時織入,另外一個是動態織入,即運行時織入。倆中方式各有優缺點,使用靜態織入,可以不破壞代碼結構,這里的破壞代碼結構是你需要使用多余配置,寫一些多余的代碼,或必須依賴某種方式(這里大家也許也還不太明白,可以看完后面倆種方式的具體代碼比較,再回頭來看,會比較好理解)。使用動態織入的優點就是可以動態調試。倆中織入方式是互補的,即動態織入的優點也是靜態織入的缺點,同理,靜態織入的優點亦是動態織入的缺點。大家在做技術選型時可以根據自己的實際情況進行選擇。
目前成熟的框架有PostSharp,這個框架是商業框架,意思就是需要付費,這里就不具體介紹了,需要了解的土豪請到官網查看,具體如何使用請查閱文檔。
BSF.Aop .Net 免費開源,靜態Aop織入(直接修改IL中間語言)框架,類似PostSharp(收費),實現前后Aop切面和INotifyPropertyChanged注入方式。其原理是在編譯生成IL后,借助Mono.Cecil的AssemblyDefinition讀取程序集,並檢測需要注入的點,並將指定的代碼注入到程序集中。有想具體深入研究的同學,可以到 BSF.Aop中下載源碼進行研究。遺憾的是這個只實現了倆個切入點,並沒有在異常時提供切入點。
我們模擬一個日志記錄的例子,我們先建一個項目。
1. 在項目中引用BSF.Aop.dll,Mono.Cecil.dll,Mono.Cecil.Pdb.dll,Microsoft.Build.dll;
2. 添加一個類LogAttribute並繼承Aop.Attributes.Around.AroundAopAttribute(切面);
3. 重寫AroundAopAttribute的Before和After方法,並寫入邏輯代碼;
4. 新建一個測試類LogTest,並添加Execute方法,並在Execute方法上面添加LogAttribute標簽;
5. 我們在main里面new一個LogTest對象並調用看看輸出結果;
具體的代碼如下:
public class LogTest { [LogAttribute] public void Execute(int a) { a = a * 100; System.Console.WriteLine("Hello world!" + a); } } public class LogAttribute : AroundAopAttribute { public virtual void Before(AroundInfo info) { System.Console.WriteLine("Log before executed value is" + info.Params["a"]); } public virtual void After(AroundInfo info) { System.Console.WriteLine("Log after executed value is" + info.Params["a"]); } }
static void Main(string[] args) { Aop.AopStartLoader.Start(null); new LogTest().Execute(2); Console.ReadLine(); }
執行代碼輸出:
上例代碼中
- aspect 日志
- join point 即AroundAopAttribute中的Before和After,即方法執行前和方法執行后
- advice 即日志的邏輯部分
- pointcut 即我們LogAttribute中的Before(上面的例子故意沒有重寫After,是因為怕大家誤解)
- target 這里我們是對方法進行切入的,即Execute方法
- weaving 這個例子中我們采用的是編譯時的織入
使用.NET提供的遠程代理,即RealProxies來實現。
1.先建一個Aop代理類AopClassAttribute繼承於ProxyAttribute,這個標簽會告訴代理,這個類需要被代理創建調用;
/// <summary> /// 標記一個類為Aop類,表示該類可以被代理注入 /// </summary> public class AopClassAttribute : ProxyAttribute { public override MarshalByRefObject CreateInstance(Type serverType) { AopProxy realProxy = new AopProxy(serverType); return realProxy.GetTransparentProxy() as MarshalByRefObject; } }
2.定義Aop的屬性,並定義織入點
/// <summary> /// Attribute基類,通過實現該類來實現切面的處理工作 /// </summary> public abstract class AopAttribute : Attribute { /// <summary> /// 調用之前會調用的方法 /// 1.如果不需要修改輸出結果,請返回null /// 2.如果返回值不為null,則不會再調用原始方法執行,而是直接將返回的參數作為結果 /// </summary> /// <param name="args">方法的輸入參數列表</param> /// <param name="resultType">方法的返回值類型</param> public abstract object PreCall(object[] args, Type resultType); /// <summary> /// 調用之后會調用的方法 /// </summary> /// <param name="resultValue">方法的返回值</param> /// <param name="args">方法的輸入參數列表</param> public abstract void Called(object resultValue, object[] args); /// <summary> /// 調用出現異常時會調用的方法 /// </summary> /// <param name="e">異常值</param> /// <param name="args">方法的輸入參數列表</param> public abstract void OnException(Exception e, object[] args); }
3.定義代理的邏輯過程,這里我對returnvalue做了判斷,是為了實現緩存更新和添加的切面代碼做的,在這里我實現了三個切入點的調用,具體可看注釋部分
/// <summary> /// 主要代理處理類 /// </summary> internal class AopProxy : RealProxy { public AopProxy(Type serverType) : base(serverType) { } public override IMessage Invoke(IMessage msg) { if (msg is IConstructionCallMessage) return InvokeConstruction(msg); else return InvokeMethod(msg); } private IMessage InvokeMethod(IMessage msg) { IMethodCallMessage callMsg = msg as IMethodCallMessage; IMessage returnMessage; object[] args = callMsg.Args; var returnType = (callMsg.MethodBase as System.Reflection.MethodInfo).ReturnType;//方法返回類型 object returnValue = null;//方法返回值 AopAttribute[] attributes = callMsg.MethodBase.GetCustomAttributes(typeof(AopAttribute), false) as AopAttribute[]; try { if (attributes == null || attributes.Length == 0) return InvokeActualMethod(callMsg); //前切點 foreach (AopAttribute attribute in attributes) returnValue = attribute.PreCall(args, returnType); //如果以前切面屬性都沒有返回值,則調用原始的方法;否則不調用 //主要是做緩存類似的業務 if (returnValue == null) { returnMessage = InvokeActualMethod(callMsg); returnValue = (returnMessage as ReturnMessage).ReturnValue; } else returnMessage = new ReturnMessage(returnValue, args, args.Length, callMsg.LogicalCallContext, callMsg); //后切點 foreach (AopAttribute attribute in attributes) attribute.Called(returnValue,args); } catch (Exception e) {
//異常切入點 foreach (AopAttribute attribute in attributes) attribute.OnException(e, args); returnMessage = new ReturnMessage(e, callMsg); } return returnMessage; } private IMessage InvokeActualMethod(IMessage msg) { IMethodCallMessage callMsg = msg as IMethodCallMessage; object[] args = callMsg.Args; object o = callMsg.MethodBase.Invoke(GetUnwrappedServer(), args); return new ReturnMessage(o, args, args.Length, callMsg.LogicalCallContext, callMsg); } private IMessage InvokeConstruction(IMessage msg) { IConstructionCallMessage constructCallMsg = msg as IConstructionCallMessage; IConstructionReturnMessage constructionReturnMessage = this.InitializeServerObject((IConstructionCallMessage)msg); RealProxy.SetStubData(this, constructionReturnMessage.ReturnValue); return constructionReturnMessage; } }
4.定義上下文邊界對象,想要使用Aop的類需要繼承此類(這個是這種Aop方式破壞性最大的地方,因為需要繼承一個類,而面向對象單繼承的特性導致了業務類不能再繼承其他的類。可以想象一下你有一個查詢基類,然后另一個查詢類想要繼承查詢基類,而又想使用Aop,這時就尷尬了);
/// <summary> /// Aop基類,需要注入的類需要繼承該類 /// 對代碼繼承有要求,后續可以改進一下 /// 注意,需要記錄的不支持上下文綁定,如果需要記錄,使用代理模式解決 /// </summary> public abstract class BaseAopObject : ContextBoundObject { }
5.定義Advice部分,即實際的業務邏輯,繼承於AopAttribute
public class IncreaseAttribute : AopAttribute { private int Max = 10; public IncreaseAttribute(int max) { Max = max; } public override object PreCall(object[] args, Type resultType) { if (args == null || args.Count() == 0 || !(args[0] is ExampleData)) return null; var data = args[0] as ExampleData; string numString = args[0].ToString(); data.Num = data.Num * 100; Console.WriteLine(data.Num); return null; } public override void Called(object resultValue, object[] args) { if (args == null || args.Count() == 0 || !(args[0] is ExampleData)) return; var data = args[0] as ExampleData; string numString = args[0].ToString(); data.Num = data.Num * 100; Console.WriteLine(data.Num); } public override void OnException(Exception e, object[] args) { } }
public class ExampleData
{
public int Num { get; set; }
}
6.完成了上面的部分,我們就可以來使用Aop了,定義一個需要使用Aop的類,繼承於BaseAopObject,並在類上面加上[AopClass],在需要切入的方法上加上剛才定義的[IncreaseAttribute]
[AopClass] public class Example : BaseAopObject { [IncreaseAttribute(10)] public static void Do(ExampleData data) { Add(data); } [IncreaseAttribute(10)] public static ExampleData Add(ExampleData data) { return new ExampleData { Num = ++data.Num }; } }
可以看到,使用上面這種織入方式,對代碼的侵入性太大,會限制代碼的可擴展性。所以我比較不建議使用。
另一種方式是借助Ioc的代理來做Aop切面注入,這里我們以Unity作為Ioc容器,以之前寫的關於Unity Ioc中的例子來介紹Aop。
1.添加AopAttribute(定義連接點),這里有個循環引用,就是AopHandler和AopAttribute之間,不過並不影響使用,如有需要大家可以自己解決一下;
/// <summary> /// 標記一個類或方法為代理,表示該類或方法可以被代理 /// </summary> [AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = true)] public abstract class AopAttribute : HandlerAttribute { /// <summary> /// 請勿重寫該方法 /// </summary> /// <param name="container"></param> /// <returns></returns> public override ICallHandler CreateHandler(IUnityContainer container) { return new AopHandler(); } /// <summary> /// 調用之前會調用的方法 /// 1.如果不需要修改輸出結果,請返回null,ouputs返回new object[0] /// 2.如果返回值不為null,則不會再調用原始方法執行,而是直接將返回的參數作為結果 /// </summary> /// <param name="inputArgs">方法的輸入參數列表</param> /// <param name="outputs">方法中的out值,如果沒有請返回null</param> /// <returns>返回值</returns> public abstract object PreCall(object[] inputArgs, out object[] outputs); /// <summary> /// 調用之后會調用的方法 /// </summary> /// <param name="resultValue">方法的返回值</param> /// <param name="inputArgs">方法的輸入參數列表</param> /// <param name="outputs">方法中的out值,如果沒有則該參數值為null</param> public abstract void Called(object resultValue, object[] inputArgs, object[] outputs); /// <summary> /// 調用出現異常時會調用的方法 /// </summary> /// <param name="e">異常值</param> /// <param name="inputArgs">方法的輸入參數列表,鍵為參數名,值為參數值</param> public abstract void OnException(Exception e, Dictionary<string, object> inputArgs); }
2.添加AopHandler(代理類);
/// <summary> /// 主要代理處理類 /// </summary> internal class AopHandler : ICallHandler { public int Order { get; set; } = 1; public IMethodReturn Invoke(IMethodInvocation input, GetNextHandlerDelegate getNext) { IMethodReturn returnValue = null; object attrReturnValue = null; object[] outputs = null; Dictionary<string, object> inputs = new Dictionary<string, object>(); //假如有忽略特性,直接忽略,不進行AOP代理 IgnoreAttribute[] ignoreAttributes = input.MethodBase.GetCustomAttributes(typeof(IgnoreAttribute), true) as IgnoreAttribute[]; if (ignoreAttributes != null && ignoreAttributes.Length > 0) return input.CreateMethodReturn(attrReturnValue, outputs); AopAttribute[] attributes = input.MethodBase.GetCustomAttributes(typeof(AopAttribute), true) as AopAttribute[]; try { if (attributes == null || attributes.Length == 0) return getNext()(input, getNext); for (var i = 0; i < input.Arguments.Count; i++) inputs.Add(input.Inputs.ParameterName(i), input.Inputs[i]); foreach (AopAttribute attribute in attributes) attrReturnValue = attribute.PreCall(inputs.Values.ToArray(), out outputs); //如果以前切面屬性都沒有返回值,則調用原始的方法;否則不調用 //主要是做緩存類似的業務 if (attrReturnValue == null) { returnValue = getNext()(input, getNext); outputs = new object[returnValue.Outputs.Count]; for (var i = 0; i < returnValue.Outputs.Count; i++) outputs[i] = returnValue.Outputs[i]; } else returnValue = input.CreateMethodReturn(attrReturnValue, outputs); if (returnValue.Exception != null) throw returnValue.Exception; foreach (AopAttribute attribute in attributes) attribute.Called(returnValue.ReturnValue, inputs.Values.ToArray(), outputs); } catch (Exception e) { foreach (AopAttribute attribute in attributes) attribute.OnException(e, inputs); returnValue = input.CreateExceptionMethodReturn(e); } return returnValue; } }
3..定義一個我們自己的功能塊(業務邏輯),這里還是以日志為例;
public class LogAttribute : AopAttribute
{
public override void Called(object resultValue, object[] inputArgs, object[] outputs)
{
Console.WriteLine("Called");
}
public override void OnException(Exception e, Dictionary<string, object> inputArgs)
{
Console.WriteLine("exception:" + e.Message);
}
public override object PreCall(object[] inputArgs, out object[] outputs)
{
Console.WriteLine("PreCall");
outputs = new object[0];
return null;
}
}
5.接下來我們稍微改造一下我們的印鈔機;
/// <summary> /// 印鈔機 /// </summary> public class CashMachine { public CashMachine() { } public void Print(ICashTemplate template) { string templateContent = template.GetTemplate("人民幣"); System.Console.WriteLine(templateContent); } } /// <summary> /// 印鈔模塊 /// </summary> public interface ICashTemplate { /// <summary> /// 獲取鈔票模板 /// </summary> /// <returns></returns> [Log] string GetTemplate(string flag); } /// <summary> /// 人民幣鈔票模板 /// </summary> public class CNYCashTemplate : ICashTemplate { public CNYCashTemplate() { } public string GetTemplate(string flag) { return "這是人民幣模板!" + flag + " 這是返回值。"; } } /// <summary> /// 美鈔鈔票模板 /// </summary> public class USDCashTemplate : ICashTemplate { public USDCashTemplate() { } public string GetTemplate(string flag) { throw new Exception("哎呀,美鈔模板有問題呀!"); } }
6.然后我們在命令行的Main里改造一下;
static void Main(string[] args) { try { ICashTemplate usdTemplate = new USDCashTemplate(); ICashTemplate rmbTemplate = new CNYCashTemplate(); new CashMachine().Print(rmbTemplate); new CashMachine().Print(usdTemplate); } catch (Exception) { } Console.ReadLine(); }
7.啟動一下看看結果
8.可以看到,只輸出了GetTemplate方法的輸出,並沒有輸出日志,我們要使用Ioc來注冊對象才能使用,繼續改造Main方法;
static void Main(string[] args) { UnityContainer container = new UnityContainer(); container.AddNewExtension<Interception>().RegisterType<ICashTemplate, CNYCashTemplate>("cny"); container.Configure<Interception>().SetInterceptorFor<ICashTemplate>("cny", new InterfaceInterceptor()); container.AddNewExtension<Interception>().RegisterType<ICashTemplate, USDCashTemplate>("usd"); container.Configure<Interception>().SetInterceptorFor<ICashTemplate>("usd", new InterfaceInterceptor()); try {new CashMachine().Print(container.Resolve<ICashTemplate>("cny")); new CashMachine().Print(container.Resolve<ICashTemplate>("usd")); } catch (Exception) { } Console.ReadLine(); }
9.啟動運行,看一下結果;
可以看到,三個方法都執行了,而在拋出異常時是不會執行Called的方法的;
10.上面我們是直接使用了UnityContainer來注冊對象,而沒有使用我們之前封裝的Ioc,我們還有更簡單的方式,就是采用配置的方式來注冊對象和攔截器實現Aop。在實際,使用一個單獨的文件來配置ioc會更易於維護。我們先添加一個unity.config文件;
<?xml version="1.0" encoding="utf-8" ?> <unity xmlns= "http://schemas.microsoft.com/practices/2010/unity "> <sectionExtension type="Microsoft.Practices.Unity.InterceptionExtension.Configuration.InterceptionConfigurationExtension, Unity.Interception.Configuration"/> <!--注入對象--> <typeAliases> <!--表示單例--> <typeAlias alias="singleton" type="Unity.Lifetime.ContainerControlledLifetimeManager,Unity.Abstractions" /> <!--表示每次使用都進行創建--> <typeAlias alias="transient" type="Unity.Lifetime.TransientLifetimeManager,Unity.Abstractions" /> </typeAliases> <container name= "Default"> <extension type="Interception"/> <!--type表示接口 格式為 帶命名空間的接口,程序集名 mapTo表示需要注入的實體類 name表示注入實體的name--> <register type= "IocWithUnity.ICashTemplate,IocWithUnity" mapTo= "IocWithUnity.CNYCashTemplate,IocWithUnity" name="cny"> <!--定義攔截器--> <interceptor type="InterfaceInterceptor"/> <policyInjection/> <!--定義對象生命周期--> <lifetime type="singleton" /> </register> <!--type表示接口 格式為 帶命名空間的接口,程序集名 mapTo表示需要注入的實體類 name表示注入實體的name--> <register type= "IocWithUnity.ICashTemplate,IocWithUnity" mapTo= "IocWithUnity.USDCashTemplate,IocWithUnity" name="usd"> <!--定義攔截器--> <interceptor type="InterfaceInterceptor"/> <policyInjection/> <!--定義對象生命周期--> <lifetime type="singleton" /> </register> </container> </unity>
11.再配置app.config(WEB項目應該是web.config);
<?xml version="1.0" encoding="utf-8" ?> <configuration> <configSections> <section name="unity" type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection, Unity.Configuration"/> </configSections> <unity configSource="unity.config"/> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" /> </startup> </configuration>
12.將我們之前寫的IocContainer修改一下讀取配置;
public static class IocContainer { private static IUnityContainer _container = null; static IocContainer() { _container = new UnityContainer(); object unitySection = ConfigurationManager.GetSection("unity"); if (unitySection == null) return; UnityConfigurationSection section = (UnityConfigurationSection)unitySection; section.Configure(_container, "Default"); } /// <summary> /// 注冊一個實例作為T的類型 /// </summary> /// <typeparam name="T">需要注冊的類型</typeparam> /// <param name="instance">需要注冊的實例</param> public static void Register<T>(T instance) { _container.RegisterInstance<T>(instance); } /// <summary> /// 注冊一個名為name的T類型的實例 /// </summary> /// <typeparam name="T">需要注冊的類型</typeparam> /// <param name="name">關鍵字名稱</param> /// <param name="instance">實例</param> public static void Register<T>(string name, T instance) { _container.RegisterInstance(name, instance); } /// <summary> /// 將類型TFrom注冊為類型TTo /// </summary> /// <typeparam name="TFrom"></typeparam> /// <typeparam name="TTo"></typeparam> public static void Register<TFrom, TTo>() where TTo : TFrom { _container.RegisterType<TFrom, TTo>(); } /// <summary> /// 將類型TFrom注冊為類型TTo /// </summary> /// <typeparam name="TFrom"></typeparam> /// <typeparam name="TTo"></typeparam> /// <typeparam name="lifetime"></typeparam> public static void Register<TFrom, TTo>(LifetimeManager lifetime) where TTo : TFrom { _container.RegisterType<TFrom, TTo>(lifetime); } /// <summary> /// 將類型TFrom注冊名為name類型TTo /// </summary> /// <typeparam name="TFrom"></typeparam> /// <typeparam name="TTo"></typeparam> public static void Register<TFrom, TTo>(string name) where TTo : TFrom { _container.RegisterType<TFrom, TTo>(name); } /// <summary> /// 通過關鍵字name來獲取一個實例對象 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="name"></param> /// <returns></returns> public static T Resolve<T>(string name) { return _container.Resolve<T>(name); } /// <summary> /// 獲取一個為T類型的對象 /// </summary> /// <typeparam name="T"></typeparam> /// <returns></returns> public static T Resolve<T>() { return _container.Resolve<T>(); } /// <summary> /// 獲取所有注冊類型為T的對象實例 /// </summary> /// <typeparam name="T">需要獲取的類型的對象</typeparam> /// <returns></returns> public static IEnumerable<T> ResolveAll<T>() { return _container.ResolveAll<T>(); } }
注意:配置時有一個坑 <section name="unity" type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection, Unity.Configuration"/> 這句話在5.0版本后dll的名稱改了,以前是Microsoft.Practices.Unity.Configuration,現在是Unity.Configuration,如果你在運行時碰到找不到文件或程序集xxx時,可以注意看一下你的具體的dll的文件名。包括后面的unity.config里面的lifetime的配置也是,大家需要注意一下自己的版本,然后找到對應的命名空間和dll文件進行配置。
13.接下來我們運行一下看看結果如何;
總結:可以看到,靜態織入方式相對較簡單,對代碼破壞性近乎於0,其原理大致是在編譯前,將需要的代碼添加到我們添加了Attribute的地方,如果用反編譯工具反編譯生成的dll就可以看到實際編譯后的代碼。這種織入方式的缺點是不易於調試工作,因為生成的pdb文件與我們的源代碼文件實際上是不一樣的。而采用真實代理的方式進行織入,這種方式比較原生,但對代碼侵入性較大,而且效率也較低。使用ioc框架的攔截器進行攔截織入的方式,是當下比較好的一種方式,但是也是有一個約束,就是對象必須經過ioc容器來委托創建。基於這些比較,各位看官可以選擇適合自己的織入方式。
本文原創,如有轉載,請注明出處。