選擇正確的軟體架構具有挑戰性,尤其是在平衡來自網路的理論和建議與實際實施時。在這篇文章中,我將分享我的旅程以及對我有用的架構選擇。
儘管標題可能表明我來這裡是為了準確地告訴您如何建立應用程式,但這不是我的目標。相反,我將重點介紹我的個人經驗、選擇以及我在建立應用程式時所採取的方法背後的推理。這並不意味著您應該以相同的方式建立事物,但由於我的許多朋友都問過我這個問題,我想我應該嘗試解釋我們在TimeMates中使用的架構(PS:我與我的朋友一起製作的個人專案)。
您可能已經了解某些術語,例如「乾淨架構」 、 “DDD” (領域驅動設計),甚至是「六角架構」 。也許您已經讀過很多關於這方面的文章。但對我來說,我發現其中大多數都存在一些問題——理論資訊太多,實踐資訊太少。他們可能會給你一些小而非真實的例子,其中一切都很完美,但它從來沒有對我有用,也從來沒有給我好的答案,只是增加了樣板。
其中一些幾乎相同或大部分相互包含,並且在大多數情況下彼此不衝突,但許多人止步於具體方法,並不認為這不是世界末日。
除了我最初建立應用程式的具體方式之外,我們將嘗試從我從中汲取靈感的不同方法中學習最有價值的資訊。然後我們會談到,特別是我的想法和實施。讓我們從大多數人在開發 Android 應用程式時所做的相同的地方開始:
乾淨的架構聽起來很簡單——你有特定的層,只做只應該在特定層完成的特定工作(我知道太多具體的事情)。 Google 推薦以下架構::
推介會
網域名稱(Google 認為可選)
資料
表示層負責您的 UI,理想情況下,它的唯一作用是在使用者(與 UI 互動)和領域模型之間進行通訊。領域層處理業務邏輯,而資料層處理低階操作,例如讀取和寫入資料庫。
聽起來很簡單,對吧?然而,這個結構中存在一個大問題:根據Google推薦的應用程式架構,為什麼領域層是可選的?那麼業務邏輯該去哪裡呢?
這個想法來自谷歌的立場,即在某些情況下可以跳過域層。在更簡單的應用程式中,您可能會發現業務邏輯放置在ViewModel (表示層的一部分)中的範例。那麼,這種方法有什麼問題呢?
問題在於MVVM/MVI/MVP模式和表示層的角色。表示層應該只處理與平台細節和 UI 相關任務的整合。在這種情況下,保持表示層(無論是使用 MVVM 或任何其他模式)不受業務邏輯的影響至關重要。它應該包含的唯一邏輯與特定於平台的要求相關。
為什麼?在清潔架構中,每一層都有特定的責任來確保關注點分離和可維護的程式碼。表示層的工作是透過 UI 與使用者互動並管理與平台相關的操作,例如渲染視圖或處理輸入。它並不意味著包含業務邏輯,因為它屬於領域層,核心規則和決策是集中的。
這個概念是在表示層中分離特定於平台的考慮因素,從而可以在不影響業務規則和其他程式碼的情況下更改或調整使用者介面或平台。例如,如果您想從 Android 應用程式過渡到 iOS 應用程式,您只需要重新設計 UI,同時保留網域邏輯,這在 Kotlin 環境中特別有用。 😋
但回到Google的敘述——大多數誤解來自於不理解業務邏輯是什麼、它應該位於哪裡以及某些示例的性質。
因此,為了解決其他問題,讓我們更多地討論域層,特別是 DDD:
領域驅動設計 (DDD) 圍繞著建立應用程式來反映核心業務領域。但簡單來說——應該編寫什麼程式碼以及以什麼方式編寫?
您肯定已經了解儲存庫或用例,有些人可能認為用例是其中的一部分。但最重要的部分不是用例或儲存庫,而是域邏輯所在的業務實體。
DDD 領域中的業務實體是反映您正在解決的業務問題的基本物件。它們不僅僅是許多初學者專案中常用的常規 DTO 或 POJO。相反,在 DDD 中,業務實體封裝了資料和行為。它們旨在代表現實世界的概念和過程,並體現了管理這些概念的規則和邏輯。但從中得出的簡單建議是什麼?
不要使用原始類型,如 String、Int、Long 等(唯一的例外是 Boolean)。在這種方法的理想維護模型中,對業務物件(域層內的實體)建模的資料不能以無效形式存在(例如,可能引發意外異常或提供無意義的資訊)。
這就是 DDD 的重要內容之一——值物件和聚合器。
值物件– 是任何業務實體的建構塊,其目的是提供有關對業務實體進行建模的資訊的類型、方式和約束的描述性資訊。他們沒有身份,這意味著他們不會單獨行動——他們只是商業實體的一部分。這也意味著它們不應該是可變的。
例如,如果您的業務模型中有某種資金流,則您將有兩個值物件:
Amount
和Currency
,而不是實體中的常規字串。
>
由此可見,值物件有自己的類型和值約束,需要檢查。最佳實踐是在建立時執行這些檢查。
讓我們透過一個值物件的範例來更好地理解它,這是帶有驗證的EmailAddress
值物件(這是來自 TimeMates 的範例):
@JvmInline
public value class EmailAddress private constructor(public val string: String) {
public companion object {
public val LENGTH_RANGE: IntRange = 5..200
public val EMAIL_PATTERN: Regex = Regex(
buildString {
append("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}")
append("\\@")
append("[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}")
append("(")
append("\\.")
append("[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}")
append(")+")
}
)
public fun create(value: String): Result<EmailAddress> {
return when {
value.size !in LENGTH_RANGE -> Result.failure(...)
!value.matches(EMAIL_PATTERN) -> Result.failure(...)
else -> EmailAddress(value)
}
}
}
}
因此,我們對使用者/伺服器應提供的電子郵件地址的大小和形式有特定的業務限制。
如果我們已經驗證了值物件中的資料,那麼為什麼我們需要聚合器?
聚合器– 是某種觀察者,用於檢查業務實體對其建模的狀態值物件是否有效。此外,如果需要,它還可以驗證彼此之間的業務實體。
>
任何修改或建立域實體中的物件的函數稱為聚合。
>
如果有任何特殊的邏輯,他們會做任何有關資料變更或突變的工作。
>
值物件中的驗證之間的差異在於,對業務實體進行建模的資料可能有效,但對於特定業務實體來說可能不正確或不一致。
它主要是可選的,因為您並不總是需要它們,並且大多數工作都會進行值物件驗證。
但這裡有一個例子:
class User private constructor(
val id: UserId,
val email: EmailAddress,
val isAdmin: Boolean,
) {
companion object {
// aggregate
fun create(
id: UserId,
email: EmailAddress,
isAdmin: Boolean
): Result<User> {
if (isAdmin && !email.string.contains("@business_email.com"))
return Result.failure(
IllegalStateException(
"Admins should always have business email"
)
)
return User(id, email, isAdmin)
}
}
// part of the aggregator (it's an aggregate)
fun promoteToAdmin(newEmail: EmailAddress? = null): User {
val email = newEmail ?: this.email
if (!email.string.contains("@business_email.com"))
return Result.failure(
IllegalStateException(
"Admins should always have business email"
)
)
return User(
id = id,
email = email,
isAdmin = true,
)
}
// ...
}
除了聚合和值物件之外,有時您可能會看到域層的服務類別。它們用於通常不能放在聚合上的邏輯,但仍然是一種業務邏輯。例如:
class ShippingService {
fun calculatePrice(
order: Order,
shippingAddress: ShippingAddress,
shippingOption: ShippingOption,
): Price {
return if (order.product.country == shippingAddress.country)
order.price
else order.price + someFee
}
}
目前我們不會討論域級服務或聚合器的有用性或有效性。請記住這一點,直到我們將這些方法組合成一個整體。
但這幾乎是一切——實施可能因專案而異,我唯一用作任何事情的規則——是盡可能的不變性。
至於我看到最多的錯誤──開發人員不明白領域層不只是物理劃分,而是正確的心理模型。讓我解釋一下:
心智模型是系統中相互互動的各個部分的工作或結構的概念表示(簡單地說,就是使用程式碼的人如何感知程式碼)。它與物理模型的不同之處在於,物理模型涉及物理交互——例如,呼叫特定函數或實現模組,即手動完成的任何事情。
軟體設計中的一個常見問題是允許領域層了解資料儲存或來源,這違反了關注點分離原則。該領域的重點應該仍然放在業務邏輯上,獨立於資料來源。但是,您可能會遇到LocalUsersRepository
或RemoteUsersRepository
等範例,以及對應的用例(例如GetCachedUserUseCase
或GetRemoteUserUseCase
。雖然這可以解決特定問題,但它違反了該領域的思維模型,該模型應該與資料來源保持不可知。
這同樣適用於androidx.room
等框架上下文中的 DAO。它們不僅違反了資料來源的表述規則,而且還違反了任何框架的獨立性規則。
您的儲存庫/用例應該遠離資料來源,即使在實作不直接在網域模型中的情況下似乎沒問題。
貧血領域模型是領域驅動設計 (DDD) 中的常見反模式,其中領域物件(實體和值物件)被簡化為缺乏行為的被動資料容器,並且僅包含其 getter 和 setter(如果適用)。該模型被認為是“貧乏的”,因為它無法封裝應該存在於域本身內的業務邏輯。相反,這種邏輯通常被推入單獨的服務類別中,這會導致整體設計中出現一些問題。
為了更好地理解這個問題,貧血領域實體有什麼特別不好的地方?讓我們回顧一下:
在了解領域實體的能力時可能會出現複雜性:當邏輯分佈在控制器或用例之間時,很難追蹤實體的職責,從而減慢理解和除錯速度(另外,請考慮到除了IDE 之外,很難找到您放在某種控制器或用例上的業務邏輯,它使程式碼審查變得更加困難)。
封裝被破壞:實體只保存資料而沒有行為,將業務邏輯推入服務中,使結構更難以維護。這意味著您應該跨用例/控制器/等等調整邏輯,並確保業務邏輯實際上更改為正確的邏輯。
更難測試:當行為分散時,測試單一功能會變得更加困難,因為邏輯沒有分組在實體本身內部。
邏輯重複:業務規則經常在服務/用例之間重複,導致不必要的重複和更高的維護成本。
不良商業實體的範例:
sealed interface TimerState : State<TimerEvent> {
override val alive: Duration
override val publishTime: UnixTime
data class Paused(
override val publishTime: UnixTime,
override val alive: Duration = 15.minutes,
) : TimerState {
override val key: State.Key<*> get() = Key
companion object Key : State.Key<Paused>
}
data class ConfirmationWaiting(
override val publishTime: UnixTime,
override val alive: Duration,
) : TimerState {
override val key: State.Key<*> get() = Key
companion object Key : State.Key<ConfirmationWaiting>
}
data class Inactive(
override val publishTime: UnixTime,
) : TimerState {
override val alive: Duration = Duration.INFINITE
override val key: State.Key<*> get() = Key
companion object Key : State.Key<Inactive>
}
data class Running(
override val publishTime: UnixTime,
override val alive: Duration,
) : TimerState {
override val key: State.Key<*> get() = Key
companion object Key : State.Key<Running>
}
data class Rest(
override val publishTime: UnixTime,
override val alive: Duration,
) : TimerState {
override val key: State.Key<*> get() = Key
companion object Key : State.Key<Rest>
}
}
這些只是包含有關 TimeMates 狀態資料的容器。問題是:我們如何將這個貧乏的領域實體轉變為富裕的領域實體?
在這種情況下,對於狀態,我有一個不同的控制器來處理所有轉換和事件:
class TimersStateMachine(
timers: TimersRepository,
sessions: TimerSessionRepository,
storage: StateStorage<TimerId, TimerState, TimerEvent>,
timeProvider: TimeProvider,
coroutineScope: CoroutineScope,
) : StateMachine<TimerId, TimerEvent, TimerState> by stateMachineController({
initial { TimerState.Inactive(timeProvider.provide()) }
state(TimerState.Inactive, TimerState.Paused, TimerState.Rest) {
onEvent { timerId, state, event ->
// ...
}
onTimeout { timerId, state ->
// ...
}
// ...
}
除了好看之外,它違反了 DDD 原則——領域物件不僅應該代表資料,還應該代表行為。實體應該是這樣的:
sealed interface TimerState : State<TimerEvent> {
override val alive: Duration
override val publishTime: UnixTime
// Now business entity can react to events by itself;
// This functions are 'aggregates' from DDD;
fun onEvent(event: TimerEvent, settings: TimerSettings): TimerState
fun onTimeout(
settings: TimerSettings,
currentTime: UnixTime,
): TimerState
data class Paused(
override val publishTime: UnixTime,
override val alive: Duration = 15.minutes,
) : TimerState {
override val key: State.Key<*> get() = Key
companion object Key : State.Key<Paused>
override fun onEvent(
event: TimerEvent,
settings: TimerSettings,
): TimerState {
return when (event) {
TimerEvent.Start -> if (settings.isConfirmationRequired) {
TimerState.ConfirmationWaiting(publishTime, 30.seconds)
} else {
TimerState.Running(publishTime, settings.workTime)
}
else -> this
}
}
override fun onTimeout(
settings: TimerSettings,
currentTime: UnixTime,
): TimerState {
return Inactive(currentTime)
}
}
// ...
}
注意:有時一些邏輯被放入用例中,它可能不像在這個特定情況下那麼明顯。
透過查看這樣的實體,您可以更快地了解它的作用、它如何對域事件做出反應以及可能發生的其他事情。
但是,對於業務物件,有時您可能會覺得它沒有任何可以加入/移動到它們的行為。這是我的此類物件的示例:
data class User(
val id: UserId,
val name: UserName,
val emailAddress: EmailAddress?,
val description: UserDescription?,
val avatar: Avatar?,
) {
data class Patch(
val name: UserName? = null,
val description: UserDescription? = null,
val avatar: Avatar?,
)
}
有趣的註解:這是來自我的使用者網域的實際程式碼,存在這樣的問題。
潛在的問題是User
和Patch
是沒有業務邏輯的資料容器。首先,我只在用例中使用Patch
,這意味著它應該放在需要的地方。將此規則用於所有內容 - 聲明而不在定義它的層上使用,意味著您做錯了什麼。
至於User
,不需要建立聚合函數 - Kotlin 自動產生的複製方法已經足夠了,因為值物件已經過驗證,整個實體沒有自訂邏輯。
要了解有關此問題的更多訊息,您可以參考例如這篇文章。
我想補充一點,你應該盡量避免貧乏的領域實體,但同時不要強迫自己──如果沒有什麼可以聚合,就不要加入聚合。如果沒有什麼可加入的,就不要發明一種行為——KISS仍然適用。
通用語言是 DDD 中的關鍵概念,但常常被忽略。領域模型和程式碼應使用與業務利害關係人相同的語言,以減少誤解。未能使程式碼與領域專家的語言保持一致會導致業務邏輯與實際實作之間的脫節。
簡單來說,即使對於非程式設計師來說,名稱也應該易於理解。這對於涉及多個具有不同知識、技能和職責的團隊的大型專案尤其有用。
這是一件小事,但非常重要。我想補充一點,相同的概念在不同的領域不應該有不同的名稱——即使在一個團隊中也會令人困惑。
現在,讓我們繼續討論我在專案中使用的另一種方法 - 六邊形架構:
六邊形架構,也稱為連接埠和適配器,與傳統方法相比,在建立應用程式方面採取了不同的角度。這一切都是為了將核心領域邏輯與外部系統隔離,以便核心業務邏輯不依賴框架、資料庫或其他基礎設施問題。這種方法提高了可測試性和可維護性,並且它與DDD非常一致,因為重點仍然放在業務邏輯上。
有兩種類型的連接埠- 入站和出站。
>
入站連接埠是關於定義外界可以在核心域上執行的操作。
出站連接埠是關於定義網域需要從外部世界獲得的服務。
DDD 和 Hexagonal 架構在隔離策略上的差異在概念上是相同的,但第二個將其提升到了一個新的水平。六邊形架構定義了您應該如何與域模型進行通訊。
因此,例如,如果您需要存取外部服務或功能以在您的網域中執行某些操作,請執行以下操作:
interface GetCurrentUserPort {
suspend fun execute(): Result<User>
}
class TransferMoneyUseCase(
private val balanceRepository: BalanceRepository,
private val getCurrentUser: GetCurrentUserPort
) {
suspend fun execute(): ... {
val currentUser = getCurrentUser.execute()
val availableAmount = balanceRepository.getCurrentBalance(user.id)
// ... transfer logic
}
// ...
}
用例通常被視為入站端口,因為它們代表由外部世界發起的操作或互動。但是,命名和實作可能會有所不同。
在我的專案中,我不想引入其他術語,通常我只是建立一個我需要的外部儲存庫介面:
interface UserRepository {
suspend fun getCurrentUser(): Result<User>
// ... other methods
}
我將所有內容整合到一個儲存庫中,以避免不必要的類別建立,為大多數熟悉儲存庫概念的人提供更清晰的抽象。
您可能不會總是需要從其他功能或系統呼叫儲存庫。有時,您可能想要呼叫不同的業務邏輯來處理您需要的內容(可以更好),稱為用例。在這種情況下,通常具有與第一個範例不同的介面。
這是可視化效果:
注意:順便說一下,「特徵」的另一個術語是 DDD 中的「有界上下文」 。它們的意思幾乎相同。
定義和使用遵循上述架構的連接埠的範例:
// FEATURE «A»
// Outbound port to get the user from another feature (bounded context)
interface GetUserPort {
fun getUserById(userId: UserId): User
}
class TransferMoneyUseCase(private val getUserPort: GetUserPort) : TransferService {
override suspend fun transfer(
val userId: UserId, val amount: USDAmount
): Boolean {
val user = getUserPort.getUserById(request.userId)
if (user.balance >= request.amount) {
println("Transferring ${request.amount} to ${user.name}")
return true
}
println("Insufficient balance for ${user.name}")
return false
}
}
連接埠的實作是透過適配器完成的——它們基本上只是實現您的介面以與外部系統一起工作的連結。該層的命名可能會有所不同——從簡單的資料或整合到簡單的適配器。它們具有很強的互換性,並且符合特定的專案命名約定。此層通常實現其他域並使用其他連接埠來實現其所需的功能。
下面是GetUserPort
實作的範例:
// UserService is the Service from another feature (B)
// Adapters are usually in separate module because they're dependent on
// another domain, to avoid straight coupling.
class GetUserAdapter(private val getUserUseCase: GetUserUseCase) : GetUserPort {
override fun getUserById(userId: String): User? {
return userService.findUserById(userId)
}
}
因此,功能僅在資料/適配器層級耦合。它的優點是,無論外部系統發生什麼情況,您的域邏輯都保持不變。這是網域的連接埠實際上不應該符合外部系統想要的一切的另一個原因 - 適配器有責任處理它。我的意思是,例如,函數簽名可能與外部系統中使用的函數簽名不同,當然,只要它可以發揮作用即可。
另一件事是,考慮如何處理域類型很重要。功能很少與其他類型的功能完全隔離。例如,如果我們有一個名為User
業務物件和一個值物件UserId
,我們經常需要重複使用使用者的 ID 來儲存與使用者相關的資訊。這就需要找到一種在系統的不同部分重複使用這種類型的方法。
在理想的六角形架構中,不同的域應該獨立存在。這意味著每個域都應該有其使用的類型的特定定義。簡單來說,它要求您在每次需要時重新聲明這些類型。
它會產生大量重複、在不同網域之間轉換每種類型時的樣板、驗證問題(特別是如果需求隨著時間的推移而變化,您可能會忽略某些內容),並且對任何開發人員來說都是巨大的痛苦。
建議是,只要看不到好處,就不應該遵守所有這些規則。在處理這個問題時尋找一個折衷的辦法,我是如何處理的,我們將在下面的部分討論。
在完成對我使用的方法的解釋之後,我想繼續進行我的實際實現以及我如何處理減少不必要的樣板和抽象。
讓我們先定義我們討論的每種方法的關鍵思想:
乾淨的架構:依照職責將程式碼分為不同的層(域、資料、表示)
領域驅動設計:領域應僅包含業務邏輯,所有類型在其整個生命週期中應保持一致和有效。
六邊形架構:關於存取域和從域存取的嚴格規則。
它們在大多數情況下彼此完美匹配,這使其成為編寫優秀程式碼的關鍵。
TimeMates 特徵(不同域)的結構如下:
領域
資料(實現與儲存或網路管理相關的所有內容,包括具有資料來源的子模組)
- **database** (integration with SQLDelight, auto-generated DataSources)
- **network** (actually, I don't have it in TimeMates, because it's replaced with [TimeMates SDK](https://github.com/timemates/sdk), but if it is not, I would add it)
依賴項(與 Koin 的整合層)
演示(帶有 Compose 和 MVI 的 UI)
到目前為止,我喜歡這種結構,但您可能希望將 UI 與 ViewModel 區分開來,以便能夠在每個平台上使用不同的 UI 框架,我不打算這樣做,所以我保持原樣。但如果我將來面臨這樣的挑戰,這對我來說並不困難,因為我不依賴 ViewModel 中的 Compose。
我遇到的主要問題是我在實現六角形架構時遇到的樣板檔案——我複製並貼上了類型,這讓我想知道「我需要它嗎」?所以,我想出了以下規則:
我有在不同系統之間重複使用的通用核心類型,它是一種最需要類型的組合域。
只有當該類型在大多數網域中使用、存在重複驗證問題且根本不是複雜的結構(有時有例外,但通常不會很多)時,該類型才可以是通用的。
「複雜結構」是什麼意思?通常,您的網域需要另一個網域的類型,並不需要給定類型中描述的所有內容。例如,您可能希望在其值物件之間共用「使用者」類型,但在大多數情況下,其他網域不需要使用者類型中的所有內容,而可能只需要名稱和 ID 等。我試圖避免這種情況,即使核心網域類型中已經存在某些內容,我也寧願使用我的特定網域所需的資訊來建立一個不同的類型。但是,關於驗證,我幾乎共享所有值物件。
您可以透過不僅建立常見的核心類型,還可以為某些子網域(有界上下文)工作的特定區域建立類型,從而將這個想法擴展到更大的專案。
總而言之,我在一個公共模組中重複使用了具有相同驗證規則的值物件;我試圖不讓我的通用核心類型模組對所有內容都太大。總應該有一個折衷的辦法。
此外,在我的專案中,我的專案中沒有「入站連接埠」這個術語。我用用例完全替換它們:
「複雜結構」是什麼意思?通常,需要另一個網域類型的網域實際上並不需要給定類型中描述的所有內容。例如,您可能希望在其值物件之間共用「使用者」類型,但在大多數情況下,其他網域不需要使用者類型的所有內容,並且可能只需要名稱和 ID。我試圖避免這種情況,即使核心網域類型中已經存在某些內容,我也寧願使用我的特定網域所需的資訊來建立不同的類型。但至於驗證,我幾乎共享所有值物件。
您可以透過不僅建立常見的核心類型,還可以為某些子網域(有界上下文)工作的特定區域建立類型,從而將這個想法擴展到更大的專案。
總而言之,我在一個公共模組中重複使用了具有相同驗證規則的值物件;我試圖不讓我的通用核心類型模組對所有內容都太大。總應該有一個折衷的辦法。
此外,在我的專案中,我的專案中沒有「入站連接埠」這個術語。我用用例完全替換它們:
class GetTimersUseCase(
private val timers: TimersRepository,
private val fsm: TimersStateMachine,
) {
suspend fun execute(
auth: Authorized<TimersScope.Read>,
pageToken: PageToken?,
pageSize: PageSize,
): Result {
val infos = timers.getTimersInformation(
auth.userId, pageToken, pageSize,
)
val ids = infos.map(TimersRepository.TimerInformation::id)
val states = ids.map { id -> fsm.getCurrentState(id) }
return Result.Success(
infos.mapIndexed { index, information ->
information.toTimer(
states.value[index]
)
},
)
}
sealed interface Result {
data class Success(
val page: Page<Timer>,
) : Result
}
}
注意:這是 TimeMates 後端的範例
它不會違反六邊形架構或 DDD,這使其成為定義外部世界如何存取您的網域的好方法。它與入站端口具有相同的含義和行為。
至於出站端口,我做了與前面範例中提供的相同的操作。
在我的專案中,我更喜歡保持實用性。雖然理論和抽像很有用,但它們可能會使簡單的事情變得過於複雜。這就是為什麼我結合了乾淨架構、DDD 和六邊形架構的優點,但又不會過於嚴格地嚴格遵循它們。使用批判性思維來確定您的實際需求以及它為何對您的專案有利,而不是盲目地遵循建議。
如果您喜歡這篇文章,我建議您查看我的其他社交活動,我在其中分享我的想法、文章和整體更新: