.NET本身就是一個基於中介軟體(middleware)的框架,它通過一系列的中介軟體元件來處理HTTP請求和響應。因此,本篇文章主要描述從使用者鍵入請求到伺服器響應的大致流程,並深入探討.NET通過kestrel將HTTP報文轉換為HttpContext物件。
通過本文,您可以瞭解以下內容:
HTTP 請求的資料流轉過程非常複雜,涉及多個協定層次和網路裝置。通過資料流轉示意圖可以簡要了解該流程:
使用者端瀏覽器會首先嚐試從本地快取中查詢目標伺服器的 IP 地址。如果快取中沒有該域名對應的 IP 地址,則會向本地 DNS 伺服器發起 DNS 查詢請求。
DNS 伺服器會根據域名資訊向上級 DNS 伺服器傳送遞迴查詢請求,直到找到能夠返回該域名對應 IP 地址的 DNS 伺服器為止。最終,DNS 伺服器將目標伺服器的 IP 地址返回給使用者端瀏覽器。
2.TCP 連線
TCP 連線需要經過三次握手的過程:
當用戶端和伺服器完成三次握手後,TCP 連線就建立成功了。
使用者在瀏覽器中輸入URL後,瀏覽器會嚮應用層傳送HTTP請求。請求報文包含請求方法、URI、協定版本和請求頭資訊等。
傳輸層負責將HTTP請求報文分成若干個資料段進行傳輸,並使用TCP協定對這些資料段進行封裝。
網路層負責對TCP資料段進行分組,並通過IP協定進行路由選擇和定址,以便將封包從本地網路送到目標伺服器。
資料鏈路層將IP封包封裝為資料框,並新增源和目標MAC地址,以便在物理層上進行傳輸。
物理層將資料框轉換為位元流,並通過物理媒介(如網線、無線電波等)將資料傳送到目標伺服器。
當封包到達目標伺服器後,網路協定棧會解析封包,並將HTTP請求報文交給Web伺服器處理。
Web伺服器處理HTTP請求,包括解析HTTP請求報文、對映URL到相應的處理器、執行請求處理程式,並生成HTTP響應報文等。
Web伺服器生成HTTP響應報文之後,通過TCP協定將響應資料分成若干個資料段進行封裝。
資料鏈路層將TCP資料段封裝為資料框,並新增源和目標MAC地址。
物理層將資料框轉換為位元流,並通過物理媒介(如網線、無線電波等)將資料傳送回使用者端瀏覽器。
使用者端瀏覽器收到HTTP響應報文後,會交給應用層進行解析和處理。響應報文包含狀態行、響應頭和響應體等資訊。
通過上文,我們已經瞭解了 HTTP 請求資料流轉的基本過程。下圖展示了資料從 HTTP 資料開始,逐層新增 TCP、IP、乙太網頭部,然後在每個層次進行解析,最終抵達目標伺服器。
下邊貼一張網路包的報文資料格式圖:
想深入瞭解更多計算機網路知識的同學,可以自行查閱書籍和資料,這裡有位博主總結的很好,地址:小林coding
Kestrel 是一個基於libuv的跨平臺Web 伺服器,是.NET中預設啟用的 Web 伺服器,可以處理來自使用者端的 HTTP 請求和響應。
圖一 內網存取程式
圖二 反向代理存取程式
HttpContext儲存有關 Http 請求的當前資訊。它包含授權,身份驗證,請求,響應,對談,專案,使用者,表單選項等資訊。收到每個 HTTP 請求時,HttpContext都會初始化一個包含當前資訊的新物件。
想要了解更多HttpContext物件的屬性和方法,請直接參閱官方檔案
// 注入IHttpContextAccessor服務
builder.Services.AddHttpContextAccessor();
// 自定義服務中存取HttpContext
public class UserRepository : IUserRepository
{
private readonly IHttpContextAccessor _httpContextAccessor;
public UserRepository(IHttpContextAccessor httpContextAccessor) =>
_httpContextAccessor = httpContextAccessor;
public void LogCurrentUser()
{
var username = _httpContextAccessor.HttpContext.User.Identity.Name;
// ...
}
}
更多的存取方式請自行查閱官方檔案
以下是原始碼的部分刪減和修改,以便於更好地理解
我們從Program開始,使用CreateBuilder方法建立一個預設的主機構建器,設定應用程式的預設設定以及注入基礎服務。
// 在Program.cs檔案中呼叫
var builder = WebApplication.CreateBuilder(args);
// CreateBuilder方法返回了WebApplicationBuilder範例
public static WebApplicationBuilder CreateBuilder(string[] args) =>
new WebApplicationBuilder(new WebApplicationOptions(){ Args = args });
在WebApplicationBuilder 類別建構函式中,關於設定Configuration和IOC容器相關的已經在歷史文章中做過解讀。本文在看下幾個主機構建器的關係和作用:
BootstrapHostBuilder 是一個基本的主機構建器,構建預設的主機(Host)和服務容器(Service Container)
IHostBuilder 定義了一組用於設定主機的方法,並返回一個IHost範例。使用IHostBuilder可以自定義應用程式的設定資訊,如應用程式的環境、紀錄檔記錄、組態檔等
ConfigureHostBuilder 擴充套件了 IHostBuilder 介面,並新增了一些特定主機的設定選項,例如應用程式名稱、組態檔路徑、紀錄檔、依賴注入等,可以根據需要進行擴充套件和客製化。
ConfigureWebHostBuilder 是 ConfigureHostBuilder 的子類,主要用於處理與 Web 主機相關的設定,例如 Kestrel 伺服器選項、HTTPS 設定、Web 根目錄等
這幾個的關係簡單來講就是通過BootstrapHostBuilder和IHostBuilder建立主機構建器,然後使用ConfigureHostBuilder和ConfigureWebHostBuilder擴充套件方法設定所需的選項,最終建立主機和服務容器範例
internal WebApplicationBuilder(WebApplicationOptions options, Action<IHostBuilder>? configureDefaults = null)
{
// configuration將在後續的設定中提供應用程式選項和引數
var configuration = new ConfigurationManager();
configuration.AddEnvironmentVariables(prefix: "ASPNETCORE_");
// 建立一個 HostApplicationBuilder 物件,並將其中包含的設定初始化為從 WebApplicationOptions 物件中獲取的值
_hostApplicationBuilder = new HostApplicationBuilder(new HostApplicationBuilderSettings
{
Args = options.Args,
ApplicationName = options.ApplicationName,
EnvironmentName = options.EnvironmentName,
ContentRootPath = options.ContentRootPath,
Configuration = configuration,
});
// 建立BootstrapHostBuilder範例
var bootstrapHostBuilder = new BootstrapHostBuilder(_hostApplicationBuilder);
// bootstrapHostBuilder 上呼叫 ConfigureWebHostDefaults 方法,以進行特定於 Web 主機的設定
bootstrapHostBuilder.ConfigureWebHostDefaults(webHostBuilder =>
{
//......
});
var webHostContext = (WebHostBuilderContext)bootstrapHostBuilder.Properties[typeof(WebHostBuilderContext)];
Environment = webHostContext.HostingEnvironment;
Host = new ConfigureHostBuilder(bootstrapHostBuilder.Context, Configuration, Services);
WebHost = new ConfigureWebHostBuilder(webHostContext, Configuration, Services);
}
使用Kestrel構建預設主機
internal static void ConfigureWebDefaults(IWebHostBuilder builder)
{
ConfigureWebDefaultsWorker(
builder.UseKestrel(ConfigureKestrel),
services =>
{
services.AddRouting();
});
}
public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder, Action<WebHostBuilderContext, KestrelServerOptions> configureOptions)
{
return hostBuilder.UseKestrel().ConfigureKestrel(configureOptions);
}
設定WebHost在Kestrel伺服器上執行,並通過QUIC協定實現高效資料傳輸的方式
public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder)
{
return hostBuilder
.UseKestrelCore()
.UseKestrelHttpsConfiguration()
.UseQuic(options =>
{
// Configure server defaults to match client defaults.
// https://github.com/dotnet/runtime/blob/a5f3676cc71e176084f0f7f1f6beeecd86fbeafc/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectHelper.cs#L118-L119
options.DefaultStreamErrorCode = (long)Http3ErrorCode.RequestCancelled;
options.DefaultCloseErrorCode = (long)Http3ErrorCode.NoError;
});
}
重點看下UseKestrelCore方法,該方法將Kestrel伺服器應用到主機構建器的上下文中,並設定相關的服務
public static IWebHostBuilder UseKestrelCore(this IWebHostBuilder hostBuilder)
{
hostBuilder.ConfigureServices(services =>
{
// Don't override an already-configured transport
services.TryAddSingleton<IConnectionListenerFactory, SocketTransportFactory>();
services.AddTransient<IConfigureOptions<KestrelServerOptions>, KestrelServerOptionsSetup>();
services.AddSingleton<IHttpsConfigurationService, HttpsConfigurationService>();
services.AddSingleton<IServer, KestrelServerImpl>();
services.AddSingleton<KestrelMetrics>();
});
return hostBuilder;
}
從Program中app.Run()開始,啟動主機,最終會呼叫IHost的StartAsync方法。
app.Run();
public void Run([StringSyntax(StringSyntaxAttribute.Uri)] string? url = null)
{
Listen(url);
HostingAbstractionsHostExtensions.Run(this);
}
public static async Task RunAsync(this IHost host, CancellationToken token = default)
{
try
{
await host.StartAsync(token).ConfigureAwait(false);
await host.WaitForShutdownAsync(token).ConfigureAwait(false);
}
finally
{
if (host is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else
{
host.Dispose();
}
}
}
將中介軟體和StartupFilters擴充套件傳入HostingApplication主機,並進行啟動
public async Task StartAsync(CancellationToken cancellationToken)
{
// ...省略了從設定中獲取伺服器監聽地址和埠...
// 這個東西就是中介軟體,下篇文章再重點解讀
RequestDelegate? application = null;
try
{
IApplicationBuilder builder = ApplicationBuilderFactory.CreateBuilder(Server.Features);
foreach (var filter in StartupFilters.Reverse())
{
configure = filter.Configure(configure);
}
configure(builder);
// Build the request pipeline
application = builder.Build();
}
catch (Exception ex)
{
Logger.ApplicationError(ex);
}
/*
* application:中介軟體
* DiagnosticListener:事件監聽器
* HttpContextFactory:HttpContext物件的工廠
*/
HostingApplication httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory, HostingEventSource.Log, HostingMetrics);
await Server.StartAsync(httpApplication, cancellationToken);
}
KestrelServerImpl類中實現Server.StartAsync方法,用於在指定地址和埠上開啟HTTP服務。本篇文章只會解讀http2的實現流程,http3的如果您感興趣,請自行查閱原始碼。
public async Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) where TContext : notnull
{
// 用於處理與繫結事件相關的邏輯
async Task OnBind(ListenOptions options, CancellationToken onBindCancellationToken)
{
// ...省略 獲取是否支援Http1/2/3/協定及TLS加密,及判斷至少支援一種協定...
if (hasHttp1 || hasHttp2
|| options.Protocols == HttpProtocols.None)
{
// 呼叫UseHttpServer方法,為HTTP連線設定中介軟體、應用程式請求處理成中介軟體
options.UseHttpServer(ServiceContext, application, options.Protocols, addAltSvcHeader);
ConnectionDelegate connectionDelegate = options.Build();
// 新增連線限制中介軟體
connectionDelegate = EnforceConnectionLimit(connectionDelegate, Options.Limits.MaxConcurrentConnections, Trace, ServiceContext.Metrics);
// 開始監聽指定地址和埠上的HTTP請求
options.EndPoint = await _transportManager.BindAsync(configuredEndpoint, connectionDelegate, options.EndpointConfig, onBindCancellationToken).ConfigureAwait(false);
}
//...省略http3...
}
AddressBindContext = new AddressBindContext(_serverAddresses, Options, Trace, OnBind);
await BindAsync(cancellationToken).ConfigureAwait(false);
}
UseHttpServer方法是將建立連線,解析等功能建立成委託中介軟體。在_transportManager.BindAsync方法中,啟動監聽後執行。我們先跳過UseHttpServer方法,先看下啟動監聽的方法。
public async Task<EndPoint> BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig? endpointConfig, CancellationToken cancellationToken)
{
// 遍歷所有的ITransportFactory物件,並查詢可以對指定地址和埠進行繫結的工廠物件
foreach (var transportFactory in _transportFactories)
{
var selector = transportFactory as IConnectionListenerFactorySelector;
if (CanBindFactory(endPoint, selector))
{
// 呼叫其BindAsync方法,在指定地址和埠上啟動傳輸通道(Transport)
var transport = await transportFactory.BindAsync(endPoint, cancellationToken).ConfigureAwait(false);
// 啟動迴圈接收傳入連線。對於每個新連線請求,ConnectionListener都會建立一個新的ConnectionContext物件,並將其傳遞給連線處理委託(ConnectionDelegate)進行處理
StartAcceptLoop(new GenericConnectionListener(transport), c => connectionDelegate(c), endpointConfig);
return transport.EndPoint;
}
}
}
該方法使用IConnectionListener介面建立一個新的連線監聽器(ConnectionListener),並啟動一個迴圈以便不斷接收傳入的連線請求。對於每個新連線請求,它都會建立一個新的BaseConnectionContext物件,並將其傳遞給連線處理委託進行相應的操作
private void StartAcceptLoop<T>(IConnectionListener<T> connectionListener, Func<T, Task> connectionDelegate, EndpointConfig? endpointConfig) where T : BaseConnectionContext
{
var transportConnectionManager = new TransportConnectionManager(_serviceContext.ConnectionManager);
var connectionDispatcher = new ConnectionDispatcher<T>(_serviceContext, connectionDelegate, transportConnectionManager);
var acceptLoopTask = connectionDispatcher.StartAcceptingConnections(connectionListener);
_transports.Add(new ActiveTransport(connectionListener, acceptLoopTask, transportConnectionManager, endpointConfig));
}
執行緒池中通過while迴圈不斷監聽連線請求
public Task StartAcceptingConnections(IConnectionListener<T> listener)
{
ThreadPool.UnsafeQueueUserWorkItem(StartAcceptingConnectionsCore, listener, preferLocal: false);
return _acceptLoopTcs.Task;
}
private void StartAcceptingConnectionsCore(IConnectionListener<T> listener)
{
// REVIEW: Multiple accept loops in parallel?
_ = AcceptConnectionsAsync();
async Task AcceptConnectionsAsync()
{
try
{
while (true)
{
var connection = await listener.AcceptAsync();
if (connection == null)
{
// We're done listening
break;
}
// 建立一個新的連線Id
var id = _transportConnectionManager.GetNewConnectionId();
var metricsContext = Metrics.CreateContext(connection);
var kestrelConnection = new KestrelConnection<T>(
id, _serviceContext, _transportConnectionManager, _connectionDelegate, connection, Log, metricsContext);
_transportConnectionManager.AddConnection(id, kestrelConnection);
Metrics.ConnectionQueuedStart(metricsContext);
ThreadPool.UnsafeQueueUserWorkItem(kestrelConnection, preferLocal: false);
}
}
}
}
IThreadPoolWorkItem執行方法就是呼叫了我們上文中,先跳過的委託部分
void IThreadPoolWorkItem.Execute()
{
using (BeginConnectionScope(connectionContext))
{
try
{
await _connectionDelegate(connectionContext);
}
catch (Exception ex)
{
}
}
}
回到上文中的UseHttpServer方法,該方法中建立HttpConnectionMiddleware物件,用於封裝處理HTTP連線和請求的中介軟體
public static IConnectionBuilder UseHttpServer<TContext>(this IConnectionBuilder builder, ServiceContext serviceContext, IHttpApplication<TContext> application, HttpProtocols protocols, bool addAltSvcHeader) where TContext : notnull
{
var middleware = new HttpConnectionMiddleware<TContext>(serviceContext, application, protocols, addAltSvcHeader);
return builder.Use(next =>
{
// 實際的請求處理
return middleware.OnConnectionAsync;
});
}
建立HttpConnection物件,並呼叫ProcessRequestsAsync處理傳入的請求
public Task OnConnectionAsync(ConnectionContext connectionContext)
{
var httpConnectionContext = new HttpConnectionContext();
var connection = new HttpConnection(httpConnectionContext);
return connection.ProcessRequestsAsync(_application);
}
建立Http2Connection連線物件,並註冊停止清理事件,呼叫ProcessRequestsAsync方法處理請求
public async Task ProcessRequestsAsync<TContext>(IHttpApplication<TContext> httpApplication) where TContext : notnull
{
IRequestProcessor? requestProcessor = new Http2Connection((HttpConnectionContext)_context);
if (requestProcessor != null)
{
// 註冊停止處理請求事件
using var shutdownRegistration = connectionLifetimeNotificationFeature?.ConnectionClosedRequested.Register(state => ((HttpConnection)state!).StopProcessingNextRequest(), this);
// 註冊執行清理操作事件
using var closedRegistration = _context.ConnectionContext.ConnectionClosed.Register(state => ((HttpConnection)state!).OnConnectionClosed(), this);
await requestProcessor.ProcessRequestsAsync(httpApplication);
}
}
從ProcessRequestsAsync方法就進入核心解析環節了,該方法負責讀取和解析傳入的HTTP/2幀,並執行相應的操作來處理請求。為了保證效能和可靠性,該方法中還使用了心跳檢測、流量控制和超時控制等技巧。
通過迴圈讀取資料並使用 ProcessFrameAsync方法處理傳入的HTTP/2幀,直到收到終止連線的幀或者出現錯誤。
private Task ProcessFrameAsync<TContext>(IHttpApplication<TContext> application, in ReadOnlySequence<byte> payload) where TContext : notnull
{
// 請求流識別符號必須是奇數
if (_incomingFrame.StreamId != 0 && (_incomingFrame.StreamId & 1) == 0)
{
throw new Http2ConnectionErrorException(CoreStrings.FormatHttp2ErrorStreamIdEven(_incomingFrame.Type, _incomingFrame.StreamId), Http2ErrorCode.PROTOCOL_ERROR);
}
// 根據幀型別分發到不同的處理方法中
return _incomingFrame.Type switch
{
Http2FrameType.DATA => ProcessDataFrameAsync(payload),
Http2FrameType.HEADERS => ProcessHeadersFrameAsync(application, payload),
Http2FrameType.PRIORITY => ProcessPriorityFrameAsync(),
Http2FrameType.RST_STREAM => ProcessRstStreamFrameAsync(),
Http2FrameType.SETTINGS => ProcessSettingsFrameAsync(payload),
Http2FrameType.PUSH_PROMISE => throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorPushPromiseReceived, Http2ErrorCode.PROTOCOL_ERROR),
Http2FrameType.PING => ProcessPingFrameAsync(payload),
Http2FrameType.GOAWAY => ProcessGoAwayFrameAsync(),
Http2FrameType.WINDOW_UPDATE => ProcessWindowUpdateFrameAsync(),
Http2FrameType.CONTINUATION => ProcessContinuationFrameAsync(payload),
_ => ProcessUnknownFrameAsync(),
};
}
讀取ProcessHeadersFrameAsync頭部資料時,如果是新的資料,就開啟新的資料流
private Task ProcessHeadersFrameAsync<TContext>(IHttpApplication<TContext> application, in ReadOnlySequence<byte> payload) where TContext : notnull
{
// ......
// 開始一個新的Stream
_currentHeadersStream = GetStream(application);
_headerFlags = _incomingFrame.HeadersFlags;
// 荷載資料
var headersPayload = payload.Slice(0, _incomingFrame.HeadersPayloadLength);
// 解析請求頭部資料
return DecodeHeadersAsync(_incomingFrame.HeadersEndHeaders, headersPayload);
}
private Task DecodeHeadersAsync(bool endHeaders, in ReadOnlySequence<byte> payload)
{
_highestOpenedStreamId = _currentHeadersStream.StreamId;
// 解碼資料
_hpackDecoder.Decode(payload, endHeaders, handler: this);
// 當頭部資訊解碼完成,開啟新的資料流並重置處理狀態,迎接下一個請求
if (endHeaders)
{
_currentHeadersStream.OnHeadersComplete();
StartStream();
ResetRequestHeaderParsingState();
}
return Task.CompletedTask;
}
Decode解碼方法中使用HPACK演演算法和狀態機演演算法對HTTP/2請求頭部進行解碼。本篇文章中就不繼續深究了......
StartStream方法用於處理 HTTP/2 的流開始,並進行一些相關的檢查和操作,如新增到流字典、計數增加、驗證檔頭等。在做了諸多校驗工作後,進行執行。
private void StartStream()
{
// _scheduleInline 僅在測試中為 true
if (!_scheduleInline)
{
// 不能讓應用程式程式碼阻塞連線處理迴圈。
ThreadPool.UnsafeQueueUserWorkItem(_currentHeadersStream, preferLocal: false);
}
else
{
_currentHeadersStream.Execute();
}
}
Execute方法在處理請求之前進行一些紀錄檔記錄和度量統計操作,並呼叫非同步方法 ProcessRequestsAsync() 來處理請求
public override void Execute()
{
KestrelEventSource.Log.RequestQueuedStop(this, AspNetCore.Http.HttpProtocol.Http2);
ServiceContext.Metrics.RequestQueuedStop(MetricsContext, AspNetCore.Http.HttpProtocol.Http2);
// REVIEW: Should we store this in a field for easy debugging?
_ = ProcessRequestsAsync(_application);
}
ProcessRequests是非同步處理請求的方法。使用迴圈來處理多個請求,並在每個請求處理的不同階段執行相應的操作,如解析請求、執行應用程式程式碼、傳送響應等。同時,它還處理了各種異常情況,並記錄紀錄檔。迴圈會一直執行,直到保持連線的標誌 _keepAlive 被設定為 false 或需要結束連線。並在此處建立了HttpContext物件
private async Task ProcessRequests<TContext>(IHttpApplication<TContext> application) where TContext : notnull
{
while (_keepAlive)
{
BeginRequestProcessing();
// 嘗試解析請求,直到成功解析請求或者需要結束連線
var result = default(ReadResult);
bool endConnection;
do
{
if (BeginRead(out var awaitable))
{
result = await awaitable;
}
} while (!TryParseRequest(result, out endConnection));
if (endConnection)
{
// 連線已經結束,停止處理請求
return;
}
// 建立訊息體
var messageBody = CreateMessageBody();
if (!messageBody.RequestKeepAlive)
{
_keepAlive = false;
}
// 初始化請求體控制器
InitializeBodyControl(messageBody);
// 建立上下文物件
var context = application.CreateContext(this);
// 執行應用程式對該請求的處理程式碼
await application.ProcessRequestAsync(context);
// 方法停止請求體控制器
await _bodyControl.StopAsync();
// 釋放上下文物件
application.DisposeContext(context, _applicationException);
// 回到 while 迴圈的開頭,繼續處理下一個請求
}
}
該方法接受一個 IFeatureCollection 型別的引數,並返回一個 HttpContext 物件
public HttpContext CreateContext(IFeatureCollection contextFeatures)
{
return _httpContextFactory?.Create(contextFeatures) ?? new DefaultHttpContext(contextFeatures);
}
初始化 DefaultHttpContext 物件的 _features、_request 和 _response 成員變數,並建立與當前上下文相關聯的預設的請求和響應物件
public DefaultHttpContext(IFeatureCollection features)
{
_features.Initalize(features);
_request = new DefaultHttpRequest(this);
_response = new DefaultHttpResponse(this);
}
通過本篇文章可以深入瞭解了HTTP請求的資料流轉過程。瞭解了資料在使用者端和伺服器之間的流動方式,以及HTTP報文的結構。
此外,我們還對Kestrel進行了原始碼解讀,並瞭解瞭如何建立和管理HttpContext。Kestrel作為高效能的Web伺服器,扮演著連線使用者端和應用程式的橋樑,而HttpContext則提供了對請求和響應的上下文資訊和處理能力。
通過深入研究和理解HTTP請求的資料流轉過程以及Kestrel和HttpContext的工作原理,我們可以清晰的認知到整個運作流程。當然還有很多細節沒有表述,在以後遇見問題的時候,可以快速定位問題或者查閱相關模組程式碼。以及瞭解如何去客製化想要的擴充套件功能。
題外話:
由於我閱讀時喜歡一次性閱讀完整篇文章,因此我寫文章時常常會花費很長時間,這也導致我的文章變得相對較長。我也會考慮你是否有足夠的耐心和時間來閱讀整篇文章,如果你有好寫作技巧,請指教。總之,完成一篇長文後,我會感到非常舒適和滿足,很有成就感!
如果您覺得這篇文章有所收穫,還請點個贊並關注。如果您有寶貴建議,歡迎在評論區留言,非常感謝您的支援!
(也可以關注我的公眾號噢:Broder,萬分感謝_)