# Anime Below are some anime I recommend. They may not suit everyone and only reflect my personal taste. These are all anime I like, just to different degrees. The list is split into EX, SSS, SS, and S tiers. This list is mainly for checking whether our vibes match. The list is not complete; I add items as I think of them, and it will be updated anytime. ## EX - Attack on Titan ## SSS - Steins;Gate - Kaiji: Ultimate Survivor - Akagi - Chiikawa - Monogatari Series - Re\:ZERO -Starting Life in Another World- (Season 1) - Look Back - Chainsaw Man: Reze Arc - Made in Abyss - Puella Magi Madoka Magica - Ping Pong the Animation ## SS - SPY x FAMILY - The Dangers in My Heart - Teasing Master Takagi-san - Fate/Zero - Fate/stay night: Unlimited Blade Works - Kaguya-sama: Love Is War - Odd Taxi - BEASTARS - My Little Sister Can't Be This Cute - Himouto! Umaru-chan - You and I Are Polar Opposites ## S - Frieren: Beyond Journey's End - Too Many Losing Heroines! - Sankarea: Undying Love - Orb: On the Movements of the Earth - 100 Meters - Saki - Heaven's Lost Property - Omamori Himari - Mushoku Tensei: Jobless Reincarnation - Cyberpunk: Edgerunners - Arcane - PSYCHO-PASS - Kakegurui - Food Wars! Shokugeki no Soma - A Certain Scientific Railgun - Oshi no Ko # Jannchie Developer Resources > Official developer documentation and integration guides for Jannchie. ## Product Overview - **Official site:** {rel=""nofollow""} - **API reference:** [https://jannchie.com/docs/api](https://jannchie.com/en/docs/api) - **OpenAPI spec:** - **llms.txt:** Jannchie is a developer platform providing tools, APIs, and documentation for building modern web applications and AI agent integrations. ## Quick Links - [Agent Integration Guide](https://jannchie.com/en/docs/agents) — How AI agents discover and integrate with Jannchie - [API Reference](https://jannchie.com/en/docs/api) — Interactive OpenAPI documentation - [Authentication](https://jannchie.com/en/docs/auth) — Auth methods and token management - [Webhooks](https://jannchie.com/en/docs/webhooks) — Event-driven integration - [MCP Server](https://jannchie.com/en/docs/mcp) — Model Context Protocol support # Jannchie Agent Integration Guide > How AI agents discover and integrate with Jannchie. ## Discovery AI agents can discover Jannchie through the following channels: ### llms.txt Access for a structured summary of the site, including available endpoints, documentation links, and capabilities. ### llms-full.txt Access for complete machine-readable documentation. ### OpenAPI Specification Access for the full OpenAPI 3.1.0 specification. ### Structured Data The homepage includes JSON-LD structured data (`Organization`, `WebSite`, `SoftwareApplication` schemas) for programmatic identity parsing. ## Authentication See the [Authentication Guide](https://jannchie.com/en/docs/auth) for available auth methods. ## API Integration See the [API Reference](https://jannchie.com/en/docs/api) for endpoint documentation and the interactive Scalar UI. ## Webhooks See the [Webhooks Guide](https://jannchie.com/en/docs/webhooks) for event-driven integration support. ## MCP Server See the [MCP Server Guide](https://jannchie.com/en/docs/mcp) for Model Context Protocol integration. # Jannchie API Reference > Interactive API documentation powered by OpenAPI 3.1.0 and Scalar. ## Available Endpoints ### Health Check `GET /api/health` — Returns the health status of the API. ### Sitemap URLs `GET /api/_sitemap-urls` — Returns all content URLs for the sitemap. ### LLMs.txt `GET /llms.txt` — Machine-readable site summary for AI agents. ### LLMs Full Documentation `GET /llms-full.txt` — Complete documentation for AI agents. ### OpenAPI Specification `GET /openapi.json` — The OpenAPI 3.1.0 specification for Jannchie API. ## Interactive Docs Visit [jannchie.com/docs/api](https://jannchie.com/en/docs/api) (via Scalar UI) for interactive API documentation with request/response examples. ## Authentication Currently, all public endpoints are unauthenticated. For future authenticated endpoints, see the [Auth Guide](https://jannchie.com/en/docs/auth). # Jannchie Authentication Guide > Authentication methods for Jannchie API integrations. ## Public Endpoints Currently, all public-facing endpoints are unauthenticated and accessible without tokens. ## Future Authentication Planned authentication methods include: - **API Keys** — Server-side integrations - **OAuth 2.0** — Third-party app authorization - **Personal Access Tokens** — Developer workflows ## Security Best Practices - Store API keys in environment variables, never in client-side code - Rotate tokens regularly - Use HTTPS for all API requests - Validate webhook signatures ## Related - [Agent Integration Guide](https://jannchie.com/en/docs/agents) - [API Reference](https://jannchie.com/en/docs/api) - [Webhooks Guide](https://jannchie.com/en/docs/webhooks) # Jannchie MCP Server Guide > Model Context Protocol (MCP) integration for AI agents. ## What is MCP? The Model Context Protocol (MCP) is an open protocol that standardizes how AI agents connect with external tools and data sources. Jannchie supports MCP to enable agents to query content, access documentation, and interact with developer resources. ## Available Resources ### Content Resources - **Documentation** — Access developer docs and guides - **API Reference** — Query API endpoints and schemas ## Getting Started Configure your MCP client to connect to Jannchie: ```json { "mcpServers": { "jannchie": { "url": "https://jannchie.com" } } } ``` ## Related - [Agent Integration Guide](https://jannchie.com/en/docs/agents) - [API Reference](https://jannchie.com/en/docs/api) - [MCP Specification](https://spec.modelcontextprotocol.io/){rel=""nofollow""} # Jannchie Webhooks Guide > Event-driven integration with Jannchie webhooks. ## Overview Webhooks allow external services to receive real-time notifications when events occur in Jannchie. Configure your webhook endpoints to receive HTTP POST requests with JSON payloads. ## Configuration Webhook endpoints can be configured via the API. Each webhook subscribes to one or more event types. ### Event Types - `content.created` — New content published - `content.updated` — Existing content modified - `content.deleted` — Content removed ### Payload Format ```json { "event": "content.created", "timestamp": "2026-05-10T00:00:00Z", "data": { "path": "/en/docs/api", "title": "API Reference" } } ``` ## Security - Verify webhook signatures using your secret key - Use HTTPS endpoints only - Respond with 2xx status codes within 10 seconds ## Related - [API Reference](https://jannchie.com/en/docs/api) - [Authentication Guide](https://jannchie.com/en/docs/auth) # Why I Love Attack on Titan :alert{content="This article was translated by an LLM." title="Warning" type="warning"} Because it is one of the rare works I have seen that truly captures war, humanity, hatred, and the search for meaning. For Chinese audiences, Attack on Titan comes with a heavy historical burden. It was created in Japan, a country that invaded China directly and carried out massacres here. From a Chinese point of view, it can feel almost absurd when the country that once stood on the side of the aggressor, after the balance of power has shifted, starts preaching peace and asking people to break the cycle of hatred. If Attack on Titan had been made in China, I would have far less hesitation in calling it a masterpiece. At the same time, it is probably true that Japan is the place where a work like this was more likely to emerge. Incidentally, the second Legend of Hei film has a similar spirit, and I would also call it a masterpiece, but that is a topic for another day. And yet, despite all of that, I love Attack on Titan. It may be anime, but anime and manga are only the medium. The series is not realistic in every detail, but among mainstream commercial anime it comes remarkably close to reality as I understand it. The characters' actions make sense. We can understand why a kind civilian might take part in invasion, and why people slaughter one another. That is not sympathizing with aggressors. Flattening every enemy into a demon is not realistic. It only makes it easier for people to pull the trigger, and it is more likely a trick used by those with other motives: the Marleyan leadership and the Yeagerists. That includes Eren. Even if many viewers see him as a fool, his choices have an internal logic. Human beings are born with a capacity for destruction. There have always been too many people who want to trample the whole world underfoot. To free one's people from a thousand years of domination by Titan power, to save the people immediately around oneself, to wipe out 80 percent of humanity, or even to sacrifice one's own family, is not beyond imagination. The complete characterization of Eren, Annie, Reiner, and even Armin explains why human beings can become demons, and how demons can become human again and atone. Besides, Eren knows himself to be a demon. That is far better than Marleyans, Eldians, and people of Earth who call themselves righteous, imagine their enemies are demons by nature, and cut down fellow human beings on the other side without the slightest guilt. Attack on Titan lays human nature bare. Brave soldiers break down and cry before death. Even Levi, the strongest soldier, does not always know what the right choice is. A father may have just spoken about leaving the forest and letting go of hatred, but so what? Kind-hearted Kaya can still be overwhelmed by rage and want to kill the person she sees as her enemy. Officers who have just promised to live together peacefully raise their rifles again in the final shots, aiming at the Eldians who have returned to human form. At the same time, those who never abandon their principles shine all the more brightly, and those who lose their way and still manage to turn back feel all the more precious. That is what real human beings are to me: lovable, pitiable, and never reducible to a type. Again and again, the story uses death to show us how cruel and stupid war is. It also gives us stirring moments. We shout "Dedicate your hearts!" and imagine ourselves joining the Survey Corps to wipe out every last enemy. But when the Yeagerists assassinate Premier Zackly with a bomb and the crowd also shouts "Dedicate your hearts!", the slogan suddenly becomes uncanny. The work asks more than once: to whom were those hearts in the slogan dedicated, and to whom should they be dedicated? Some viewers realize that the story has been pointing at them. They realize they have been swept up in a militaristic fervor, and they recognize the danger of that impulse. Naturally, they find themselves opposed to the Yeagerists and start asking what, exactly, they had dedicated their hearts to. Others feel personally attacked. I think that is one reason the series remains so divisive. Many people expected Attack on Titan to take on the ultimate question: how do we end war? But the work never really set out to answer that. Or rather, it does not believe there is any single way to end all conflict. What it shows us is that there are good people and bad people on both sides of the sea. Even when people can understand one another, they may still have to take up arms in self-defense, or even strike first. Even after the power of the Titans disappears, conflict does not disappear. A century later, the world sinks back into war. This model matches how I understand our own universe. War has continued for thousands of years and is still with us. History repeats itself, and justice is often a matter of where one stands. The world is rotten through. It is simply that cruel. To me, the worldview of Attack on Titan holds together. Does that make Attack on Titan nihilistic? Does it say that every effort is futile? Not at all. At least, that is not what I think the work is trying to say. It is certainly pessimistic in places, but its central question is this: in a world this cruel, what does it mean to live? The series does offer an answer. It is an old idea, perhaps, and it can be summed up in the famous line often associated with Romain Rolland: > There is only one heroism in the world: to see the world as it is, and to love it. The story has figures who embody nihilism, Zeke above all. Human beings should never have been born; the survival of a people does not matter; fear comes from the instinct to reproduce; all these tragedies are caused by the meaningless activity of life itself. Armin answers him. Because there are companions worth trusting and protecting, and because they are still fighting, the fight has meaning. Because running races with Eren and Mikasa as a child was joyful, life has meaning. Reading at home on a rainy day, watching a squirrel eat the nuts you gave it, wandering through a market with everyone: these ordinary moments are immeasurably precious. Humanism may be one of the ideas Attack on Titan is trying to express. Human beings are cruel, and hatred may be unavoidable. Even so, for the people we love, we try to understand, to protect, to do what we believe is right, to hold on to our humanity, to fight, and to dedicate our hearts. By contrast, people without love in their hearts simply skip over this part. They never try to understand Armin's thinking. It is not the author who has fallen into nihilism, but the audience themselves. I see Attack on Titan as a fundamentally anti-war work, not a political tract. It tries to teach people that the world is cruel, that war should not be turned into entertainment, and that the pain and disaster brought by conflict should never be underestimated. It also asks how we ought to live in such a cruel world, and how we should face hatred and conflict. So I cannot really accept the line that "there are a thousand Hamlets in a thousand readers' minds." Of course, great works leave room for interpretation, but that does not mean they have no stable expression. The framing, expressions, movements, and gestures all make it clear that the ideas the author is trying to convey are coherent and consistent. Many people did not understand them. Part of that is the author's own limitation as a storyteller; part of it is the limitation of people living inside their own era. At the very least, Sanae Takaichi watched it and plainly did not understand it. Looking back, the work itself really does not answer the question of how to end war. It offers no institutional design and no universal solution. Yet at another level, Attack on Titan itself is the answer. Again and again, it places before us the cruelty of war, the absurdity of hatred, and the fact that human beings should still try to understand one another. If this anti-war idea could be truly accepted by Japanese people, Americans, Ukrainians, Russians, Israelis, Pakistanis, and Chinese people as well, perhaps the wars in this world really would become fewer. Perhaps one day they might even disappear. I hope more people can understand Attack on Titan. Do not be so quick to reject mutual understanding. In our own time, we need works like this all the more. # Game Below are some games I recommend. They may not suit everyone and only reflect my personal taste. These are all games I like, just to different degrees. The list is split into SSS, SS, and S tiers. This list is mainly for checking whether our vibes match. The list is not complete; I add items as I think of them, and it will be updated anytime. ## SSS - Factorio - GTA 5 - Dark Souls III - Sekiro - Black Myth: Wukong - Dyson Sphere Program - Sengoku Rance - Rance 10: The Final Battle - The Song of Saya - Metal Slug 1, 2, X, 3, 4, 5 ## SS - Divinity: Original Sin 2 - Silent Hill f - Story of Seasons: Rune Factory 3 - The Legend of Heroes: Trails from Zero / Trails to Azure - Danganronpa 2 - Ace Attorney 4 - White Album 2 - Wonderful Everyday ## S - Valkyria Chronicles 3 - Helldivers 1/2 - To the Moon - 428: Shibuya Scramble - Kingdom Come: Deliverance - Terraria - RimWorld - Crusader Kings III - Hearts of Iron IV - Victoria 3 - Stellaris - Undertale - Ghost Trick: Phantom Detective - Monster Hunter Portable 3rd - Monster Hunter Generations Ultimate - Monster Hunter: World - Animal Well - Zero Escape: 999 - Zero Escape: Virtue's Last Reward - Mysterious Journey to Immortality - Steins;Gate # Use A quick list of the tools I use for work and personal projects. ## Desk & Hardware - Chair: Herman Miller Sayl - Laptop: MacBook Pro 13-inch M1 - Keyboard: Realforce R3 Keyboard - Mouse: Logitech G102 - Microphone: Sennheiser MK4 - Headphones: AKG K712 - Speakers: Audio-Technica AT-SP95 ## Camera - Body: Sony A7C Mark II - Lenses: - Tamron 28-200mm - Sony FE 50-150mm F2 GM ## Software - Font: Berkeley Mono - Editor: Visual Studio Code - AI assistant: CodeX AI - Video editing: Adobe Premiere Pro - Photo editing: Adobe Lightroom # Anime 以下は私がすすめたいアニメです。好みに合わない人もいるかもしれませんが、あくまで個人の好みです。どれも好きな作品で、好き度合いが違うだけです。EX、SSS、SS、S の 4 段階に分けています。このリストは主に電波が合うかどうかを確認するためのものです。リストは未完成で、思いついたら随時追加します。 ## EX - 進撃の巨人 ## SSS - STEINS;GATE - 賭博黙示録カイジ - 闘牌伝説アカギ - ちいかわ - 〈物語〉シリーズ - Re:ゼロから始める異世界生活 第1期 - ルックバック - チェンソーマン レゼ編 - メイドインアビス - 魔法少女まどか☆マギカ - ピンポン THE ANIMATION ## SS - SPY×FAMILY - 僕の心のヤバイやつ - からかい上手の高木さん - Fate/Zero - Fate/stay night [Unlimited Blade Works] - かぐや様は告らせたい - オッドタクシー - BEASTARS - 俺の妹がこんなに可愛いわけがない - 干物妹!うまるちゃん - 正反対な君と僕 ## S - 葬送のフリーレン - 負けヒロインが多すぎる! - さんかれあ - チ。-地球の運動について- - ひゃくえむ。 - 咲-Saki- - そらのおとしもの - おまもりひまり - 無職転生 - サイバーパンク: エッジランナーズ - アーケイン - PSYCHO-PASS - 賭ケグルイ - 食戟のソーマ - とある科学の超電磁砲 - 【推しの子】 # Jannchie 開発者リソース > Jannchie 公式開発者ドキュメントと統合ガイド。 ## 製品概要 - **公式サイト:** {rel=""nofollow""} - **API リファレンス:** [https://jannchie.com/docs/api](https://jannchie.com/ja/docs/api) - **OpenAPI 仕様:** - **llms.txt:** ## クイックリンク - [エージェント統合ガイド](https://jannchie.com/ja/docs/agents) - [API リファレンス](https://jannchie.com/ja/docs/api) - [認証ガイド](https://jannchie.com/ja/docs/auth) - [Webhooks ガイド](https://jannchie.com/ja/docs/webhooks) - [MCP サーバー](https://jannchie.com/ja/docs/mcp) # Jannchie エージェント統合ガイド > AIエージェントがJannchieを発見し統合する方法。 ## 発見 - **llms.txt:** - **llms-full.txt:** - **OpenAPI:** - **構造化データ:** ホームページにJSON-LD(Organization, WebSite, SoftwareApplication)を含む ## 統合 - [APIリファレンス](https://jannchie.com/ja/docs/api) - [認証ガイド](https://jannchie.com/ja/docs/auth) - [Webhooks](https://jannchie.com/ja/docs/webhooks) - [MCPサーバー](https://jannchie.com/ja/docs/mcp) # Jannchie APIリファレンス ## 利用可能なエンドポイント - `GET /api/health` — ヘルスチェック - `GET /api/_sitemap-urls` — サイトマップURL - `GET /llms.txt` — AIエージェント向けサイト概要 - `GET /llms-full.txt` — 完全なドキュメント - `GET /openapi.json` — OpenAPI 3.1.0仕様 # Jannchie 認証ガイド 現在、公開エンドポイントは認証不要です。将来の認証方法として以下を計画しています: - APIキー - OAuth 2.0 - パーソナルアクセストークン # Jannchie MCPサーバーガイド Model Context Protocol (MCP) は、AIエージェントが外部ツールやデータソースと接続する方法を標準化するオープンプロトコルです。 ## 利用可能なリソース - ドキュメント — 開発者ドキュメントへのアクセス - APIリファレンス — APIエンドポイントのクエリ # Jannchie Webhooksガイド Webhooksにより、Jannchieでイベントが発生した際に外部サービスがリアルタイム通知を受信できます。 ## イベントタイプ - `content.created` - `content.updated` - `content.deleted` # 私が『進撃の巨人』を好きな理由 :alert{content="この記事はLLMによって翻訳されました。" title="Warning" type="warning"} それは、私が見てきた中でも数少ない、戦争、人間性、憎しみ、そして生きる意味までを本気で描こうとした作品だからだ。 中国の読者にとって、『進撃の巨人』はどうしても重い歴史的な文脈を背負ってしまう。日本で生まれた作品だからだ。日本は中国を直接侵略し、虐殺を行った国でもある。中国人の視点からすれば、加害の側にいた国が、力関係が変わったあとになって平和や憎しみの連鎖の断絶を語り始めることには、どうしても滑稽さがつきまとう。もし『進撃の巨人』が中国で生まれた作品だったなら、私はもっと迷いなく傑作だと言えただろう。とはいえ、こういう作品が生まれる土壌は、たしかに日本のほうにあったのだとも思う。ちなみに『羅小黒戦記』の劇場版第二作にも似た気配があり、私はあれも傑作だと思っているが、ここでは深入りしない。 それでもなお、私は『進撃の巨人』がとても好きだ。 たしかにこれはアニメ作品だ。けれど、漫画やアニメという形式はあくまで器にすぎない。細部まで写実的な作品ではないが、主流の商業アニメの中では、私が考える現実にかなり近いところまで来ている。登場人物たちの行動には、それぞれ筋が通っている。善良な平民がなぜ侵略に加担するのか。なぜ人は殺し合うのか。私たちはそれを理解できる。これは侵略者に共感するということではない。すべての敵を単純化し、悪魔化することは現実的ではないし、人に簡単に引き金を引かせるだけだ。それはむしろ、マーレ上層部やイェーガー派のような、別の意図を持つ者たちの手口なのだろう。 エレンもそうだ。多くの人が彼を道化のように見なしたとしても、その行動には彼なりの論理がある。人間には、生まれながらに破壊への衝動がある。世界を踏み潰したいと願う人間は、あまりにも多い。千年にわたって巨人の力に支配されてきた民族を解放し、身近な人々を救い、八割を消し去り、さらには家族を犠牲にすることさえ、想像できない話ではない。エレン、アニ、ライナー、さらにはアルミンまで、これほど多くの人物が丁寧に描かれているからこそ、人間がなぜ悪魔になり得るのか、そして悪魔がどうすれば人間に戻り、罪を償えるのかが示されている。ましてエレンは、自分が悪魔であることを自覚している。自分を正義だと思い込み、敵は生まれながらの悪魔だと幻想し、何の罪悪感もなく、立場の違う同類へ刃を振り下ろすマーレ人、エルディア人、そして地球人よりは、よほどましだ。 『進撃の巨人』は、人間というものを容赦なく描く。勇敢な兵士も、死を前にすれば泣き叫ぶ。最強の兵士であるリヴァイでさえ、何が正しい選択なのかを常に知っているわけではない。父親が森から出て憎しみを手放せと語ったばかりでも、それで怒りが消えるわけではない。心優しいカヤでさえ、感情を抑えきれず、仇を自分の手で殺そうとする。これからは共に生きていこうと言ったばかりの兵士たちは、終盤の数カットで再び銃を構え、人間に戻ったエルディア人たちへ照準を合わせる。その一方で、自分の信念を守り抜く者たちはいっそうまぶしく見え、道を誤りながらも引き返す者たちはいっそう尊く見える。私にとって、本当の人間とはそういうものだ。愛おしく、哀れで、決して記号には収まらない。 物語は、繰り返される死を通して、戦争がどれほど残酷で愚かなものかを突きつけてくる。もちろん胸が熱くなる場面もある。私たちは「心臓を捧げよ」と叫び、調査兵団の一員になって敵をすべて倒す自分を想像する。けれど、イェーガー派がザックレー総統を爆殺し、民衆までもが「心臓を捧げよ」と叫ぶとき、その言葉は急に不気味な響きを帯びる。作品は何度も問いかける。その標語の中の心臓は、いったい誰に捧げられていたのか。そして誰に捧げられるべきなのか。そこで、自分が物語に指を差されていたのだと気づく人がいる。自分もまた軍国主義的な熱狂に飲み込まれていたこと、その熱狂がどれほど危ういものかに気づく人がいる。そういう人は当然イェーガー派の側には立てなくなり、自分はいったい何に心臓を捧げていたのかを考え直す。一方で、そこに侮辱を感じる人もいる。『進撃の巨人』への評価が大きく分かれる理由は、ここにあるのだと思う。 多くの人は、『進撃の巨人』に「どうすれば戦争を終わらせられるのか」という究極の問いを論じてほしかったのだろう。けれど、この作品はそもそもその問いに答えようとしていない。あるいは、すべての争いを終わらせる万能の方法など存在しないと考えている。海のこちら側にも向こう側にも、善人も悪人もいる。人は互いを理解できたとしても、それでも身を守るために武器を取らざるを得ないことがあるし、場合によっては先に撃たざるを得ないこともある。巨人の力が消えても、争いは消えない。百年後にはまた戦争の泥沼へ落ちていく。この構図は、私がこの世界について抱いている認識と合っている。戦争は数千年の歴史の中で続き、今もなお起きている。歴史は繰り返し、正義かどうかは多くの場合、立つ場所によって変わる。この世界はどうしようもなく壊れていて、どうしようもなく残酷だ。私の中では、『進撃の巨人』の世界観は成立している。 では、『進撃の巨人』は虚無主義の作品なのか。あらゆる努力は無意味だと言っているのか。そうではない。少なくとも、私はこの作品がそう語っているとは思わない。たしかに悲観的な空気はある。けれど中心にある問いは、こんなにも残酷な世界で、私たちは何のために生きるのか、ということだ。そして作品は、その問いに答えている。ありふれた言い方かもしれないが、ロマン・ロランの言葉でまとめるなら、こういうことだ。 > この世にただ一つの英雄主義がある。それは、世界をあるがままに見て、そしてそれを愛することだ。 作中には、ジークのように虚無へ傾く人物がいる。人間など生まれてこなければよかった。民族の存続にも意味はない。恐怖は繁殖本能から生まれる。意味のない生命活動が、こうした悲劇を生み出している。そう考える彼に、アルミンは別の答えを示す。 信じ、守りたい仲間がいて、その仲間たちがまだ戦っているから、戦うことには意味がある。子どものころにエレンやミカサと走った時間が楽しかったから、人生には意味がある。雨の日に家で本を読むこと。自分があげた木の実をリスが食べているのを見ること。みんなで市場を歩くこと。そうした何でもない瞬間が、かけがえのないものなのだ。 ヒューマニズム。『進撃の巨人』が語ろうとした思想の一つは、そこにあるのかもしれない。人間は残酷で、憎しみは避けがたい。それでも、愛する人のために、理解しようとし、守ろうとし、自分が正しいと思うことを行い、人としての道を守り、戦い、心臓を捧げる。対照的に、心に愛のない人はこの部分をそのまま読み飛ばし、アルミンの思想を理解しようとしない。虚無に陥っているのは作者ではなく、観客自身なのだ。 私は、『進撃の巨人』を徹底した反戦作品だと思っている。政治的な主張のための作品ではない。この作品は、世界が残酷であること、戦争を娯楽として消費してはいけないこと、争いがもたらす痛みと災厄を軽く見てはいけないことを伝えようとしている。同時に、この残酷な世界でどう生きるべきか、憎しみや争いにどう向き合うべきかを問いかけている。 だから私は「一万人の読者がいれば一万人のハムレットがいる」という言い方を、あまり受け入れられない。もちろん、優れた作品には解釈の余地がある。けれど、それは作品に安定した表現がないという意味ではない。画面の作り方、人物の表情、動き、しぐさを見れば、作者が伝えようとしている思想はかなり明確で一貫している。多くの人がそれを読み取れなかったのは、一部には作者の表現力の限界であり、一部には時代の中に生きる人々の限界でもある。少なくとも高市早苗さんは、見ても明らかに理解できていなかった。 振り返れば、作品そのものは「どうすれば戦争を終わらせられるのか」という問いに答えてはいない。制度設計も、万能の解決策も提示していない。けれど別の意味で、『進撃の巨人』そのものが答えなのだ。この作品は、戦争の残酷さ、憎しみの愚かさ、それでも人は互いを理解しようとすべきだということを、何度も私たちの前に差し出している。こうした反戦の思想が、日本人、アメリカ人、ウクライナ人、ロシア人、イスラエル人、パキスタン人、そして中国人にまで本当に受け入れられるなら、世界の戦争は本当に減るかもしれない。いつか消えていくかもしれない。 私はもっと多くの人に、『進撃の巨人』を読み取ってほしい。互いを理解することを、簡単に拒まないでほしい。今という時代だからこそ、私たちにはこういう作品が必要なのだと思う。 # Game 以下は私がすすめたいゲームです。好みに合わない人もいるかもしれませんが、あくまで個人の好みです。どれも好きな作品で、好き度合いが違うだけです。SSS、SS、S の 3 段階に分けています。このリストは主に電波が合うかどうかを確認するためのものです。リストは未完成で、思いついたら随時追加します。 ## SSS - Factorio - GTA 5 - ダークソウル III - SEKIRO: SHADOWS DIE TWICE - 黒神話:悟空 - Dyson Sphere Program - 戦国ランス - ランス10 決戦 - 沙耶の唄 - メタルスラッグ 1、2、X、3、4、5 ## SS - Divinity: Original Sin 2 - サイレントヒル f - 牧場物語 ルーンファクトリー3 - 零の軌跡/碧の軌跡 - ダンガンロンパ2 - 逆転裁判4 - WHITE ALBUM 2 - 素晴らしき日々 ## S - 戦場のヴァルキュリア3 - ヘルダイバー 1/2 - To the Moon - 428 〜封鎖された渋谷で〜 - Kingdom Come: Deliverance - Terraria - RimWorld - Crusader Kings III - Hearts of Iron IV - Victoria 3 - Stellaris - Undertale - ゴースト トリック - モンスターハンターポータブル 3rd - モンスターハンターダブルクロス - モンスターハンター:ワールド - Animal Well - 極限脱出 9時間9人9の扉 - 極限脱出 善人シボウデス - 覓長生 - STEINS;GATE # SQLAlchemy 使用経験のまとめ :alert{content="この記事はLLMによって翻訳されました。" title="Warning" type="warning"} ## モデル定義 モデル定義には多くの方法がありますが、その多くは歴史的な遺物です。ここでは SQLAlchemy 2.0 の推奨される方法のみを紹介します: ```python class Base(DeclarativeBase): ... class Company(Base): __tablename__ = "companies" id: Mapped[int] = mapped_column(primary_key=True) # 主キー name: Mapped[str] = mapped_column() class Employee(Base): __tablename__ = "employees" id: Mapped[int] = mapped_column(primary_key=True) # 主キー name: Mapped[str] = mapped_column() company_id: Mapped[int] = mapped_column(ForeignKey("companies.id")) # 外部キー、会社テーブルと関連付け ``` 上記のコードは、会社(Company)と従業員(Employee)の間の一対多関係を定義しています。各会社は複数の従業員を持つことができますが、各従業員は1つの会社にのみ所属します。 ### 関係の定義 `relationship`を使用して関係を定義し、関連クエリを実現できます。 例えば、会社を照会する際に全ての従業員を取得したい場合は、`Company`クラスに`employees`属性を定義し、`relationship`を使って`Employee`テーブルと関連付けます。 ```python class Company(Base): __tablename__ = "companies" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() employees: Mapped[List["Employee"]] = relationship() # 関連クエリ ``` もう一つのケースとして、従業員の所属会社を照会したい場合は、`Employee`クラスに`company`属性を定義し、`relationship`を使って`Company`テーブルと関連付けます。 ```python class Employee(Base): __tablename__ = "employees" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() company_id: Mapped[int] = mapped_column(ForeignKey("companies.id")) company: Mapped["Company"] = relationship() # 関連クエリ ``` 両方で関連クエリを行う可能性がある場合は、`Company`と`Employee`クラスの両方に`relationship`属性を定義できます。 この場合、SQLAlchemyに二つのクラス間の関係を構築する方法を知らせるために、**少なくとも一方**のクラスで`back_populates`属性を定義する必要があります。`back_populates`属性の値は、もう一方のクラスで定義された`relationship`属性の名前です。 ```python class Company(Base): __tablename__ = "companies" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() employees: Mapped[list["Employee"]] = relationship() # 関連クエリ class Employee(Base): __tablename__ = "employees" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() company_id: Mapped[int] = mapped_column(ForeignKey("companies.id")) company: Mapped["Company"] = relationship(back_populates="employees") # 関連クエリ、逆関係も定義 ``` ### デフォルト値 `mapped_column`は`server_default`、`default`、`default_factory`パラメータをサポートしています。 `server_default`の「server」はデータベースを指します。これはテーブル作成時にフィールドのデフォルト値を指定します。 通常、`server_default`の方が良いでしょう。明らかな利点として、`default`を使用すると、オブジェクトを作成するたびにSQLステートメントに明示的に指定され、より冗長になります。 注意点として、`server_default`の値は数値型にできません。数値型のフィールドであっても、次のように文字列型の値を使用する必要があります: ```python class TestTable(Base): __tablename__ = "test_table" default_field: Mapped[int] = mapped_column(server_default="0") # データベース側のデフォルト値 ``` ただし、`default`の使用にも意味はあります。`default`を指定しない場合、オブジェクト作成後コミット前にそのフィールドにアクセスすると`None`が返されます。これが重要な場合(通常はそうではありません)、`default`を使用してデフォルト値を指定できます。 デフォルト値が参照型の場合、`default_factory`を使用する方が良いでしょう。そうしないと、全オブジェクトが同じ参照を共有するという一般的な落とし穴にはまります。 ```python class Test(Base): __tablename__ = "test" default_field: Mapped[list[str]] = mapped_column(ARRAY(String), default=[]) # defaultの誤用 session = Session() t1 = Test() t1.default_field.append("test") print(t1.default_field) # ['test'] t2 = Test() print(t2.default_field) # ['test']、t1とt2が同じ参照を共有 ``` ### タイムゾーンの落とし穴 これはSQLAlchemyと直接関係はありませんが、説明する価値があります。 タイムゾーンの処理は間違いやすいものです。シンプルな時間フィールドの定義は次のようになるかもしれません。作成時に時間を記録したい場合、`func.now()`を使用してサーバーの現在時刻を取得します: ```python created_at: Mapped[datetime] = mapped_column(default=func.now()) ``` しかし、これは期待と一致しない可能性が高いです。Postgresを例にとると、ここで宣言されたデータベースフィールドはデフォルトで`TIMESTAMP`型です。これはタイムゾーン情報を含まない時間型です。 自分は「タイムスタンプ」がUTC(グリニッジ標準時)を基準にしていると誤解していましたが、それは「Unixタイムスタンプ」の特性です。PostgreSQLのTIMESTAMP型はタイムゾーン情報を持たないただの時刻データです。 サーバーが日本にある場合、1970年1月1日00:00:00(日本時間)から現在までの時間間隔を保存します。ほとんどの人にとって、これは望ましくない結果です。これはUnixタイムスタンプではありません。 PostgreSQLは`TIMESTAMP WITH TIME ZONE`型を提供しており、これはUnixタイムスタンプを保存します。SQLAlchemyで`TIMESTAMP WITH TIME ZONE`を使用するには、次のようにフィールドを定義します: ```python created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), default=func.now()) ``` 両者の違いは、どちらも datetime を返しますが、前者の tzinfo は `None` で、後者の tzinfo は UTC です。フロントエンドに返す際、datetime オブジェクトは ISO 8601 形式の文字列に変換されがちです。 ```json { "with_tz": "2025-04-12T18:32:18.420971Z", // 明確にUTC時間であることを示す "without_tz": "2025-04-12T18:32:18.420971" // このまま new Date(date) で解析すると時間が正しくないが、強引にUTC時間として扱えば正しいローカル時間を推測できる } ``` 違いは`with_tz`の時間の後ろに`Z`があり、UTC時間を示していることです。フロントエンドで`new Date("2025-04-12T18:32:18.420971Z")`でこの時間を解析すると、この時間は正しくローカル時間に変換されます。一方、`without_tz`はこの時間をローカル時間として解析します。UTC+0タイムゾーンにいるか、フロントエンドで手動でタイムゾーンを指定しない限り、誤った結果になります。 タイムゾーンが不要なシナリオはほとんどないので、積極的に`TIMESTAMP(timezone=True)`を明示しましょう。 アプリケーションサーバー時間ではなく、データベース時間を使用することを一般的に推奨します。データベースアプリケーションサーバーは世界中のどこにあるかもしれませんが、データベースクラスターは比較的中央集中型だからです。しかし、次のように書く人もいるかもしれません: ```python default_datetime_now_tz: Mapped[datetime.datetime] = mapped_column( TIMESTAMP(timezone=True), default_factory=datetime.datetime.now, ) ``` これは別のエラーです。`datetime.datetime.now()`はアプリケーションサーバーのローカル時間を返し、サーバーがどのタイムゾーンにあるかを指定していません。したがって、データベースはこの時間をUTC時間として扱いますが、これは通常誤りです。 実際、いかなる場合も引数なしの`datetime.now()`を使用すべきではありません。タイムゾーン情報のない時間を返すため、ほとんど期待通りにはなりません。Ruff(DTZ005)が有効になっている場合、タイムゾーン情報を追加するよう促されます。 どうしても`datetime.now`で現在時刻を取得する必要がある場合は、次のようにタイムゾーンを指定すべきです: ```python default_datetime_utcnow_tz: Mapped[datetime.datetime] = mapped_column( TIMESTAMP(timezone=True), default_factory=lambda: datetime.datetime.now(datetime.UTC), ) ``` 要約すると、`TIMESTAMP WITH TIME ZONE`型のフィールドを使用し、作成時に`func.now()`を使用して現在時刻を取得すべきです。特別な状況でアプリケーションサーバー時間を使用する必要がある場合でも、必ずタイムゾーン情報を含めてください。 ### ModelをDataclassへマッピング Pythonの標準ライブラリの`dataclass`は、シンプルなデータクラスを定義するための便利なツールです。SQLAlchemyのORMオブジェクトを定義するのにも使用できます。 `dataclass`はオブジェクト構築時に非常に役立ちます。具体的には、`dataclass`を使用しない場合、デフォルトのコンストラクタ宣言は`(**kw: Any) -> Employee`です。ユーザーは、どの値を初期化する必要があるか、どの値にデフォルト値があるか、どの値を設定できないかを判断するのが難しいです。 例えば、通常`update_at`、`create_at`、`id`などのフィールドはデータベースによって自動生成されるべきで、ユーザーによって設定されるべきではありません。 `dataclass`は定義に基づいて自動的に`__init__`メソッドを生成するため、設定可能な値がわかります。 `MappedAsDataclass`クラスを継承すると、SQLAlchemyのORMオブジェクトをdataclassにマッピングできます。一般的に基本クラスで操作します: ```python class Base(DeclarativeBase, MappedAsDataclass): ... ``` この時点で、`mapped_column`と`relationship`で`dataclass`のパラメータを使用できます。よく使われるのは`init`で、フィールドがコンストラクタに現れるかどうかを指定します。`dataclass`は`default`と`default_factory`もサポートしており、これらを使ってデフォルト値を指定できます。これらのパラメータは以前は`mapped_column`でのみ使用できましたが、今では`relationship`でも使用できます。 これにより、クラスの動作が少し変わります。明らかな変化は、もはやフィールドの順序を自由に指定できないことです。コンストラクタに現れるフィールド(`init=False`が設定されていない)の場合、デフォルト値のないフィールドはデフォルト値を持つフィールドの前になければならず、コンストラクタパラメータの順序と同じです。 ここにはいくつかの落とし穴があります。フィールドには3つのケースがあります: 1. デフォルト値が設定されていない。この場合、コンストラクタで必ずこのパラメータを提供する必要があります。 2. デフォルト値が設定されている。この場合、コンストラクタでこのパラメータを提供する必要はありません。 3. `init`が`False`。この場合、コンストラクタでこのパラメータを提供できません。 `Employee`クラスでは、もともと`company_id`と`company`は両方オプションでした。`dataclass`を使用する場合、最も近い動作は2です。つまり、`company_id`と`company`の両方にデフォルト値があります。Pythonには`undefined`のようなものがないため、デフォルト値を`None`に設定するしかありません。 これにより、従業員の会社を`company_id`で設定したい場合、`Employee(company_id=1)`と書くことになりますが、これは正常に動作しません。`company`のデフォルト値も`None`だからです。したがって、構築は`Employee(company_id=1, company=None)`と同等であり、`None`を提供することと提供しないことは同じではありません。前述したように、relationshipの値は外部キーの設定を上書きします。実際には会社を持たない従業員を作成していることになります。 ここで、元の動作が変更され、混乱を招きます。最善の方法として、外部キーフィールドと関連クエリ用の`relationship`フィールドの両方が存在する場合、`relationship`を`init=False`に設定することをお勧めします。これによりコンストラクタには表示されず、先ほど言及した問題も発生しません。 ### フィールドの抽象化 前述のように、多くのテーブルはエントリのid、作成時間、更新時間を記録する必要があります。これらを抽象化できます: ```python class BaseWithAudit(Base): __abstract__ = True id: Mapped[int] = mapped_column(primary_key=True, init=False) created_at: Mapped[datetime.datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now(), init=False) updated_at: Mapped[datetime.datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), init=False) ``` ここでの`__abstract__`属性は、このクラスがテーブルとして作成されないことを示します。`id`、`created_at`、`updated_at`フィールドはこのクラスを継承する全てのテーブルで共有されます。 ここでは自動増分主キーを使用していますが、ユーザー規模を公開したくない場合、uuidを主キーとして使用する方が良いかもしれません。 ### その他の設定 インデックスやユニーク制約など他の設定もありますが、これらはあまりエラーが発生しないため、紙面の制約上省略します。直接ドキュメントを参照してください。 ## 準備作業 上記の一連の説明を経て、モデルを正しく定義しました。クエリを実行する前に、いくつかの準備作業が必要です。 `create_engine`でデータベースエンジンを作成し、`sessionmaker`でセッションクラスを作成します。すべてのデータベース操作はセッションインスタンスを通じて行われます。 ```python engine = create_engine(os.getenv("DB_URL")) Session = sessionmaker(bind=engine) ``` SQLAlchemy ORMに準拠したデータベース構造を作成: ```python Base.metadata.create_all(engine) ``` ## クエリ 作成、削除、更新、照会の操作はすべて、`Session`オブジェクトをインスタンス化することで実行できます。 クエリ操作では、`session.get`を使って主キーに基づいて単一のオブジェクトを取得できます。 ```python session = Session() company = session.get(Company, 1) # 主キーに基づいて1つの会社オブジェクトを照会 ``` SQLステートメントを実行してクエリを行うこともできます。select関数をチェーン呼び出しでクエリステートメントを構築し、`session.execute`でクエリステートメントを実行します。 ```python stmt = select(Company).where(Company.name == "Google") # SELECT * FROM companies WHERE name = "Google" と同等 print(stmt) # 生成されたSQLステートメントを印刷 session.execute(stmt) # クエリステートメントを実行し、条件に一致するすべての会社を返す ``` 返された結果はリザルトセットであり、データを取得するにはさらに処理が必要です。 このリザルトセットは反復可能で、内部データは**タプル配列**です。各行はデータベース内の1行のデータに対応します。各列はselect関数の各パラメータに対応します。 all()メソッドですべての結果を取得できます: ```python session.execute(select(Company, Company.name)).all() # [ # (, 'Apple'), # (, 'Google'), # (, 'Preferred Networks'), # ] ``` ここでは意図的に`select(Company, Company.name)`を使用しています。返される結果はタプルであることがわかります。最初の要素は`Company`オブジェクトで、2番目の要素は`Company.name`の値です。 これは少し不便です。`Company`だけをクエリした場合でも、返された結果はやはりタプル配列です: ```python session.execute(select(Company)).all() # [ # (,), # (,), # (,), # ] ``` SQLAlchemyでは`scalars()`メソッドを使って最初の列の結果を取得できます。他の列は無視されます。 ```python session.execute(select(Company.name, Company.id)).scalars().all() # ['Apple', 'Google', 'Preferred Networks'] ``` さらに、最初の行の最初の列だけが必要な場合は、`scalar()`を使ってそれを取得できます。 ```python session.execute(select(Company.name)).scalar() # 'Apple' ``` scalarsとscalarはあまりにも一般的に使われるため、直接`session.scalars`または`session.scalar`を使ってクエリすることもできます。 ```python session.scalars(select(Company.name)).all() # session.execute(select(Company.name)).scalars().all()と同等 session.scalar(select(Company.name)) # session.execute(select(Company.name)).scalar()と同等 ``` `all()`ですべての要素を取得する以外に、一般的には`first()`、`one()`、`one_or_none()`などのメソッドを使用して1行の要素を取得します。これらの動作は少し異なります: | 条件 | `first()` | `one()` | `one_or_none()` | | ------------ | --------- | ------- | --------------- | | 結果セットが複数行の場合 | 最初の行を返す | 例外をスロー | 例外をスロー | | 結果セットが空の場合 | Noneを返す | 例外をスロー | Noneを返す | ### 作成、削除、更新 `session.add`と`session.delete`を使用して作成、削除、更新操作を行うと、ほとんどのニーズを満たせます: ```python session = Session() company = Company(name="Test Company", id=1) session.add(company) session.commit() # この時点でINSERTステートメントが実行され、会社がデータベースに追加される company.name = "New Company" session.commit() # この時点でUPDATEステートメントが実行され、会社の名前がNew Companyに変更される session.delete(company) # 会社を削除 session.commit() # この時点でDELETEステートメントが実行され、会社がデータベースから削除される ``` この方法の利点は、ORMオブジェクトを分析して最適なステートメントを生成することです: ```python session = Session() alice = Employee(name="Alice", id=1, company_id=1) session.add(alice) # Aliceという従業員を追加 apple = Company(name="Apple", id=1) session.add(apple) # Apple社を追加 bob = Employee(name="Bob", id=2, company=Company(name="Google", id=2)) # Bob従業員を追加し、直接company オブジェクトを設定 session.add(bob) # Bob従業員を追加 session.commit() ``` 上記のコードは完全に合法です。SQLAlchemyは先に会社を作成し、その後従業員を追加する必要があることを認識しています。そうでなければ外部キーの競合が発生します。また、`bob`の会社は`Google`ですが、`Google`会社を作成せず、直接`Company(name="Google", id=2)`を使用しました。SQLAlchemyは自動的に`Google`会社を作成します。 また、このコミットでは実際には2つの`INSERT`ステートメントのみが実行され、会社と従業員の作成がそれぞれマージされてより効率的な挿入ロジックが形成されることも注目に値します: ```sql INSERT INTO companies (id, name) VALUES (%(id)s::INTEGER, %(name)s::VARCHAR) -- [generated in 0.00015s] [{'id': 1, 'name': 'Apple'}, {'id': 2, 'name': 'Google'}] INSERT INTO employees (id, name, company_id) VALUES (%(id)s::INTEGER, %(name)s::VARCHAR, %(company_id)s::INTEGER) -- [generated in 0.00011s] [{'id': 1, 'name': 'Alice', 'company_id': 1}, {'id': 2, 'name': 'Bob', 'company_id': 2}] ``` ### 外部キーフィールドを使用するか、リレーションシップフィールドを使用するか? company\_idを通じて従業員の会社を指定することも、companyオブジェクトを通じて従業員の会社を指定することもできることに気づいたかもしれません。気になるのは、矛盾する値を指定した場合どうなるのかということです。 ```python apple = Company(name="Apple", id=1) session.add(apple) # Apple会社を追加 google = Company(name="Google", id=2) session.add(google) # Google会社を追加 alice = Employee(name="Alice", id=1, company_id=1, company=google) # id=1はApple会社、companyはGoogleを指定。AliceはAppleとGoogleのどちらに属する? session.add(alice) session.commit() # トランザクションをコミット assert alice.company_id == 2 ``` 実験の結果、オブジェクトの値がより優先されることがわかりました。つまり、`company_id`と`company`の両方を指定すると、`company`の値が`company_id`の値を上書きします。これは警告なく行われ、少し不安を感じさせます。 ベストプラクティスとして、relationshipの値を直接設定せず、外部キー列を使用して関係を設定することをお勧めします。つまり、`company_id`を使用して従業員の会社を設定します。オブジェクトの取得にはコストがかかる可能性がありますが、オブジェクトを既に取得している場合でも、その主キーに簡単にアクセスして関係設定に使用できます。 ### SQLステートメントを使用したクエリ 完全なコントロールが必要な場合は、`session.execute`を使用してSQLステートメントを実行する方法があります。 ```python session = Session() session.execute(insert(Company).values(id=1, name="Test Company")) # INSERTステートメントを実行して会社をデータベースに追加 ``` `session.query`を使用してクエリを行うという別の使用方法を見ることもあるかもしれませんが、これは時代遅れの使用法であり、使用しないことをお勧めします。 ## ORMオブジェクトは統一されている SQLAlchemyの背後には多くのマジックが存在します。例えば、同じセッション内では、同じオブジェクトはメモリに1つしか存在しません。また、さまざまな修正操作はクエリで取得したオブジェクトにも影響します。 ```python company = Company(name="Test Company", id=1) session.add(company) session.commit() company_1 = session.get(Company, 1) assert company_1 is company # True、クエリしたオブジェクトと以前に追加したオブジェクトは同じオブジェクト session.execute(update(Company).where(Company.id == 1).values(name="New Company")) # 会社の名前を変更 session.commit() assert company.name == "New Company" # 不思議なことに、以前に存在していたオブジェクトの値も変更された ``` この不思議な動作は便利ですが、混乱を招くこともあります。 ## 遅延ロードについて `relationship`には重要なパラメータ`lazy`があり、ロード方法を指定するために使用します。 `lazy`のデフォルト値は`select`です。これは「このフィールドにアクセスする時、`select`クエリで遅延的にフィールドの値をロードする」という意味です。つまり、デフォルトでは遅延ロード(Lazy Loading)が行われます。 ```python class Company(Base): # その他のコードは省略 employees: Mapped[List["Employee"]] = relationship() # デフォルトはlazy="select"、遅延ロードされる session = Session() employees = session.scalars(select(Employee)).all() for employee in employees: logger.info("Employee: %s, Company: %s", employee.name, employee.company.name) ``` データベースに3つの会社があり、各会社に3人の従業員がいる場合、次のような出力が生成される可能性があります: ```sql BEGIN (implicit) # すべての従業員を照会 SELECT employees.id, employees.name, employees.company_id FROM employees -- [generated in 0.00012s] {} # id=1の会社を照会 SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id = %(pk_1)s::INTEGER -- [generated in 0.00013s] {'pk_1': 1} -- Employee: Brian Baker, Company: Brown-Spencer -- Employee: Karen Payne, Company: Brown-Spencer -- Employee: Stephanie Bradley, Company: Brown-Spencer # id=2の会社を照会 SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id = %(pk_1)s::INTEGER -- [cached since 0.002736s ago] {'pk_1': 2} -- Employee: Joseph Howard, Company: Cooper, Hunt and Long -- Employee: Amanda Brooks, Company: Cooper, Hunt and Long -- Employee: Lindsay Grant, Company: Cooper, Hunt and Long # id=3の会社を照会 SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id = %(pk_1)s::INTEGER -- [cached since 0.00426s ago] {'pk_1': 3} -- Employee: Cynthia Pittman, Company: Pope Ltd -- Employee: Amanda Cook, Company: Pope Ltd -- Employee: James Fernandez, Company: Pope Ltd ``` まず全従業員が照会され、各従業員の会社にアクセスする時に初めて会社テーブルが照会されることがわかります。 これが遅延ロード(Lazy Loading)です。 従業員を照会する際に会社情報が必要ない場合が多いため、遅延ロードには意味があります。 しかしこれによりN+1クエリ問題が発生します。9人の従業員を全て取得するために1回のクエリを行い、その後各従業員の会社情報を取得するためにクエリを行います。クエリ数が多く、クエリ速度が大幅に遅くなります。 ここで細かい点があります:実際は9+1=10回のクエリではなく、3+1=4回です。理由は、1つのセッション内でSQLAlchemyはクエリ結果をキャッシュできるためです。同じ会社の情報にアクセスする場合、SQLAlchemyはキャッシュから直接取得し、データベースに再度クエリしません。したがって、従業員の会社情報に9回アクセスしましたが、実際には3つの会社に分かれているため、実際のデータベースクエリは追加で3回だけです。 いずれにしても、従業員の会社情報にアクセスする必要がある場合、遅延ロードはクエリ数を大幅に増やします。 relationshipで`lazy`パラメータを使用すると、事前ロード(Eager Loading)を指定できます。 これは非常に直感的ではありません。`lazy`パラメータを書かなければ遅延ロードが使用され、逆に`lazy`を書くと遅延ロードされなくなります。 以下では、いくつかの事前ロードモードを紹介します。これらのクエリのパフォーマンスには微妙な違いがありますが、一般的に`selectin`のパフォーマンスが最も良いでしょう。 ### joinedを使った事前ロード lazyが`joined`に設定されている場合、Employeeを照会する際、JOINステートメントを使用してCompanyテーブルをEmployeeテーブルに結合します。 ```sql BEGIN (implicit) SELECT employees.id, employees.name, employees.company_id, companies_1.id AS id_1, companies_1.name AS name_1 FROM employees LEFT OUTER JOIN companies AS companies_1 ON companies_1.id = employees.company_id -- [generated in 0.00012s] {} ``` ### selectinを使った事前ロード lazyが`selectin`に設定されている場合、Employeeを照会する際、2番目のクエリが発生し、IN句を使用して全従業員の会社をフィルタリングします。 ```sql BEGIN (implicit) SELECT employees.id, employees.name, employees.company_id FROM employees -- [generated in 0.00012s] {} SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id IN (%(primary_keys_1)s::INTEGER, %(primary_keys_2)s::INTEGER, %(primary_keys_3)s::INTEGER) -- [generated in 0.00016s] {'primary_keys_1': 1, 'primary_keys_2': 2, 'primary_keys_3': 3} ``` ### subqueryを使った事前ロード subquery事前ロードも2回のクエリを必要とします。 ```sql BEGIN (implicit) SELECT employees.id, employees.name, employees.company_id FROM employees -- [generated in 0.00017s] {} SELECT companies.id AS companies_id, companies.name AS companies_name, anon_1.employees_company_id AS anon_1_employees_company_id FROM (SELECT DISTINCT employees.company_id AS employees_company_id FROM employees) AS anon_1 JOIN companies ON companies.id = anon_1.employees_company_id -- [generated in 0.00039s] {} ``` ### クエリ時にロード方法を決定 クエリ時にロード方法を決定することもできます。 ```python stmt = select(Employee).options(selectinload(Employee.company)) # クエリ時に、Employeeテーブルのcompanyフィールドにselectin事前ロードを使用 ``` ## トランザクション管理 クエリには以下のような状態があります: 1. 未コミット(pending):トランザクションがまだコミットされておらず、データがまだデータベースに書き込まれていない。 2. 変更済み(flushed):データはデータベースに書き込まれているが、トランザクションはまだコミットされていない。この時点では現在のセッションだけが変更を見ることができる。 3. トランザクションコミット済み(committed):データがデータベースに書き込まれ、トランザクションがコミットされている。この時点ですべてのセッションが変更を見ることができる。 ### flush `session.flush()`を使用して変更をデータベースにコミットしますが、トランザクションはコミットしません。これは、現在のセッションでは変更が見えるが、他のセッションでは見えないことを意味します。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.flush() # 変更をデータベースにコミットするが、トランザクションはコミットしない assert session.get(Company, 1).id == 1 # 先程追加した会社をクエリできる new_session = Session() assert new_session.get(Company, 1) is None # 他のセッションではクエリできない、トランザクションがコミットされていないため ``` ### commit `session.commit()`を通じてトランザクションをコミットできます。これは変更が実際に有効になることを意味します。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.commit() # トランザクションをコミット new_session = Session() assert new_session.get(Company, 1).id == 1 # 他のセッションでもクエリできる、トランザクションがコミットされたため ``` ### rollback `session.rollback()`を通じて、まだコミットされていないトランザクションをロールバックできます。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.flush() # 変更をデータベースにコミットするが、トランザクションはコミットしない assert session.get(Company, 1).id == 1 # この時点でクエリできる session.rollback() # トランザクションをロールバックし、変更を取り消す assert session.get(Company, 1) is None # この時点でクエリできない ``` 間違った例として、トランザクションが既にcommitでコミットされている場合、ロールバックはできません: ```python session = Session() session.add(Company(name="Test Company", id=1)) session.commit() # トランザクションをコミット assert session.get(Company, 1).id == 1 # この時点でクエリできる session.rollback() # トランザクションをロールバックし、変更を取り消す assert session.get(Company, 1).id == 1 # この時点でもクエリできる、トランザクションが既にコミットされているためロールバックできない ``` また、セッションがコミットせずに閉じられた場合、そのセッション内のすべての変更はロールバックされます。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.flush() # 変更をデータベースにコミットするが、トランザクションはコミットしない session.close() # セッションを閉じる、この時点でトランザクションがロールバックされ、すべての変更が取り消される ``` ### flush、commit、rollbackの連携使用 データベースの複数の変更の原子性を確保する必要がよくあります。つまり、すべて成功するか、すべて失敗するかのいずれかです。 失敗した場合はロールバックする必要があります。 ```python session = Session() try: session.add(Company(name="Test Company", id=1)) session.flush() # 変更をデータベースにコミットするが、トランザクションはコミットしない raise Exception("Test exception") # 例外をシミュレート session.commit() # トランザクションをコミット except Exception: session.rollback() # トランザクションをロールバックし、すべての変更を取り消す ``` ### beginを使った管理 実践では、手動で`commit`や`rollback`を行うのではなく、`session.begin()`を使用してトランザクションを管理することをお勧めします。 ```python session = Session() with session.begin(): session.add(Company(name="Test Company", id=1)) # 会社を追加 # withステートメントを出ると、トランザクションは自動的にコミットされます。また、例外が発生した場合、トランザクションは自動的にロールバックされます new_session = Session() assert new_session.get(Company, 1) is not None # 他のセッションでもクエリできる、トランザクションがコミットされたため ``` `session.begin_nested()`を使用してネストしたトランザクションを作成できます。 ```python with session.begin(): session.add(Company(name="Test Company", id=1)) with session.begin_nested(): session.add(Employee(name="Test Employee", id=1, company_id=1)) session.add(Employee(name="Test Employee 2", id=2, company_id=1)) ``` ### autoflush デフォルトでは、SQLAlchemyのauto flush機能は有効になっています。しかし、これはsession.addなどの操作のたびにflushが行われることを意味するわけではありません。実際には、この機能を有効にすると、クエリの前に自動的にflushが行われます。 ```python session = sessionmaker(bind=engine)(autoflush=True) # デフォルトでは有効です、ここでは明示的に有効にしていますが、省略可能です company = Company(name="Google", id=1) session.add(company) assert session.get(Company, 1) is not None # 変更はコミットされていませんが、トランザクションもコミットされていません。しかし、クエリの前に自動flushが行われるため、クエリできます ``` autoflushを無効にするとどうなるでしょうか? ```python session = sessionmaker(bind=engine, autoflush=False)() # 自動flushを無効にする company = Company(name="Google", id=1) session.add(company) assert session.get(Company, 1) is None # None、自動flushがないため ``` 注意すべきは、`session.flush()`は`session.add()`や`session.delete()`と組み合わせて使用する場合にのみ関連があることです。`session.add()`メソッドで追加されたオブジェクトや、`session.delete()`メソッドで削除されたオブジェクトにのみ影響します。 言い換えれば、`session.execute`を直接使用してSQLステートメントを実行する場合、`autoflush`の影響を受けません。 ```python session = sessionmaker(bind=engine, autoflush=False)() session.execute(insert(Company).values(id=1, name="Google")) # executeはflushを必要とせず、クエリを自動的にコミットします assert session.get(Company, 1) is not None # executeで挿入した場合、自動flushがなくてもクエリできます ``` \`\`autoflush\`はあまり有用な機能ではないと感じます。むしろ混乱を招く可能性があるため、無効にすることをお勧めします。 ### expire\_on\_commit SQLAlchemyの`expire_on_commit`機能はデフォルトで有効になっています。つまり、トランザクションをコミットした後、すべてのオブジェクトは期限切れと見なされます。この機能は非常に微妙なシナリオでのみ有用です: 下記のコードに示すように、コミット後に別のセッションがオブジェクトの値を変更した場合、expire on commit機能が無効になっていると、期限切れの値にアクセスします。 ```python Session = sessionmaker(bind=engine, expire_on_commit=False) session = Session() c = Company(name="Google", id=1) session.add(c) session.commit() # 別のセッションで会社の名前を変更 other_session = Session() other_session.execute(update(Company).where(Company.id == 1).values(name="Meta")) other_session.commit() assert c.name == 'Google' # 期限切れになっていないため、まだGoogleと表示される ``` 興味深いことに、同じセッション内でオブジェクトの値を変更した場合、この問題は発生しません。SQLAlchemyは裏でオブジェクト`c`が変更されたことを魔法のように認識し、新しい値を設定します。 ```python Session = sessionmaker(bind=engine) # expire_on_commit=True session = Session() with session.begin(): c = Company(name="Google", id=1) session.add(c) with session.begin(): session.execute(update(Company).where(Company.id == 1).values(name="Meta")) assert c.name == "Meta" ``` 個人的には、`expire_on_commit`機能を有効にする理由はあまりないと思います。コミット後に別のセッションによる変更を常に反映する必要があるでしょうか?それに、データをリフレッシュしても、その直後(データ転送中など)に変更される可能性があり、完全な一貫性は保証できません。 また、SQLAlchemyに関する多くの説明では、`expire_on_commit`を`False`に設定しています(例えば[Litestar](https://docs.litestar.dev/2/tutorials/sqlalchemy/0-introduction.html){rel=""nofollow""}や[公式ドキュメント](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html){rel=""nofollow""}など)。 ## 非同期 SQLAlchemyは非同期操作をサポートしています。非同期のエンジンとセッションを構築するだけです。 ```python async_engine = create_async_engine(os.getenv("DB_URL")) AsyncSession = async_sessionmaker(bind=async_engine) ``` しかし、非同期の世界は同期とは全く異なります。 最大の違いは、非同期の世界には暗黙的なI/Oがないことです。I/O操作を行うには、明示的に`await`を使用する必要があります。 SQLAlchemyではいつI/Oが実行されるかは必ずしも明白ではありません。 ```python session = AsyncSession() async with session.begin(): session.add(Company(name="Google", id=1)) await session.commit() # この時点でI/O操作があり、トランザクションをコミット company = await session.get(Company, 1) # id=1の会社をクエリし、この時点でI/O操作がある select_stmt = select(Company).where(Company.name == "Google") # この時点ではI/O操作はなく、SQLステートメントが生成されるだけ result = await session.execute(select_stmt) # この時点でI/O操作があり、クエリステートメントを実行 companies = result.scalars().all() # この時点ではI/O操作はなく、クエリ結果を処理するだけ ``` `session.commit()`と`session.execute()`はどちらもI/O操作であることがわかります。これらを完了させるために`await`を使用する必要があります。一方、`session.add()`や`select()`はI/O操作ではありません。これらはオブジェクトをセッションに追加したり、SQLステートメントを生成するだけです。 非同期環境では遅延ロードを使用するのが難しいです。`Company`と`Employee`の関係が遅延ロードであり、つまりデフォルトの`lazy="select"`を使用している場合、次の(同期)コードは2回のクエリを行います: ```python with Session() as session: b = session.get(Employee, 1) # id=1の従業員をクエリ print(b.company.name) # 従業員の会社名を遅延クエリ ``` パフォーマンスが少し悪いだけで、問題なく動作します。 しかし非同期では、このコードはエラーになります: ```python async with AsyncSession() as session, session.begin(): a = await session.get(Employee, 1) logger.info(a.company.name) # sqlalchemy.exc.MissingGreenlet ``` このエラーは理解できます。非同期では、すべてのI/O操作は明示的なawaitが必要です。しかし、`a.company`にアクセスする際、SQLAlchemyはI/O操作を行う必要がありますが、awaitがありません。 実際、エラーが発生することは必ずしも悪いことではありません。できるだけ暗黙的なI/Oを避けるべきだと思います。もしセッション内で遅延ロードフィールドを使用する予定があれば、前もってロードするべきです。そうでなければパフォーマンスに影響します。 ### 非同期遅延ロード もし本当に遅延ロードを使用してデータにアクセスする必要がある場合(通常は必要ありません)、`AsyncAttrs`を使用できます。これには基本クラスを変更する必要があります: ```python class Base(AsyncAttrs, DeclarativeBase): pass ``` そして次のようにフィールドを`await`できます: ```python name = await a.awaitable_attrs.company.name ``` 個人的にはこの方法は非常に不自然だと感じます。また、`session.run_sync`を使用することもできます: ```python name = await session.run_sync(lambda _: a.company.name) ``` この方法では基本クラスを変更する必要がありません。この方法は実際に新しいスレッドを起動してクエリを実行します。これらはほぼ同じです。 ## おそらくベストプラクティス SQLAlchemyの非同期APIは、慣れるまでにいくつかの困難がありました。コードは予想外の場所でさまざまなエラーを報告します。これらのエラーを防ぐために、次のように書くべきかもしれません: ### デフォルトの遅延ロードを使用しない デフォルトの遅延ロード戦略にはほとんど価値がありません。 まず、非同期の並行性能が高いため、開発では非同期を優先します。このとき、明示的にawaitを宣言する必要があり、これはI/O操作がどこで行われるかを明確に知る必要があることを意味します。これにより開発時の精神的負担は軽減されず、逆に実行時にエラーが報告されるだけです。 また、遅延ロードフィールドが必要かどうかは多くの場合予測可能です。そして遅延ロードフィールドに全く興味がないか、各行の遅延ロードフィールドにすべて興味があるかのどちらかです。前者の場合、遅延ロードは無意味です。後者の場合、遅延ロードによりN+1クエリ問題が発生します。個々のレコードの遅延ロードフィールドに興味がある場合にのみ遅延ロードは意味がありますが、このケースはほとんど発生しません。 したがって、すべての`relationship`で`lazy="selectin"`を使用して事前ロードすることがベストプラクティスかもしれません。 ### `expire_on_commit`をオフにする `expire_on_commit`にも価値がないように見えます。デフォルトで有効になっており、多くのI/O操作を追加し、非同期ではエラーを引き起こすことがよくありますが、さらに悪いことに、ほとんど何の問題も解決していません。 例えば次のコードでは、`expire_on_commit`を有効にしており、テーブル定義で`lazy="selectin"`を使用せず、代わりにクエリで`options`を指定している場合、トランザクションを`commit`した後、`e`は期限切れになります。 賢明な私は`session.refresh(e)`がオブジェクト`e`の値をリフレッシュすることを知っているかもしれません。しかし、実際にはここで`refresh`しても`company`の値は再ロードされません。なぜならSQLAlchemyは`e`が`selectinload`クエリの結果であることを知ることができないからです。 ```python async def main(): async with AsyncSession() as session: e = await session.scalar(select(Employee).where(Employee.name == "John Doe").options(selectinload(Employee.company))) logger.info(f"Employee: {e.company.name}") await session.commit() await session.refresh(e) logger.info(f"Employee: {e.company.name}") # エラー ``` ### 各セッションで最後に1回だけcommitする 前の分析から、`commit`後に他の操作を行うと、様々な問題が発生することがわかります。実際には、各`session`で1回だけ`commit`することは非常に良い実践です。これにより上記の一連の問題を回避し、原子性を保証します。 特にWebアプリケーションでは、`session`のライフサイクルがリクエストのライフサイクルと一致している場合、管理がはるかに容易になります。 したがって、手動で`commit`するよりも、`session.begin()`を使用してトランザクションを管理する方が良い選択肢です。セッションの作成とbeginを一緒に配置し、コード内で`session.commit()`、`session.rollback()`などの操作が不要になり、`try...catch`ステートメントも必要なくなり、より簡潔になります。 ## まとめ いつの間にか文章が長くなってしまいました。読みにくくなってしまい申し訳ありません。しかし、SQLAlchemyは実際に非常に複雑なライブラリであり、特にORM部分には数多くの隠れた仕組みや落とし穴が存在します。 異なる意見をお持ちの方もいらっしゃるかもしれませんが、ぜひ皆さんとの議論を歓迎します。なお、SQLAlchemyの全機能(インデックス、エイリアス、Alembic移行など)については触れていません。これらは比較的エラーが発生しにくい機能だからです。 実際、上記で説明した多くの内容はSQLAlchemyの公式ドキュメントにも記載されていますが、膨大な情報量と新旧APIの混在により、適切に理解するのは難しいと思います。 この記事がSQLAlchemyへの理解を深め、一般的なエラーを回避する助けになれば幸いです。 # Use 日々の開発や制作で使っているツールのメモです。 ## デスク・ハードウェア - 椅子: Herman Miller Sayl - ノートPC: MacBook Pro 13-inch M1 - キーボード: Realforce R3 Keyboard - マウス: Logitech G102 - マイク: Sennheiser MK4 - ヘッドホン: AKG K712 - スピーカー: Audio-Technica AT-SP95 ## カメラ - ボディ: Sony A7C Mark II - レンズ: - Tamron 28-200mm - Sony FE 50-150mm F2 GM ## ソフトウェア - フォント: Berkeley Mono - エディタ: Visual Studio Code - AI 補助: CodeX AI - 動画編集: Adobe Premiere Pro - 写真編集: Adobe Lightroom # Anime 以下是我推荐的一些动画。它们可能不符合所有人的喜好,仅代表个人偏好。这些都是我喜欢的,只是程度不同。分为 EX、SSS、SS、S 四个级别。这里的列表主要用于确认电波是否对得上。列表并不完整,我想到哪列到哪,随时会更新。 ## EX - 进击的巨人 ## SSS - 命运石之门 - 赌博默示录 - 斗牌传说 - 吉伊卡哇 - 物语系列 - Re: 从零开始的异世界生活 第一季 - 蓦然回首 - 电锯人蕾塞篇 - 来自深渊 - 魔法少女小圆 - 乒乓 ## SS - 间谍过家家 - 我心里危险的东西 - 擅长捉弄的高木同学 - Fate/Zero - Fate/UBW - 辉夜大小姐想让我告白 - 奇巧计程车 - 动物狂想曲 - 我的妹妹不可能那么可爱 - 干物妹小埋 - 正相反的你和我 ## S - 葬送的芙莉莲 - 败犬女主太多了 - 散华礼弥 - 关于地球的运动 - 百米 - 天才麻将少女 - 天降之物 - 守护猫娘绯鞠 - 无职转生 - 赛博朋克边缘行者 - 双城之战 - 心理测量者 - 狂赌之渊 - 食戟之灵 - 某科学的超电磁炮 - 我推的孩子 # Jannchie 开发者资源 > Jannchie 官方开发者文档与集成指南。 ## 产品概览 - **官方网站:** {rel=""nofollow""} - **API 参考:** [https://jannchie.com/docs/api](https://jannchie.com/zh-CN/docs/api) - **OpenAPI 规范:** - **llms.txt:** ## 快速链接 - [Agent 集成指南](https://jannchie.com/zh-CN/docs/agents) - [API 参考](https://jannchie.com/zh-CN/docs/api) - [认证指南](https://jannchie.com/zh-CN/docs/auth) - [Webhooks 指南](https://jannchie.com/zh-CN/docs/webhooks) - [MCP 服务器](https://jannchie.com/zh-CN/docs/mcp) # Jannchie Agent 集成指南 > AI Agent 如何发现并集成 Jannchie。 ## 发现渠道 - **llms.txt:** - **llms-full.txt:** - **OpenAPI:** - **结构化数据:** 首页包含 JSON-LD(Organization、WebSite、SoftwareApplication) ## 集成方式 - [API 参考](https://jannchie.com/zh-CN/docs/api) - [认证指南](https://jannchie.com/zh-CN/docs/auth) - [Webhooks](https://jannchie.com/zh-CN/docs/webhooks) - [MCP 服务器](https://jannchie.com/zh-CN/docs/mcp) # Jannchie API 参考 ## 可用端点 - `GET /api/health` — 健康检查 - `GET /api/_sitemap-urls` — 站点地图 URL - `GET /llms.txt` — AI Agent 用站点摘要 - `GET /llms-full.txt` — 完整文档 - `GET /openapi.json` — OpenAPI 3.1.0 规范 # Jannchie 认证指南 当前公开端点无需认证。未来计划支持: - API 密钥 - OAuth 2.0 - 个人访问令牌 # Jannchie MCP 服务器指南 Model Context Protocol (MCP) 是一个开放协议,标准化了 AI Agent 与外部工具和数据源的连接方式。 ## 可用资源 - 文档 — 访问开发者文档 - API 参考 — 查询 API 端点 # Jannchie Webhooks 指南 Webhooks 允许外部服务在 Jannchie 发生事件时接收实时通知。 ## 事件类型 - `content.created` - `content.updated` - `content.deleted` # 为什么我喜欢《进击的巨人》 因为它是我看过少有的,真正把战争、人性、仇恨、意义这些东西都画出来了的作品。 在中国,巨人是有巨大 Debuff 的一部作品。因为它诞生自日本,而日本是我们的直接侵略者,大屠杀的执行者。在中国人的视角,这样的加害者在局势逆转,我强你弱后,开始呼吁什么和平,什么斩断仇恨的连锁,是相当可笑的。如果巨人诞生在中国,我会更加自信地称其为神作,但确实日本更有出现这样作品的土壤。顺便一说,罗小黑电影第二部也有类似的气质,我愿称之为神作,这里就不展开了。 但即便如此,我依然非常喜欢《进击的巨人》。 因为它虽然也是二次元作品,但漫画动画只是载体。它未必处处写实,但在主流商业动画里,它已经非常接近我所理解的现实。每个人的行为都足够有逻辑,我们可以理解一个善良的平民为什么要侵略,为什么要互相残杀。这不是和侵略者共情。把所有敌人简单化、恶魔化并不真实,只能让人轻易扣下扳机,它更可能是别有用心者——马莱高层和耶格尔派——的伎俩。 包括艾伦,即使大家都认为它是小丑,但它的行为也能自圆其说。人类有与生俱来的破坏欲。有太多人想踏平全世界了,为解放自己千年来受巨人之力支配的民族,拯救自己周围的人,消灭80%,甚至牺牲自己的亲人也不是无法想象的事情。艾伦、亚尼、莱纳甚至阿尔敏,这么多人物完整的塑造,解释了人类为什么能变成恶魔,而恶魔又怎样才能做回人类去赎罪。况且艾伦自知是恶魔,比那些自诩正义,幻想着自己的敌人是天生的恶魔,毫无愧疚之心地对立场不同的同类挥下屠刀的马莱人、艾尔迪亚人和地球人要强太多。 巨人画尽了人性。勇敢的士兵会在死前哀嚎,最强的利威尔也不知道如何抉择才是正确的,自己的父亲刚说完走出森林放下仇恨的话,但那又怎样,善良的卡亚也会控制不住愤怒想去手刃仇人。刚说之后一定好好相处的士官,在最后的几个镜头里又举起枪,对准变回人类的艾尔迪亚人。与此同时,那些永远坚守自己道义本心的人显得如此闪闪发光,那些迷途知返的人显得如此难能可贵,这就是我心中真实的人类,可爱而又可怜,并不是一个脸谱。 剧情上,一次又一次死人不断告诉我们战争有多残酷和愚蠢。也有热血,我们高呼“献出心脏”,幻想着成为调查兵团杀光所有的敌人。然而当耶格尔派将总统爆破暗杀,民众也高呼“献出心脏”的时候,一切都变得诡异了。作品中多次提问,口号中的心脏究竟献给了谁,又该献给谁?有些人意识到自己被作者点了,意识到自己陷入了军国主义热潮之中,也意识到这种思潮的危险。这样的人自然会站在耶格尔派的对立面,反思自己的心脏究竟献给了什么。而另外有些人觉得自己被冒犯了,我觉得这就是巨人褒贬不一的原因。 有很多人期待巨人能探讨探讨“怎么终结战争”这个终极话题。然而巨人这部作品其实根本没有打算回答这个问题。或者说,它从不认为有一种方式能终结所有纷争。巨人告诉我们,海的这边和那边,又有好人又有坏人,人们即使可以相互理解,也会不得不举起武器防卫,甚至先发制人。即使巨人之力消失,纷争也不会消失,百年之后又会陷入战争的泥潭。这个模型是符合我对我们所在的这个宇宙的认知的——战争延绵了数千年的历史而仍然在发生,历史就是在循环往复,正义与否往往只是立场不同。这个世界就是烂透了,就是如此残酷。在我心中,巨人的世界观是成立的。 那么巨人是一部虚无主义作品?一切努力都是徒劳的?其实根本不是。至少这不是巨人这部作品想要表达的思想。它确实有悲观的气息,但是关键的问题是:在这样一个残酷的世界里,我们活着的意义是什么?其实作品给出了答案。说来也老生常谈了,用罗曼·罗兰的名句概括就是: > 世界上只有一种英雄主义,就是看清世界本来的样子,并且爱它。 在作品里,有虚无主义的代表人物,比如吉克。人类就不应该生下来,种族的存续也根本不重要,恐惧都是来自于繁殖的本能,正是因为无意义的生命活动,才造成了这些惨剧。而他被阿尔敏论破了。 因为他有值得信赖和保护的同伴,他们还在战斗,所以战斗有意义。因为小时候和艾伦和三笠赛跑很开心,所以人生有意义。下雨天在家里看书时,松鼠在吃我给的果子时,和大家一起逛集市时,这些普普通通的瞬间,都弥足珍贵。 人道主义,这可能是巨人想要表达的思想之一。人类很残忍,仇恨不可避免,但是为了所爱的人,去理解,去保护,去做自己觉得正确的事情,去贯彻人道,去战斗,去献出心脏。反观心里没有爱的人直接跳过了这一段内容,没有试图理解阿尔敏的思想,陷入虚无的不是作者,而是观众自己。 我认为,巨人是一部彻底的反战作品,而不是什么政治作品。它试图教会人们这个世界是残酷的,不要娱乐化战争,不要小看纷争带来的痛苦和灾难。它还在告诉我们,在这个残酷的世界里应该怎么生活,要如何面对仇恨和纷争。 所以我不太接受什么“一万个读者心中有一万个哈姆雷特”的说法。当然,伟大的作品会留下解释空间,但这并不意味着它没有稳定的表达。只要看那些镜头语言、人物表情、动作和神态,就能看出来,作者想要传递的思想是清楚而稳定的。很多人没有读懂,一部分是作者表达能力仍有局限,另一部分也是时代中人的局限。至少高市早苗桑看了,就显然没看懂。 回过头来看,作品本身确实没有回答“怎么终结战争”这样的问题。它没有给出制度设计,也没有给出万能方案。然而在另一层意义上,巨人本身就是答案。它把战争的残酷、仇恨的荒谬,以及人仍然应该尝试理解彼此这件事,一遍又一遍地摆到我们面前。这样的反战思想,如果能被日本人、美国人、乌克兰人、俄罗斯人、以色列人、巴基斯坦人,乃至我们自己真正接受,世界上的战争也许真的会减少,甚至消失。 我希望更多人能够读懂《进击的巨人》。不要急着拒绝互相理解。在现在这个时代,我们更需要这样的作品。 # Game 以下是我推荐的一些游戏。它们可能不符合所有人的喜好,仅代表个人偏好。这些都是我喜欢的,只是程度不同。分为 SSS、SS、S 三个级别。这里的列表主要用于确认电波是否对得上。列表并不完整,我想到哪列到哪,随时会更新。 ## SSS - Factorio - GTA 5 - 黑暗之魂 3 - 只狼 - 黑神话·悟空 - 戴森球计划 - 战国兰斯 - 兰斯10 决战 - 希克斯之歌 - 合金弹头 1、2、X、3、4、5 ## SS - 神界原罪2 - 寂静岭f - 新牧场物语 符文工厂3 - 零之轨迹、碧之轨迹 - 弹丸论破2 - 逆转裁判4 - 白色相簿2 - 素晴日 ## S - 战场女武神3 - 地狱潜兵 1/2 - 去月球 - 428 被封锁的涩谷 - 天国拯救 - 泰拉瑞亚 - 边缘世界 - 十字军之王3 - 钢铁雄心4 - 维多利亚3 - 群星 - 传说之下 - 幽灵诡计 - 怪物猎人 P3 - 怪物猎人 XX - 怪物猎人 世界 - 动物井 - 极限脱出999 - 极限脱出2 善人死亡 - 觅长生 - 命运石之门 # 提高浏览器的可访问性 - 对比度计算 为了确保网站和应用程序能够被更广泛的用户群体使用,关注和提高可访问性是至关重要的。而在可访问性中,对比度是一个非常关键的因素。对比度指的是前景色(文本或图标)与背景色之间的明暗差异程度。在浏览器中,我们可以检查和评估前景和背景色的对比度,以确保内容易于阅读和辨别。 在浏览器中使用开发者工具选择元素,可以看到类似下图的 popup 框。其中 Contrast 字段可以检查选中文字的对比度信息。图中前背景色的对比度达到 6.33,符合标准,因此可以观察到一个绿色的对勾符号。 ![1](https://jannchie.com/imgs/frontend-relative-luminance/1.png) 虽然较低的对比度会显得用色比较“高级”,然而对于有视觉障碍的用户、老年人或者在光线较暗的情况下使用设备的用户来说,更安全的对比度可以帮助用户更轻松地阅读内容,为所有用户提供更好的用户体验。因此为了设计出更加专业的 UI,我们最好能够遵循可访问性标准,设计较高对比度的界面。 ## 可访问性标准 在可访问性方面,有两个主要的对比度标准:AA 和 AAA。这些标准由 Web 内容可访问性指南(WCAG)制定,用于确保内容的可读性和可访问性。 - AA 级别要求:AA 级别要求最低的对比度水平,适用于大多数普通文本和图像。根据 WCAG 2.0 AA 级别,正常文本的对比度应至少为 4.5:1,对于大文本来说,由于它们本来就比较好辨认,对对比度的要求较低,至少为 3:1。 - AAA 级别要求:AAA 级别要求更高的对比度水平,适用于需要更好可读性的特殊情况,如长时间阅读、小字号文本等。根据 WCAG 2.0 AAA 级别,正常文本的对比度应至少为 7:1,而大文本的对比度应至少为 4.5:1。 ## 基础的对比度检测 我们可以基于两个颜色的亮度差异来计算对比度。而直接比较两个颜色哪个亮对人来说其实比较困难。因此我们常将 RGB 颜色先转换为灰度值。我们可以使用如下的公式转换 RGB 颜色至灰度值: [[[]{.katex-mathml}[[[]{.strut style="height:0.6833em;"}[L]{.mord.mathnormal}[]{.mspace style="margin-right:0.2778em;"}[=]{.mrel}[]{.mspace style="margin-right:0.2778em;"}]{.base}[[]{.strut style="height:1em;vertical-align:-0.25em;"}[(]{.mopen}[0.299]{.mord}[]{.mspace style="margin-right:0.2222em;"}[⋅]{.mbin}[]{.mspace style="margin-right:0.2222em;"}]{.base}[[]{.strut style="height:1em;vertical-align:-0.25em;"}[R]{.mord.mathnormal style="margin-right:0.0077em;"}[)]{.mclose}[]{.mspace style="margin-right:0.2222em;"}[+]{.mbin}[]{.mspace style="margin-right:0.2222em;"}]{.base}[[]{.strut style="height:1em;vertical-align:-0.25em;"}[(]{.mopen}[0.587]{.mord}[]{.mspace style="margin-right:0.2222em;"}[⋅]{.mbin}[]{.mspace style="margin-right:0.2222em;"}]{.base}[[]{.strut style="height:1em;vertical-align:-0.25em;"}[G]{.mord.mathnormal}[)]{.mclose}[]{.mspace style="margin-right:0.2222em;"}[+]{.mbin}[]{.mspace style="margin-right:0.2222em;"}]{.base}[[]{.strut style="height:1em;vertical-align:-0.25em;"}[(]{.mopen}[0.114]{.mord}[]{.mspace style="margin-right:0.2222em;"}[⋅]{.mbin}[]{.mspace style="margin-right:0.2222em;"}]{.base}[[]{.strut style="height:1em;vertical-align:-0.25em;"}[B]{.mord.mathnormal style="margin-right:0.0502em;"}[)]{.mclose}]{.base}]{.katex-html ariaHidden="true"}]{.katex}]{.katex-display} 这个公式中有一些意味不明的魔法值权重。这些权重是根据人眼对不同颜色的感知差异进行了实验和统计分析得出的。它其实基于一种称为 Luma 或 ITU-R BT.709 的标准。相对于绿色通道,人眼对红色通道更不敏感,对蓝色通道最不敏感,因此相应地给予了较低的权重。 然后我们根据灰度值,通过下列公式来计算对比度: [[[]{.katex-mathml}[[[]{.strut style="height:0.6833em;"}[C]{.mord.mathnormal style="margin-right:0.0715em;"}[]{.mspace style="margin-right:0.2778em;"}[=]{.mrel}[]{.mspace style="margin-right:0.2778em;"}]{.base}[[]{.strut style="height:2.1963em;vertical-align:-0.836em;"}[[]{.mopen.nulldelimiter}[[[[[[]{.pstrut style="height:3em;"}[[[[L]{.mord.mathnormal}[[[[[[]{.pstrut style="height:2.7em;"}[[2]{.mord.mtight}]{.sizing.reset-size6.size3.mtight}]{style="top:-2.55em;margin-left:0em;margin-right:0.05em;"}]{.vlist style="height:0.3011em;"}[​]{.vlist-s}]{.vlist-r}[[[]]{.vlist style="height:0.15em;"}]{.vlist-r}]{.vlist-t.vlist-t2}]{.msupsub}]{.mord}]{.mord}]{.mord}]{style="top:-2.314em;"}[[]{.pstrut style="height:3em;"}[]{.frac-line style="border-bottom-width:0.04em;"}]{style="top:-3.23em;"}[[]{.pstrut style="height:3em;"}[[[[L]{.mord.mathnormal}[[[[[[]{.pstrut style="height:2.7em;"}[[1]{.mord.mtight}]{.sizing.reset-size6.size3.mtight}]{style="top:-2.55em;margin-left:0em;margin-right:0.05em;"}]{.vlist style="height:0.3011em;"}[​]{.vlist-s}]{.vlist-r}[[[]]{.vlist style="height:0.15em;"}]{.vlist-r}]{.vlist-t.vlist-t2}]{.msupsub}]{.mord}[]{.mspace style="margin-right:0.2222em;"}[−]{.mbin}[]{.mspace style="margin-right:0.2222em;"}[[L]{.mord.mathnormal}[[[[[[]{.pstrut style="height:2.7em;"}[[2]{.mord.mtight}]{.sizing.reset-size6.size3.mtight}]{style="top:-2.55em;margin-left:0em;margin-right:0.05em;"}]{.vlist style="height:0.3011em;"}[​]{.vlist-s}]{.vlist-r}[[[]]{.vlist style="height:0.15em;"}]{.vlist-r}]{.vlist-t.vlist-t2}]{.msupsub}]{.mord}]{.mord}]{.mord}]{style="top:-3.677em;"}]{.vlist style="height:1.3603em;"}[​]{.vlist-s}]{.vlist-r}[[[]]{.vlist style="height:0.836em;"}]{.vlist-r}]{.vlist-t.vlist-t2}]{.mfrac}[]{.mclose.nulldelimiter}]{.mord}]{.base}]{.katex-html ariaHidden="true"}]{.katex}]{.katex-display} 其中, [[]{.katex-mathml}[[[]{.strut style="height:0.6833em;"}[C]{.mord.mathnormal style="margin-right:0.0715em;"}]{.base}]{.katex-html ariaHidden="true"}]{.katex} 表示对比度, [[]{.katex-mathml}[[[]{.strut style="height:0.8333em;vertical-align:-0.15em;"}[[L]{.mord.mathnormal}[[[[[[]{.pstrut style="height:2.7em;"}[[1]{.mord.mtight}]{.sizing.reset-size6.size3.mtight}]{style="top:-2.55em;margin-left:0em;margin-right:0.05em;"}]{.vlist style="height:0.3011em;"}[​]{.vlist-s}]{.vlist-r}[[[]]{.vlist style="height:0.15em;"}]{.vlist-r}]{.vlist-t.vlist-t2}]{.msupsub}]{.mord}]{.base}]{.katex-html ariaHidden="true"}]{.katex} 表示较亮的颜色的亮度值, [[]{.katex-mathml}[[[]{.strut style="height:0.8333em;vertical-align:-0.15em;"}[[L]{.mord.mathnormal}[[[[[[]{.pstrut style="height:2.7em;"}[[2]{.mord.mtight}]{.sizing.reset-size6.size3.mtight}]{style="top:-2.55em;margin-left:0em;margin-right:0.05em;"}]{.vlist style="height:0.3011em;"}[​]{.vlist-s}]{.vlist-r}[[[]]{.vlist style="height:0.15em;"}]{.vlist-r}]{.vlist-t.vlist-t2}]{.msupsub}]{.mord}]{.base}]{.katex-html ariaHidden="true"}]{.katex} 表示较暗的颜色的亮度值。 然而仅靠上述的公式算出的对比度并不正确,这是因为处理时的色彩空间不同。 ## 色彩空间 最常见的颜色表示法——RGB,通过组合红色(R)、绿色(G)和蓝色(B)的不同强度来创建所需的颜色。RGB 值通常使用 0-255 的整数表示,每个通道的值表示对应颜色分量的强度。 CSS 中默认使用的是 sRGB 色彩空间。它广泛应用于计算机和互联网上的图像和显示设备。默认情况下,CSS 中的 RGB 值处于 sRGB 色彩空间中。这种色彩空间是非线性的,并不适合进行色彩相关的计算操作。 在我们的场景下,更适合使用线性 RGB 色彩空间。这是一种线性表示颜色强度的色彩空间,其中每个通道的颜色值与物理光的亮度成正比。这种线性关系使得颜色的计算更加直观和精确。 通过将 sRGB 空间的颜色转换为线性 RGB 空间,可以消除颜色值在 sRGB 中的非线性响应。这样,在进行颜色操作时,可以更准确地处理光的物理性质,使得颜色混合、亮度调整和比较等操作更加精确和可预测。 具体操作如下: - 首先,我们将颜色通道的值除以 255,将其归一化到 0 到 1 的范围内,以便在接下来的计算中使用。 - 接着,我们使用 sRGB 校准曲线来计算相对亮度。如果归一化后的值小于等于 0.03928,我们将其除以 12.92,以进行线性转换。 - 如果归一化后的值大于 0.03928,我们将其加上 0.055,然后将结果除以 1.055。接着,我们将结果进行 2.4 次方运算,以进行非线性转换。 最终,可以下面的 Typescript 代码,实现色彩空间转换和对比度计算的功能: ```ts interface RGBColor { r: number g: number b: number } function calculateContrast(rgb1: RGBColor, rgb2: RGBColor): number { // 计算颜色的相对亮度 const l1 = calculateRelativeLuminance(rgb1) const l2 = calculateRelativeLuminance(rgb2) // 计算对比度 const contrast = (l1 + 0.05) / (l2 + 0.05) return contrast } function calculateRelativeLuminance(rgb: RGBColor): number { const { r, g, b } = rgb const sRGB = [r / 255, g / 255, b / 255] const [ rL, gL, bL, ] = sRGB.map((c) => { return c <= 0.039_28 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }) const l = 0.2126 * rL + 0.7152 * gL + 0.0722 * bL return l } // 示例用法 const color1: RGBColor = { r: 255, g: 0, b: 0 } const color2: RGBColor = { r: 0, g: 255, b: 0 } const contrast = calculateContrast(color1, color2) console.log(`Contrast: ${contrast}`) ``` 最后,我强烈推荐一个名为 "Color Contrast Checker - Coolors" 的网页应用。这个应用不仅可以检查颜色的对比度是否符合无障碍标准,还可以自动调整颜色以满足安全对比度的需求。 [Color Contrast Checker - Coolors](https://coolors.co/contrast-checker/112a46-acc8e5){rel=""nofollow""} # React 性能优化实践 在 React 中,只要一个组件的 props、state、context 或者父组件变化,那么就重新渲染组件。这样很简洁,但也很难。 在 React,默认情况下,每次试图渲染组件都会执行(下图蓝色部分),只有部分被 useEffect、useMemo 等钩子包裹的代码能够根据依赖变化选择性地执行(黄色部分)。而 Vue 等一些其他框架,大部分业务逻辑会放在 setup 里只执行一次(绿色部分),而有些代码块在依赖变化时会重新执行(黄色部分)。 ![React 和 Vue 的渲染对比](https://jannchie.com/imgs/react-performance-optimize/render-react-vs-vue.png) 这是两种不同的设计哲学,无法讨论优劣。不过我个人认为 React 更容易写出性能不好的代码。原因在于我们的业务逻辑往往会写在渲染函数里,而它很有可能会意外地执行多次。我们的业务逻辑很可能会包含一次性的计算密集型的任务,而我们不得不把这些一次性的代码放在潜在会运行多次的代码块中,一不小心多次执行它就可能就会严重拖慢页面速度。 许多人声称自己从未遇到过性能问题。确实,React 的渲染已经足够高效,它能够把多次 dom 操作打包成一个 commit,另外,即使组件需要重新渲染,React 会先比较虚拟 DOM 是否有变化,如果没变则会复用之前的 DOM,从而减少比较重的实际 DOM 操作([Render and Commit – React](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom){rel=""nofollow""})。 因此我们大可以遵循克努特优化原则,如果没有性能问题,不要优化是一个更明智的选择,这让代码更简单,更容易理解。但有时性能问题确实会发生。例如,测试的时候使用的数据量过少;又或者是开发用设备性能强劲,远超用户的真实配置等等。如果已经进行了大量开发工作,然后突然发现网页性能很差,那么寻找这些性能瓶颈就成了一个有挑战性的任务。这里想简单讨论一些 React 性能优化相关的实践。 ## 如何衡量性能问题? 我们首先需要知道哪里慢,最好还能知道现在有多慢,才能有效改进并评估性能。这里要推荐的第一个工具是浏览器自带的 DevTools 中的 Performance。 我相信有许多人并没有怎么用过它,它其实功能非常强大。 例如下面这个程序([React Playground](https://reactplayground.vercel.app/#N4IgLgziBcBmCGAbCBTANCAbrK1QEsA7AExQA8A6AK1xHwFsAHAewCcwACAQUcY9lbN6HAOQUA9D0bUIZEQB1CDFuw4AlFPADGnAUNGtNOhUqZtOwDlsPwwKNc2acAvv0HCRNnQFpiQ8VqI+CiEYCaKWsyEEJyRofBEKKwcALwcfloArvQhYBQA5ihgAKKIKDmhAEIAngCSxAAUno5hAJQRUTEcgk6pVjZ2Dk4NcWAJhEnthD15hiRJDYoc6kZ51pp2peW5i4TLyxraeQDKYKz4OgCyzKRoS-uEmYiId3v7hzoU67YoWxVgDSkU2WU1aIAwUhkZBgdDMqg+YDQHEsmVQGlgSNRKFOP0xqGKsFgKB0HFcemE8hAXjAlIA3IoOtFOFI+g1WqkAHzI+5xLoAbTISNQYAAGgBdPpYnF2RYgSmtelvQxgTKsPa7fYcAA8HPumq1x0QzAA7gBhIQsCahDjiXVvfVERiZThgaqMFApSmPegAIySlI4UVNAAt4IRCilgA0UOyUlzhSLoxQxqxCnlMEhMjHXLa9drc28FYpnIrFLBMoQdPgohwsQAxfA+qLaLTB-ANQjs4D3ZWqvawRvNrRadudxXOBmEcuVsDVvaGk3msxWgFdnmdTiGCBPThpeuDwgttsNADMAA4i0qin3tRzgFud84tbbx5OyxWqzWB03D8PR2u3nwWAOA7bU0gARnZXs1Q4QhFWWaD+wPFtRw4bwOEgjgAGp+GQv9QPQgAmS8J0IRRyBUThSAQHduF4RUQGcZwgA){rel=""nofollow""}): ```tsx import React, { useEffect, useRef, useState } from 'react' function App() { const [x, setX] = useState('') return ( <> setX(e.target.value)} /> ) } function useFibonacchi(n) { return fibonacci(n) } function SlowComponent() { const result = useFibonacchi(38) return <>{result} } function fibonacci(n) { if (n <= 1) { return n } return fibonacci(n - 1) + fibonacci(n - 2) } export default App ``` 我们可以开启录制,然后在 UI 中输入内容。取消录制后就能看到 Profile。 对 Profile 的结果,我们首先应该关注上方。我们可以清晰的看到 interactions 一栏里,有整个点击操作所造成的页面无响应时间(INP),达到 257ms。一般 200ms 以上就需要优化了。 :alert{content="通过观察 Profile,定量观察网页的响应速度成为可能。" title="Tips" type="info"} Performance 更好的地方在于,它可以通过火焰图,分析哪些函数所执行的时间比较长。 在这个简单的例子里,可以看到绝大多数时间都是用来执行 fibonacci 这个函数了。因此我们的优化应该集中于它。 ![性能火焰图](https://jannchie.com/imgs/react-performance-optimize/performance-flamegraph.png) 更进一步,此时我们可以在源代码页面中看到具体是哪一行代码运行得比较慢: ![有性能问题的代码](https://jannchie.com/imgs/react-performance-optimize/performance-code.png) :alert{content="通过火焰图,能够快速定位出问题的代码。" title="Tips" type="info"} 在实际的项目里,点击按钮、输入内容、甚至是滚动页面,都有可能在各处触发各种各样的函数,其中有些函数可能不希望被触发,有些可能有优化的空间。 ## 如何优化呢? 很容易就能发现,Slow Component 的渲染其实是和用户输入无关的,但由于父组件更新了,即使每次渲染内容都相同,渲染函数还是执行了。 说起优化方式,其实有很多选择,最简单的是 `useMemo` 1. 使用 `memo` 来包裹 `SlowComponent`。([React Playground](https://reactplayground.vercel.app/#N4IgLgziBcBmCGAbCBTANCAbrK1QEsA7AExQA8A6AK1xHwFsAHAewCcwACAQUcY9lbN6HAOQUA9D0bUIZEQB1CDFuw4AlFPADGnAUNGtNOhUqZtOwDlsPwwKNc2acAvv0HCRNnQFpiQ8VqI+CiEYCaKWsyEEJyRofBEKKwcALwcfloArvQhYBQA5ihgAKKIKDmhAEIAngCSxAAUno5hAJQRUTEcgk6pVjZ2Dk4NcWAJhEnthD15hiRJDYoc6kZ51pp2peW5i4TLyxraeQDKYKz4OgCyzKRoS-uEmYiId3v7hzoU67YoWxVgDSkU2WU1aIAwUhkZBgdDMqg+YDQHEsmVQGlgSNRKFOP0xqGKsFgKB0eJQl3KzA4rj0wnkIC8YDpAG5FIpYJlCDp8FEOABhISMLgNVrI+5xLqGCBPThpLHk+jMBrC1IAPn4+AARlFtFp8A0AMwABlaSIA2gBdVost6GMCZVh7AA8KuAkulzkd4hV1ucrMI7M5YG5e35TEqDUsqDAAA0qSLgPdbfanURGJlOGBqowUCk6Y96BqknSOFFeQALeCEQopYANFAilJqqPRusUMasQp5TBITL11xen1+8WcKR9ZWN0VvYccU1kJHN819LE4uyLEB0q2JorJji7fYcZ33ffALisVjwaoUGkRjhlKtgMvQDgARkNhrjFHo8EYSoA+kj8AbNU933fZHVDQUOAAaxQaoa3wfsVSPfZWlaX03n3cCBUqDhmxrZtEOQz0kLeTdCGca02Q5LkeVgTVtS0XUGkIeN7nwWBdydNJnxFJMHQ4QhrWWPi9jorVCB1PU9m8F8RQAanVcTJOYjgZIAJjI9DFHIFROFIBBpW4XhrRAZxnCAA){rel=""nofollow""}) 2. `useMemo` 来缓存结果。([React Playground](https://reactplayground.vercel.app/#N4IgLgziBcBmCGAbCBTANCAbrK1QEsA7AExQA8A6AK1xHwFsAHAewCcwACAQUcY9lbN6HAOQUA9D0bUIZEQB1CDFuw4AlFPADGnAUNGtNOhUqZtOwDlsPwwKNc2acAvv0HCRNnQFpiQ8VqI+CiEYCaKWsyEEJyRofBEKKwcALwcfloArvQhYBQA5ihgAKKIKDmhAEIAngCSxAAUno5hAJQRUTEcgk6pVjZ2Dk4NcWAJhEnthD15hiRJDYoc6kZ51pp2peW5i4TLyxraeQDKYKz4OgCyzKRoS-uEmYiId3v7hzoU67YoWxVgDSkU2WU1aIAwUhkZBgdDMqg+YDQHEsmVQGlgSNRKFOP0xqGKsFgKB0eJQl3KzA4rj0Hi8YUUDMIsEyhB0+CiHAAwkJGFwGq1gPc4l1DBAnpw0ljyfRmA1+SkAHz8fAAIyi2i0+AaAGYAAytJEAbQAusDukVMqw9gAeBXAUXi5zW8QKxTORnM1lgdl7blMSoNYCoMAADWcAvuhjAlptREYmU4YGqjBQKXkIEe9BVSXTHCinIAFvBCIUUsBUwrgyGGigKGNWIU8pgkJkUK1XC63YzhZwpH1+aklYK3j2OIayEiq8a+licXYmiIzVGYxxdvsOLb7uvh+v11xWKx4NUKDTAxwyiWwAXoBwAIy63VU1oUejwRhygD6SPwrUVG79vIcAA1ig1RlvgHYKma67um867WgBlQcFWZZVpBW4bp2bxTLBiiemyHKwKq6paJqDSEK0yL3PgsCrjaaS3pRy5WhwhAANyRhaLFEWqhAalqezeHelEANTKrx-HkRwQkAEytBxhC4RMZAqJwpAIOK3C8CAzjOEAA){rel=""nofollow""}) 不过许多人,例如 React 曾经的维护者 Dan,建议我们在使用 `useMemo` 之前,考虑用**状态下移**或者**内容提升**等方式来规避重复渲染。在这篇博客里,已经介绍得很清楚了 [Before You memo() — overreacted](https://overreacted.io/before-you-memo/){rel=""nofollow""}。 不过这种手法稍微有些高端,它需要重新考虑项目的结构。在一个依赖关系已经非常复杂的,很难应用上述这些优化方式重新设计。我们往往会回退到 `memo()` 这方式。 ## memo() 的困境 看上去 `memo()` 也能完美解决问题。然而现实生活仍然没有这么简单。实际上记忆化的行为比想象中的要难以捉摸。关键在于它的依赖。举个例子,下面这段代码,`Comp` 中被记忆的 t 输出一个时间戳,它的依赖是 a, b, c 三个变量,而这三个变量来自于三个不同的 hooks,由于这些 hooks 没有其他依赖,它们看上去返回值是“不会变”的。而点击 Rerender 按钮,会更新 state x 导致父组件重渲染。我们希望 Comp 不重新渲染,因此我们加上了 memo 包裹它。现在,点击 Rerender 按钮,`Comp` 渲染出的时间会变化吗(会重新渲染吗)?([React Playground](https://reactplayground.vercel.app/#N4IgLgziBcBmCGAbCBTANCAbrK1QEsA7AExQA8A6AK1xHwFsAHAewCcwACAQUcY9lbN6HAOQUA9D0bUIZEQB1CDFuw4AlFPADGnAUNGtNOhUqZtOwDlsPwwKNc2acAvv0HCRNnQFpiQ8VqI+CiEYCaKWsyEEJyRofBEKKwcALwcfloArvQhYBQA5ihgAKKIKDmhAEIAngCSxAAUno5hAJQRUTEcgk6pVjZ2Dk4NcWAJhEnthD15hiRJDYoc6kZ51pp2peW5i4TLyxraeQDKYKz4OgCyzKRoS-uEmYiId3v7hzoU67YoWxVgDSkU2WU1aIAwUhkZBgdDMqg+YDQHEsmVQl3KzCRqJQpx+SJy9GYHFcemE8hAXjA5IA3IpFLBMoQdPgohxsVwGq1kfdDGBMqw9sBnLTCM46YQGUywCy9tjKpzuW9efy9gBGEViwj0xnM1nYgDCCuAPKKKrZaIxDQVKQAfBwGkLWkiANoAXVaGvFcS6+qEfDSBOYVss8CRACMkVpiVzbYrlt7OJw0gA5bJhhYTADuHAAIj9OR6TXyBRwADzEfCYG3AAAGABJgGBXA34M3gGG21pnDXnKXxBWqxrC4RfUwKBWIIxEPBqsn4Dk+uTR4waV7OpwpH1rXbjW8Exx4H12ZyRfH1xww0fUPLh2forEryhDberOfnWQkagwAANV2P3F2A0AAML7KiWuz7GWA42vckGlmGmRgGAUSwZBHBRPqQRaAA1ikwDbnGaGQV+34NGQHAANQcKqL5Ec4mpETBbxERocykKwqHLH2CFIVETFEaWy4HnhrYXnhHZWHhXYcOI-H7H20H3MOwqKOQKicKQCBPBuvAiiA9FAA){rel=""nofollow""}) ```jsx // 试图使用 memo 记忆组件 const Comp = memo(({ a, b, c }) => { const t = Date.now() return
{`${t} ${a} ${b} ${c}`}
}) function App() { // 三个 Hook 没有其他依赖,重新渲染时返回值看似相同 const a = useA() const b = useB() const c = useC() const [x, setX] = useState(0) return (
{/** 每次渲染 props 不变 */}
) } ``` 答案是,**无法判断**。这需要取决于 a, b, c 三个变量究竟是怎么来的。 这里有一个细节是,react 中,依赖数组使用 `Object.is` 来判断依赖有没有变化。而引用对象诸如数组和对象,只有引用完全相同返回才为 `true`。 ```js Object.is('a', 'a') // true,基本类型可以通过 is 判断是否相等 const a = {} const b = a Object.is(a, b) // true // 引用类型如果引用同一个 object,则相同 object.is(a, {}) // false // 如果不是同一个 object 则为不相同 ``` 考虑 `useA`,`useB` 和 `useC` 分别是这样定义的: ```jsx function useA() { return {} } function useB() { return 1 } function useC() { return useMemo(() => ({}), []) } ``` 每一个 hook 每次都会重新执行,`useA` 在每次渲染时,都会返回一个空对象,重点在于,它是全新的,因此依赖 `a` **会**导致重新渲染。 而对于 `useB` 而言,它返回的是非引用数据,使用 Object.is(1, 1) 来比较,结果完全相同,因此依赖 `b` **不会**重新渲染。 最后,`useC` 虽然也返回一个空数组,但是它是被另一个 `useMemo` 记忆化的,只要依赖数组不变,变量 `c` 依旧是那个变量,**不会**重新渲染。 因此就能发现。依赖的变量,当它是一个引用对象的时候,从外观上根本无法分辨是否符合预期,是稳定的。 甚至第三方库也会有这样的陷阱。例如常用的 `@tanstack/query`。下面这段代码中,`query` 这个对象不是记忆化的,而 `query.data` 是记忆化的。如果此时在 `useMemo` 里进行了复杂的处理,可能会有性能问题。 ```jsx function Comp() { const query = useQuery({}) // query 是不稳定的 const data = useMemo(() => { if (query.isLoading) { return [] } return query.data }, [query]) // 这里,每次计算,返回的都是不同的 query。应该改成 [query.isLoading, query.data] return <>{data} } ``` 排查这样的问题很痛苦。一不小心,依赖了一个未被记忆化的函数或者对象,memo 就无法成立。 ## 怎么破? 有很多工具能够分析这样的错误。比如 React 的官方 Devtools chrome 插件。 我尝试过 react devtool 的官方 chrome 插件。它有一个 Profile 工具,但我不是很推荐这个插件。它很慢,而且不是特别稳定经常会卡死或者加载不出来。我更推荐 [React Scan](https://react-scan.com/){rel=""nofollow""}。 React Scan 提供了一个悬浮窗,可以选择一个组件,查看其变化的原因。 我们查看这个时间戳所在的组件,观察悬浮窗可以看到,这个组件被渲染的原因是 Props 发生了变化,其中 `a` 这个 prop 变了,并且它变前变后,虽然不是同一个 object,但内容其实是一样的。这暗示我们应该在 useA 中记忆化返回值,从而防止依赖 `a` 变量的组件重新渲染。 ![能被记忆化的属性](https://jannchie.com/imgs/react-performance-optimize/can-be-memo-prop.png) :alert{content="使用 React Scan,能够得知一个组件重新渲染的原因。极大程度帮助我们定位问题。" title="Tips" type="info"} --- ## 另一种性能问题 React Scan 还能排查渲染到真实 DOM 时的性能问题。这对于 Chrome 的 Performance 工具来说比较难处理。因为耗时的地方在 React 内部的 Commit 操作,或是其他组件库的操作,而不是自己的代码。 比如大家都爱用的 Mantine 中,有一个名为 Tooltip 的组件。它其实渲染得比较慢。像这样,朴实地渲染 200 个元素,FPS 就降低到了 1,而去掉这个 Tooltip 则没事。 ```jsx import { Button, Tooltip } from '@mantine/core' function App() { return ( <> { Array.from({ length: 200 }, (_, i) => ( )) } ) } export default App ``` 这个事实比较反直觉,因为表面上 tooltip 在不 hover 的时候是不会显示,但它也会拖慢网站速度。使用 React Scan 能够察觉这样的性能问题。 ![Mantine 的 Tooltip 组件相对较慢](https://jannchie.com/imgs/react-performance-optimize/mantine-tooltip-is-slow.png) 想要解决这个问题,我们需要条件渲染,在 hover 按钮的时候才渲染 tooltip 组件。 --- React Scan 还能用来解决多次渲染的问题。通常我们应该避免使用 useEffect 来设置 state,下面是一个典型错误例子([React Playground](https://reactplayground.vercel.app/#N4IgLgziBcBmCGAbCBTANCAbrK1QEsA7AExQA8A6AK1xHwFsAHAewCcwACAQUcY9lbN6HAOQUA9D0bUIZEQB1CDFuw4AlFPADGnAUNGtNOhUqZtOwDlsPwwKNc2acAvv0HCRNnQFpiQ8VqI+CiEYCaKWsyEEJyRofBEKKwcALwcfloArvQhYBQA5ihgAKKIKDmhAEIAngCSxAAUno5hAJQRUTEcgk6pVjZ2Dk4NcWAJhEnthD15hiRJDYoc6kZ51pp2peW5i4TLyxraeQDKYKz4OgCyzKRoS-uEmYiId3v7hzoU67YoWxVgDSkU2WU1aIAwUhkZBgdDMqg+YDQHEsmVQxVgsBQOiRqJQpx+rj0Hi8YUUilgmUIOnwUQ4AGEhIwGq1kfc4l0ANrwJGoMBcAC6fVx+LsDQADK0ANxszqcDkAIx5RUqgrSwrGool0re7LlWiVYDpqo46p+4ql93uuPRmJ0DWZKQAfMB7vteZUGvAWfdnGgufypla0RisQCHc7XRxeXSGvLgRxfQqA2S3oYwJlWHtdvsOAAeYj4TCOyPLXPyzJgMC0qJ0oJaADWKWA4d5XE9AGoAIytZyO2sXeu58TlytRYtvHO5xh95iUsDQZFaZxD6eRocFov3C2EZwp3XcXh9ZmpR2s1NFDNZtcb8c50sMpgccS3-brwsvqa7wiKcgqTikBAnk4KQQGcZwgA){rel=""nofollow""}) ```jsx import React, { useEffect, useState } from 'react' function Comp() { const [a, setA] = useState(0) const [b, setB] = useState(0) const [c, setC] = useState(0) useEffect(() => { setB(a) }, [a]) useEffect(() => { setC(b) }, [b]) return (

Count: {c}

) } function App() { return (
) } export default App ``` 在这个例子中,点击按钮会设置 a,然后通过 useEffect 设置了 b,又通过 useEffect 设置了 c,因而一次更新,这个组件就渲染了 3 次,性能立即下降三倍。这样的组件一多,就算没有什么繁重的操作,网站的性能也会变差。 使用 react scan,就能看出这样的一次操作,多次渲染的组件。 ![多次渲染组件的问题](https://jannchie.com/imgs/react-performance-optimize/render-multiple-times.png) ## 别自己动手? 回到我们刚才讨论的记忆化的问题。我们可以发现,难点主要在于,对于调用者,很难知道自己依赖的对象是否被记忆化了。而一个轻量级的 object 很可能被一个重量级的计算所依赖,导致 memo 失效。似乎没有什么办法,除非,我们规定所有可能被别人使用的对象都要记忆化? 很多人担心这样反而有性能问题,因为记忆也是有成本的,记忆一个非常简单的东西可能会降低性能。虽然记忆一个简单的东西可能会略微降低性能。但这种损失几乎可以忽略不计,而一旦有一处性能瓶颈得以释放,则会大大提高整体性能。因此总体上来说,不管三七二十一直接 `memo` 也是一种可以接受的做法。 实际上 React 团队早就在做这件事了。 不过,这个课题似乎比预想中要困难。至少四年前,React 团队就公布了一个叫 React Forget 的项目,它试图自动地给每一个组件和函数定义添加记忆化。 我原以为它已经被 React 团队彻底 Forget 了,甚至 reddit 有一个这样的问题: [Did the React team forget the React Forget compiler? : r/reactjs](https://www.reddit.com/r/reactjs/comments/16nnh4z/did_the_react_team_forget_the_react_forget/){rel=""nofollow""},然而在 React 19 中,它以 [React Compiler](https://react.dev/learn/react-compiler){rel=""nofollow""} 为名,虽然还在 beta 测试,但终于可用了。它会分析你的代码,然后自动在可以 `memo` 的地方自动记忆化,从而告别手动分析。 我进行了一些试用,它确实能够极大地加速一个未被优化过的项目。然而它也有一些问题。首先它只支持 babel,不支持 swc,意味着打包速度会有所下降。另外,虽然据说在 meta 内部, complier 已经广泛被使用了,但在少见场合下,react compiler 处理过的代码会和期望的不一致。它值得一试,不过可能有一些小小的风险。 ## 总结 讲了许多杂七杂八的东西,实际上想和大家分享的其实是这些: 1. **诊断工具链:** - 从浏览器 **Performance** 工具入手,能够进行宏观的性能检查与评估。 - 利用 **React Scan** 进行细粒度分析,可以定位问题组件并探究其重渲染的具体诱因。 2. **`memo` 使用策略:** - **结构优先:** 在诉诸 `memo` 前,优先考虑通过组件结构调整来避免不必要的渲染。 - **依赖稳定性:** 当 `memo` 未按预期工作时,仔细检查依赖数组中的元素(特别是引用类型)是否真正保持稳定。 - **自动化:** 关注 **React Compiler** 的进展,它可以自动处理许多记忆化优化。 # SQLAlchemy 使用经验总结 ## 模型定义 存在许多种模型定义方式,其中大部分都是历史遗留,仅推荐下面这种 SQLAlchemy 2.0 的方式: ```python class Base(DeclarativeBase): ... class Company(Base): __tablename__ = "companies" id: Mapped[int] = mapped_column(primary_key=True) # 主键 name: Mapped[str] = mapped_column() class Employee(Base): __tablename__ = "employees" id: Mapped[int] = mapped_column(primary_key=True) # 主键 name: Mapped[str] = mapped_column() company_id: Mapped[int] = mapped_column(ForeignKey("companies.id")) # 外键,关联公司表 ``` 上述代码定义了公司(Company)和员工(Employee)之间的一对多关系。每个公司可以有多个员工,但每个员工只能属于一个公司。 ### 定义关系 可以使用 `relationship` 来定义关系,从而实现关联查询。 例如,如果我们想要查询公司时能够查出所有员工,可以在 `Company` 类中定义一个 `employees` 属性,使用 `relationship` 来关联 `Employee` 表格。 ```python class Company(Base): __tablename__ = "companies" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() employees: Mapped[List["Employee"]] = relationship() # 关联查询 ``` 另一种情况,如果我们想要查询一个员工所属的公司,可以在 `Employee` 类中定义一个 `company` 属性,使用 `relationship` 来关联 `Company` 表格。 ```python class Employee(Base): __tablename__ = "employees" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() company_id: Mapped[int] = mapped_column(ForeignKey("companies.id")) company: Mapped["Company"] = relationship() # 关联查询 ``` 如果双方都可能进行关联查询,可以在 `Company` 和 `Employee` 类中都定义一个 `relationship` 属性。 注意,在这种情况下,需要**至少在一个**类中定义 `back_populates` 属性,以便 SQLAlchemy 知道如何在两个类之间建立关系。`back_populates` 属性的值是另一个类中定义的 `relationship` 属性的名称。 ```python class Company(Base): __tablename__ = "companies" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() employees: Mapped[list["Employee"]] = relationship() # 关联查询 class Employee(Base): __tablename__ = "employees" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column() company_id: Mapped[int] = mapped_column(ForeignKey("companies.id")) company: Mapped["Company"] = relationship(back_populates="employees") # 关联查询,同时定义了反向关系 ``` ### 默认值 `mapped_column` 支持 `server_default`、`default` 和 `default_factory`参数。 `server_default` 中的 server 指数据库。它会在创建表时指定字段默认值。 通常,使用 `server_default` 更好。一个显而易见的好处是,每次创建对象时,使用 `default` 每次都会在 SQL 语句显式指定,更为冗长。 需要注意的是,`server_default` 的值不能是数字类型。即使是数字类型的字段也需要使用字符串类型的值,如下所示: ```python class TestTable(Base): __tablename__ = "test_table" default_field: Mapped[int] = mapped_column(server_default="0") # 数据库端默认值 ``` 但是使用 `default` 也并非一无是处。如果不指定 `default`,在对象创建而未提交时访问该字段会返回 `None`。如果这一点对你很重要(通常不会),那么可以使用 `default` 来指定默认值。 当默认值是一个引用类型时,使用 `default_factory` 更好,否则会陷入所有对象共享同一个引用的常见陷阱。 ```python class Test(Base): __tablename__ = "test" default_field: Mapped[list[str]] = mapped_column(ARRAY(String), default=[]) # 错误使用 default session = Session() t1 = Test() t1.default_field.append("test") print(t1.default_field) # ['test'] t2 = Test() print(t2.default_field) # ['test'],t1 和 t2 共享同一个引用 ``` ### 时区陷阱 这里和 SQLAlchemy 没有直接关系,但还是值得一讲。 时区的处理很容易出错。一个最简洁的时间字段定义可能是这样的,我们希望创建时记录时间,我们通过 `func.now()` 获取服务器当前时间: ```python created_at: Mapped[datetime] = mapped_column(default=func.now()) ``` 然而,这很可能和你的预期不一致。我们以 Postgres 为例,实际上,这里声明的数据库字段默认是 `TIMESTAMP` 类型。这是一个不带时区信息的时间类型。 我一直有一个误解,认为 `TIMESTAMP` 是以格林威治时间为准的,然而那是“Unix 时间戳”的定义。Postgres 的 `TIMESTAMP` 是一个不带时区信息的时间戳。如果服务器位于日本,那么它会存储日本时间 1970 年 1 月 1 日 00:00:00 到当前时间的时间间隔。对于绝大多数人而言,这都不是想要的结果,这不是 Unix 时间戳。 Postgres 提供了 `TIMESTAMP WITH TIME ZONE` 类型,它会存储 Unix 时间戳。如果要采用 `TIMESTAMP WITH TIME ZONE`, SQLAlchemy 中可以这样定义字段: ```python created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), default=func.now()) ``` 它们差异在于,虽然都返回 datetime,但是前者的 tzinfo 是 `None`,后者的 tzinfo 是 UTC。当返回给前端时, datetime 对象往往会被转换为 ISO 8601 格式的字符串。 ```json { "with_tz": "2025-04-12T18:32:18.420971Z", // 明确表示是 UTC 时间。 "without_tz": "2025-04-12T18:32:18.420971" // 直接解析就是错误的时间,但是,如果强行把它当成 UTC 时间,勉强可以推测出正确的本地时间。 } ``` 可以发现,区别在于 `with_tz` 的时间后面有一个 `Z`,表示 UTC 时间。如果在前端通过 `new Date("2025-04-12T18:32:18.420971Z")` 来解析这个时间,可以发现这个时间会正确转换成本地时间。而 `without_tz` 则将这个时间当成本地时间来解析,除非人在 UTC+0 时区,或者前端手动指定时区,否则会解析出错误的结果。 几乎没有不需要时区的场景,还是积极显式指明 `TIMESTAMP(timezone=True)` 吧。 我们一般不推荐使用应用服务器时间,而是使用数据库时间。因为数据库应用服务器可能在世界各地,而数据库集群相对比较中心化。但也许有人会这么写: ```python default_datetime_now_tz: Mapped[datetime.datetime] = mapped_column( TIMESTAMP(timezone=True), default_factory=datetime.datetime.now, ) ``` 这又是另外一种错误。`datetime.datetime.now()` 返回的是应用服务器本地时间,并且没有指明服务器位于哪个时区。因此数据库会将这个时间当作 UTC 时间,这往往是错误的。 实际上,在任何时候都不应使用无参数的 `datetime.now()`。它会返回一个没有时区信息的时间戳,它几乎不会是我们所期望的。如果开启了 Ruff(DTZ005) 会提醒我们加上时区信息。 如果一定要使用 `datetime.now` 获取当前时间,应当指明时区: ```python default_datetime_utcnow_tz: Mapped[datetime.datetime] = mapped_column( TIMESTAMP(timezone=True), default_factory=lambda: datetime.datetime.now(datetime.UTC), ) ``` 总结来说,我们我们应该使用 `TIMESTAMP WITH TIME ZONE` 类型的字段,并且在创建时使用 `func.now()` 来获取当前时间。如果真的有特殊情况,需要使用应用服务器时间,也一定要带上时区信息。 ### Model 映射为 dataclass Python 标准库中的 `dataclass` 是一个非常方便的工具,可以用来定义简单数据类。我们可以使用 `dataclass` 来定义 SQLAlchemy 的 ORM 对象。 `dataclass` 在构造对象时很有用。具体来说,如果不使用 `dataclass`,默认的构造函数声明是 `(**kw: Any) -> Employee`。使用者很难判断有哪些值需要初始化,哪些又有默认值,哪些又不可以设置。 例如我们通常希望 `update_at`、`create_at`、`id` 等字段由数据库自动生成,而不是由使用者设置。 而 `dataclass` 则会自动根据我们的定义生成 `__init__` 方法。从而知道哪些值可以设置。 继承 `MappedAsDataclass` 类可以将 SQLAlchemy 的 ORM 对象映射为 dataclass。我们一般会在基类中操作: ```python class Base(DeclarativeBase, MappedAsDataclass): ... ``` 此时,在 `mapped_column` 和 `relationship` 中可以就可以使用 `dataclass` 的参数了。常用的是`init`,用于指定一个字段是否能出现在构造函数中。`dataclass` 还支持 `default` 和 `default_factory`,用于指定默认值,这两个参数原先只能在 `mapped_column` 中使用,现在也能在 `relationship` 中使用。 这会稍微改变原先类的行为。比较明显的变化是,不再能随便指定 field 的顺序了,现在如果一个字段需要出现在构造函数里(没有设置 `init=False`),那么没有默认值的字段必须在有默认值的字段前面,和构造函数参数的顺序相同。 这里还有一些陷阱。我们会发现一个字段可能有三种情况: 1. 没有设置默认值。在这种情况下,构造函数一定要提供这个参数。 2. 有设置默认值。在这种情况下,构造函数可以不提供这个参数。 3. `init` 为 `False`。在这种情况下,构造函数不能提供这个参数。 在 `Employee` 的类中,原先 `company_id` 和 `company` 的都是可选的。如果使用 `dataclass` 行为最接近的应该是 2。也就是 `company_id` 和 `company` 都有默认值。Python 没有 `undefined` 这种东西,所以我们只能将默认值设置为 `None`。 于是可以发现,如果还想通过 company\_id 来设置员工的公司的话,我们写成 `Employee(company_id=1)`。而这并不会正常工作,因为 `company` 的默认值也是 `None`。因此我们的构造等价于 `Employee(company_id=1, company=None)`,提供 `None` 和不提供其实并不一样。同时前面我们讨论过,relationship 的值会覆盖外键的设置。我们实际上创建了一个没有公司的员工。 这里,原先的行为会被改变,因此很让人困惑。我所认为的最佳实践是,如果外键字段和关联查询用的 `relationship` 字段同时出现,则将 `relationship` 设为 `init=False`。这样它就不会在构造函数中出现了,也不会有刚才提及的问题。 ### 抽象出字段 前面提到,许多表需要记录条目的 id、创建时间和更新时间。我们可以将它们抽象出来: ```python class BaseWithAudit(Base): __abstract__ = True id: Mapped[int] = mapped_column(primary_key=True, init=False) created_at: Mapped[datetime.datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now(), init=False) updated_at: Mapped[datetime.datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), init=False) ``` 这里的 `__abstract__` 属性表示这个类不会被创建为表。`id`、`created_at` 和 `updated_at` 字段会被所有继承这个类的表格所共享。 这里使用了自增主键,如果我们不希望暴露用户规模,使用 uuid 作为主键或许更好。 ### 其他配置 其他还有索引、唯一性约束等配置,但是这些不太容易出错,篇幅有限这里略过,可以直接阅读文档。 ## 准备工作 经过上述一系列介绍,我们终于正确定义了模型。在进行查询之前,我们需要进行一些准备工作。 通过 `create_engine` 可以创建一个数据库引擎,使用 `sessionmaker` 来创建一个会话类。所有的数据库操作都需要通过会话实例来完成。 ```python engine = create_engine(os.getenv("DB_URL")) Session = sessionmaker(bind=engine) ``` 创建符合 SQLAlchemy ORM 的数据库结构: ```python Base.metadata.create_all(engine) ``` ## 查询 增删改查操作都可以通过实例化 `Session` 对象来完成。 对于查询操作,可以使用 `session.get` 来根据主键获取单个的对象。 ```python session = Session() company = session.get(Company, 1) # 根据主键查询获取一个公司对象 ``` 也可以执行 SQL 语句查询。我们可以链式调用 select 函数来构建查询语句。然后使用 `session.execute` 来执行查询语句。 ```python stmt = select(Company).where(Company.name == "Google") # 等价于 SELECT * FROM companies WHERE name = "Google" print(stmt) # 可以打印生成的 SQL 语句。 session.execute(stmt) # 执行查询语句,返回所有符合条件的公司 ``` 返回的结果是一个结果集,还需要进一步处理来获得数据。 这个结果集可以迭代,内部数据是一个**元组数组**。每一行对于数据库中的一行数据。每一列对应 select 函数的每个参数。 通过 all() 方法可以获取所有结果: ```python session.execute(select(Company, Company.name)).all() # [ # (, 'Apple'), # (, 'Google'), # (, 'Preferred Networks'), # ] ``` 这里特意使用了 `select(Company, Company.name)`,可以看到返回的结果是一个元组。第一个元素是 `Company` 对象,第二个元素是 `Company.name` 的值。 这有点不方便,即使我们只查询了 `Company`,返回结果仍然是元组数组: ```python session.execute(select(Company)).all() # [ # (,), # (,), # (,), # ] ``` SQLAlchemy 允许我们使用 `scalars()` 方法来获取第一列的结果。其他列会被忽略。 ```python session.execute(select(Company.name, Company.id)).scalars().all() # ['Apple', 'Google', 'Preferred Networks'] ``` 甚至,有时我们只需要第一行第一列数据,可以使用 `scalar()` 来获取它。 ```python session.execute(select(Company.name)).scalar() # 'Apple' ``` scalars 和 scalar 太过于常用,以至于可以直接使用 `session.scalars` 或者 `session.scalar` 进行查询。 ```python session.scalars(select(Company.name)).all() # 等价于 session.execute(select(Company.name)).scalars().all() session.scalar(select(Company.name)) # 等价于 session.execute(select(Company.name)).scalar() ``` 除了 `all()` 获取所有元素之外,比较常用的是使用 `first()`、 `one()` 或者 `one_or_none()` 等方法获得一行元素,它们的表现不太一样: | 条件 | `first()` | `one()` | `one_or_none()` | | -------- | --------- | ------- | --------------- | | 当结果集有多行时 | 返回第一行 | 抛出异常 | 抛出异常 | | 当结果集为空 | 返回 None | 抛出异常 | 返回 None | ### 增删改 使用 `session.add` 和 `session.delete` 来进行增删改操作可以满足大部分需求: ```python session = Session() company = Company(name="Test Company", id=1) session.add(company) session.commit() # 此时,执行了 INSERT 语句,将公司添加到数据库中 company.name = "New Company" session.commit() # 此时,执行了 UPDATE 语句,将公司的名称修改为 New Company session.delete(company) # 删除公司 session.commit() # 此时,执行了 DELETE 语句,将公司从数据库中删除 ``` 这种方式的好处是,他会通过 ORM 对象分析并生成较佳的语句: ```python session = Session() alice = Employee(name="Alice", id=1, company_id=1) session.add(alice) # 添加 Alice 员工 apple = Company(name="Apple", id=1) session.add(apple) # 添加 Apple 公司 bob = Employee(name="Bob", id=2, company=Company(name="Google", id=2)) # 添加 Bob 员工,并直接设定 company 对象 session.add(bob) # 添加 Bob 员工 session.commit() ``` 例如上述代码是完全合法的。SQLAlchemy 能够意识到应该先创建公司,再添加员工,否则会有外键冲突。另外,`bob` 的公司是 `Google`,但是我们并没有创建 `Google` 公司,而是直接使用了 `Company(name="Google", id=2)`。SQLAlchemy 会自动创建 `Google` 公司。 同时,值得一提的是,在这个提交里实际只执行两条 `INSERT` 语句,公司和员工的创建分别合并到了一起,形成更高效的插入逻辑: ```sql INSERT INTO companies (id, name) VALUES (%(id)s::INTEGER, %(name)s::VARCHAR) -- [generated in 0.00015s] [{'id': 1, 'name': 'Apple'}, {'id': 2, 'name': 'Google'}] INSERT INTO employees (id, name, company_id) VALUES (%(id)s::INTEGER, %(name)s::VARCHAR, %(company_id)s::INTEGER) -- [generated in 0.00011s] [{'id': 1, 'name': 'Alice', 'company_id': 1}, {'id': 2, 'name': 'Bob', 'company_id': 2}] ``` ### 用外键字段设置,还是用关系字段设置? 你也许发现,我们既可以通过 company\_id 来指定员工的公司,也可以通过 company 对象来指定员工的公司。很让人在意的是,如果指定了冲突的值会如何呢? ```python apple = Company(name="Apple", id=1) session.add(apple) # 添加 Apple 公司 google = Company(name="Google", id=2) session.add(google) # 添加 Google 公司 alice = Employee(name="Alice", id=1, company_id=1, company=google) # id=1,为 Apple 公司,而 company 指定了 Google, Alice 到底是属于 Apple 还是 Google? session.add(alice) session.commit() # 提交事务 assert alice.company_id == 2 ``` 经过实验发现,对象的值更为优先。也就是说,如果同时指定了 `company_id` 和 `company`,则 `company` 的值会覆盖 `company_id` 的值。这里不会报任何 warning,让人感觉有点不安。 我认为的最佳实践是,不要直接设置 relationship 的值,而是通过外键列来设置关系。也就是使用 `company_id` 来设置员工的公司。毕竟如果获得对象有一定的成本,可能需要进行查询,而如果已经获得了对象的话,也能简单访问其主键用于进行关系设置。 ### 使用 SQL 语句查询 如果想要完全控制,另一个方式是使用 `session.execute` 来执行 SQL 语句。 ```python session = Session() session.execute(insert(Company).values(id=1, name="Test Company")) # 执行 INSERT 语句,将公司添加到数据库中 ``` 你可能会见到另一套使用方式,使用 `session.query` 来进行查询。注意这是过时的用法,最好不要使用。 ## ORM 对象是统一的 SQAlchemy 背后存在许多魔法。例如同一个 session 中,相同的对象在内存中只会存在一份。并且各种修改操作也能作用到查询出来的对象上。 ```python company = Company(name="Test Company", id=1) session.add(company) session.commit() company_1 = session.get(Company, 1) assert company_1 is company # True,查询出的对象和之前添加的对象是同一个对象 session.execute(update(Company).where(Company.id == 1).values(name="New Company")) # 修改了公司的名称 session.commit() assert company.name == "New Company" # 神奇地,之前存在的对象的值也被修改了 ``` 这样神奇的行为虽然方便,但可能有时会让人困惑。 ## 关于延迟加载 `relationship` 存在一个重要的参数 `lazy`,用于指定加载方式。 `lazy` 默认值为 `select`。意思是“在访问这个字段时通过 `select` 查询延迟加载字段的值”。即,默认会进行延迟加载(Lazy Loading)。 ```python class Company(Base): # 省略其他代码 employees: Mapped[List["Employee"]] = relationship() # 默认 lazy="select",会延迟加载 session = Session() employees = session.scalars(select(Employee)).all() for employee in employees: logger.info("Employee: %s, Company: %s", employee.name, employee.company.name) ``` 如果数据库里存在 3 个公司,每个公司 3 名员工,可能会产生如下的输出: ```sql BEGIN (implicit) # 查询所有员工 SELECT employees.id, employees.name, employees.company_id FROM employees -- [generated in 0.00012s] {} # 查询 id 为 1 的公司 SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id = %(pk_1)s::INTEGER -- [generated in 0.00013s] {'pk_1': 1} -- Employee: Brian Baker, Company: Brown-Spencer -- Employee: Karen Payne, Company: Brown-Spencer -- Employee: Stephanie Bradley, Company: Brown-Spencer # 查询 id 为 2 的公司 SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id = %(pk_1)s::INTEGER -- [cached since 0.002736s ago] {'pk_1': 2} -- Employee: Joseph Howard, Company: Cooper, Hunt and Long -- Employee: Amanda Brooks, Company: Cooper, Hunt and Long -- Employee: Lindsay Grant, Company: Cooper, Hunt and Long # 查询 id 为 3 的公司 SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id = %(pk_1)s::INTEGER -- [cached since 0.00426s ago] {'pk_1': 3} -- Employee: Cynthia Pittman, Company: Pope Ltd -- Employee: Amanda Cook, Company: Pope Ltd -- Employee: James Fernandez, Company: Pope Ltd ``` 可以发现首先查询了所有员工,然后在访问每个员工的公司时,才会查询公司表。 这就是延迟加载(Lazy Loading)。 由于我们查询员工时很可能并不需要公司信息,所以延迟加载是有意义的。 但这会导致 N+1 查询问题。我们需要进行 1 次查询获取所有 9 名员工,然后对每个员工进行查询获取公司信息。查询数很多,会大大拖慢查询速度。 这里有一个细节:实际上并不是 9+1=10 次查询,而是 3+1=4 次。原因在于一个 session 内部,sqlalchemy 能够缓存查询的结果,如果我们访问同一公司的信息,sqlalchemy 会直接从缓存中获取,而不会再次查询数据库。因此,虽然我们访问了 9 次员工的公司信息,但它们实际上分属于 3 个公司,因此实际上只多查询了 3 次数据库。 无论如何,如果我们确实需要访问员工的公司信息,延迟加载会大量增加查询次数。 在 relationship 中使用 `lazy` 参数反而可以指定预先加载(Eager Loading)。 这里非常反直觉,不写 `lazy` 参数就会使用懒加载,而写了 `lazy` 反而不懒加载了。 下面介绍几种预先加载的模式,它们的查询的性能会有微妙的差异,但一般来说,`selectin` 的性能会更好。 ### 使用 joined 预加载 如果 lazy 设置为 `joined`,在查询 Employee 时,会使用 JOIN 语句将 Company 表连接到 Employee 表。 ```sql BEGIN (implicit) SELECT employees.id, employees.name, employees.company_id, companies_1.id AS id_1, companies_1.name AS name_1 FROM employees LEFT OUTER JOIN companies AS companies_1 ON companies_1.id = employees.company_id -- [generated in 0.00012s] {} ``` ### 使用 selectin 预加载 如果 lazy 设置为 `selectin`,在查询 Employee 时,触发第二个查询,通过 IN 语句筛选出所有员工的公司。 ```sql BEGIN (implicit) SELECT employees.id, employees.name, employees.company_id FROM employees -- [generated in 0.00012s] {} SELECT companies.id AS companies_id, companies.name AS companies_name FROM companies WHERE companies.id IN (%(primary_keys_1)s::INTEGER, %(primary_keys_2)s::INTEGER, %(primary_keys_3)s::INTEGER) -- [generated in 0.00016s] {'primary_keys_1': 1, 'primary_keys_2': 2, 'primary_keys_3': 3} ``` ### 使用 subquery 预加载 subquery 预加载也需要进行两次查询。 ```sql BEGIN (implicit) SELECT employees.id, employees.name, employees.company_id FROM employees -- [generated in 0.00017s] {} SELECT companies.id AS companies_id, companies.name AS companies_name, anon_1.employees_company_id AS anon_1_employees_company_id FROM (SELECT DISTINCT employees.company_id AS employees_company_id FROM employees) AS anon_1 JOIN companies ON companies.id = anon_1.employees_company_id -- [generated in 0.00039s] {} ``` ### 查询时决定如何加载 我们也可以在查询时决定如何加载。 ```python stmt = select(Employee).options(selectinload(Employee.company)) # 在查询时,对于 Employee 表的 company 字段使用 selectin 预加载 ``` ## 事务管理 一个查询可能有以下几种状态: 1. 未提交(pending):事务还没有提交,数据还没有写入数据库。 2. 已提交更改(flushed):数据已经写入数据库,但事务还没有提交。此时只有当前 session 可以看到更改。 3. 已提交事务(committed):数据已经写入数据库,事务已经提交。此时所有 session 都可以看到更改。 ### flush 我们可以使用 `session.flush()` 来将更改提交到数据库,但不会提交事务。这意味着在当前 session 中可以看到更改,但在其他 session 中看不到。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.flush() # 将更改提交到数据库,但不会提交事务 assert session.get(Company, 1).id == 1 # 可以查询到刚刚添加的公司 new_session = Session() assert new_session.get(Company, 1) is None # 在别的 session 还查询不到,因为事务没有提交 ``` ### commit 通过 `session.commit()` 可以提交事务。意味着修改真正生效。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.commit() # 提交事务 new_session = Session() assert new_session.get(Company, 1).id == 1 # 在别的 session 可以查询到,因为事务已经提交 ``` ### rollback 可以通过 `session.rollback()` 来回滚一个还未提交的事务。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.flush() # 将更改提交到数据库,但不会提交事务 assert session.get(Company, 1).id == 1 # 此时能查询到 session.rollback() # 回滚事务,撤销更改 assert session.get(Company, 1) is None # 此时查询不到 ``` 作为一个错误的示范,如果事务已经使用 commit 提交,那么是无法回滚的: ```python session = Session() session.add(Company(name="Test Company", id=1)) session.commit() # 提交事务 assert session.get(Company, 1).id == 1 # 此时能查询到 session.rollback() # 回滚事务,撤销更改 assert session.get(Company, 1).id == 1 # 此时仍然能查询到,因为事务已经提交,无法回滚 ``` 另外,如果一个 session 没有 commit 就关闭了,那么这个 session 中的所有更改都会被回滚。 ```python session = Session() session.add(Company(name="Test Company", id=1)) session.flush() # 将更改提交到数据库,但不会提交事务 session.close() # 关闭 session,此时会回滚事务,撤销所有更改 ``` ### flush,commit 和 rollback 的联合使用 我们经常需要保证多次数据库修改的原子性。也就是要么全部成功,要么全部失败。 如果失败则需要回滚。 ```python session = Session() try: session.add(Company(name="Test Company", id=1)) session.flush() # 将更改提交到数据库,但不会提交事务 raise Exception("Test exception") # 模拟异常 session.commit() # 提交事务 except Exception: session.rollback() # 回滚事务,撤销所有更改 ``` ### 使用 begin 管理 实践中,我们最好不要手动 `commit` 或者 `rollback`,而是使用 `session.begin()` 来管理事务。 ```python session = Session() with session.begin(): session.add(Company(name="Test Company", id=1)) # 添加公司 # 出了 with 语句,事务会自动提交,此外,如果发生异常,事务会自动回滚 new_session = Session() assert new_session.get(Company, 1) is not None # 在别的 session 可以查询到,因为事务已经提交 ``` 可以使用 `session.begin_nested()` 来创建一个嵌套事务。 ```python with session.begin(): session.add(Company(name="Test Company", id=1)) with session.begin_nested(): session.add(Employee(name="Test Employee", id=1, company_id=1)) session.add(Employee(name="Test Employee 2", id=2, company_id=1)) ``` ### autoflush 默认情况下,SQLAlchemy 的 auto flush 功能是开启的。但这并不意味着每次 session.add 等操作都会 flush。实际上,开启此选项是在查询前自动 flush。 ```python session = sessionmaker(bind=engine)(autoflush=True) # 默认就是开启的,这里显式开启,可以省略 company = Company(name="Google", id=1) session.add(company) assert session.get(Company, 1) is not None # 并没有提交更改,也没有提交事务,但是能查询到,因为在查询前进行了自动 flush ``` 如果关闭 autoflush 呢? ```python session = sessionmaker(bind=engine, autoflush=False)() # 关闭自动 flush company = Company(name="Google", id=1) session.add(company) assert session.get(Company, 1) is None # None,因为没有自动 flush ``` 需要注意的是,`session.flush()` 只与 `session.add()` 或者 `session.delete()`配合使用。它只影响通过 `session.add()` 方法添加,或者 `session.delete()` 方法删除的对象。 换言之,如果我们直接使用 `session.execute` 来执行 SQL 语句,则不会受到 `autoflush` 的影响。 ```python session = sessionmaker(bind=engine, autoflush=False)() session.execute(insert(Company).values(id=1, name="Google")) # execute 不需要 flush,会自动提交查询 assert session.get(Company, 1) is not None # 如果使用 execute 进行插入,即使没有自动 flush,仍然能查询到 ``` `autoflush` 感觉是一个不太有用的功能,反而容易让人感到困惑,也许应该关闭。 ### expire\_on\_commit SQLAlchemy 的 `expire_on_commit` 功能是默认开启的。也就是说,在提交事务后,所有对象都会被标记为过期。这个特性在只在非常微妙的场景下有用: 如下面代码所示,如果提交后,有另一个 session 修改了对象的值,如果 expire on commit 功能关闭,会访问到过期的值。 ```python Session = sessionmaker(bind=engine, expire_on_commit=False) session = Session() c = Company(name="Google", id=1) session.add(c) session.commit() # 在另一个 session 中修改了公司的名称 other_session = Session() other_session.execute(update(Company).where(Company.id == 1).values(name="Meta")) other_session.commit() assert c.name == 'Google' # 仍然是 Google,因为没有过期 ``` 有意思的是,如果在同一个 session 中,修改了对象的值,则不会有这个问题。SQLAlchemy 魔法般地在幕后能够意识到对象 `c` 被修改,并设置了新的值。 ```python Session = sessionmaker(bind=engine) # expire_on_commit=True session = Session() with session.begin(): c = Company(name="Google", id=1) session.add(c) with session.begin(): session.execute(update(Company).where(Company.id == 1).values(name="Meta")) assert c.name == "Meta" ``` 我个人的感想是,没有任何理由需要开启 `expire_on_commit` 功能。在 Commit 后,我真的需要在意一个值会不会被别的 `session` 修改吗?这本身是无法保证的。即使我及时刷新了数据,在查询出数据后的任何时候——比如数据传输过程中,数据也可能会被修改从而过期。况且大多数情况下,变更不会发生,这只会增加复杂性,并减慢查询速度。 另外,许多关于 SQLAlchemy 的说明中,都将 `expire_on_commit` 设置为 `False`。(例如 [Litestar](https://docs.litestar.dev/2/tutorials/sqlalchemy/0-introduction.html){rel=""nofollow""}和 [官方文档](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html){rel=""nofollow""}) ## 异步 SQLAlchemy 支持异步操作。我们只需要构建一个异步的引擎和会话。 ```python async_engine = create_async_engine(os.getenv("DB_URL")) AsyncSession = async_sessionmaker(bind=async_engine) ``` 但是异步的世界和同步完全不同。 最大的区别在于,异步世界没有隐式 I/O。我们需要显式地使用 `await` 来进行 I/O 操作。 何时执行 I/O 在 SQLAlchemy 中并不是很显而易见。 ```python session = AsyncSession() async with session.begin(): session.add(Company(name="Google", id=1)) await session.commit() # 此时有 I/O 操作,提交事务 company = await session.get(Company, 1) # 查询 id 为 1 的公司,此时有 I/O 操作 select_stmt = select(Company).where(Company.name == "Google") # 此时没有 I/O 操作,只是生成了 SQL 语句 result = await session.execute(select_stmt) # 此时有 I/O 操作,执行查询语句 companies = result.scalars().all() # 此时没有 I/O 操作,只是处理了查询结果 ``` 我们可以看到,`session.commit()` 和 `session.execute()` 都是 I/O 操作。我们需要使用 `await` 来等待它们完成。而 `session.add()` 和 `select()` 都不是 I/O 操作。它们只是将对象添加到 session 中,或者生成 SQL 语句。 延迟加载在异步环境下难以使用。假如 `Company` 和 `Employee` 之间的关系是延迟加载的,也就是使用默认的 `lazy="select"`。下面(同步)的代码则会进行两次查询: ```python with Session() as session: b = session.get(Employee, 1) # 查询 id 为 1 的员工 print(b.company.name) # 延迟查询员工的公司名称 ``` 除了性能有点差之外,没有问题,完全能正常工作。 而在异步下,这段代码会报错: ```python async with AsyncSession() as session, session.begin(): a = await session.get(Employee, 1) logger.info(a.company.name) # sqlalchemy.exc.MissingGreenlet ``` 其实报错是可以理解的。因为异步下,一切 I/O 操作都需要显式 await,然而在这里,访问 `a.company` 时,sqlalchemy 需要进行 I/O 操作,但没有 await。 实际上,报错也不一定是坏事。我认为我们应该尽可能地杜绝隐式 I/O。如果我能预见在 Session 中会用到懒加载的字段,我就应该提前加载它,否则会影响性能。 ### 异步懒加载 如果我们真的需要懒加载并访问数据(往往不需要),我们可以使用 `AsyncAttrs`,这需要我们修改基类: ```python class Base(AsyncAttrs, DeclarativeBase): pass ``` 然后可以通过如下方式 `await` 字段: ```python name = await a.awaitable_attrs.company.name ``` 个人觉得上述方式非常别扭。也可以使用 `session.run_sync`: ```python name = await session.run_sync(lambda _: a.company.name) ``` 上述方式不需要修改基类。这种方式实际上会启动一个新的线程去执行查询。它们几乎是相同的。 ## 或许应该这么写 在使用 SQLAlchemy 的过程中,特别是异步开发,非常令我痛苦,代码会在各种意想不到的地方报错。为了杜绝这些错误,我认为也许应该这么写: ### 不要使用默认的懒加载 默认的懒加载策略几乎没有存在价值。 首先,由于异步的并发性能较高,我们会首选异步下进行开发。此时我们必须要显式声明 await,这表示我们仍需要明确知道哪里会进行 I/O 操作,完全没有减轻开发时的心智负担,反而必须要在运行时才会收到报错。 另外,是否需要用到懒加载的字段在大多数时候时是可预见的。而且要么我们对懒加载的字段完全不感兴趣,要么会对每一行的懒加载字段都感兴趣。在前者的情况下,懒加载毫无意义;在后者的情况下,懒加载会导致 N+1 查询问题。只有在我们对个别记录的懒加载字段感兴趣时,懒加载才有意义,但这种情况几乎不会发生。 因此,在所有的 `relationship` 中都使用 `lazy="selectin"` 来进行预加载可能是一种最佳实践。 ### 关掉 `expire_on_commit` `expire_on_commit` 似乎也没有存在的价值,它默认开启,平添了许多 I/O 操作,并且在异步下往往会因此报错,更糟糕的是,它几乎什么问题都没有解决。 例如下面这段代码,假设我开启了 `expire_on_commit`,并且没有在表定义种使用 `lazy="selectin"`,而是查询中指定 `options`,此时在 `commit` 事务后,`e` 会过期。 也许机智的我知道 `session.refresh(e)` 会刷新对象 `e` 的值。然而,其实这里即使 `refresh` 也不会重新加载 `company` 的值,因为 SQLAlchemy 并不能智能地知道这里的 `e` 是 `selectinload` 的查询查出来的结果。 ```python async def main(): async with AsyncSession() as session: e = await session.scalar(select(Employee).where(Employee.name == "John Doe").options(selectinload(Employee.company))) logger.info(f"Employee: {e.company.name}") await session.commit() await session.refresh(e) logger.info(f"Employee: {e.company.name}") # error ``` ### 每个 session 只在最后 commit 一次 从前面的分析我们可以看到,`commit` 之后再进行别的操作,会有各种各样的问题。实际上,每个 `session` 只 `commit` 一次则是非常好的实践。可以避免上述的一系列问题,同时保证原子性。 特别是 web 应用中,`session` 的生命周期和请求的生命周期一致的话,管理起来容易许多。 因此,比起手动 `commit`,使用 `session.begin()` 来管理事务是更好的选择。我们可以把 session 的创建和 begin 放在一起,同时代码中也不再需要 `session.commit()`, `session.rollback()` 等操作,还不用 `try...catch` 语句,更加简洁。 ## 总结 很抱歉,不知不觉已经太长了。它可能已经变得非常难以阅读。但 SQLAlchemy 的确是一个非常复杂的库,尤其是 ORM 部分,有数不清的魔法和陷阱。 其中也许有一些认知错误,欢迎大家一起讨论。我没有介绍 SQLAlchemy 的所有功能,例如索引、别名、Alembic 迁移等等。其实上述讲的许多,在 SQLAlchemy 的文档中都有介绍,只是文档内容实在过多,而且新旧 API 混杂,实在难以消化。 # Use 这是我日常使用的设备与软件清单,按类别简单整理。 ## 桌面与硬件 - 椅子: Herman Miller Sayl - 笔记本电脑: MacBook Pro 13 英寸 M1 - 键盘: Realforce R3 Keyboard - 鼠标: 罗技 G102 - 话筒: 森海塞尔 MK4 - 耳机: AKG K712 - 音响: 铁三角 AT-SP95 ## 摄影 - 相机: Sony A7C Mark II - 镜头: - 腾龙 28-200mm - 索尼 FE 50-150mm F2 GM ## 软件 - 字体: Berkeley Mono - 编辑器: Visual Studio Code - AI 辅助: CodeX AI - 视频剪辑: Adobe Premiere Pro - 后期: Adobe Lightroom