diff --git a/.env b/.env new file mode 100644 index 0000000..73f6877 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +HUGO_VERSION=0.145.0 diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml new file mode 100644 index 0000000..ea2cec1 --- /dev/null +++ b/.github/workflows/hugo.yml @@ -0,0 +1,74 @@ +# Sample workflow for building and deploying a Hugo site to GitHub Pages +name: Deploy Hugo site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["gh-pages"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +# Default to bash +defaults: + run: + shell: bash + +jobs: + # Build job + build: + runs-on: ubuntu-latest + env: + HUGO_VERSION: 0.145.0 + steps: + - name: Install Hugo CLI + run: | + wget -O "${{ runner.temp }}/hugo.deb" "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb" \ + && sudo dpkg -i "${{ runner.temp }}/hugo.deb" + - name: Install Dart Sass Embedded + run: sudo snap install dart-sass-embedded + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + - name: Setup Pages + id: pages + uses: actions/configure-pages@v6 + - name: Install Node.js dependencies + run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" + - name: Build with Hugo + env: + # For maximum backward compatibility with Hugo modules + HUGO_ENVIRONMENT: production + HUGO_ENV: production + run: | + hugo \ + --minify \ + --baseURL "${{ steps.pages.outputs.base_url }}/" + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: ./public + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 66fd13c..23dd690 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,21 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +### Hugo ### +# Generated files by hugo +/public/ +/resources/_gen/ +/assets/jsconfig.json +hugo_stats.json + +# Executable may be added to repository +hugo.exe +hugo.darwin +hugo.linux + +# Temporary lock file while building +.hugo_build.lock +**/.DS_Store + +.playwright-mcp/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6c0a1db --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "themes/LoveIt"] + path = themes/LoveIt + url = git@github.com:dillonzq/LoveIt.git diff --git a/LICENSE b/LICENSE index 0e259d4..33bec29 100644 --- a/LICENSE +++ b/LICENSE @@ -1,121 +1,427 @@ -Creative Commons Legal Code - -CC0 1.0 Universal - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS - PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM - THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED - HEREUNDER. - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator -and subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for -the purpose of contributing to a commons of creative, cultural and -scientific works ("Commons") that the public can reliably and without fear -of later claims of infringement build upon, modify, incorporate in other -works, reuse and redistribute as freely as possible in any form whatsoever -and for any purposes, including without limitation commercial purposes. -These owners may contribute to the Commons to promote the ideal of a free -culture and the further production of creative, cultural and scientific -works, or to gain reputation or greater distribution for their Work in -part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any -expectation of additional consideration or compensation, the person -associating CC0 with a Work (the "Affirmer"), to the extent that he or she -is an owner of Copyright and Related Rights in the Work, voluntarily -elects to apply CC0 to the Work and publicly distribute the Work under its -terms, with knowledge of his or her Copyright and Related Rights in the -Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not -limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, - communicate, and translate a Work; - ii. moral rights retained by the original author(s) and/or performer(s); -iii. publicity and privacy rights pertaining to a person's image or - likeness depicted in a Work; - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - v. rights protecting the extraction, dissemination, use and reuse of data - in a Work; - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation - thereof, including any amended or successor version of such - directive); and -vii. other similar, equivalent or corresponding rights throughout the - world based on applicable law or treaty, and any national - implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention -of, applicable law, Affirmer hereby overtly, fully, permanently, -irrevocably and unconditionally waives, abandons, and surrenders all of -Affirmer's Copyright and Related Rights and associated claims and causes -of action, whether now known or unknown (including existing as well as -future claims and causes of action), in the Work (i) in all territories -worldwide, (ii) for the maximum duration provided by applicable law or -treaty (including future time extensions), (iii) in any current or future -medium and for any number of copies, and (iv) for any purpose whatsoever, -including without limitation commercial, advertising or promotional -purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each -member of the public at large and to the detriment of Affirmer's heirs and -successors, fully intending that such Waiver shall not be subject to -revocation, rescission, cancellation, termination, or any other legal or -equitable action to disrupt the quiet enjoyment of the Work by the public -as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason -be judged legally invalid or ineffective under applicable law, then the -Waiver shall be preserved to the maximum extent permitted taking into -account Affirmer's express Statement of Purpose. In addition, to the -extent the Waiver is so judged Affirmer hereby grants to each affected -person a royalty-free, non transferable, non sublicensable, non exclusive, -irrevocable and unconditional license to exercise Affirmer's Copyright and -Related Rights in the Work (i) in all territories worldwide, (ii) for the -maximum duration provided by applicable law or treaty (including future -time extensions), (iii) in any current or future medium and for any number -of copies, and (iv) for any purpose whatsoever, including without -limitation commercial, advertising or promotional purposes (the -"License"). The License shall be deemed effective as of the date CC0 was -applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder -of the License, and in such case Affirmer hereby affirms that he or she -will not (i) exercise any of his or her remaining Copyright and Related -Rights in the Work or (ii) assert any associated claims and causes of -action with respect to the Work, in either case contrary to Affirmer's -express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - b. Affirmer offers the Work as-is and makes no representations or - warranties of any kind concerning the Work, express, implied, - statutory or otherwise, including without limitation warranties of - title, merchantability, fitness for a particular purpose, non - infringement, or the absence of latent or other defects, accuracy, or - the present or absence of errors, whether or not discoverable, all to - the greatest extent permissible under applicable law. - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the - Work. - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to - this CC0 or use of the Work. +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + + including for purposes of Section 3(b); and + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. \ No newline at end of file diff --git a/archetypes/default.md b/archetypes/default.md new file mode 100644 index 0000000..e7880dc --- /dev/null +++ b/archetypes/default.md @@ -0,0 +1,22 @@ +--- +title: "{{ replace .TranslationBaseName "-" " " | title }}" +subtitle: "" +date: {{ .Date }} +lastmod: {{ .Date }} +draft: true +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +lightgallery: true +--- + + diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml new file mode 100644 index 0000000..9926392 --- /dev/null +++ b/config/_default/hugo.toml @@ -0,0 +1,143 @@ +baseURL = "https://kawin.dev/" + +# Change the default theme to be use when building the site with Hugo +theme = "LoveIt" + +# website title +title = "Kawin's Pages" + +defaultContentLanguage = "th" + +# language code ["en", "zh-CN", "fr", "pl", ...] +languageCode = "th" +# language name ["English", "简体中文", "Français", "Polski", ...] +languageName = "ภาษาไทย" +# whether to include Chinese/Japanese/Korean +hasCJKLanguage = false + +# default amount of posts in each pages +[pagination] +pagerSize = 12 +# google analytics code [UA-XXXXXXXX-X] +# googleAnalytics = "" +# copyright description used only for seo schema +copyright = "" + +# whether to use robots.txt +enableRobotsTXT = true +# whether to use git commit log +enableGitInfo = true +# whether to use emoji code +enableEmoji = true + +[params] +title = "Kawin's Pages" +description = "Kawin's Pages & Blogs" +# site default theme ["auto", "light", "dark"] +defaultTheme = "auto" +# public git repo url only then enableGitInfo is true +gitRepo = "https://github.com/bouroo/bouroo.github.io" +# website images for Open Graph and Twitter Cards +images = ["https://avatars.githubusercontent.com/u/1217739"] +[params.header] +[params.header.title] +name = "Kawin vir." +pre = "🧑‍💻 " +[params.footer] +license = 'CC BY-SA' +[params.home] +[params.home.profile] +title = "" +avatarURL = "https://avatars.githubusercontent.com/u/1217739?size=192" +subtitle = '🌐 Remote Working lover.
🧑‍💻 Backend Dev & System Admin.
🎮 Love to play games.
🏡 Like watching Japanese anime.' +disclaimer = "" +# Social config about the author +[params.social] +GitHub = "bouroo" +Linkedin = "kawin-vir" +Twitter = "bouroo" +RSS = true + +# Author config +[params.author] +name = "Kawin Viriyaprasopsook" +email = "kawin.v@kkumail.com" +link = "https://kawin.dev" + +# Markup related configuration in Hugo +[markup] +# Syntax Highlighting (https://gohugo.io/content-management/syntax-highlighting) +[markup.highlight] +# false is a necessary configuration (https://github.com/dillonzq/LoveIt/issues/158) +noClasses = false + +[menu] +[[menu.main]] +weight = 1 +identifier = "posts" +# you can add extra information before the name (HTML format is supported), such as icons +pre = "" +# you can add extra information after the name (HTML format is supported), such as icons +post = "" +name = "Posts" +url = "/posts/" +# title will be shown when you hover on this menu link +title = "" +[[menu.main]] +weight = 2 +identifier = "tags" +pre = "" +post = "" +name = "Tags" +url = "/tags/" +title = "" +[[menu.main]] +weight = 3 +identifier = "categories" +pre = "" +post = "" +name = "Categories" +url = "/categories/" +title = "" +[[menu.main]] +weight = 4 +identifier = "about" +pre = "" +post = "" +name = "About" +url = "/about/" +title = "" +[[menu.main]] +weight = 5 +identifier = "portfolio" +pre = "" +post = "" +name = "Portfolio" +url = "/portfolio/" +title = "" +[[menu.main]] +weight = 9 +identifier = "github" +pre = "" +post = "" +name = "GitHub" +url = "https://github.com/bouroo" +title = "GitHub" + +[languages] + +[languages.th] +weight = 1 +languageCode = "th" +languageName = "ภาษาไทย" + +[languages.en] +weight = 2 +languageCode = "en" +languageName = "English" + +[frontmatter] +date = ['date', 'publishDate', 'lastmod'] +expiryDate = ['expiryDate'] +lastmod = [':git', 'lastmod', 'date', 'publishDate'] +publishDate = ['publishDate', 'date'] diff --git a/content/about/index.en.md b/content/about/index.en.md new file mode 100644 index 0000000..3fd7631 --- /dev/null +++ b/content/about/index.en.md @@ -0,0 +1,101 @@ +--- +title: "About Kawin Vir." +subtitle: "" +date: 2023-08-12T15:32:50+07:00 +lastmod: 2023-08-12T15:32:50+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "About Kawin Viriyaprasopsook — a senior backend engineer with 10+ years of experience in Go microservices, cloud-native infrastructure, and remote work, based out of Khon Kaen, Thailand." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image" + +lightgallery: true +--- + +![profilePic](https://avatars.githubusercontent.com/u/1217739) + + +## Professional Experience + +Senior Backend Engineer possessing over a decade of experience in architecting and deploying distributed systems, Go microservices, and cloud-native infrastructure. Demonstrates proven expertise in integrating development and operations (DevOps) to ensure the delivery of scalable, fault-tolerant platforms within the Fintech and E-commerce sectors. Experienced in technical team leadership and mentoring engineers on principles of clean architecture and implementation of best practices. + +Open to permanent remote opportunities from Khon Kaen or hybrid arrangements in Bangkok + +## Personal Characteristics + +- **Remote Work Enthusiast**: I thrive in remote working environments. +- **Backend Developer and Systems Administrator**: Specialized in Go/JS programming. +- **Gaming Enthusiast**: I enjoy playing video games. +- **Anime Aficionado**: I have a keen interest in watching Japanese anime. + + + +## Socials +[![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://linkedin.com/in/kawin-vir) [![Twitter](https://img.shields.io/badge/Twitter-%231DA1F2.svg?logo=Twitter&logoColor=white)](https://twitter.com/bouroo) + +# Tech Stack +![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![Shell Script](https://img.shields.io/badge/shell_script-%23121011.svg?style=for-the-badge&logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Cloudflare](https://img.shields.io/badge/Cloudflare-F38020?style=for-the-badge&logo=Cloudflare&logoColor=white) ![JWT](https://img.shields.io/badge/JWT-black?style=for-the-badge&logo=JSON%20web%20tokens) ![Nginx](https://img.shields.io/badge/nginx-%23009639.svg?style=for-the-badge&logo=nginx&logoColor=white) ![MariaDB](https://img.shields.io/badge/MariaDB-003545?style=for-the-badge&logo=mariadb&logoColor=white) ![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?style=for-the-badge&logo=redis&logoColor=white) ![Supabase](https://img.shields.io/badge/Supabase-3ECF8E?style=for-the-badge&logo=supabase&logoColor=white) ![SQLite](https://img.shields.io/badge/sqlite-%2307405e.svg?style=for-the-badge&logo=sqlite&logoColor=white) ![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) ![LINUX](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![Rancher](https://img.io/badge/rancher-%230075A8.svg?style=for-the-badge&logo=rancher&logoColor=white) + +--- +# Professional Experience + +## Arise By Infinitas Co., Ltd. +### Senior Associate Back-end Engineer +Nov 2025 - present +- Architected high-performance Go microservices handling heavy daily API traffic. +- Mentored junior engineers on Go best practices, microservices patterns, and Clean Architecture. +- Collaborated with cross-functional teams to deliver major features on schedule. + +## LINE Pay (Thailand) +### Senior Software Engineer +Jun 2025 - Nov 2025 +- Built payment processing microservices supporting high-volume financial transactions. +- Optimized database queries and caching strategies, reducing API response time. +- Collaborated with product and QA teams to deliver user-friendly payment tools. + +## Extend IT Resource Co., Ltd. +### Assistant Manager (Backend Lead) +October 2023 - May 2025 +- Led Go/JS backend teams delivering scalable fintech microservices for insurance and loan systems. +- Architected and deployed K8s cloud infrastructure, improving reliability and storage durability. +- Optimized CI/CD, monitoring, and logging to reduce latency. + +## Tomato Ideas Co., Ltd. +### Technical Lead +May 2023 - September 2023 +- Guided backend teams in developing scalable fintech and e-commerce microservices. +- Managed the architecture and deployment of K8s cloud infrastructure. + +## COOP NIX Co., Ltd. +### Senior System Engineer +August 2020 - April 2023 +- Built Proxmox/Ceph HCI clusters supporting a large-scale VM environment. +- Managed private cloud infrastructure with zero unplanned downtime. +- Optimized performance, security, and horizontal scalability. + +## Canghan Coffee +### Co-Founder, Coffee Roaster +November 2019 - present +- Develop coffee and beverage recipes. +- Roast coffee beans. + +## Provincial Waterworks Authority +### Computer Engineer +February 2014 - July 2020 +- Managed server and network infrastructure across 22 regional branches. +- Led a Windows-to-Linux migration project, saving significantly on licensing and maintenance costs. +- Designed robust network architectures to minimize unplanned downtime. + +## Zercle Technology Co., Ltd. +### Technical Co-Founder +January 2012 - January 2014 +- Led engineering teams in building Go/JavaScript web applications. +- Established Agile/DevOps practices to reduce time-to-market. +- Managed the full technical stack from infrastructure to application deployment. \ No newline at end of file diff --git a/content/about/index.md b/content/about/index.md new file mode 100644 index 0000000..ade63d4 --- /dev/null +++ b/content/about/index.md @@ -0,0 +1,99 @@ +--- +title: "เกี่ยวกับ Kawin Vir." +subtitle: "" +date: 2023-08-12T15:32:50+07:00 +lastmod: 2023-08-12T15:32:50+07:00 +draft: false +author: "กวิน วิริยะประสพสุข" +authorLink: "https://kawin.dev" +description: "เกี่ยวกับกวิน วิริยะประสพสุข — Senior Backend Engineer ที่มีประสบการณ์กว่า 10 ปี เชี่ยวชาญ Go microservices, cloud-native infrastructure และการทำงานแบบ remote" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image" + +lightgallery: true +--- + +![profilePic](https://avatars.githubusercontent.com/u/1217739) + + +## ประสบการณ์การทำงาน + +วิศวกร Backend ระดับ Senior ที่มีประสบการณ์กว่า 10 ปี ในการออกแบบและสร้างระบบ backend ที่ซับซ้อน จัดการทีม และปรับขนาดระบบ ทำงานกับ Go, Kubernetes และสถาปัตยกรรม cloud-native เชี่ยวชาญด้าน distributed systems โครงสร้างพื้นฐาน high-availability (Ceph/ProxmoxVE) และ Linux มีความสามารถในการกำหนดกลยุทธ์ทางเทคนิค ส่งเสริมการทำงานแบบ remote-first และนำ DevOps best practices มาใช้ สร้างผลกระทบเชิงธุรกิจอย่างมีนัยสำคัญในภาค Fintech, SaaS และโครงสร้างพื้นฐาน + +เปิดรับโอกาสทำงาน Remote จากขอนแก่น และ Hybrid ในกรุงเทพฯ + +## ลักษณะส่วนตัว + +- **ชอบการทำงาน Remote**: ฉันทำงานได้ดีในสภาพแวดล้อมการทำงานแบบ remote +- **Backend Developer และผู้ดูแลระบบ**: เชี่ยวชาญด้าน Go/JS +- **คนรักเกม**: ฉันชอบเล่นเกม +- **คนรักอนิเมะ**: ฉันสนใจชมอนิเมะญี่ปุ่น + + + +## โซเชียล +[![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://linkedin.com/in/kawin-vir) [![Twitter](https://img.shields.io/badge/Twitter-%231DA1F2.svg?logo=Twitter&logoColor=white)](https://twitter.com/bouroo) + +# Tech Stack +![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![Shell Script](https://img.shields.io/badge/shell_script-%23121011.svg?style=for-the-badge&logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Cloudflare](https://img.shields.io/badge/Cloudflare-F38020?style=for-the-badge&logo=Cloudflare&logoColor=white) ![JWT](https://img.shields.io/badge/JWT-black?style=for-the-badge&logo=JSON%20web%20tokens) ![Nginx](https://img.shields.io/badge/nginx-%23009639.svg?style=for-the-badge&logo=nginx&logoColor=white) ![MariaDB](https://img.shields.io/badge/MariaDB-003545?style=for-the-badge&logo=mariadb&logoColor=white) ![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?style=for-the-badge&logo=redis&logoColor=white) ![Supabase](https://img.shields.io/badge/Supabase-3ECF8E?style=for-the-badge&logo=supabase&logoColor=white) ![SQLite](https://img.shields.io/badge/sqlite-%2307405e.svg?style=for-the-badge&logo=sqlite&logoColor=white) ![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) ![LINUX](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![Rancher](https://img.io/badge/rancher-%230075A8.svg?style=for-the-badge&logo=rancher&logoColor=white) + +--- +## Arise By Infinitas Co., Ltd. +### Senior Associate Back-end Engineer +พ.ย. 2025 - ปัจจุบัน +- ออกแบบ microservices Go ที่มีความเร็วสูงรองรับ API traffic จำนวนมากในแต่ละวัน +- ฝึกสอนวิศวกรระดับ junior เกี่ยวกับ Go best practices, microservices patterns และ Clean Architecture +- ทำงานร่วมกับทีม cross-functional เพื่อส่งมอบฟีเจอร์สำคัญตามกำหนดเวลา + +## LINE Pay (Thailand) +### Senior Software Engineer +มิ.ย. 2025 - พ.ย. 2025 +- สร้าง microservices สำหรับประมวลผลการชำระเงินรองรับธุรกรรมทางการเงินปริมาณสูง +- ปรับปรุง query ของฐานข้อมูลและกลยุทธ์ caching ลดเวลาในการตอบสนอง API +- ทำงานร่วมกับทีม product และ QA เพื่อส่งมอบเครื่องมือชำระเงินที่ใช้งานง่าย + +## Extend IT Resource Co., Ltd. +### Assistant Manager (Backend Lead) +ต.ค. 2023 - พ.ค. 2025 +- นำทีม backend Go/JS ส่งมอบ microservices fintech ที่ปรับขนาดได้สำหรับระบบประกันและสินเชื่อ +- ออกแบบและติดตั้งโครงสร้างพื้นฐาน cloud K8s ปรับปรุงความน่าเชื่อถือและความทนทานของ storage +- ปรับปรุง CI/CD, monitoring และ logging เพื่อลด latency + +## Tomato Ideas Co., Ltd. +### Technical Lead +พ.ค. 2023 - ก.ย. 2023 +- นำทีม backend พัฒนา microservices fintech และ e-commerce ที่ปรับขนาดได้ +- จัดการ architecture และการติดตั้งโครงสร้างพื้นฐาน cloud K8s + +## COOP NIX Co., Ltd. +### Senior System Engineer +ส.ค. 2020 - เม.ย. 2023 +- สร้างคลัสเตอร์ Proxmox/Ceph HCI รองรับสภาพแวดล้อม VM ขนาดใหญ่ +- จัดการโครงสร้างพื้นฐาน cloud ส่วนตัวโดยไม่มี downtime ที่ไม่คาดคิด +- ปรับปรุงประสิทธิภาพ ความปลอดภัย และ scalability แบบ horizontal + +## Canghan Coffee +### Co-Founder, Coffee Roaster +พ.ย. 2019 - ปัจจุบัน +- พัฒนาสูตรกาแฟและเครื่องดื่ม +- คั่วเมล็ดกาแฟ + +## การประปาส่วนภูมิภาค +### Computer Engineer +ก.พ. 2014 - ก.ค. 2020 +- จัดการโครงสร้างพื้นฐาน server และเครือข่ายในสาขาภูมิภาค 22 แห่ง +- นำโครงการ migration จาก Windows เป็น Linux ช่วยประหยัดค่าใช้จ่ายด้าน license และการบำรุงรักษา +- ออกแบบโครงสร้างเครือข่ายที่มั่นคงเพื่อลด downtime ที่ไม่คาดคิด + +## Zercle Technology Co., Ltd. +### Technical Co-Founder +ม.ค. 2012 - ม.ค. 2014 +- นำทีมวิศวกรรมสร้าง web applications Go/JavaScript +- สร้างแนวปฏิบัติ Agile/DevOps เพื่อลดเวลาในการนำผลิตภัณฑ์ออกสู่ตลาด +- จัดการ technical stack ทั้งหมดตั้งแต่โครงสร้างพื้นฐานไปจนถึงการ deploy application diff --git a/content/portfolio/index.en.md b/content/portfolio/index.en.md new file mode 100644 index 0000000..8789b16 --- /dev/null +++ b/content/portfolio/index.en.md @@ -0,0 +1,39 @@ +--- +title: "Portfolio | Kawin Viriyaprasopsook" +subtitle: "" +date: 2025-07-12T02:29:14Z +lastmod: 2025-07-12T02:29:14Z +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "A selection of backend projects I've worked on — university management systems for KKU and KMUTT, fintech and insurtech platforms (goodmoneybygsb, insurverse), and SCB's next-gen lending workflow." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +lightgallery: true +--- + + +## Here are some of the projects I've worked on: + +* **University Management Systems (KKU & KMUTT)**: I developed and maintained several backend services and infrastructure for Khon Kaen University and King Mongkut's University of Technology Thonburi. These systems were built with **Go, NodeJS, Redis, MySQL, Docker, and Angular**, and include: + * [dms.kku.ac.th](https://dms.kku.ac.th/): Document Management System. + * [payment-web.kku.ac.th](https://payment-web.kku.ac.th/): Payment Facilitator system. + * [validation.kku.ac.th](https://validation.kku.ac.th/): Data and certificate validation system. + * [donation.kku.ac.th](https://donation.kku.ac.th/): Real-time e-donation platform. + * [vaccine.kku.ac.th](https://vaccine.kku.ac.th/): Vaccination registration portal. + * [app-reserve.kku.ac.th](https://app-reserve.kku.ac.th/): Adobe and Zoom licensing reservation system. + * [paymenthub.kmutt.ac.th](https://paymenthub.kmutt.ac.th/): Payment Facilitator system. + * [license.kmutt.ac.th](https://license.kmutt.ac.th/): Adobe licensing reservation system. + +* **Fintech & Insurtech Platforms**: I contributed to the development of high-traffic financial and insurance platforms using **Go, NodeJS, Redis, PostgreSQL, NATs, and GKE**. + * **[goodmoneybygsb.com](https://goodmoneybygsb.com/)**: A digital lending platform in partnership with GSB (Government Savings Bank). + * **[insurverse.co.th](https://insurverse.co.th/)**: An online insurance platform with features like OCR data entry and 24-hour claim tracking. + +* **SCB NextGen Lending**: I worked on improving SCB's new lending platform, focusing on workflow integration and development using **Zoral**, a JavaScript-based workflow engine. diff --git a/content/portfolio/index.md b/content/portfolio/index.md new file mode 100644 index 0000000..fe3d9d8 --- /dev/null +++ b/content/portfolio/index.md @@ -0,0 +1,39 @@ +--- +title: "Portfolio | กวิน วิริยะประสพสุข" +subtitle: "" +date: 2025-07-12T02:29:14Z +lastmod: 2025-07-12T02:29:14Z +draft: false +author: "กวิน วิริยะประสพสุข" +authorLink: "https://kawin.dev" +description: "รวมโปรเจค backend ที่ผมเคยทำ ตั้งแต่ระบบบริหารจัดการมหาวิทยาลัย (มข. และ มจธ.), แพลตฟอร์มฟินเทค/ประกันภัย (goodmoneybygsb, insurverse) ไปจนถึง workflow สินเชื่อ next-gen ของ SCB" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +lightgallery: true +--- + + +## นี่คือโปรเจคบางส่วนที่ผมเคยทำครับ: + +* **ระบบบริหารจัดการมหาวิทยาลัย (มข. & มจธ.)**: ผมได้พัฒนาและดูแลระบบหลังบ้านและโครงสร้างพื้นฐานหลายๆ ตัว ให้กับมหาวิทยาลัยขอนแก่นและมหาวิทยาลัยเทคโนโลยีพระจอมเกล้าธนบุรี ระบบเหล่านี้สร้างด้วย **Go, NodeJS, Redis, MySQL, Docker, และ Angular** ประกอบไปด้วย: + * [dms.kku.ac.th](https://dms.kku.ac.th/): ระบบจัดการเอกสาร + * [payment-web.kku.ac.th](https://payment-web.kku.ac.th/): ระบบอำนวยความสะดวกในการชำระเงิน + * [validation.kku.ac.th](https://validation.kku.ac.th/): ระบบตรวจสอบข้อมูลและใบรับรอง + * [donation.kku.ac.th](https://donation.kku.ac.th/): แพลตฟอร์มบริจาคเงินอิเล็กทรอนิกส์แบบเรียลไทม์ + * [vaccine.kku.ac.th](https://vaccine.kku.ac.th/): พอร์ทัลลงทะเบียนฉีดวัคซีน + * [app-reserve.kku.ac.th](https://app-reserve.kku.ac.th/): ระบบจองสิทธิ์การใช้งาน Adobe และ Zoom + * [paymenthub.kmutt.ac.th](https://paymenthub.kmutt.ac.th/): ระบบอำนวยความสะดวกในการชำระเงิน + * [license.kmutt.ac.th](https://license.kmutt.ac.th/): ระบบจองสิทธิ์การใช้งาน Adobe + +* **แพลตฟอร์มฟินเทคและประกัน**: ผมได้มีส่วนร่วมในการพัฒนาแพลตฟอร์มทางการเงินและประกันที่มีผู้ใช้งานจำนวนมาก โดยใช้ **Go, NodeJS, Redis, PostgreSQL, NATs, และ GKE** + * **[goodmoneybygsb.com](https://goodmoneybygsb.com/)**: แพลตฟอร์มสินเชื่อดิจิทัลร่วมกับธนาคารออมสิน + * **[insurverse.co.th](https://insurverse.co.th/)**: แพลตฟอร์มประกันออนไลน์ที่มีฟีเจอร์อย่างการกรอกข้อมูลด้วย OCR และการติดตามเคลม 24 ชั่วโมง + +* **SCB NextGen Lending**: ผมได้ทำงานในการปรับปรุงแพลตฟอร์มสินเชื่อใหม่ของ SCB โดยเน้นที่การรวมและพัฒนาเวิร์กโฟลว์โดยใช้ **Zoral** ซึ่งเป็น workflow engine ที่ทำงานบน JavaScript diff --git a/content/posts/cloudflare/.DS_Store b/content/posts/cloudflare/.DS_Store new file mode 100644 index 0000000..33e7ec4 Binary files /dev/null and b/content/posts/cloudflare/.DS_Store differ diff --git a/content/posts/cloudflare/secure_origin/featured-image.jpg b/content/posts/cloudflare/secure_origin/featured-image.jpg new file mode 100644 index 0000000..e89d36d Binary files /dev/null and b/content/posts/cloudflare/secure_origin/featured-image.jpg differ diff --git a/content/posts/cloudflare/secure_origin/index.en.md b/content/posts/cloudflare/secure_origin/index.en.md new file mode 100644 index 0000000..858424a --- /dev/null +++ b/content/posts/cloudflare/secure_origin/index.en.md @@ -0,0 +1,122 @@ +--- +title: "Hiding Web Services Behind Cloudflare" +date: 2023-07-15T21:57:40+07:00 +lastmod: 2023-07-16T14:45:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "This article will guide you on how to securely hide your website behind Cloudflare." +aliases: +- /posts/go_solid/ +images: [] +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +tags: ["Cloudflare", "DevOps", "DevOpsSec"] +categories: ["DevOps"] + +lightgallery: true +--- + + + +## What is Cloudflare? + +Cloudflare is a global network of servers. When we add our application to Cloudflare, we use this network to act as an intermediary between requests and our origin server. + +Cloudflare sits between requests and our origin server. This position allows us to do many things, such as speeding up content delivery and user experience (CDN), protecting our website from malicious activity (DDoS, firewall), routing traffic (load balancing, waiting rooms), and more. + +## How does Cloudflare work? + +Using Cloudflare means our domain or subdomain is using proxied DNS records. DNS lookups for our application's URL will resolve to a Cloudflare Anycast IP instead of the original DNS target. + +### Before using Cloudflare proxy + +{{< mermaid >}} +flowchart LR + user(Users) + srv(Servers) + user <-- http request --> srv +{{< /mermaid >}} + +### After using Cloudflare proxy + +{{< mermaid >}} +flowchart LR + user(Users) + cf(Cloudflare) + srv(Servers) + user <-- http request --> cf + cf <-- http request --> srv +{{< /mermaid >}} + +## Seriously Hiding Web Services Behind Cloudflare + +From the image above, it seems like our website is safe from the outside world, right? But wait, if you look closely, you'll find that if someone already knew our server's IP address, our website could still be attacked, right? So, the simplest way is to allow only Cloudflare to directly access our web services. We can achieve this using the operating system's firewall. + +{{< mermaid >}} +flowchart LR + user(Users) + cf(Cloudflare) + srv(Servers) + user <-- http request --> cf + user -- http request --x srv + cf <-- http request --> srv +{{< /mermaid >}} + +## Allow only Cloudflare to access your website + +I will use Debian 12 as an example. +> You need `root` privileges. + +### Install ipset and its friends +```bash +# install ipset +apt -y install ipset iptables-persistent ipset-persistent curl +``` + +### Add Cloudflare IPs to ipset +```bash +# get cloudflare IPs +mkdir -p /etc/zones/ +curl -L -s https://www.cloudflare.com/ips-v4 -o /etc/zones/cf-ips-v4 +curl -L -s https://www.cloudflare.com/ips-v6 -o /etc/zones/cf-ips-v6 + +# add cloudflare IPs to ipset +ipset -N cloudflare-ips-v4 hash:net +for i in $(cat /etc/zones/cf-ips-v4 ); do ipset -A cloudflare-ips-v4 $i; done +ipset -N cloudflare-ips-v6 hash:net family inet6 +for i in $(cat /etc/zones/cf-ips-v6 ); do ipset -A cloudflare-ips-v6 $i; done +``` + +### Allow http, https only from Cloudflare +```bash +# allow only http/https from cloudflare ipv4 +iptables -I INPUT -p tcp -m set --match-set cloudflare-ips-v4 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +iptables -I INPUT -p udp -m set --match-set cloudflare-ips-v4 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +iptables -A INPUT -p tcp -m multiport --dports 80,443,8080,8443 -j DROP +iptables -A INPUT -p udp -m multiport --dports 80,443,8080,8443 -j DROP + +# allow only http/https from cloudflare ipv6 +ip6tables -I INPUT -p tcp -m set --match-set cloudflare-ips-v6 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +ip6tables -I INPUT -p udp -m set --match-set cloudflare-ips-v6 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +ip6tables -A INPUT -p tcp -m multiport --dports 80,443,8080,8443 -j DROP +ip6tables -A INPUT -p udp -m multiport --dports 80,443,8080,8443 -j DROP +``` + +### Save +```bash +# save ipset configurations to /etc/iptables/ipsets +dpkg-reconfigure ipset-persistent + +# save iptables and ip6tables configurations to /etc/iptables/rules.v{4|6} +dpkg-reconfigure iptables-persistent +``` + +## Test +```bash +curl -L -v -H "Host: your.domain" your.server.ip.address +``` + +## Bash script `behind_cloudflare.sh` +{{< gist bouroo 6877eb823c2eee859e50ffeef67ea19e behind_cloudflare.sh >}} diff --git a/content/posts/cloudflare/secure_origin/index.md b/content/posts/cloudflare/secure_origin/index.md new file mode 100644 index 0000000..bc4c23b --- /dev/null +++ b/content/posts/cloudflare/secure_origin/index.md @@ -0,0 +1,123 @@ +--- +title: "ซ่อนเว็บเซอร์วิสไว้ข้างหลัง Cloudflare" +date: 2023-07-15T21:57:40+07:00 +lastmod: 2023-07-16T14:45:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "บทความนี้จะแนะนำวิธีการซ่อนเว็บของเราไว้ข้างหลัง cloudflare อย่างมิดชิด" +aliases: +- /posts/go_solid/ +images: [] +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +tags: ["Cloudflare", "DevOps", "DevOpsSec"] +categories: ["DevOps"] + +lightgallery: true +--- + + + +## Cloudfalre คืออะไร ? + +Cloudflare เป็นเครือข่ายเซิร์ฟเวอร์ทั่วโลก เมื่อเราเพิ่มแอปพลิเคชันของเราไปยัง Cloudflare เราจะใช้เครือข่ายนี้เพื่อทำหน้าที่ระหว่างคำขอและเซิร์ฟเวอร์ต้นทางของเรา + +Cloudflare อยู่ระหว่างคำขอและเซิร์ฟเวอร์ต้นทางของเรา +ตำแหน่งนี้ช่วยให้เราทำสิ่งต่าง ๆ ได้หลายอย่าง เช่น เพิ่มความเร็วในการจัดส่งเนื้อหาและประสบการณ์ของผู้ใช้ (CDN) ปกป้องเว็บไซต์ของเราจากกิจกรรมที่เป็นอันตราย (DDoS, ไฟร์วอลล์) กำหนดเส้นทางการรับส่งข้อมูล (โหลดบาลานซ์ ห้องรอคิว) และอื่น ๆ + +## Cloudflare ทำงานอย่างไร ? + +ด้วย Cloudflare หมายความว่าโดเมนหรือโดเมนย่อยของเรากำลังใช้ระเบียน DNS พร็อกซี การค้นหา DNS สำหรับ URL ของแอปพลิเคชันของเราจะแปลงเป็น Cloudflare Anycast IP แทนเป้าหมาย DNS ดั้งเดิม + +### ก่อนใช้งานผ่าน cloudflare proxy + +{{< mermaid >}} +flowchart LR + user(Users) + srv(Servers) + user <-- http request --> srv +{{< /mermaid >}} + +### หลังจากใช้งานผ่าน cloudflare proxy + +{{< mermaid >}} +flowchart LR + user(Users) + cf(Cloudflare) + srv(Servers) + user <-- http request --> cf + cf <-- http request --> srv +{{< /mermaid >}} + +## ซ่อนเว็บเซอร์วิสไว้ข้างหลัง Cloudflare อย่างจริงจัง + +จากภาพด้านบนเหมือนว่าเว็บเราจะปลอดภัยจากโลกภายนอกแล้วใช่ไหมครับ แต่เดี๋ยวก่อนถ้าหากสังเกตดี ๆ จะพบว่าถ้าก่อนหน้านี้มีคนรู้ IP ของเซิฟเวอร์เราไปแล้วละ เว็บเราก็ก็ยังโดนยิงเข้ามาได้ถูกไหมครับ เพราะฉะนั้นวิธีที่ง่ายที่สุดคือ เราก็เปิดให้เฉพาะ Cloudflare เข้าถึงเว็บเซอร์วิสของเราได้โดยตรงแต่เพิ่งผู้เดียวไปเลยสิ โดยเราจะอาศัยไฟร์วอลของระบบปฏิบัติการนี่แหละ + +{{< mermaid >}} +flowchart LR + user(Users) + cf(Cloudflare) + srv(Servers) + user <-- http request --> cf + user -- http request --x srv + cf <-- http request --> srv +{{< /mermaid >}} + +## เปิดให้เฉพาะ Cloudflare เข้าถึงเว็บเรา + +โดยจะขอยกตัวอย่างจากของ Debian 12 นะครับ +> ต้องใช้สิทธิ์ `root` นะครับ + +### ติดตั้ง ipset และผองเพื่อน +```bash +# install ipset +apt -y install ipset iptables-persistent ipset-persistent curl +``` + +### เพิ่ม IPs ของ Cloudflare เข้า ipset +```bash +# get cloudflare IPs +mkdir -p /etc/zones/ +curl -L -s https://www.cloudflare.com/ips-v4 -o /etc/zones/cf-ips-v4 +curl -L -s https://www.cloudflare.com/ips-v6 -o /etc/zones/cf-ips-v6 + +# add cloudflare IPs to ipset +ipset -N cloudflare-ips-v4 hash:net +for i in $(cat /etc/zones/cf-ips-v4 ); do ipset -A cloudflare-ips-v4 $i; done +ipset -N cloudflare-ips-v6 hash:net family inet6 +for i in $(cat /etc/zones/cf-ips-v6 ); do ipset -A cloudflare-ips-v6 $i; done +``` + +### เปิด http,https ให้เฉพาะ Cloudflare +```bash +# allow only http/https from cloudflare ipv4 +iptables -I INPUT -p tcp -m set --match-set cloudflare-ips-v4 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +iptables -I INPUT -p udp -m set --match-set cloudflare-ips-v4 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +iptables -A INPUT -p tcp -m multiport --dports 80,443,8080,8443 -j DROP +iptables -A INPUT -p udp -m multiport --dports 80,443,8080,8443 -j DROP + +# allow only http/https from cloudflare ipv6 +ip6tables -I INPUT -p tcp -m set --match-set cloudflare-ips-v6 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +ip6tables -I INPUT -p udp -m set --match-set cloudflare-ips-v6 src -m multiport --dports 80,443,8080,8443 -j ACCEPT +ip6tables -A INPUT -p tcp -m multiport --dports 80,443,8080,8443 -j DROP +ip6tables -A INPUT -p udp -m multiport --dports 80,443,8080,8443 -j DROP +``` + +### บันทึก +```bash +# save ipset configurations to /etc/iptables/ipsets +dpkg-reconfigure ipset-persistent + +# save iptables and ip6tables configurations to /etc/iptables/rules.v{4|6} +dpkg-reconfigure iptables-persistent +``` + +## ทดสอบ +```bash +curl -L -v -H "Host: your.domain" your.server.ip.address +``` + +## Bash script `behind_cloudflare.sh` +{{< gist bouroo 6877eb823c2eee859e50ffeef67ea19e behind_cloudflare.sh >}} \ No newline at end of file diff --git a/content/posts/cloudflare/tunnel_k3s/featured-image.webp b/content/posts/cloudflare/tunnel_k3s/featured-image.webp new file mode 100644 index 0000000..6f3bed5 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/featured-image.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/Access_Diagram.webp b/content/posts/cloudflare/tunnel_k3s/img/Access_Diagram.webp new file mode 100644 index 0000000..2f499ae Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/Access_Diagram.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/create_tunnel.webp b/content/posts/cloudflare/tunnel_k3s/img/create_tunnel.webp new file mode 100644 index 0000000..36d1fb5 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/create_tunnel.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/daemonset.webp b/content/posts/cloudflare/tunnel_k3s/img/daemonset.webp new file mode 100644 index 0000000..5454050 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/daemonset.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/ingress.webp b/content/posts/cloudflare/tunnel_k3s/img/ingress.webp new file mode 100644 index 0000000..84cdd47 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/ingress.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/kube_proxy.webp b/content/posts/cloudflare/tunnel_k3s/img/kube_proxy.webp new file mode 100644 index 0000000..d4163cd Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/kube_proxy.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/naming_tunnel.webp b/content/posts/cloudflare/tunnel_k3s/img/naming_tunnel.webp new file mode 100644 index 0000000..f0dba83 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/naming_tunnel.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/rancher.webp b/content/posts/cloudflare/tunnel_k3s/img/rancher.webp new file mode 100644 index 0000000..e67c963 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/rancher.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/tls_skip_verify.webp b/content/posts/cloudflare/tunnel_k3s/img/tls_skip_verify.webp new file mode 100644 index 0000000..ce62041 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/tls_skip_verify.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/tunnel_config.webp b/content/posts/cloudflare/tunnel_k3s/img/tunnel_config.webp new file mode 100644 index 0000000..07d83ac Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/tunnel_config.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/img/tunnel_token.webp b/content/posts/cloudflare/tunnel_k3s/img/tunnel_token.webp new file mode 100644 index 0000000..adadb23 Binary files /dev/null and b/content/posts/cloudflare/tunnel_k3s/img/tunnel_token.webp differ diff --git a/content/posts/cloudflare/tunnel_k3s/index.en.md b/content/posts/cloudflare/tunnel_k3s/index.en.md new file mode 100644 index 0000000..b546434 --- /dev/null +++ b/content/posts/cloudflare/tunnel_k3s/index.en.md @@ -0,0 +1,70 @@ +--- +title: "Exposing Web Services to the World with K3S + Cloudflare Tunnel" +subtitle: "" +date: 2023-07-20T19:49:54+07:00 +lastmod: 2023-07-20T19:49:54+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "This article will guide you on how to expose your web services to the outside world with K3S + Cloudflare Tunnel without needing an External Public IP." +aliases: +- /posts/behind_cloudflare/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Cloudflare", "DevOps", "K3S", "K8S"] +categories: ["DevOps"] + +lightgallery: true +--- + +If you have a home lab or a service in K3S/K8S that you want to expose to the world, but you don't have a public IP, what can you do? Especially in today's world where ISPs often provide Carrier-grade NAT, or what's commonly known as large-scale NAT (LSN), making it difficult to update IPs with DDNS. This is where the hero of this article comes in. + + + +## What is Cloudflare Tunnel? +![Access Diagram](img/Access_Diagram.webp "Access Diagram") +Cloudflare Tunnel, as the name suggests, creates a tunnel from your network to the Cloudflare network, making Cloudflare the front-end between you and the outside world. + +## Creating a Cloudflare Tunnel +> You need to have at least one domain linked to Cloudflare. + +Start by enabling it at https://one.dash.cloudflare.com/ +Then go to Access > Tunnels > Create a tunnel +![create_tunnel](img/create_tunnel.webp "create_tunnel") + +Name your Tunnel +![naming_tunnel](img/naming_tunnel.webp "naming_tunnel") + +Get the tunnel token to put into a kube secret when deploying. +![tunnel_token](img/tunnel_token.webp "tunnel_token") + +## Deploying Cloudflare Tunnel on K3S/K8S + +Create a `cloudflared-daemonset.yml` file like this, by converting the tunnel token to base64 and putting it in a secret named `cf_tunnel_token`. +{{< gist bouroo 624c6cd6d515c5e0af54904dba60f073 cloudflared-daemonset.yml >}} + +Then deploy with: +```bash +kubectl apply -f cloudflared-daemonset.yml +``` + +## Exposing services to the outside world +Check the status in the Cloudflare One dashboard to confirm that your tunnel is connected. +![tunnel_config](img/tunnel_config.webp "tunnel_config") + +It's like having a kube-proxy + load balancer all in one! +![kube_proxy](img/kube_proxy.webp "kube_proxy") + +Then we can reverse proxy to our service in the cluster, for example, `service_name.namespace`, such as `https` to `rancher` on the `cattle-system` namespace, as shown in the image. +![ingress](img/ingress.webp "ingress") +If the service uses a self-signed SSL, we need to tell Cloudflare to skip verification. +![tls_skip_verify](img/tls_skip_verify.webp "tls_skip_verify") + + +## Testing the service access +![rancher](img/rancher.webp "rancher") +![daemonset](img/daemonset.webp "daemonset") + diff --git a/content/posts/cloudflare/tunnel_k3s/index.md b/content/posts/cloudflare/tunnel_k3s/index.md new file mode 100644 index 0000000..67d52a1 --- /dev/null +++ b/content/posts/cloudflare/tunnel_k3s/index.md @@ -0,0 +1,70 @@ +--- +title: "เปิดเว็บเซอร์วิสสู่ชาวโลกด้วย K3S + Cloudflare Tunnel" +subtitle: "" +date: 2023-07-20T19:49:54+07:00 +lastmod: 2023-07-20T19:49:54+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "บทความนี้จะแนะนำวิธีการนำเสนอเว็บเซอร์วิสของเราไปสู่โลกภายนอกด้วย K3S + Cloudflare Tunnel แบบไม่ต้องง้อ External Public IP" +aliases: +- /posts/behind_cloudflare/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Cloudflare", "DevOps", "K3S", "K8S"] +categories: ["DevOps"] + +lightgallery: true +--- + +หากเรามี home lab หรือ service ที่อยู่ใน k3s/k8s อยากเปิดให้ชาวโลกได้เข้ามาใช้งาน แต่ไม่มี public IP จะทำอย่างไรได้นะ ยิ่งในโลกที่ทุกวันนี้ ISP แจก IP แบบ Carrier-grade NAT หรือเรียกติดปากกันว่า large-scale NAT (LSN) ทำให้จะใช้ DDNS ก็ลำบากอัปเดต IP กันอีก จึงเป็นที่มาของพระเอกในบทความนี้ครับ + + + +## cloudflare tunnel คืออะไร +![Access Diagram](img/Access_Diagram.webp "Access Diagram") +Cloudflare tunnel ตามชื่อเลยคือการสร้าง tunnel จากเราเข้าไปที่ cloudflare network ทำให้ cloudflare กลายมาเป็นหน้าด่านให้ระหว่างเราและโลกภายนอก + +## สร้าง cloudflare tunnel +> เราต้องมีโดเมนที่ผูกไว้กับ cloudflare ก่อนอย่างน้อยหนึ่งชื่อนะครับ + +เริ่มต้นจากเปิดใช้งานที่ https://one.dash.cloudflare.com/ +แล้วเข้าเมนู Access > Tunnels > Create a tunnel +![create_tunnel](img/create_tunnel.webp "create_tunnel") + +ตั้งชื่อ Tunnel +![naming_tunnel](img/naming_tunnel.webp "naming_tunnel") + +เอาค่า tunnel token เพื่อไปใส่ใน kube secret ตอน deploy +![tunnel_token](img/tunnel_token.webp "tunnel_token") + +## deploy cloudflare tunnel บน k3s/k8s + +สร้างไฟล์ `cloudflared-daemonset.yml` หน้าตาประมาณนี้ โดยเอาค่า tunnel token ไปแปลงเป็น base64 แล้วใส่ไว้ใน secret ชื่อ `cf_tunnel_token` +{{< gist bouroo 624c6cd6d515c5e0af54904dba60f073 cloudflared-daemonset.yml >}} + +จากนั้น deploy ด้วย +```bash +kubectl apply -f cloudflared-daemonset.yml +``` + +## expose service สู่โลกภายนอก +เช็คสถานะใน cloudflare one dashboard ว่า tunnel เราเชื่อมต่อได้แล้ว +![tunnel_config](img/tunnel_config.webp "tunnel_config") + +เสมือนว่าได้ kube-proxy + load balancer กันเลยทีเดียว +![kube_proxy](img/kube_proxy.webp "kube_proxy") + +แล้วเราก็สามารถทำ reverse proxy เข้าไปหา servic ใน cluster เราได้เลยในเช่น `service_name.namespace` เช่น `https` ไปที่ `rancher` บน namspace `cattle-system` จะได้ตามภาพ +![ingress](img/ingress.webp "ingress") +กรณีที่ service ใช้งาน self sign SSL เราต้องตั้งให้ cloudflare skip verify ไปด้วยครับ +![tls_skip_verify](img/tls_skip_verify.webp "tls_skip_verify") + + +## ทดสอบเข้า service +![rancher](img/rancher.webp "rancher") +![daemonset](img/daemonset.webp "daemonset") + diff --git a/content/posts/db/.DS_Store b/content/posts/db/.DS_Store new file mode 100644 index 0000000..ff21b29 Binary files /dev/null and b/content/posts/db/.DS_Store differ diff --git a/content/posts/db/dragonfly/featured-image.webp b/content/posts/db/dragonfly/featured-image.webp new file mode 100644 index 0000000..92b8887 Binary files /dev/null and b/content/posts/db/dragonfly/featured-image.webp differ diff --git a/content/posts/db/dragonfly/index.en.md b/content/posts/db/dragonfly/index.en.md new file mode 100644 index 0000000..5d09654 --- /dev/null +++ b/content/posts/db/dragonfly/index.en.md @@ -0,0 +1,175 @@ +--- +title: "DragonflyDB vs Redis" +subtitle: "" +date: 2023-08-22T08:35:52+07:00 +lastmod: 2023-08-22T08:35:53+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Benchmarking DragonflyDB against Redis 7 using memtier_benchmark over TCP and Unix sockets, with notes on the 2024-2025 licensing landscape." +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["DevOps", "Database", "DragonflyDB", "Redis"] +categories: ["DevOps"] + +lightgallery: true +--- + +Normally, when we think of in-memory databases, Redis and Memcached often come to mind. Today, I'd like to introduce another interesting option: Dragonfly, which claims to be a **multi-threaded Redis replacement**. So, let's test how Dragonfly compares to Redis 7. + + + +## Redis test compose + +First, let's create a compose file for testing Redis. + +```yaml +services: + redis: + image: redis + ulimits: + memlock: -1 + volumes: + - redis-data:/data + hostname: redis + command: + ["redis-server", "--loglevel warning", "--unixsocket", "/data/redis.sock"] + + redis_tcp: + depends_on: + - redis + image: redislabs/memtier_benchmark + volumes: + - redis-data:/redis-data + - ./tmp:/data + command: ["-h", "redis", "--out-file=/data/redis-tcp.log"] + + redis_sock: + depends_on: + - redis + image: redislabs/memtier_benchmark + volumes: + - redis-data:/redis-data + - ./tmp:/data + command: ["-S", "/redis-data/redis.sock", "--out-file=/data/redis-sock.log"] + +volumes: + redis-data: +``` + +## Dragonfly test compose + +Next, the compose file for Dragonfly. + +{{< admonition note "Note" >}} +Dragonfly typically requires a minimum RAM limit of 256MB per logical CPU. +{{< /admonition >}} + +```yaml +services: + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + ulimits: + memlock: -1 + volumes: + - dragonfly-data:/data + hostname: dragonfly + command: + ["dragonfly", "--logtostderr", "--unixsocket", "/data/dragonfly.sock"] + + dragonfly_tcp: + depends_on: + - dragonfly + image: redislabs/memtier_benchmark + volumes: + - dragonfly-data:/dragonfly-data + - ./tmp:/data + command: ["-h", "dragonfly", "--out-file=/data/dragonfly-tcp.log"] + + dragonfly_sock: + depends_on: + - dragonfly + image: redislabs/memtier_benchmark + volumes: + - dragonfly-data:/dragonfly-data + - ./tmp:/data + command: ["-S", "/dragonfly-data/dragonfly.sock", "--out-file=/data/dragonfly-sock.log"] + +volumes: + dragonfly-data: +``` +## TCP + +Let's start with the common scenario where everyone uses TCP/IP between containers. + +### Redis + +```bash +docker compose -f docker-compose.redis.yml up redis_tcp +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 6446.59 | --- | --- | 2.82053 | 2.68700 | 5.40700 | 10.36700 | 496.50 | +| Gets | 64395.05 | 0.00 | 64395.05 | 2.82098 | 2.68700 | 5.40700 | 10.68700 | 2508.47 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 70841.64 | 0.00 | 64395.05 | 2.82093 | 2.68700 | 5.40700 | 10.62300 | 3004.97 | + +### Dragonfly + +```bash +docker compose -f docker-compose.dragonfly.yml up dragonfly_tcp +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 12683.03 | --- | --- | 1.53450 | 0.66300 | 10.87900 | 16.12700 | 976.82 | +| Gets | 126690.88 | 0.00 | 126690.88 | 1.51768 | 0.65500 | 10.94300 | 16.31900 | 4935.16 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 139373.91 | 0.00 | 126690.88 | 1.51921 | 0.65500 | 10.94300 | 16.31900 | 5911.97 | + +## UNIX socket + +Now let's try testing with UNIX sockets between containers. + +### Redis + +```bash +docker compose -f docker-compose.redis.yml up redis_sock +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 17540.93 | --- | --- | 1.04733 | 0.99900 | 1.82300 | 4.99100 | 1350.96 | +| Gets | 175216.52 | 0.00 | 175216.52 | 1.04597 | 0.99900 | 1.82300 | 3.87100 | 6825.44 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 192757.45 | 0.00 | 175216.52 | 1.04610 | 0.99900 | 1.82300 | 4.03100 | 8176.40 | + +### Dragonfly + +```bash +docker compose -f docker-compose.dragonfly.yml up dragonfly_sock +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 23136.83 | --- | --- | 0.80438 | 0.27900 | 7.74300 | 14.39900 | 1781.94 | +| Gets | 231114.05 | 0.00 | 231114.05 | 0.78618 | 0.27900 | 7.39100 | 12.67100 | 9002.89 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 254250.88 | 0.00 | 231114.05 | 0.78783 | 0.27900 | 7.42300 | 12.79900 | 10784.83 | + +## Licensing landscape (2024-2025) + +The in-memory database licensing landscape shifted significantly after this benchmark was written: + +- **Redis** — In March 2024, Redis Ltd. moved from BSD to a dual SSPL/RSALv2 license. Redis 8.0 (2025) added AGPLv3 as a third option. This triggered the community fork below. +- **Valkey** — A BSD-licensed fork of Redis 7.2.4, created under the Linux Foundation (March 2024), backed by AWS, Google Cloud, and Oracle. Valkey 8.1 (March 2025) delivers roughly 8% higher throughput, 22% lower P99 latency, and 20% less memory than Redis OSS. +- **Dragonfly** — Has used the Business Source License (BSL 1.1) from the start. BSL is source-available and free for self-hosting; it transitions to Apache 2.0 after a change date (typically four years). Dragonfly is not OSI-certified open source, but it avoids the restrictions that Redis's SSPL imposes on managed-service providers. + +If licensing matters for your use case (e.g., offering a managed service), Valkey (BSD) is the most permissive option. Dragonfly's BSL is more restrictive than BSD but less contentious than Redis's SSPL. For pure self-hosted benchmarks, all three are free to use. + +## Conclusion +With the power of multi-threading, Dragonfly performs very well (especially through UNIX sockets, where the difference from TCP/IP is noticeable). However, it's worth noting that Dragonfly doesn't currently support 100% of the Redis API. As of 2025, Dragonfly's Redis API compatibility has improved significantly, though some edge-case commands may still differ — always check the [compatibility reference](https://www.dragonflydb.io/docs/command-reference/compatibility) for your specific use case. If you're using advanced or special features, you'll need to check if it can be used as a direct replacement. You can find more information in the [command-reference](https://www.dragonflydb.io/docs/command-reference/compatibility). From my testing, if you normally use Redis with only a single instance, and don't use `Graph` or `Geo location` features, primarily using `Set`, `Get`, and occasionally `PubSub` or `Stream`, you can replace it with Dragonfly. The code and connection methods remain the same. diff --git a/content/posts/db/dragonfly/index.md b/content/posts/db/dragonfly/index.md new file mode 100644 index 0000000..d7248fd --- /dev/null +++ b/content/posts/db/dragonfly/index.md @@ -0,0 +1,175 @@ +--- +title: "DragonflyDB vs Redis" +subtitle: "" +date: 2023-08-22T08:35:52+07:00 +lastmod: 2023-08-22T08:35:53+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เปรียบเทียบประสิทธิภาพระหว่าง DragonflyDB และ Redis 7 ด้วย memtier_benchmark ผ่าน TCP และ Unix socket พร้อมข้อมูลลิขสิทธิ์ 2024-2025" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["DevOps", "Database", "DragonflyDB", "Redis"] +categories: ["DevOps"] + +lightgallery: true +--- + +ปกติแล้วเวลาเราใช้งาน In-memory database ก็มักจะนึกถึง Redis กับ Memcache วันนี้เลยจะมาแนะนำอีกตัวนึงที่น่าสนใจนั่นคือ Dragonfly ซึ่งเคลมตัวเองว่าเป็น **multi-threaded Redis replacement** เลยมาลองทดสอบดูว่าระหว่าง Dragonfly กับ Redis 7 ผลจะเป็นอย่างไร + + + +## Redis test compose + +เริ่มจากสร้าง compose สำหรับทดสอบ Redis + +```yaml +services: + redis: + image: redis + ulimits: + memlock: -1 + volumes: + - redis-data:/data + hostname: redis + command: + ["redis-server", "--loglevel warning", "--unixsocket", "/data/redis.sock"] + + redis_tcp: + depends_on: + - redis + image: redislabs/memtier_benchmark + volumes: + - redis-data:/redis-data + - ./tmp:/data + command: ["-h", "redis", "--out-file=/data/redis-tcp.log"] + + redis_sock: + depends_on: + - redis + image: redislabs/memtier_benchmark + volumes: + - redis-data:/redis-data + - ./tmp:/data + command: ["-S", "/redis-data/redis.sock", "--out-file=/data/redis-sock.log"] + +volumes: + redis-data: +``` + +## Dragonfly test compose + +ต่อด้วย compose สำหรับ Dragonfly + +{{< admonition note "Note" >}} +ปกติ Dragonfly จะใช้ Ram Limit ขั้นต่ำต่อ Logical CPU อยู่ที่ 256MB นะครับ +{{< /admonition >}} + +```yaml +services: + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + ulimits: + memlock: -1 + volumes: + - dragonfly-data:/data + hostname: dragonfly + command: + ["dragonfly", "--logtostderr", "--unixsocket", "/data/dragonfly.sock"] + + dragonfly_tcp: + depends_on: + - dragonfly + image: redislabs/memtier_benchmark + volumes: + - dragonfly-data:/dragonfly-data + - ./tmp:/data + command: ["-h", "dragonfly", "--out-file=/data/dragonfly-tcp.log"] + + dragonfly_sock: + depends_on: + - dragonfly + image: redislabs/memtier_benchmark + volumes: + - dragonfly-data:/dragonfly-data + - ./tmp:/data + command: ["-S", "/dragonfly-data/dragonfly.sock", "--out-file=/data/dragonfly-sock.log"] + +volumes: + dragonfly-data: +``` +## TCP + +เริ่มจากท่าที่ทุกคนน่าจะใช้งานกันปกติทดสอบผ่านทาง TCP/IP ระหว่าง container + +### Redis + +```bash +docker compose -f docker-compose.redis.yml up redis_tcp +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 6446.59 | --- | --- | 2.82053 | 2.68700 | 5.40700 | 10.36700 | 496.50 | +| Gets | 64395.05 | 0.00 | 64395.05 | 2.82098 | 2.68700 | 5.40700 | 10.68700 | 2508.47 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 70841.64 | 0.00 | 64395.05 | 2.82093 | 2.68700 | 5.40700 | 10.62300 | 3004.97 | + +### Dragonfly + +```bash +docker compose -f docker-compose.dragonfly.yml up dragonfly_tcp +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 12683.03 | --- | --- | 1.53450 | 0.66300 | 10.87900 | 16.12700 | 976.82 | +| Gets | 126690.88 | 0.00 | 126690.88 | 1.51768 | 0.65500 | 10.94300 | 16.31900 | 4935.16 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 139373.91 | 0.00 | 126690.88 | 1.51921 | 0.65500 | 10.94300 | 16.31900 | 5911.97 | + +## UNIX socket + +แล้วก็มาลองทดสอบด้วยการใช้ UNIX socket ระหว่าง container ดู + +### Redis + +```bash +docker compose -f docker-compose.redis.yml up redis_sock +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 17540.93 | --- | --- | 1.04733 | 0.99900 | 1.82300 | 4.99100 | 1350.96 | +| Gets | 175216.52 | 0.00 | 175216.52 | 1.04597 | 0.99900 | 1.82300 | 3.87100 | 6825.44 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 192757.45 | 0.00 | 175216.52 | 1.04610 | 0.99900 | 1.82300 | 4.03100 | 8176.40 | + +### Dragonfly + +```bash +docker compose -f docker-compose.dragonfly.yml up dragonfly_sock +``` + +| Type | Ops/sec | Hits/sec | Misses/sec | Avg. Latency | p50 Latency | p99 Latency | p99.9 Latency | KB/sec | +|-----------|------------|------------|---------------|--------------|-------------|-------------|--------------|----------| +| Sets | 23136.83 | --- | --- | 0.80438 | 0.27900 | 7.74300 | 14.39900 | 1781.94 | +| Gets | 231114.05 | 0.00 | 231114.05 | 0.78618 | 0.27900 | 7.39100 | 12.67100 | 9002.89 | +| Waits | 0.00 | --- | --- | --- | --- | --- | --- | --- | +| Totals | 254250.88 | 0.00 | 231114.05 | 0.78783 | 0.27900 | 7.42300 | 12.79900 | 10784.83 | + +## ภาพรวมลิขสิทธิ์ (2024-2025) + +วงการ in-memory database มีการเปลี่ยนแปลงลิขสิทธิ์ครั้งใหญ่หลังจากที่เขียน benchmark นี้: + +- **Redis** — ในเดือนมีนาคม 2024 Redis Ltd. เปลี่ยนจาก BSD เป็น dual license SSPL/RSALv2 และใน Redis 8.0 (2025) เพิ่ม AGPLv3 เป็นตัวเลือกที่สาม การเปลี่ยนแปลงนี้เป็นสาเหตุที่ทำให้เกิด community fork ด้านล่าง +- **Valkey** — เป็น fork ของ Redis 7.2.4 ภายใต้ลิขสิทธิ์ BSD สร้างโดย Linux Foundation (มีนาคม 2024) โดยมี AWS, Google Cloud และ Oracle สนับสนุน Valkey 8.1 (มีนาคม 2025) มี throughput สูงขึ้นประมาณ 8%, P99 latency ต่ำลง 22% และใช้ memory น้อยลง 20% เมื่อเทียบกับ Redis OSS +- **Dragonfly** — ใช้ Business Source License (BSL 1.1) มาตั้งแต่แรก BSL เป็น source-available และใช้งานได้ฟรีสำหรับ self-hosting โดยจะเปลี่ยนเป็น Apache 2.0 หลังจาก change date (ปกติประมาณสี่ปี) Dragonfly ไม่ใช่ open source ที่ผ่านการรับรองโดย OSI แต่ไม่มีข้อจำกัดเหมือน SSPL ของ Redis สำหรับผู้ให้บริการ managed service + +ถ้าลิขสิทธิ์สำคัญสำหรับการใช้งานของคุณ (เช่น ต้องการให้บริการ managed service) Valkey (BSD) จะเป็นตัวเลือกที่เปิดกว้างที่สุด BSL ของ Dragonfly มีข้อจำกัดมากกว่า BSD แต่ไม่ซับซ้อนเท่า SSPL ของ Redis สำหรับ self-hosted benchmark ทั้งสามตัวใช้งานได้ฟรี + +## สรุป +ด้วยพลังแห่ง Multi-thread ทำให้ผลที่ออกมา Dragonfly ดูดีเลยทีเดียว (ยิ่งผ่าน UNIX socket ยิ่งเห็นความต่างจาก TCP/IP) แต่ก็มีข้อสังเกตุคือ ตอนนี้ Dragonfly ไม่ได้รองรับ Redis API แบบ 100% นะครับ ซึ่ง ณ ปี 2025 ความเข้ากันได้ของ Redis API บน Dragonfly ดีขึ้นอย่างมาก แต่บางคำสั่ง edge-case อาจยังมีพฤติกรรมต่างกัน — ควรเช็ค [compatibility reference](https://www.dragonflydb.io/docs/command-reference/compatibility) สำหรับ use case ของคุณเสมอ ถ้ามีการใช้ท่ายากท่าพิเศษ จะต้องเช็คกันก่อนว่าสามารถเอามาใช้งานแทนได้เลยหรือไม่ ที่ [command-reference](https://www.dragonflydb.io/docs/command-reference/compatibility) ซึ่งจากการทดลองใช้งานดูแล้ว ถ้าโดยปกติใช้งาน Redis แค่ Instance เดียว ไม่ได้ใช้งาน ด้าน `Graph`, `Geo location` โดยใช้แค่ หลัก ๆ เป็น `Set` `Get` และใช้ `PubSub` `Stream` นิดหน่อย ก็สามารถแทนด้วย Dragonfly ได้เลย โดยโค้ดและการเชื่อมต่อยังใช้ตามเดิม diff --git a/content/posts/db/interview_nosql/featured-image.webp b/content/posts/db/interview_nosql/featured-image.webp new file mode 100644 index 0000000..76ae4d5 Binary files /dev/null and b/content/posts/db/interview_nosql/featured-image.webp differ diff --git a/content/posts/db/interview_nosql/img/nosql_db_tpyes.webp b/content/posts/db/interview_nosql/img/nosql_db_tpyes.webp new file mode 100644 index 0000000..d32380c Binary files /dev/null and b/content/posts/db/interview_nosql/img/nosql_db_tpyes.webp differ diff --git a/content/posts/db/interview_nosql/index.en.md b/content/posts/db/interview_nosql/index.en.md new file mode 100644 index 0000000..3a03793 --- /dev/null +++ b/content/posts/db/interview_nosql/index.en.md @@ -0,0 +1,114 @@ +--- +title: "Getting to Know Each Type of NoSQL Database" +subtitle: "" +date: 2023-09-12T20:15:55+07:00 +lastmod: 2023-09-12T20:15:55+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "What is NoSQL? What types are there? And what are they suitable for?" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "NoSQL"] +categories: ["NoSQL"] + +lightgallery: true +--- + + + +## What is NoSQL? +The term **NoSQL** usually makes most people think of **NoSQL databases** or **non-relational databases**, but it actually means **non SQL** or **not only SQL**. In layman's terms, it's a database that stores data outside of relational tables. Its main advantage is that the schema is more flexible than being locked down by a table structure. +> You might argue that **SQL DB** can `ALTER TABLE`, but if you have a massive amount of data or have scaled to use a **distributed DB**, `ALTER`ing it once will not be fun. + +## Advantages of NoSQL +### Scalability +Instead of scaling up by increasing the resources of a server like relational DBs (Vertical scaling), NoSQL DBs often scale up by using distributed data clusters (Horizontal scaling). Some cloud providers manage these operations behind the scenes, making it quick to scale up or down according to the incoming load. +### Flexibility +NoSQL DBs often have flexible schemas that allow for faster development and easier adaptation to changes. Their flexible data models make them suitable for semi-structured and unstructured data. +### High-performance +Compared to relational DBs, NoSQL DBs are designed to be optimized for specific data types, resulting in higher performance (if used for the right type of task). + +## Types of NoSQL DB +![nosql_db_tpyes](img/nosql_db_tpyes.webp "nosql_db_tpyes") +Let's go through the main types of NoSQL DBs that I know and have used. + +### Key-value DB +Key-value DBs store data as pairs, where each pair has a unique ID, and the data value can be stored flexibly because the value doesn't have a strict structure. +- Suitable for: + - Session management systems + - Storing user settings +- Examples: + - [BadgerDB](https://github.com/dgraph-io/badger) + - [TiKV](https://github.com/tikv/tikv) + +### In-memory key-value DB +The structure is similar to Key-value DB, but the data is stored in memory (mainly RAM). This makes reading and writing very fast, but it comes at the cost of RAM usage, and data can be lost if the system crashes. We can reduce this risk by taking snapshots and storing them periodically. +- Examples: + - [Dragonfly](https://github.com/dragonflydb/dragonfly) + - [Redis](https://github.com/redis/redis) + - [Memcached](https://github.com/memcached/memcached) + +### Document DB +The structure is similar to Key-value DB, except that the Key-value of Document DB is stored in document formats like JSON, XML, or YAML, grouped into collections. +- Suitable for: + - User profiles + - Product catalogs + - CMS page content +- Examples: + - [MongoDB](https://github.com/mongodb/mongo) + +### Wide-column DB +Wide-column DBs have tables similar to relational tables, but they are better because they don't have as strict column schemas as relational ones. This means that each row doesn't necessarily need to have values in every column, and you can combine different parts of rows and columns with different data formats (I tried this and loved it; it's like a hybrid of relational and non-relational). +- Suitable for: + - Telemetry data + - Analytics data + - Time-series data + - Online Messaging / Chat systems +- Examples: + - [ScyllaDB](https://github.com/scylladb/scylladb) + +### Graph DB +This is a database that stores graph data to map relationships between data. +- Suitable for: + - Social relationship data + - Data used for recommendation engines (e.g., advertising) +- Examples: + - [Dgraph](https://github.com/dgraph-io/dgraph) + - [Neo4j](https://github.com/neo4j/neo4j) + +### Time series DB +As the name suggests, this is a database that doesn't store data by ID but by time. +- Suitable for: + - Data from industrial systems + - DevOps data + - Data collected from the Internet of Things (IoT) +- Examples: + - [QuestDB](https://github.com/questdb/questdb) + - [InfluxDB](https://github.com/influxdata/influxdb) + +### Immutable DB +Immutable DBs store data changes used to verify data integrity, similar to Blockchain, Version Control (I admit I tried playing with this and it's still confusing, seems a bit too specialized). +- Suitable for: + - Accounting systems + - Event logging systems that require change tracking +- Examples: + - [immudb](https://github.com/codenotary/immudb) + - [Dolt](https://github.com/dolthub/dolt) + +### Vector DB +A vector database stores data as high-dimensional numerical vectors (embeddings) and retrieves items by similarity rather than exact match. This makes them the backbone of modern AI workloads where you search by meaning, not by keyword. +- Suitable for: + - Retrieval-Augmented Generation (RAG) for LLMs + - Semantic and similarity search over text, images, or audio + - Recommendation engines + - AI agents that need long-term memory +- Examples: + - [Milvus](https://github.com/milvus-io/milvus) — cloud-native, built for very large scale + - [Qdrant](https://github.com/qdrant/qdrant) — Rust-based, low latency, strong filtering + - [Weaviate](https://github.com/weaviate/weaviate) — GraphQL API with modular vectorizer integrations + - [pgvector](https://github.com/pgvector/pgvector) — PostgreSQL extension, ideal when you already run Postgres + - [Chroma](https://github.com/chroma-core/chroma) — lightweight, popular for prototyping diff --git a/content/posts/db/interview_nosql/index.md b/content/posts/db/interview_nosql/index.md new file mode 100644 index 0000000..1f308e6 --- /dev/null +++ b/content/posts/db/interview_nosql/index.md @@ -0,0 +1,114 @@ +--- +title: "ทำความรู้จักกับ NoSQL แต่ละประเภท" +subtitle: "" +date: 2023-09-12T20:15:55+07:00 +lastmod: 2023-09-12T20:15:55+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "NoSQL คืออะไร มีอะไรบ้าง แล้วเหมาะกับอะไรบ้างนะ" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "NoSQL"] +categories: ["NoSQL"] + +lightgallery: true +--- + + + +## NoSQL คือ? +คำว่า **NoSQL** คนส่วนใหญ่จะนึกถึง **NoSQL database** หรือ **non-relational database** แต่จริง ๆ แล้วมันคือ **non SQL** หรือ **not only SQL** ภาษาคนคือ ฐานข้อมูลที่เก็บข้อมูลโดยนอกเหนือจากการเก็บในตารางความสัมพันธ์ (relational table) โดยจุดเด่นเลยคือ schema จะยืดหยุนกว่าการโดนล็อคไว้ด้วยโครงสร้างตาราง +> ถึงจะเถียงว่า **SQL DB** ก็ `ALTER TABLE` เอาสิ แต่ถ้ามีข้อมูลมหาศาล หรือได้สเกลไปใช้งาน **distributed DB** แล้วละก็ `ALTER` ทีนึง ไม่สนุกแน่ ๆ + +## จุดเด่นของ NoSQL +### Scalability +แทนที่จะ scale up โดยการเพิ่มขนาด resources ของเซิร์ฟเวอร์แบบ relational DB (Vertical scaling) NoSQL DB มักจะ scale up โดยใช้คลัสเตอร์ข้อมูลแบบกระจายศูนย์ (Horizontal scaling) ซึ่งผู้ให้บริการคลาวด์บางรายจะจัดการการดำเนินงานเหล่านี้เบื้องหลังให้เสร็จสรรพ ทำให้การทำให้การเพิ่มลดขนาดตามโหลดที่เข้ามาทำได้รวดเร็ว +### Flexibility +NoSQL DB มักจะมีโครงร่างที่ยืดหยุ่นซึ่งช่วยให้การพัฒนาเร็วขึ้นและเป็นตอบสนองต่อการเปลี่ยนแปลงได้ง่ายขึ้น แถมการที่มีรูปแบบข้อมูลแบบยืดหยุ่น ทำให้เหมาะสำหรับข้อมูลกึ่งโครงสร้างความสัมพันธ์และข้อมูลที่ไม่มีโครงสร้างตายตัว +### High-performance +เมื่อเปรียบเทียบกับ relational DB แล้ว NoSQL DB จะได้รับการออกแบบให้เหมาะสมสำหรับข้อมูลที่มีรูปแบบเฉพาะทางตามประเภทของข้อมูล ซึ่งส่งผลให้ประสิทธิภาพสูงขึ้น (ถ้าใช้ถูกกับประเภทของงานละนะ) + +## ประเภทของ NoSQL DB +![nosql_db_tpyes](img/nosql_db_tpyes.webp "nosql_db_tpyes") +มาไล่เรียงประเภทหลัก ๆ ของ NoSQL DB เท่าที่ผมรู้จักและเคยใช้งานมาบ้าง + +### Key-value DB +Key-value DB จะจัดเก็บข้อมูลเป็นคู่ โดยแต่ละคู่จะมี ID ที่ไม่ซ้ำกัน และค่าข้อมูลสามารถเก็บได้แบบยืดหยุน เนื่องจาก value ไม่ได้มีโครงสร้างมาบังคับ +- เหมาะสำหรับ + - ระบบจัดการ Session + - จัดเก็บการตั้งค่าของ users +- ตัวอย่าง + - [BadgerDB](https://github.com/dgraph-io/badger) + - [TiKV](https://github.com/tikv/tikv) + +### In-memory key-value DB +รูปแบบจะเหมือนกับ Key-value DB แต่ข้อมูลจะถูกเก็บไว้เป็นในหน่วยความจำ (หลัก ๆ ก็ RAM นะแหละ) ทำให้การอ่านการเขียนเร็วมาก แต่ก็แลกมาด้วยปริมาณการใช้งาน RAM และข้อมูลอาจจะหายถ้าระบบล่ม ซึ่งเราก็ลดความเสี่ยงลงได้ จากการตั้ง snapshot เก็บลง storage เป็นระยะ +- ตัวอย่าง + - [Dragonfly](https://github.com/dragonflydb/dragonfly) + - [Redis](https://github.com/redis/redis) + - [Memcached](https://github.com/memcached/memcached) + +### Document DB +โครงสร้างจะคล้ายกับ Key-value DB เลย ยกเว้นว่า Key-value ของ Document DB จะถูกเก็บไว้ในรูปแบบ document เช่น JSON, XML หรือ YAML รวมกันเป็น collections +- เหมาะสำหรับ + - โปรไฟล์ผู้ใช้งาน + - แคตตาล็อกผลิตภัณฑ์ + - เนื้อหาเพจ CMS +- ตัวอย่าง + - [MongoDB](https://github.com/mongodb/mongo) + +### Wide-column DB +Wide-column DB จะมี table คล้าย ๆ กับ relational table แต่ดีตรงที่ไม่มีรูปแบบ column ที่เข้มงวดเท่ากับ relational ก็คือ ในแต่ละ row ไม่จำเป็นต้องมีค่าในทุก column และสามารถรวมส่วนต่าง ๆ ของ row และ column ที่มีรูปแบบข้อมูลที่แตกต่างกันไว้ด้วยได้ (อันนี้ลองเล่นแล้ว ชอบมากเหมือนลูกครึ่ง relational กับ non-relational เลย) +- เหมาะสำหรับ + - ข้อมูล Telemetry + - ข้อมูล Analytics + - ข้อมูลตามช่วงเวลา + - ระบบ Online Messaging / Chat +- ตัวอย่าง + - [ScyllaDB](https://github.com/scylladb/scylladb) + +### Graph DB +เป็น DB ที่เก็บข้อข้อมูลกราฟสร้างแผนที่ความสัมพันธ์ระหว่างข้อมูล +- เหมาะสำหรับ + - ข้อมูลความสัมพันธ์ทางสังคม + - ข้อมูลที่ใช้ทำเครื่องมือแนะนำ (เช่น ระบบโฆษณา) +- ตัวอย่าง + - [Dgraph](https://github.com/dgraph-io/dgraph) + - [Neo4j](https://github.com/neo4j/neo4j) + +### Time series DB +ก็ตามชื่อเลยเป็น DB ที่ไม่ได้เก็บข้อมูลเรียงตาม ID แต่เก็บข้อมูลตามกระแสเวลา +- เหมาะสำหรับ + - ข้อมูลจากระบบอุตสาหกรรม + - ข้อมูลงานทาง DevOps + - ข้อมูลที่เก็บมาจาก Internet of Things (IoT) +- ตัวอย่าง + - [QuestDB](https://github.com/questdb/questdb) + - [InfluxDB](https://github.com/influxdata/influxdb) + +### Immutable DB +Immutable DB เป็น DB ที่จัดเก็บการเปลี่ยนแปลงข้อมูลที่ใช้เพื่อตรวจสอบความสมบูรณ์ของข้อมูล คล้าย ๆ Block Chain, Version Control (อันนี้ยอมรับก่อนเลยว่าลองเล่นดูแล้วยัง งง ๆ เหมือนจะเฉพาะทางไปหน่อย) +- เหมาะสำหรับ + - ระบบบัญชี + - ระบบบันทึกเหตุการณ์ที่ต้องการติดตามความเปลี่ยนแปลง +- ตัวอย่าง + - [immudb](https://github.com/codenotary/immudb) + - [Dolt](https://github.com/dolthub/dolt) + +### Vector DB +Vector database เก็บข้อมูลในรูปแบบเวกเตอร์หลายมิติ (embeddings) และดึงข้อมูลโดยเรียงตามความคล้ายคลึงแทนการ match แบบตรงตัว ทำให้เป็นแกนหลักของงาน AI สมัยใหม่ที่ค้นหาด้วยความหมายไม่ใช่คีย์เวิร์ด +- เหมาะกับ: + - Retrieval-Augmented Generation (RAG) สำหรับ LLM + - การค้นหาแบบ semantic และ similarity บนข้อความ รูปภาพ หรือเสียง + - ระบบ recommendation + - AI agent ที่ต้องการความจำระยะยาว +- ตัวอย่าง: + - [Milvus](https://github.com/milvus-io/milvus) — cloud-native ออกแบบมาสำหรับสเกลใหญ่มาก + - [Qdrant](https://github.com/qdrant/qdrant) — เขียนด้วย Rust latency ต่ำ กรองข้อมูลได้ดี + - [Weaviate](https://github.com/weaviate/weaviate) — มี GraphQL API พร้อม modular vectorizer + - [pgvector](https://github.com/pgvector/pgvector) — extension ของ PostgreSQL เหมาะมากถ้ามี Postgres อยู่แล้ว + - [Chroma](https://github.com/chroma-core/chroma) — เบา นิยมใช้ตอนทำ prototype \ No newline at end of file diff --git a/content/posts/db/microservices_pattern/featured-image.webp b/content/posts/db/microservices_pattern/featured-image.webp new file mode 100644 index 0000000..0b076ef Binary files /dev/null and b/content/posts/db/microservices_pattern/featured-image.webp differ diff --git a/content/posts/db/microservices_pattern/img/db_pattern.gif b/content/posts/db/microservices_pattern/img/db_pattern.gif new file mode 100644 index 0000000..56f2426 Binary files /dev/null and b/content/posts/db/microservices_pattern/img/db_pattern.gif differ diff --git a/content/posts/db/microservices_pattern/img/db_pattern.mp4 b/content/posts/db/microservices_pattern/img/db_pattern.mp4 new file mode 100644 index 0000000..02b3c30 Binary files /dev/null and b/content/posts/db/microservices_pattern/img/db_pattern.mp4 differ diff --git a/content/posts/db/microservices_pattern/index.en.md b/content/posts/db/microservices_pattern/index.en.md new file mode 100644 index 0000000..b7ca57b --- /dev/null +++ b/content/posts/db/microservices_pattern/index.en.md @@ -0,0 +1,63 @@ +--- +title: "DB patterns for building microservices" +subtitle: "" +date: 2023-09-28T23:45:41+07:00 +lastmod: 2023-09-28T23:45:41+07:00 +draft: true +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Introduces four must-know database patterns for building microservices: database-per-service, shared database, API composition, and CQRS with event sourcing — and the trade-offs of each." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- + +Building microservices is NOT easy. + +Even in the best of times, you might end up in a mess. + +But if you still have to build microservices for some reason, it’s better to know a few patterns that can make your life easy. + +Here are 4 must-know patterns for building microservices: + + + +![db_pattern](img/db_pattern.gif "DB patterns for building microservices") + +- Database per service + +- In this pattern, each microservice manages its own data. +- This means that no other service can access that data directly. +- Communication or exchange of data can only happen through the owner service. +- The success of this pattern depends on how well you’ve defined the bounded context of your application. + +- Shared Database + +- Avoid this pattern as much as possible +- But sometimes, it’s the only viable option to incrementally move to a microservice architecture. +- This approach is lenient in the sense that multiple services use a shared database. +- However, it creates a bigger impact surface and chances of run-time issues. + +- API Composition + +- This pattern tries to solve the problem of implementing complex queries in a microservices architecture. +- An API Composer invokes other services in the required order. +- After fetching the results, it performs an in-memory join of the data before returning it to the caller. +- This is an inefficient approach due to in-memory joins on potentially large datasets. + +- CQRS + Event Sourcing + +- Next, we have CQRS +- It can help get around the issues with the API Composition Pattern. +- An application listens to domain events from other services and updates a separate query database. This makes it easy to serve complex aggregation queries. +- CQRS can also be combined with Event Sourcing where you store the state of the entity (aggregate) as a sequence of events. +- Of course, this pattern can sometimes be unfamiliar to the developers. + +At the end of the day, no pattern is perfect. diff --git a/content/posts/db/microservices_pattern/index.md b/content/posts/db/microservices_pattern/index.md new file mode 100644 index 0000000..655163c --- /dev/null +++ b/content/posts/db/microservices_pattern/index.md @@ -0,0 +1,63 @@ +--- +title: "DB patterns for building microservices" +subtitle: "" +date: 2023-09-28T23:45:41+07:00 +lastmod: 2023-09-28T23:45:41+07:00 +draft: true +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "แนะนำ 4 database pattern ที่ควรรู้สำหรับการสร้าง microservices: database per service, shared database, API composition และ CQRS + event sourcing พร้อมข้อดีข้อเสียของแต่ละแบบ" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- + +Building microservices is NOT easy. + +Even in the best of times, you might end up in a mess. + +But if you still have to build microservices for some reason, it’s better to know a few patterns that can make your life easy. + +Here are 4 must-know patterns for building microservices: + + + +![db_pattern](img/db_pattern.gif "DB patterns for building microservices") + +- Database per service + +- In this pattern, each microservice manages its own data. +- This means that no other service can access that data directly. +- Communication or exchange of data can only happen through the owner service. +- The success of this pattern depends on how well you’ve defined the bounded context of your application. + +- Shared Database + +- Avoid this pattern as much as possible +- But sometimes, it’s the only viable option to incrementally move to a microservice architecture. +- This approach is lenient in the sense that multiple services use a shared database. +- However, it creates a bigger impact surface and chances of run-time issues. + +- API Composition + +- This pattern tries to solve the problem of implementing complex queries in a microservices architecture. +- An API Composer invokes other services in the required order. +- After fetching the results, it performs an in-memory join of the data before returning it to the caller. +- This is an inefficient approach due to in-memory joins on potentially large datasets. + +- CQRS + Event Sourcing + +- Next, we have CQRS +- It can help get around the issues with the API Composition Pattern. +- An application listens to domain events from other services and updates a separate query database. This makes it easy to serve complex aggregation queries. +- CQRS can also be combined with Event Sourcing where you store the state of the entity (aggregate) as a sequence of events. +- Of course, this pattern can sometimes be unfamiliar to the developers. + +At the end of the day, no pattern is perfect. \ No newline at end of file diff --git a/content/posts/db/pg_col_data_padding/featured-image.webp b/content/posts/db/pg_col_data_padding/featured-image.webp new file mode 100644 index 0000000..d9c7cd8 Binary files /dev/null and b/content/posts/db/pg_col_data_padding/featured-image.webp differ diff --git a/content/posts/db/pg_col_data_padding/index.en.md b/content/posts/db/pg_col_data_padding/index.en.md new file mode 100644 index 0000000..2ba409f --- /dev/null +++ b/content/posts/db/pg_col_data_padding/index.en.md @@ -0,0 +1,127 @@ +--- +title: "Did you know that column ordering in DBs is also important?" +subtitle: "" +date: 2024-02-15T19:41:41+07:00 +lastmod: 2024-02-15T19:41:41+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Explains how column ordering in PostgreSQL affects tuple alignment and the on-disk row storage size through data padding." +license: "" +images: [] + +tags: ["Database", "SQL", "PostgreSQL"] +categories: ["SQL"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +When creating tables in a database, have you ever noticed why we have to choose the data size? Let's take a look at how important column ordering is, using PGSQL as an example. + + +## Data Alignment in PGSQL +First, let's look at the documentation to see what's related to data types in the DB. + +{{< admonition note "Note" >}} +`typalign` char + +`typalign` is the alignment required when storing a value of this type. It applies to storage on disk as well as most representations of the value inside PostgreSQL. **When multiple values are stored consecutively, such as in the representation of a complete row on disk, padding is inserted before a datum of this type so that it begins on the specified boundary**. The alignment reference is the beginning of the first datum in the sequence. Possible values are: + +- c = char alignment, i.e., no alignment needed. +- s = short alignment (2 bytes on most machines). +- i = int alignment (4 bytes on most machines). +- d = double alignment (8 bytes on many machines, but by no means all). +{{< /admonition >}} +Source: [pg_type](https://www.postgresql.org/docs/current/catalog-pg-type.html) + +As you can see, each data type uses a different amount of space, and the actual storage size depends on the order of the columns. + +{{< admonition note "Note" >}} +Interpreting the actual data can only be done with information obtained from other tables, mostly `pg_attribute`. **The key values needed to identify field locations are `attlen` and `attalign`**. There is no way to directly get a particular `attribute`, except when there are only fixed width fields and no null values. All this trickery is wrapped up in the functions `heap_getattr`, `fastgetattr` and `heap_getsysattr`. +{{< /admonition >}} +Source: [Table Row Layout](https://www.postgresql.org/docs/current/storage-page-layout.html#STORAGE-TUPLE-LAYOUT) + +From the emphasized text, we can see that good column ordering when creating a table not only affects storage but also the memory and CPU used by that table. + +## About Padding +After reading the Docs, if you're still confused, let's see how it actually works. + +Start by creating two tables. +```sql +CREATE TABLE tb_fragment (col_1 smallint, col_2 bigint, col_3 int, col_4 bigint); + +CREATE TABLE tb_continuous (col_1 bigint, col_2 bigint, col_3 int, col_4 smallint); +``` + +Then let's examine how they look in storage. +```sql +SELECT a.attname, t.typname, t.typalign, t.typlen +FROM pg_class c + JOIN pg_attribute a ON (a.attrelid = c.oid) + JOIN pg_type t ON (t.oid = a.atttypid) +WHERE c.relname = 'tb_fragment' AND a.attnum >= 0; + attname | typname | typalign | typlen +---------+---------+----------+-------- + col_1   | int2    | s        |      2 + col_2   | int8    | d        |      8 + col_3   | int4    | i        |      4 + col_4   | int8    | d        |      8 +(4 rows) + +SELECT a.attname, t.typname, t.typalign, t.typlen +FROM pg_class c + JOIN pg_attribute a ON (a.attrelid = c.oid) + JOIN pg_type t ON (t.oid = a.atttypid) +WHERE c.relname = 'tb_continuous' AND a.attnum >= 0; + attname | typname | typalign | typlen +---------+---------+----------+-------- + col_1  | int8    | d        |      8 + col_2  | int8    | d        |      8 + col_3  | int4    | i        |      4 + col_4  | int2    | s        |      2 +(4 rows) +``` + +As you can see: +- `BIGINT` uses 8 bytes +- `INT` uses 4 bytes +- `SMALLINT` uses 2 bytes + +We calculate that both tables should use 22 bytes per row, but let's look at the reality. + +```sql +select pg_column_size(tb_fragment.*) - 24 as row_size from tb_fragment limit 1; + row_size +---------- +       32 +(1 row) + +select pg_column_size(tb_continuous.*) - 24 as row_size from tb_continuous limit 1; + row_size +---------- +       22 +(1 row) +``` + +You might wonder why the results differ. + +At this point, if you look at `typlen`, those who have read ([Designing Go structs with knowledge of Computer Architecture and Data Structures]({{< ref "/posts/go/struct_memory" >}} "Designing Go structs with knowledge of Computer Architecture and Data Structures")) will find that it's similar. Yes, it's the same thing. What happened is the padding between fields. + +We can use the `pageinspect` extension to explain what happened: + +```sql +SELECT t_data FROM heap_page_items(get_raw_page('tb_fragment','main',0))gx +-[ RECORD 1 ]-------------------------------------------------------------- +t_data | xff7f000000000000ffffffffffffff7fffffff7f00000000ffffffffffffff7f + +SELECT t_data FROM heap_page_items(get_raw_page('tb_continuous','main',0))gx +-[ RECORD 1 ]------------------------------------------ +t_data | xffffffffffffff7fffffffffffffff7fffffff7fff7f +``` + +As you can see, in the `tb_fragment` table, when data is retrieved, there will be zero padding until it reaches 8 bytes, and then it moves to the next column because **col_1 (2 bytes) + col_2 (8 bytes) exceeds 8 bytes, causing them to be separated**. This is different from `tb_continuous`, where **col_3 (4 bytes) + col_4 (2 bytes) does not exceed 8 bytes, allowing them to be combined into the same word**. + +## Conclusion +Planning and designing the structure by considering low-level aspects not only helps us avoid wasting storage but also improves memory and CPU usage. This is because we don't have to waste time loading unnecessary data. However, this comes at the cost of column order readability. So, we might leave PKEYs or other indexes that make the table easier to read in the first columns, as is commonly done, without forcing an order just for performance. diff --git a/content/posts/db/pg_col_data_padding/index.md b/content/posts/db/pg_col_data_padding/index.md new file mode 100644 index 0000000..d9c8771 --- /dev/null +++ b/content/posts/db/pg_col_data_padding/index.md @@ -0,0 +1,127 @@ +--- +title: "รู้หรือไม่ ว่าการเรียง column ใน DB ก็สำคัญนะจร๊ะส์" +subtitle: "" +date: 2024-02-15T19:41:41+07:00 +lastmod: 2024-02-15T19:41:41+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "อธิบายว่าการเรียงลำดับ column ใน PostgreSQL มีผลต่อการจัดเรียงข้อมูลและขนาด storage ของ row ผ่าน data padding อย่างไร" +license: "" +images: [] + +tags: ["Database", "SQL", "PostgreSQL"] +categories: ["SQL"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +เวลาสร้างตารางในฐานข้อมูลเคยสังเกตไหมครับว่า ทำไมเราต้องมานั่งเลือกขนาดของข้อมูล เลยจะมาลองดูกันว่ามันสำคัญยังไงไอ้การเรียง column เนี้ย โดยตัวอย่างที่ยกมาจะเป็น PGSQL เด้อ + + +## การจัดเรียงข้อมูลบน PGSQL +อันดับแรกเรามาดูใน document กันว่ามีอะไรที่เกี่ยวข้องกับประเภทของข้อมูลบน DB กันบ้าง + +{{< admonition note "Note" >}} +`typalign` char + +`typalign` is the alignment required when storing a value of this type. It applies to storage on disk as well as most representations of the value inside PostgreSQL. **When multiple values are stored consecutively, such as in the representation of a complete row on disk, padding is inserted before a datum of this type so that it begins on the specified boundary**. The alignment reference is the beginning of the first datum in the sequence. Possible values are: + +- c = char alignment, i.e., no alignment needed. +- s = short alignment (2 bytes on most machines). +- i = int alignment (4 bytes on most machines). +- d = double alignment (8 bytes on many machines, but by no means all). +{{< /admonition >}} +ที่มา [pg_type](https://www.postgresql.org/docs/current/catalog-pg-type.html) + +จะเห็นว่าแต่ละประเภทข้อมูลจะใช้พื้นที่ไม่เท่ากันตามแต่ละประเภท ซึ่งขนาดบน storage ที่ใช้งานจริงจะขึ้นอยู่กับการเรียงลำดับของ column ด้วย + +{{< admonition note "Note" >}} +Interpreting the actual data can only be done with information obtained from other tables, mostly `pg_attribute`. **The key values needed to identify field locations are `attlen` and `attalign`**. There is no way to directly get a particular `attribute`, except when there are only fixed width fields and no null values. All this trickery is wrapped up in the functions `heap_getattr`, `fastgetattr` and `heap_getsysattr`. +{{< /admonition >}} +ที่มา [Table Row Layout](https://www.postgresql.org/docs/current/storage-page-layout.html#STORAGE-TUPLE-LAYOUT) + +จากข้อความที่เน้นไว้จะพบว่าการเรียง column ให้ดี ตอนที่สร้างตารางไม่ได้มีผลแค่ storage เท่านั้นยังมีผลต่อ memory กับ cpu ที่ใช้งานตารางนั้นด้วย + +## ว่าด้วยเรื่อง Padding +หลังจากอ่าน Docs แล้วยัง งง กันอยู่เรามาลองดูว่าพอใช้งานจริง ๆ มันจะเป็นยังไงนะ + +เริ่มจากสร้างตารางมา 2 อัน +```sql +CREATE TABLE tb_fragment (col_1 smallint, col_2 bigint, col_3 int, col_4 bigint); + +CREATE TABLE tb_continuous (col_1 bigint, col_2 bigint, col_3 int, col_4 smallint); +``` + +แล้วก็มาส่องดูว่าหน้าตาบน storage เป็นยังไง +```sql +SELECT a.attname, t.typname, t.typalign, t.typlen +FROM pg_class c + JOIN pg_attribute a ON (a.attrelid = c.oid) + JOIN pg_type t ON (t.oid = a.atttypid) +WHERE c.relname = 'tb_fragment' AND a.attnum >= 0; + attname | typname | typalign | typlen +---------+---------+----------+-------- + col_1   | int2    | s        |      2 + col_2   | int8    | d        |      8 + col_3   | int4    | i        |      4 + col_4   | int8    | d        |      8 +(4 rows) + +SELECT a.attname, t.typname, t.typalign, t.typlen +FROM pg_class c + JOIN pg_attribute a ON (a.attrelid = c.oid) + JOIN pg_type t ON (t.oid = a.atttypid) +WHERE c.relname = 'tb_continuous' AND a.attnum >= 0; + attname | typname | typalign | typlen +---------+---------+----------+-------- + col_1  | int8    | d        |      8 + col_2  | int8    | d        |      8 + col_3  | int4    | i        |      4 + col_4  | int2    | s        |      2 +(4 rows) +``` + +จากที่เห็นก็คือ +- `BIGINT` ใช้ 8 bytes +- `INT` ใช้ 4 bytes +- `SMALLINT` ใช้ 2 bytes + +เราคำนวณออกมาได้ว่าทั้ง 2 ตารางใช้ก็น่าจะใช้ 22 bytes ต่อหนึ่ง row แต่แล้วเรามาดูความเป็นจริง + +```sql +select pg_column_size(tb_fragment.*) - 24 as row_size from tb_fragment limit 1; + row_size +---------- +       32 +(1 row) + +select pg_column_size(tb_continuous.*) - 24 as row_size from tb_continuous limit 1; + row_size +---------- +       22 +(1 row) +``` + +อาจสงสัยว่าทำไมผลลัพธ์ถึงไม่เท่ากัน + +มาถึงตรงนี้ถ้าสังเกตจาก `typlen` คนที่เคยอ่าน ([ออกแบบ Go struct ด้วยความรู้วิชา Computer Architecture และ Data Structure]({{< ref "/posts/go/struct_memory" >}} "ออกแบบ Go struct ด้วยความรู้วิชา Computer Architecture และ Data Structure")) ก็จะพบว่ามันคล้าย ๆ กันเลยนะ ใช่ครับมันคือสิ่งเดียวกันเลย สิ่งที่เกิดขึ้นคือ padding ระหว่าง filed ที่ออกมานั่นเองครับ + +ซึ่งเราสามารถใช้ extension `pageinspect` เพื่ออธิบายสิ่งที่เกิดขึ้นได้ตามนี้ + +```sql +SELECT t_data FROM heap_page_items(get_raw_page('tb_fragment','main',0))gx +-[ RECORD 1 ]-------------------------------------------------------------- +t_data | xff7f000000000000ffffffffffffff7fffffff7f00000000ffffffffffffff7f + +SELECT t_data FROM heap_page_items(get_raw_page('tb_continuous','main',0))gx +-[ RECORD 1 ]------------------------------------------ +t_data | xffffffffffffff7fffffffffffffff7fffffff7fff7f +``` + +จะเห็นว่าในตาราง `tb_fragment` เวลาดึงข้อมูลออกมานั้นจะมี zero padding จนเต็ม 8 bytes แล้วค่อยเป็น column ถัดไปเพราะ **col_1(2 bytes) + col_2(8 bytes) เกิน 8 bytes ทำให้โดนแยกออกจากกัน** ซึ่งจะต่างจาก `tb_continuous` ที่ **col_3(4 bytes) + col_4(2 bytes) ไม่เกิน 8 bytes ทำให้รวมกันออกมาด้วย word เดียวกันได้เลย** + +## สรุป +การวางแผนออกแบบโครงสร้างโดยพิจารณาสิ่งที่จะเกิดขึ้นระดับ low-level นอกจากจะช่วยให้เราไม่สูญเสีย storage ไปเปล่า ๆ แล้วยังช่วยเพิ่มประสิทธิภาพในการใช้งาน memory กับ cpu ได้อีกด้วย เนื่องจากไม่ต้องเสียไปกับการ load data ที่ไม่จำเป็น แต่ว่าก็ต้องแลกมากับลำดับความสัมพันของ column ที่จะดูไม่รู้เรื่อง เราก็เลยอาจจะเว้นไว้สำหรับ PKEY หรือ index อื่น ๆ ที่จะทำให้เราอ่านตารางได้ง่าย ให้มาอยู่ column แรก ๆ แบบที่ชาวโลกปกติทำกัน โดยไม่ต้องไปฝืนเรียงเพื่อเอา perfomance เพียงอย่างเดียว diff --git a/content/posts/db/query_pagination/featured-image.webp b/content/posts/db/query_pagination/featured-image.webp new file mode 100644 index 0000000..b8ce497 Binary files /dev/null and b/content/posts/db/query_pagination/featured-image.webp differ diff --git a/content/posts/db/query_pagination/img/query_exec_order.webp b/content/posts/db/query_pagination/img/query_exec_order.webp new file mode 100644 index 0000000..46428bd Binary files /dev/null and b/content/posts/db/query_pagination/img/query_exec_order.webp differ diff --git a/content/posts/db/query_pagination/index.en.md b/content/posts/db/query_pagination/index.en.md new file mode 100644 index 0000000..72c039a --- /dev/null +++ b/content/posts/db/query_pagination/index.en.md @@ -0,0 +1,76 @@ +--- +title: "What's lurking when you need to paginate?" +subtitle: "" +date: 2023-09-08T10:26:39+07:00 +lastmod: 2023-09-08T10:26:39+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Compares SQL pagination strategies including Offset/Limit, keyset cursor, and deferred joins, and explains when each one is efficient." +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "SQL", "Pagination"] +categories: ["SQL"] + +lightgallery: true +--- + + + +## Offset / Limit +Let's start with the classic approach that everyone has probably used: Offset / Limit. +```sql +SELECT + * +FROM + users +ORDER BY + created_at +LIMIT + 20 OFFSET 800; +``` + +This seems convenient as you can jump to any page, but it comes at a cost: Offset is expensive. This is because Offset reads all the data first and then discards what's not needed. This means that Offset is not an offset of the Datasource, but an offset of the Query Result. The more data there is, the more data needs to be accessed and then discarded. +![query_exec_order](img/query_exec_order.webp "query_exec_order") +If you don't understand Query Execution Order, [you can learn more from ByteByteGo here](https://www.youtube.com/watch?v=BHwzDmr6d7s). + +## Cursor +Next is using an Index Cursor. +```sql +SELECT + * +FROM + users +WHERE + _id > ${last_seen} +ORDER BY + created_at +LIMIT + 20; +``` +The advantage of this method is that it jumps to access data at the point indicated by the Index, so no matter how much data there is, it won't read everything and then discard it. However, the disadvantage is that this method cannot use page-based pagination like the Offset method, but it can use the Index as `last_seen`, which makes fetching data very fast. This is suitable for infinite scrolling pages. + +## Offset / Limit + Deferred Joins +Since Offset / Limit is convenient but wasteful, we can reduce the waste by reducing the amount of data discarded by using Deferred Joins. +```sql +SELECT + * +FROM + users + INNER JOIN ( + SELECT + _id + FROM + users + ORDER BY + created_at + LIMIT + 20 OFFSET 800 + ORDER BY + created_at; + ) AS sub_users USING (_id) +``` +This means we select the index based on the filter to create a subset, and then join it with the actual data only for what we need. diff --git a/content/posts/db/query_pagination/index.md b/content/posts/db/query_pagination/index.md new file mode 100644 index 0000000..eb26366 --- /dev/null +++ b/content/posts/db/query_pagination/index.md @@ -0,0 +1,76 @@ +--- +title: "เมื่อต้องทำ pagination มันมีอะไรซุ่มรอเราอยู่" +subtitle: "" +date: 2023-09-08T10:26:39+07:00 +lastmod: 2023-09-08T10:26:39+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เปรียบเทียบกลยุทธ์การทำ pagination ใน SQL ทั้ง Offset/Limit, keyset cursor และ deferred joins พร้อมอธิบายว่าแต่ละแบบเหมาะกับสถานการณ์ใด" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "SQL", "Pagination"] +categories: ["SQL"] + +lightgallery: true +--- + + + +## Offset / Limit +เริ่มจากท่า classic ที่ทุกคนน่าจะเคยใช้กันอยู่แล้วคือ Offset / Limit นั่นเอง +```sql +SELECT + * +FROM + users +ORDER BY + created_at +LIMIT + 20 OFFSET 800; +``` + +ซึ่งก็ดูสะดวกดีที่สามารถเลือกกระโดดไปหน้าไหนก็ได้ แต่มันมีสิ่งที่ต้องแลกมานั่นคือ Offset มันแพงนั่นเอง เพราะ Offset คือการอ่านข้อมูลออกมาก่อนแล้วค่อยปัดทิ้ง หมายความว่า Offset ไม่ใช่ Offset ของ Datasource แต่มันคือ Offset ของ Query Result ซึ่งถ้ายิ่งข้อมูลมากขึ้นก็หมายถึงจำนวนข้อมูลที่ต้อง Access ออกมาแล้วก็ Discard มากขึ้นนั่นเอง +![query_exec_order](img/query_exec_order.webp "query_exec_order") +ถ้ายังไม่เข้าใจ Query Execution Order [สามารถศึกษาเพิ่มเติมจาก ByteByteGo ได้ที่นี้](https://www.youtube.com/watch?v=BHwzDmr6d7s) + +## Cursor +ต่อมาคือการใช้ Index Cursor +```sql +SELECT + * +FROM + users +WHERE + _id > ${last_seen} +ORDER BY + created_at +LIMIT + 20; +``` +ข้อดีของวิธีนี้คือเป็นการกระโดดไป Access ข้อมูล ณ จุดที่ Index ชี้อยู่ทำให้ไม่ว่าข้อมูลจะเยอะ ก็จะไม่เกิดการไล่อ่านทั้งหมดแล้วปัดทิ้ง แต่ข้อเสียเลยคือ วิธีนี้จะไม่สามารถใช้ Pagination แบบกำหนดหน้าได้เหมือนวิธี Offset แต่ใช้ Index เป็น last_seen ได้เลยทำให้การ fetch ข้อมูลได้รวดเร็ว ซึ่งจะเหมาะกับงาน Infinite page ที่เลื่อนต่อ ๆ กันได้เลย + +## Offset / Limit + Deferred Joins +เนื่องจาก Offset / Limit ใช้งานสะดวกแต่ก็แลกมาด้วยความเปลือง แต่เราสามารถลดความเปลืองลงได้ด้วยการลดขนาดการโยนข้อมูลทิ้งได้ด้วยการใช้ Deferred Joins นั่นเอง +```sql +SELECT + * +FROM + users + INNER JOIN ( + SELECT + _id + FROM + users + ORDER BY + created_at + LIMIT + 20 OFFSET 800 + ORDER BY + created_at; + ) AS sub_users USING (_id) +``` +นั่นคือเรา select index ตาม filter ออกมาเพื่อทำเป็น subset แล้วไป join เอาตัวข้อมูลจริง ๆ ออกมาเฉพาะที่เราใช้งานนั่นเอง \ No newline at end of file diff --git a/content/posts/db/sql_collation/featured-image.webp b/content/posts/db/sql_collation/featured-image.webp new file mode 100644 index 0000000..5024217 Binary files /dev/null and b/content/posts/db/sql_collation/featured-image.webp differ diff --git a/content/posts/db/sql_collation/index.en.md b/content/posts/db/sql_collation/index.en.md new file mode 100644 index 0000000..02c8bed --- /dev/null +++ b/content/posts/db/sql_collation/index.en.md @@ -0,0 +1,156 @@ +--- +title: "What is COLLATE in a Database and What Does It Do?" +subtitle: "" +date: 2023-09-08T10:24:29+07:00 +lastmod: 2023-09-08T10:24:29+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Explains what COLLATE is in a database and how it controls string sorting and comparison, including case and accent sensitivity." +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "SQL"] +categories: ["SQL"] + +lightgallery: true +--- + + + +## What is COLLATE? +When creating a Database or Table, we define the CHARACTER SET and COLLATE. For example, in MariaDB, `utf8mb4_unicode_ci` consists of: + +### Character set +`utf8mb4` means using a character set encoded in full 4-byte UTF-8 (older `utf8` was 3-byte UTF-8, which doesn't support full emojis and Unicode). + +### Collation Algorithm +`unicode` means sorting (the value of) characters according to Unicode. + +### Diacritical sensitivity +- `ai` (ancient insensitive) e.g., a == ᾰ +- `as` (ancient sensitive) e.g., a != ᾰ +- `ci` (case insensitive) e.g., A == a +- `cs` (case sensitive) e.g., A != a + +## Usecase +The simplest example is probably searching for names, whether people or places, like: + +```sql +SELECT + _id, + first_name, + last_name +FROM + users +WHERE + LOWER(first_name) = 'kawin'; +``` + +Result: + +```json +[ + { + "_id": 5, + "first_name": "KAWIN", + "last_name": "eiei" + }, + { + "_id": 4, + "first_name": "kawin", + "last_name": "naja" + }, + { + "_id": 3, + "first_name": "Kawin", + "last_name": "Viriyaprasopsook" + } +] +``` + +We get `Kawin`, `kawin`, `KAWIN`, which seems normal. But if we try to EXPLAIN it: + +```sql +EXPLAIN FORMAT=JSON +SELECT + _id, + first_name, + last_name +FROM + users +WHERE + LOWER(first_name) = 'kawin'; +``` + +We will find that our query reads all 1000 rows in the search. + +```json +{ + "query_block": { + "select_id": 1, + "nested_loop": [ + { + "table": { + "table_name": "users", + "access_type": "index", + "key": "udx_full_name", + "key_length": "518", + "used_key_parts": ["first_name", "last_name"], + "rows": 1000, + "filtered": 100, + "attached_condition": "lcase(users.first_name) = 'kawin'", + "using_index": true + } + } + ] + } +} +``` + +This is called **index obscure**, meaning the database sees the `WHERE` clause as `fn(column)` and has to **iterate through the entire table to apply LOWER()**. If the table/column was already created as case-insensitive, we can just use `=`. + +```sql +EXPLAIN FORMAT=JSON +SELECT + _id, + first_name, + last_name +FROM + users +WHERE + first_name = 'kawin'; +``` + +Which will EXPLAIN to this, reading only 3 rows instead of the entire table, and the result is the same as using LOWER(). + +```json +{ + "query_block": { + "select_id": 1, + "nested_loop": [ + { + "table": { + "table_name": "users", + "access_type": "ref", + "possible_keys": ["udx_full_name"], + "key": "udx_full_name", + "key_length": "259", + "used_key_parts": ["first_name"], + "ref": ["const"], + "rows": 3, + "filtered": 100, + "attached_condition": "users.first_name = 'kawin'", + "using_index": true + } + } + ] + } +} +``` + +{{< admonition warning >}} +Since uppercase and lowercase characters are treated as equal, be careful when using `UNIQUE` constraints, as the database will consider "Kawin" to be a duplicate of "kawin". +{{< /admonition >}} diff --git a/content/posts/db/sql_collation/index.md b/content/posts/db/sql_collation/index.md new file mode 100644 index 0000000..80a2ced --- /dev/null +++ b/content/posts/db/sql_collation/index.md @@ -0,0 +1,156 @@ +--- +title: "COLLATE ใน Database มันคืออิหยังกันนะ แล้วมันทำอะไรได้" +subtitle: "" +date: 2023-09-08T10:24:29+07:00 +lastmod: 2023-09-08T10:24:29+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "อธิบายว่า COLLATE ในฐานข้อมูลคืออะไร และส่งผลต่อการเรียงลำดับและการเปรียบเทียบ string อย่างไร รวมถึงเรื่อง case และอักษรพิเศษ" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "SQL"] +categories: ["SQL"] + +lightgallery: true +--- + + + +## COLLATE คือ +เวลาสร้าง Database หรือ Table เราจะกำหนด CHARACTER SET และ COLLATE ยกตัวอย่างของ MariaDB เช่น `utf8mb4_unicode_ci` ประกอบด้วย + +### Character set +utf8mb4 หมายถึงใช้ character set ที่ encoding เป็น UTF-8 แบบเต็ม 4 bytes (utf8 เฉย ๆ ของรุ่นเก่า ๆ คือ UTF-8 แบบ 3 bytes ซึ่งจะไม่รองรับ emoji กับ unicode แบบเต็มรูปแบบ) + +### Collation Algorithm +unicode หมายถึงใช้การเรียงลำดับ (ค่าของ) ตัวอักษรตาม unicode + +### Diacritical sensitivity +- ai (ancient insensitive) ตัวอย่าง a == ᾰ +- as (ancient sensitive) ตัวอย่าง a != ᾰ +- ci (case insensitive) ตัวอย่าง A == a +- cs (case sensitive) ตัวอย่าง A != a + +## Usecase +ยกตัวอย่างที่ง่ายที่สุดน่าจะเป็นการค้นหาชื่อไม่ว่าจะคนหรือสถานที่เช่น + +```sql +SELECT + users._id, + users.first_name, + users.last_name +FROM + users +WHERE + LOWER(users.first_name) = 'kawin'; +``` + +ผลที่ได้ + +```json +[ + { + "_id": 5, + "first_name": "KAWIN", + "last_name": "eiei" + }, + { + "_id": 4, + "first_name": "kawin", + "last_name": "naja" + }, + { + "_id": 3, + "first_name": "Kawin", + "last_name": "Viriyaprasopsook" + } +] +``` + +เราก็จะได้มาทั้ง `Kawin`, `kawin`, `KAWIN` ซึ่งดูเหมือนจะได้ผลตามปกติ แต่ถ้าลอง EXPLAIN ดู + +```sql +EXPLAIN FORMAT=JSON +SELECT + users._id, + users.first_name, + users.last_name +FROM + users +WHERE + LOWER(users.first_name) = 'kawin'; +``` + +จะพบว่า query เราอ่านไปทั้งหมด 1000 rows ในการค้นหาเลย + +```json +{ + "query_block": { + "select_id": 1, + "nested_loop": [ + { + "table": { + "table_name": "users", + "access_type": "index", + "key": "udx_full_name", + "key_length": "518", + "used_key_parts": ["first_name", "last_name"], + "rows": 1000, + "filtered": 100, + "attached_condition": "lcase(users.first_name) = 'kawin'", + "using_index": true + } + } + ] + } +} +``` + +สิ่งนี้เรียกว่า **index obscure** ก็คือ database มองว่า where จาก ผลของ fn(column) ทำให้ต้อง**ไล่ LOWER() ไปจนสุดตาราง** ซึ่งถ้าในตอนที่เราสร้างตาราง/คอลัมน์ เป็นแบบ case insensitive อยู่แล้วเราก็สามารถใช้ `=` ได้เลย + +```sql +EXPLAIN FORMAT=JSON +SELECT + users._id, + users.first_name, + users.last_name +FROM + users +WHERE + users.first_name = 'kawin'; +``` + +ซึง EXPLAIN ออกมาจะได้ตามนี้ อ่านไปแค่ 3 rows แทนที่จะอ่านจากทั้งตาราง ซึ่งก็ได้ผลออกมาเหมือนกับใช้ LOWER() ครอบเลย + +```json +{ + "query_block": { + "select_id": 1, + "nested_loop": [ + { + "table": { + "table_name": "users", + "access_type": "ref", + "possible_keys": ["udx_full_name"], + "key": "udx_full_name", + "key_length": "259", + "used_key_parts": ["first_name"], + "ref": ["const"], + "rows": 3, + "filtered": 100, + "attached_condition": "users.first_name = 'kawin'", + "using_index": true + } + } + ] + } +} +``` + +{{< admonition warning >}} +เนื่องจากจะตัวเล็กจะตัวใหญ่ มีค่าเท่ากันต้องระวังในการใช้ unique เพราะ database จะมองว่า Kawin ซ้ำกับ kawin นั่นเอง +{{< /admonition >}} \ No newline at end of file diff --git a/content/posts/db/sql_optimized/featured-image.webp b/content/posts/db/sql_optimized/featured-image.webp new file mode 100644 index 0000000..6ae8c36 Binary files /dev/null and b/content/posts/db/sql_optimized/featured-image.webp differ diff --git a/content/posts/db/sql_optimized/index.en.md b/content/posts/db/sql_optimized/index.en.md new file mode 100644 index 0000000..80cd4b6 --- /dev/null +++ b/content/posts/db/sql_optimized/index.en.md @@ -0,0 +1,57 @@ +--- +title: "Living with SQL: How to Make It Work for You" +subtitle: "" +date: 2023-09-05T21:30:43+07:00 +lastmod: 2023-09-05T22:10:43+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Shares practical query and table design techniques to keep RDBMS performance from degrading as data grows." +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "SQL"] +categories: ["SQL"] + +lightgallery: true +--- + +When working with Relational Database Management Systems (RDBMS), a common issue is that as data grows, performance slows down. Why isn't it as fast as it was during development? Today, let's look at how to design queries and tables to achieve the performance they should have. + + + +## Orderable/Sortable Indexes +Keys and indexes must always be sortable. Simply put, they shouldn't be random. + +## Primary Keys +Always use primary keys as the main key to separate data in rows. Oh, and don't be lazy and use `auto_increment`, because if you scale up to a distributed database, life will be difficult. Use ULID, Snowflake IDs, KSUID, or UUIDv7 instead. + +## Field Length and Data Type +Use appropriate data types and sizes for the data being used. For example, a good index shouldn't be longer than 64 characters. Don't just use `varchar(255)` for everything. If you're using JSON, [index the fields within the JSON](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING) that will be used in the WHERE clause so you don't have to use `WHERE LIKE` in JSON. + +## Data Quantity and Retention +Only access/query the data you need. Don't sweep the entire table when you only use a few dozen fields. + +## Searchable Arguments (SARGable Queries) +Always use Searchable Arguments (SARGable) in your queries. What is SARGable? Simply put, they are operators that can use indexes, which will make your queries faster (because they don't have to scan the entire table to check). Operations that effectively utilize B+Tree indexes include: + - Equals, IN (=): For example, `WHERE indexed_column = value` + - Inequality operators (<, <=, >, >=): Comparison operators like `WHERE indexed_column > value`. This does not include `<>` or `!=`. + - BETWEEN: Compares a range, for example, `WHERE indexed_column BETWEEN low_value AND high_value` + - LIKE (only `prefix%`): For example, `WHERE indexed_column LIKE 'prefix%'`. `%suffix` is not SARGable. If you need to use `LIKE '%key word%'`, you should consider using a database for full-text search. + - IS NULL and IS NOT NULL: These are operations, for example, `WHERE indexed_column IS NULL`. `ISNULL(column)` (as a function) is not SARGable. + - DISTINCT: Always use with **unique** indexed_column values. + - EXISTS and NOT EXISTS: Always use with indexed_column values. + - CASE: If used for direct comparisons, such as `CASE WHEN indexed_column = value THEN result END`, it can be SARGable. + - Avoid using non-**MATH functions** in the WHERE clause, such as `WHERE YEAR(column) BETWEEN low_value AND high_value` or `WHERE LEFT(column, 1) = 'K'`. + - JOIN ON, USING: Always with the conditions mentioned above. + +## Concurrent Activities +In systems with concurrent operations, if you design it such that reads and writes occur on the same field simultaneously, it will naturally be slow. If there's no other way to design it, using a queue can help mitigate the issue. + +## Data Staging +If you have a huge query that causes timeouts, breaking down large queries into smaller ones and then combining them can, in some cases, not only make them easier to understand but also improve performance. However, if a query looks like program code, it's probably not a good SQL query and should be moved into your application code. + +## Flexible Column +If your data frequently changes its table structure or if a column in the table is excessively long, it's more suitable to use a **NoSQL** database. diff --git a/content/posts/db/sql_optimized/index.md b/content/posts/db/sql_optimized/index.md new file mode 100644 index 0000000..3d49714 --- /dev/null +++ b/content/posts/db/sql_optimized/index.md @@ -0,0 +1,57 @@ +--- +title: "เมื่อต้องใช้ชีวิตกับ SQL ก็ต้องอยู่ให้เป็น" +subtitle: "" +date: 2023-09-05T21:30:43+07:00 +lastmod: 2023-09-05T22:10:43+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "แชร์เทคนิคการออกแบบ query และ table เพื่อรักษาประสิทธิภาพของ RDBMS ให้ไม่ช้าลงเมื่อข้อมูลเติบโตขึ้น" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Database", "SQL"] +categories: ["SQL"] + +lightgallery: true +--- + +เมื่อเราต้องทำงานกับ Relational database (RDBMS) สิ่งที่พบบ่อย ๆ เลยคือเมื่อข้อมูลมาก ขึ้นทำไมมันถึงได้ช้าลง ทำไมมันถึงไม่เร็วเหมือนตอน Dev กันนะ วันนี้มาดูวิธีออกแบบ Query และ Table ให้สามารถ Access ได้เร็วอย่างที่ควรจะเป็นกัน + + + +## Orderable/Sortable Indexes +Keys, Indexes ต้องสามารถเรียงลำดับกันได้เสมอ เอาง่าย ๆ ว่าไม่สุ่มนั่นแหละ + +## Primary Keys +ใช้ primary keys เป็นคีย์หลักแยกข้อมูลในแถวเสมอ อ้อ อย่าไปมักง่ายใช้ **auto_increment** นะ เพราะถ้าได้สเกลขึ้นไปใช้ distributed database ชีวิตจะลำบากเอา ไปใช้พวก ULID, Snowflake IDs, KSUID, UUIDv7 เถิด + +## Field Length and Data Type +ใช้ประเภทของข้อมูลและขนาดของข้อมูลให้เหมาะ / พอดีกับข้อมูลที่ใช้งาน เช่น Index ให้ดีก็อย่าให้ยาวเกิน 64 ตัว, ไม่ใช่เอะอะก็ varchar(255), ถ้าใช้ JSON ก็[ทำ Index จากฟิลล์ ใน JSON](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING) ที่จะเอาไปใช้ใน WHERE clause ด้วยจะได้ไม่ต้อง WHERE LIKE ใน JSON อะไรแบบนี้ + +## Data Quantity and Retention +เลือก access / query เฉพาะ ข้อมูลที่ต้องการใช้งานเท่านั้น ไม่ใช่กวาดไปทั้งตาราง ทั้ง ๆ ที่ใช้อยู่ไม่กี่สิบฟิลล์ + +## Searchable Arguments (SARGable Queries) +ใช้ Searchable arguments (SARGable) ใน query เสมอ แล้ว SARGable มันคืออัลไล ง่าย ๆ ก็คือตัวดำเนินการ (operators) ที่สามารถใช้งาน Indexes ได้ ซึ่งจะช่วยใน query เราเร็วขึ้น (เพราะมันไม่ต้องไล่กวาดข้อมูลทั้งตารางมาเช็คอะ) ดำเนินการที่ใช้ประโยชน์จาก B+Tree index ได้ดี ยกตัวอย่าง + - Equals, IN (=): ก็เท่ากับนั่นแหละแหละ เช่น `WHERE indexed_column = value` + - Inequality operators (<, <=, >, >=): ตัวดำเนินการแนวเปรียบเทียบ เช่น `WHERE indexed_column > value` ไม่รวม **<>, !=** นะ + - BETWEEN: เปรียบเทียบช่วงระหว่าง เช่น `WHERE indexed_column BETWEEN low_value AND high_value` + - LIKE (เฉพาะ `prefix%` นะ): เช่น `WHERE indexed_column LIKE 'prefix%'` ส่วน `%suffix` ไม่ใช่นะ ถ้าจะใช้ `LIKE '%key word%'` ก็ควรย้ายไปใช้พวก database สำหรับ full text search ไป + - IS NULL กับ IS NOT NULL: ที่เป็นดำเนินการ เช่น `WHERE indexed_column IS NULL` ส่วน `ISNULL(column)` ที่เป็น fn อันนี้ไม่ใช่นะ + - DISTINCT: ใช้กับ **unique** indexed_column values เสมอนะ + - EXISTS กับ NOT EXISTS: ใช้กับ indexed_column values เสมอนะ + - CASE: ถ้าใช้กับการเปรียบเทียบตรง ๆ เช่น `CASE WHEN indexed_column = value THEN result END` เป็นใช้ได้ + - หลีกเลี่ยงการใช้ฟังก์ชันที่ไม่ใช่ **MATH fn** ใน WHERE clause เช่น `WHERE YEAR(column) BETWEEN low_value AND high_value` หรือ `WHERE LEFT(column, 1) = 'K'` + - JOIN ON, USING: ด้วยเงื่อนไขที่ว่ามาข้างต้นเสมอ + +## Concurrent Activities +ในระบบที่มีการทำงานพร้อม ๆ กัน ถ้าหลงไปออกแบบแล้วมีการอ่านเขียนลงใน ฟิลล์เดียวกันพร้อม ๆ กับจะช้าเป็นธรรมดา ถ้าไม่สามารถออกแบบอื่นได้แล้วจริง ๆ ก็ใช้ queue มาช่วยบรรเทาได้ + +## Data Staging +ถ้ามี query ที่ใหญ่เบิ้มจน query time out การแบ่งซอย query ใหญ่ ๆ ให้เล็กลงมาแล้วค่อยเอามารวมกัน ในบางกรณีนอกจากจะอ่านเข้าใจง่ายแล้วยังช่วยให้ได้ประสิทธิภาพเพิ่มขึ้นด้วยนะเอ้อ แต่จริง ๆ ถ้า query มันดูแล้วเหมือนจะเป็น code program แสดงว่ามันไม่ใช่แล้วอะ เอาออกมาทำเป็น program เถอะ + +## Flexible Column +ถ้าข้อมูลที่มีการปรับเปลี่ยนโครงสร้างตารางบ่อย ๆ หรือ Column ในตารางมันยาวสุดลูกหูลูกตา ก็ย้ายไปใช้ **NoSQL** จะเหมาะกว่า \ No newline at end of file diff --git a/content/posts/dev/.DS_Store b/content/posts/dev/.DS_Store new file mode 100644 index 0000000..4e3f7cb Binary files /dev/null and b/content/posts/dev/.DS_Store differ diff --git a/content/posts/dev/big_o/index.en.md b/content/posts/dev/big_o/index.en.md new file mode 100644 index 0000000..ce55562 --- /dev/null +++ b/content/posts/dev/big_o/index.en.md @@ -0,0 +1,153 @@ +--- +title: "Understanding Big O Notation" +subtitle: "" +date: 2024-08-07T23:13:16+07:00 +lastmod: 2024-08-07T23:13:16+07:00 +draft: true +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "A practical, beginner-friendly introduction to Big O notation covering O(1), O(n), O(log n), O(n²), O(n log n), and O(2ⁿ) with short Go examples for each." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +lightgallery: true +--- +## Why should we care about Big O Notation? + +Think of Big O Notation as a "fuel gauge" for your code's performance. It tells us how the performance of a program will change as the size of the data increases. This is crucial for building applications that run smoothly, even when dealing with massive amounts of data. + + + +## Let's understand Big O Notation in detail + +* **Big O Notation indicates the worst-case scenario.** It's a way to express how the number of operations your program performs will grow relative to the size of the input data. +* **We use symbols like O(n), O(n^2), O(log n), etc.** to represent different growth rates. + +## Common Big O Notations + +### O(1) - Constant Time +The time taken for an operation does not depend on the size of the input data. + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 2, 3, 4, 5} + fmt.Println(slice[2]) // O(1) +} +``` + +### O(n) - Linear Time +The time taken for an operation increases linearly with the size of the input data. + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 2, 3, 4, 5} + for i := 0; i < len(slice); i++ { // O(n) + fmt.Println(slice[i]) + } +} +``` + +### O(log n) - Logarithmic Time +The time taken for an operation increases logarithmically with the size of the input data. + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 3, 5, 7, 9} + target := 7 + left := 0 + right := len(slice) - 1 + for left <= right { // O(log n) + mid := (left + right) / 2 + if slice[mid] == target { + fmt.Println("Found!") + break + } else if slice[mid] < target { + left = mid + 1 + } else { + right = mid - 1 + } + } +} +``` + +### O(n^2) - Quadratic Time +The time taken for an operation increases quadratically with the size of the input data. + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 2, 3, 4, 5} + for i := 0; i < len(slice); i++ { // O(n) + for j := 0; j < len(slice); j++ { // O(n) + fmt.Println(slice[i], slice[j]) + } + } +} +``` + +### O(n log n) - Linearithmic Time + +```go +package main + +import "fmt" + +func main() { + slice := []int{5, 2, 4, 6, 1, 3} + for i := 0; i < len(slice)-1; i++ { // O(n) + for j := i + 1; j < len(slice); j++ { // O(log n) + if slice[i] > slice[j] { + temp := slice[i] + slice[i] = slice[j] + slice[j] = temp + } + } + } + fmt.Println(slice) +} +``` + +* **O(2^n) - Exponential Time:** + +```go +package main + +import "fmt" + +func main() { + n := 5 + result := 0 + for i := 0; i < n; i++ { // O(2^n) + for j := 0; j < n; j++ { + result += 1 + } + } + fmt.Println(result) +} +``` + +## Conclusion +* Understanding Big O Notation is crucial for writing efficient code. +* We should strive to use algorithms with lower time complexity, especially when dealing with large datasets. +* Using tools like code profiling can help analyze code performance and identify potential bottlenecks. diff --git a/content/posts/dev/big_o/index.md b/content/posts/dev/big_o/index.md new file mode 100644 index 0000000..6c91acf --- /dev/null +++ b/content/posts/dev/big_o/index.md @@ -0,0 +1,153 @@ +--- +title: "ทำความเข้าใจ Big O Notation" +subtitle: "" +date: 2024-08-07T23:13:16+07:00 +lastmod: 2024-08-07T23:13:16+07:00 +draft: true +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "แนะนำ Big O Notation แบบใช้งานได้จริง ครอบคลุม O(1), O(n), O(log n), O(n²), O(n log n) และ O(2ⁿ) พร้อมตัวอย่างสั้น ๆ ในภาษา Go สำหรับแต่ละรูปแบบ" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +lightgallery: true +--- +## ทำไมเราถึงต้องสนใจ Big O Notation? + +ลองนึกภาพ Big O Notation เป็นเหมือน "เกจวัดเชื้อเพลิง" สำหรับประสิทธิภาพของโค้ดของคุณ มันจะช่วยบอกเราว่าประสิทธิภาพของโปรแกรมจะเปลี่ยนแปลงไปอย่างไรเมื่อขนาดของข้อมูลเพิ่มขึ้น ซึ่งเป็นสิ่งสำคัญอย่างยิ่งในการสร้างแอปพลิเคชันที่ทำงานได้อย่างราบรื่น แม้จะต้องจัดการกับข้อมูลจำนวนมหาศาล + + + +## มาทำความเข้าใจ Big O Notation กันอย่างละเอียด + +* **Big O Notation บ่งบอกถึงสถานการณ์ที่เลวร้ายที่สุด.** เป็นวิธีการแสดงว่าจำนวนการดำเนินการที่โปรแกรมของคุณทำจะเพิ่มขึ้นอย่างไรเมื่อเทียบกับขนาดของข้อมูล +* **เราใช้สัญลักษณ์ เช่น O(n), O(n^2), O(log n) เป็นต้น** เพื่อแสดงถึงอัตราการเติบโตที่แตกต่างกัน + +## Big O Notation ทั่วไป + +### O(1) - เวลาคงที่ +เวลาที่ใช้ในการดำเนินการไม่ขึ้นอยู่กับขนาดของข้อมูล + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 2, 3, 4, 5} + fmt.Println(slice[2]) // O(1) +} +``` + +### O(n) - เวลาเชิงเส้น +เวลาที่ใช้ในการดำเนินการจะเพิ่มขึ้นตามขนาดของข้อมูล + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 2, 3, 4, 5} + for i := 0; i < len(slice); i++ { // O(n) + fmt.Println(slice[i]) + } +} +``` + +### O(log n) - เวลาลอการิทึม +เวลาที่ใช้ในการดำเนินการจะเพิ่มขึ้นแบบลอการิทึม ตามขนาดของข้อมูล + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 3, 5, 7, 9} + target := 7 + left := 0 + right := len(slice) - 1 + for left <= right { // O(log n) + mid := (left + right) / 2 + if slice[mid] == target { + fmt.Println("Found!") + break + } else if slice[mid] < target { + left = mid + 1 + } else { + right = mid - 1 + } + } +} +``` + +### O(n^2) - เวลากำลังสอง +เวลาที่ใช้ในการดำเนินการจะเพิ่มขึ้นแบบกำลังสอง ตามขนาดของข้อมูล + +```go +package main + +import "fmt" + +func main() { + slice := []int{1, 2, 3, 4, 5} + for i := 0; i < len(slice); i++ { // O(n) + for j := 0; j < len(slice); j++ { // O(n) + fmt.Println(slice[i], slice[j]) + } + } +} +``` + +### O(n log n) - เวลาล็อกเชิงเส้น + +```go +package main + +import "fmt" + +func main() { + slice := []int{5, 2, 4, 6, 1, 3} + for i := 0; i < len(slice)-1; i++ { // O(n) + for j := i + 1; j < len(slice); j++ { // O(log n) + if slice[i] > slice[j] { + temp := slice[i] + slice[i] = slice[j] + slice[j] = temp + } + } + } + fmt.Println(slice) +} +``` + +* **O(2^n) - เวลาเลขชี้กำลัง:** + +```go +package main + +import "fmt" + +func main() { + n := 5 + result := 0 + for i := 0; i < n; i++ { // O(2^n) + for j := 0; j < n; j++ { + result += 1 + } + } + fmt.Println(result) +} +``` + +## สรุป +* การทำความเข้าใจ Big O Notation เป็นสิ่งสำคัญอย่างยิ่งสำหรับการเขียนโค้ดที่มีประสิทธิภาพ +* เราควรพยายามใช้อัลกอริธึมที่มีความซับซ้อนของเวลาต่ำกว่า โดยเฉพาะเมื่อต้องจัดการกับชุดข้อมูลขนาดใหญ่ +* การใช้เครื่องมือ เช่น การสร้างโปรไฟล์โค้ด จะช่วยวิเคราะห์ประสิทธิภาพของโค้ดและระบุจุดที่อาจเกิดปัญหาได้ \ No newline at end of file diff --git a/content/posts/dev/carreer_roles/featured-image.jpg b/content/posts/dev/carreer_roles/featured-image.jpg new file mode 100644 index 0000000..34be528 Binary files /dev/null and b/content/posts/dev/carreer_roles/featured-image.jpg differ diff --git a/content/posts/dev/carreer_roles/index.en.md b/content/posts/dev/carreer_roles/index.en.md new file mode 100644 index 0000000..95a708c --- /dev/null +++ b/content/posts/dev/carreer_roles/index.en.md @@ -0,0 +1,70 @@ +--- +title: "Programmer vs Software Developer vs Software Engineer" +subtitle: "" +date: 2025-01-19T11:49:47+07:00 +lastmod: 2025-01-19T11:49:47+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Explore the differences between Programmers, Software Developers, and Software Engineers." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- +In the world of technology, the terms **Programmer**, **Software Developer**, and **Software Engineer** are often used interchangeably. However, each term represents distinct roles and responsibilities. Understanding these differences can help in choosing a career path or in hiring the right personnel for a team. + + + +## Programmer + +A **Programmer** primarily focuses on writing code. They are experts in one or more programming languages and are responsible for translating software designs into executable code. Programmers typically have the following duties: + +- Writing clean and efficient code according to specifications. +- Debugging code to ensure functionality. +- Understanding algorithms and data structures. +- Collaborating with developers and engineers to build software solutions. + +While the term "Programmer" might seem simple, highly skilled Programmers are crucial for creating efficient and functional software. + +## Software Developer + +A **Software Developer** has a broader role that encompasses involvement in the entire software development lifecycle, from initial conception to deployment and maintenance. Their responsibilities include: + +- Designing software solutions to meet client or stakeholder requirements. +- Writing and testing code during the development process. +- Managing software development projects and timelines. +- Collaborating with other developers, designers, and product managers. + +Software Developers often act as a bridge between the technical and business aspects of a project, ensuring that the software aligns with user needs and business goals. + +## Software Engineer + +A **Software Engineer** applies engineering principles to the entire software development process. This role is typically more advanced and involves a deeper understanding of system architecture and design. Their key responsibilities include: + +- Designing complex software systems and architectures. +- Conducting feasibility studies and analyzing system requirements. +- Ensuring software scalability, reliability, and maintainability. +- Applying engineering principles to solve complex problems. + +Software Engineers often work on large-scale systems and projects that require a thorough understanding of both software and hardware systems. + +## Key Differences + +While there is significant overlap in these roles, the key differences lie in their scope and responsibilities: + +- **Programmer** focuses on coding and technical tasks. +- **Software Developer** manages the development process from design to deployment. +- **Software Engineer** applies engineering principles to build robust and scalable software systems. + +## Conclusion + +Understanding the distinctions between these roles can greatly aid in career development and team building. Whether we aim to develop coding skills, lead development projects, or design complex systems, each role offers unique opportunities and challenges. + +Choosing the right path depends on one's interests, skills, and career goals. Regardless of the chosen path, all roles are essential in creating software. diff --git a/content/posts/dev/carreer_roles/index.md b/content/posts/dev/carreer_roles/index.md new file mode 100644 index 0000000..76ae357 --- /dev/null +++ b/content/posts/dev/carreer_roles/index.md @@ -0,0 +1,70 @@ +--- +title: "Programmer vs Software Developer vs Software Engineer" +subtitle: "" +date: 2025-01-19T11:49:47+07:00 +lastmod: 2025-01-19T11:49:47+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "สำรวจความแตกต่างระหว่าง โปรแกรมเมอร์ นักพัฒนาซอฟต์แวร์ และวิศวกรซอฟต์แวร์" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- +ในโลกของเทคโนโลยี คำว่า **Programmer** **Software Developer** และ **Software Engineer** มักถูกใช้แทนกันได้ อย่างไรก็ตาม แต่ละคำมีบทบาทและความรับผิดชอบที่แตกต่างกัน การเข้าใจความแตกต่างนี้สามารถช่วยในการเลือกเส้นทางอาชีพหรือในการจ้างงานบุคลากรที่เหมาะสมสำหรับทีม + + + +## Programmer + +**Programmer** มุ่งเน้นไปที่การเขียนโค้ดเป็นหลัก พวกเขาเป็นผู้เชี่ยวชาญในภาษาโปรแกรมหนึ่งหรือมากกว่าและมีหน้าที่ในการเปลี่ยนการออกแบบซอฟต์แวร์ให้กลายเป็นโค้ดที่คอมพิวเตอร์สามารถทำงานได้ Programmerมักมีหน้าที่ดังต่อไปนี้: + +- เขียนโค้ดที่สะอาดและมีประสิทธิภาพตามข้อกำหนด +- แก้ไขข้อบกพร่องในโค้ดเพื่อให้แน่ใจว่าฟังก์ชันการทำงาน +- เข้าใจอัลกอริธึมและโครงสร้างข้อมูล +- ร่วมมือกับนักพัฒนาและวิศวกรในการสร้างโซลูชันซอฟต์แวร์ + +แม้ว่าคำว่า "Programmer" อาจดูเรียบง่าย แต่ Programmer ที่มีทักษะสูงนั้นมีความสำคัญต่อการสร้างซอฟต์แวร์ที่ทำงานได้อย่างมีประสิทธิภาพ + +## Software Developer + +**Software Developer** มีบทบาทที่กว้างขวางกว่าซึ่งรวมถึงการมีส่วนร่วมในวงจรชีวิตของซอฟต์แวร์ตั้งแต่แนวคิดแรกเริ่มไปจนถึงการนำไปใช้งานและการบำรุงรักษา หน้าที่ของพวกเขามีดังนี้: + +- ออกแบบโซลูชันซอฟต์แวร์เพื่อตอบสนองความต้องการของลูกค้าหรือผู้มีส่วนได้ส่วนเสีย +- เขียนและทดสอบโค้ดในกระบวนการพัฒนา +- จัดการโครงการพัฒนาซอฟต์แวร์และกำหนดเวลา +- ร่วมมือกับนักพัฒนาอื่น ๆ นักออกแบบ และผู้จัดการผลิตภัณฑ์ + +Software Developer มักทำหน้าที่เป็นสะพานเชื่อมระหว่างด้านเทคนิคและธุรกิจของโครงการ เพื่อให้แน่ใจว่าซอฟต์แวร์นั้นสอดคล้องกับความต้องการของผู้ใช้และเป้าหมายทางธุรกิจ + +## Software Engineer + +**Software Engineer** เป็นผู้ที่นำหลักการทางวิศวกรรมมาใช้ในกระบวนการพัฒนาซอฟต์แวร์ทั้งหมด บทบาทนี้มักจะมีความก้าวหน้ามากกว่าและเกี่ยวข้องกับความเข้าใจในสถาปัตยกรรมและการออกแบบระบบในระดับที่ลึกซึ้ง หน้าที่หลักของพวกเขารวมถึง: + +- ออกแบบระบบซอฟต์แวร์และสถาปัตยกรรมซอฟต์แวร์ที่ซับซ้อน +- ทำการศึกษาและวิเคราะห์ความเป็นไปได้และความต้องการของระบบ +- รับประกันว่าซอฟต์แวร์มีความสามารถในการขยายตัว ความน่าเชื่อถือ และการบำรุงรักษา +- ใช้หลักวิศวกรรมในการแก้ไขปัญหาที่ซับซ้อน + +Software Engineer มักทำงานในระบบและโครงการขนาดใหญ่ซึ่งต้องการความเข้าใจอย่างถ่องแท้ทั้งในซอฟต์แวร์และระบบฮาร์ดแวร์ + +## ความแตกต่างที่สำคัญ + +แม้ว่าจะมีความทับซ้อนกันอย่างมากในบทบาทเหล่านี้ แต่ความแตกต่างที่สำคัญอยู่ที่ขอบเขตและความรับผิดชอบ: + +- **Programmer** มุ่งเน้นไปที่การเขียนโค้ดและงานทางเทคนิค +- **Software Developer** จัดการกระบวนการพัฒนาตั้งแต่การออกแบบไปจนถึงการนำไปใช้งาน +- **Software Engineer** ใช้หลักการทางวิศวกรรมในการสร้างระบบซอฟต์แวร์ที่แข็งแกร่งและสามารถขยายตัวได้อย่างมั่นคง + +## สรุป + +การเข้าใจความแตกต่างระหว่างบทบาทเหล่านี้สามารถช่วยในการพัฒนาอาชีพและการสร้างทีมได้อย่างมาก ไม่ว่าเราจะตั้งเป้าหมายในการพัฒนาทักษะการเขียนโค้ด นำโครงการพัฒนา หรือออกแบบระบบที่ซับซ้อน แต่ละบทบาทมีโอกาสและความท้าทายที่ไม่เหมือนใคร + +การเลือกเส้นทางที่เหมาะสมขึ้นอยู่กับความสนใจ ทักษะ และเป้าหมายทางอาชีพ ไม่ว่าจะเลือกเส้นทางใด ทุกบทบาทล้วนมีส่วนสำคัญในการสร้างซอฟต์แวร์ \ No newline at end of file diff --git a/content/posts/dev/ddd_tdd_bdd/featured-image.webp b/content/posts/dev/ddd_tdd_bdd/featured-image.webp new file mode 100644 index 0000000..aeb6fbd Binary files /dev/null and b/content/posts/dev/ddd_tdd_bdd/featured-image.webp differ diff --git a/content/posts/dev/ddd_tdd_bdd/index.en.md b/content/posts/dev/ddd_tdd_bdd/index.en.md new file mode 100644 index 0000000..51924a4 --- /dev/null +++ b/content/posts/dev/ddd_tdd_bdd/index.en.md @@ -0,0 +1,122 @@ +--- +title: "DDD, TDD, BDD: What Are They?" +subtitle: "" +date: 2024-01-27T13:49:01+07:00 +lastmod: 2024-01-27T21:49:01+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "A side-by-side comparison of Domain-Driven Design (DDD), Test-Driven Development (TDD), and Behavior-Driven Development (BDD), covering their principles, practices, suitable use cases, and limitations." +license: "" +images: [] + +tags: ["dev"] +categories: ["dev"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +Software development methodologies are designed to help developers deliver code smoothly and with fewer defects. Recently, three terms have been frequently discussed: Domain Driven Design (DDD), Test Driven Development (TDD), and Behavior Driven Development (BDD). Although their names are similar, these methodologies have distinct differences. Here's a comparison of DDD, TDD, and BDD from a lazy person's perspective. + + +## Domain Driven Design (DDD) +### Principles +DDD focuses on designing business models into domains to reduce the likelihood of software models being designed or implemented in a way that doesn't align with the business domain. A simple example is when, after working for a while, you end up with tangled or unused tables and fields. + +- Focuses on the Core Domain and the logic of each domain. +- Developers work with Domain Experts (those who understand the business of the product). +- Creates a development model of the domain. +- Communicates using a "ubiquitous language" that all team members understand. +- Separates complexity through domain boundaries. + +### Practices +DDD relies on building domain models as entities, value objects, aggregates, and bounded contexts. Developers must continuously review models and domains to refine their understanding of what each domain is responsible for. + +- Start by dividing events for each domain with a domain expert. The trick is that the term used to refer to something will change in each event, e.g., a user can have multiple statuses depending on the domain event, such as visitor -> member -> customer -> debtor -> package recipient. +- Use the simplest language understandable by both business and tech sides. +- Define the boundaries of each domain, e.g., when a user makes a payment in the payment domain, it then enters the delivery domain. +- Explore and review to find subdomains. For example, in the payment domain, there might be subdomains like debit, credit, and reconcile that share payment responsibilities but have different operational responsibilities. + +### Use Cases +DDD is most beneficial for complex business domains that require intricate logic. DDD provides a clear structure for separating domain complexity. + +#### DDD is suitable for: +- Financial systems +- Insurance application systems +- Healthcare systems +- Other domains with constantly changing business methods + +#### Limitations of DDD +- High learning curve regarding patterns and methodological principles. Requires experience. +- Difficult to correctly identify domains, contexts, and layers. +- Modeling requires upfront analysis and iterative review for accuracy and correct understanding. + +## Test Driven Development (TDD) +### Principles +TDD integrates into the development loop where requirements are transformed into specific test cases for each function. Code is then written to pass these test cases. + +- Write tests before coding any functions. +- Write just enough code to pass the tests. Avoid over-engineering. +- Refactoring code is a normal practice. +- Keep test cases and coding as simple as possible. + +### Practices +- Write test cases based on requirements. +- Run tests to see if they work; initially, they should fail (red). +- Write code to pass the tests (green). +- Refactor, comment, optimize as needed (refactor). +- Repeat the cycle. + +This might seem like a hellish loop, but if we do it just enough and avoid over-engineering, we'll find that with every addition, we'll have tests to ensure we don't unknowingly break existing functionality. + +### Use Cases +TDD integrates well with self-contained functions. The testing process forces developers to review and understand requirements before coding (of course, they're forced to write test cases based on requirements). The side benefits are technical documentation from test cases and higher quality work with fewer defects (assuming the test cases are correct). + +#### TDD is suitable for: +- Back-end tasks (APIs, microservices) +- Functions based on business logic +- Anything that is a function that is extended or reused. + +#### Limitations of TDD +- Requires time to write tests before coding. +- The quality of work depends on the proficiency in writing tests (can be mitigated by senior developers writing tests first and junior developers writing functions). +- Becomes useless if requirements are not stable, as it will waste time in the loop of writing new tests and code. In this case, you should consider Behavior Driven Development (BDD). + +## Behavior Driven Development (BDD) +### Principles +BDD focuses on defining system behavior in a way that is understandable by both business and technical stakeholders. + +- Test cases are written in a language understandable by both business and tech. +- Test cases are written based on business outcomes, not technical development details. +- Business and tech teams collaborate on requirements. +- Requirements are transformed into automated regression tests. + +### Practices +BDD brings business and technical teams together to define system behavior. They collaborate to write scripts describing the expected system behavior, following this sequence: + +- Given [context] +- When [an event occurs] +- Then [expected outcome] + +These scripts describe the overall function's behavior rather than just technical details. The tests will be automated regression tests. + +### Use Cases +BDD facilitates collaboration between business and technical teams, helping everyone understand requirements upfront before coding begins. + +#### BDD is suitable for: +- Web Apps/Mobile Apps or user-facing parts. +- Projects with unstable requirements. +- Projects where not all stakeholders are technically proficient. + +#### Limitations of BDD +- Requires more upfront time for collaboration and test writing. +- Can be cumbersome when applied to back-end tasks. + +## Conclusion +- DDD focuses on modeling complex business domains into suitable development models. +- TDD focuses on coding and quality, preventing new development from breaking existing functionality. +- BDD focuses on defining system behavior, preventing developers from getting lost. + +All three methodologies can be used together, depending on how they are adapted to suit the specific task. diff --git a/content/posts/dev/ddd_tdd_bdd/index.md b/content/posts/dev/ddd_tdd_bdd/index.md new file mode 100644 index 0000000..91af0c0 --- /dev/null +++ b/content/posts/dev/ddd_tdd_bdd/index.md @@ -0,0 +1,122 @@ +--- +title: "DDD, TDD, BDD มันคืออิหยังนะ" +subtitle: "" +date: 2024-01-27T13:49:01+07:00 +lastmod: 2024-01-27T21:49:01+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เปรียบเทียบ Domain-Driven Design (DDD), Test-Driven Development (TDD) และ Behavior-Driven Development (BDD) พร้อมหลักการ แนวปฏิบัติ กรณีที่เหมาะสม และข้อจำกัดของแต่ละแนวทาง" +license: "" +images: [] + +tags: ["dev"] +categories: ["dev"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +Software development methodologies ถูกคิดค้นขึ้นเพื่อช่วยให้ Dev ส่งมอบโค้ดได้ราบลื่นและมีข้อบกพร่องน้อย ซึ่งช่วงนี้จะมีการพูดถึงบ่อย ๆ 3 ตัว ได้แก่ Domain Driven Design (DDD), Test Driven Development (TDD) และ Behavior Driven Development (BDD) แม้ว่าชื่อจะคล้ายกัน แต่วิธีการเหล่านี้มีความแตกต่างกัน ซึ่งจะเปรียบเทียบ DDD, TDD และ BDD ดูตามความเข้าใจแบบคนขี้เกียจได้ประมาณนี้ + + +## Domain Driven Design (DDD) +### หลักการ +DDD จะเป็นการการออกแบบโมเดลทางธุรกิจออกเป็น Domain เพื่อลดการเกิดโมเดลของตัว sofware ออกแบบหรือทำออกมาแล้วไม่สอดคล้องกับ business domain ตย. ง่าย ๆ เลยคือ เมื่อทำงานไปทำงานมาตอนจะจบงาน มันจะมี tables, fields งอกขึ้นมาพันกัน หรือ ไม่ได้ใช้งานจริง ๆ เต็มไปหมด + +- มุ่งเน้นไปที่ Core Domain และ logic ของแต่ละ domain +- Dev ทำงานร่วมกับ Domain expert (ผู้ที่เข้าใจใน business ของตัว product) +- สร้างแบบจำลองการพัฒนาของโดเมน +- คุยกันด้วย "ภาษาที่เข้าใจง่าย" ที่สมาชิกในทีมทุกคนเข้าใจ +- แบ่งแยกความซับซ้อนผ่านขอบเขตของ domain + +### แนวปฏิบัติ +DDD อาศัยการสร้างแบบจำลองโดเมนเป็น entities, value objects, aggregates และ bounded contexts ซึ่ง Dev จะต้องทบทวน models และ domains อย่างต่อเนื่องเพื่อปรับความเข้าใจในสิ่งที่แต่ละ domain ที่รับผิดชอบ + +- เริ่มด้วยการแบ่ง event ของแต่ละ domain โดย domain expert ทริกคือคำที่ใช้เรียกสิ่ง ๆ นึงจะเปลี่ยนไปในแต่ละ event เช่น user ที่มีได้หลายสถานะตามแต่ event ของ domain ตย. ผู้เยี่ยมชม -> สมาชิก -> ลูกค้า -> ลูกหนี้ -> ผู้รับพัสดุ +- จะต้องใช้ภาษาที่เข้าใจกันง่ายที่สุด ทั้งฝั่ง business และ tech +- กำหนดขอบเขตของแต่ละ domain เช่น เมื่อ user ทำการชำระเงิน ใน domain payment แล้วเข้าสู่ domain delivery +- สำรวจทบทวนหา sub domain โดยออาจจะจำลองขึ้นมา เช่น ใน doamin payment อาจจะมี sub domain เป็น debit, credit, reconcile ที่รับผิดชอบ payment ร่วมกัน แต่การทำงานรับผิดชอบต่างกัน + +### กรณีการใช้งาน +DDD มีประโยชน์มากที่สุดสำหรับโดเมนธุรกิจที่ซับซ้อนซึ่งต้องใช้ตรรกะที่ซับซ้อน DDD ให้โครงสร้างความซับซ้อนของโดเมนแยกออกมาชัดเจน + +#### DDD เหมาะสำหรับ +- ระบบการเงิน +- การสมัครประกันภัย +- ระบบด้านการดูแลสุขภาพ +- Domain อื่นๆ ที่มีวิธีการทางธุรกิจที่เปลี่ยนแปลงอยู่ตลอดเวลา + +#### ข้อจำกัดของ DDD +- learning curve เกี่ยวกับรูปแบบและหลักการทางวิธีทำสูง ต้องอาศัย ปสก. เข้าช่วย +- ยากที่จะระบุ domain, context, layer ได้อย่างถูกต้อง +- การสร้างแบบจำลองจำเป็นต้องมีการวิเคราะห์ล่วงหน้าและการทำซ้ำทบทวนเพื่อให้แม่นยำและเข้าใจถูกต้อง + +## Test Driven Development (TDD) +### หลักการ +TDD จะเข้าไปอยู่ใน loop การ dev โดยที่ requirement จะถูกแปลงเป็น test case เฉพาะ function นั้น ๆ จากนั้นโค้ดจะถูกเขียนขึ้นมาเพื่อให้ ผ่าน test case + +- เขียน test ก่อนที่จะ coding functions ใด ๆ +- เขียนโค้ดให้เพียงพอที่จะผ่านการทดสอบเท่านั้น ไม่ over engineering +- refactor โค้ดเป็นเรื่องปกติ +- ทำให้ test case และ coding เรียบง่ายที่สุด + +### แนวปฏิบัติ +- เขียน test case ขึ้นมาตาม requirement +- สั่ง test เพื่อดูว่า test ทำงานได้ซึ่งในครั้งแรกจะต้อง fail (red) +- เขียนโค้ดเพื่อผ่านการทดสอบ (green) +- refactor, comment, optimized ตามที่จำเป็น (refactor) +- ทำซ้ำวนไปจ้า + +ซึ่งอาจจะดูเหมือน loop นรก แต่ว่าหากเราทำแต่พอดี ไม่ over engineering จะพบว่าในทุก ๆ การต่อเติมเราจะมี test กำกับไม่ทำให้ของเดิมพังแบบไม่รู้ตัว + +### กรณีการใช้งาน +TDD จะเข้าได้ดีกับการทำงานเป็น function แล้วจบไป การทดสอบการเขียนจะบังคับให้ dev ได้ทบทวนทำความเข้าใจ requirement ก่อนจะ coding (ก็แน่หล่ะ โดนบังคับเขียน test case ตาม requirement) ซึ่งผลพลอยได้คือ technical docs จาก test case และ คุณภาพของงานที่มีข้อบกพร่องน้อยลง (ถ้า test case ไม่ผิดละนะ) + +#### TDD เหมาะสำหรับ +- งาน back-end (API, microservices) +- function ตาม business logic +- อะไรก็ตามเป็น function ที่มีการต่อเติมหรือใช้งานซ้ำ ๆ + +#### ข้อจำกัดของ TDD +- ต้องใช้เวลาไปกับการเขียน test ก่อนการ coding +- งานจะดีไม่ดีขึ้นอยู่กับความชำนาญในการเขียน test (อาจจะแก้ขัดได้ด้วย senior ช่วยเขียน test ให้ก่อนแล้วให้ junior เขียน function) +- จะไร้ประโยชน์ไปเลยถ้า requirement ยังไม่นิ่ง เพราะจะเสียเวลาไปกับ loop การเขียน test และ coding ใหม่ อันนี้ต้องไป Behavior Driven Development (BDD) + +## Behavior Driven Development (BDD) +### หลักการ +BDD มุ่งเน้นไปที่การกำหนดพฤติกรรมของระบบในลักษณะที่ทั้งฝั่ง business และ tech เข้าใจร่วมกัน + +- test case เขียนเป็นภาษาที่เข้าใจทั้ง business และ tech +- test case เขียนขึ้นด้วยโดยผลลัพธ์ทาง business ไม่ใช่รายละเอียดทางเทคนิคที่ใช้พัฒนา +- ทีม business และ tech ทำงานร่วมกันตาม requirements +- requirements จะถูกแปลงมาเป็น automated regression tests + +### แนวปฏิบัติ +BDD นำทีม business และ tech มารวมกันเพื่อกำหนดพฤติกรรมของระบบ โดยจะร่วมมือกันเขียน script พฤติกรรมของระบบที่คาดหวังโดยจะมีหัวข้อลำดับตามนี้ + +- Given [บริบท] +- When [เหตุการณ์เกิดขึ้น] +- Then [ผลลัพธ์ที่คาดหวัง] + +ซึ่ง script เหล่านี้จะอธิบายลักษณะการทำงานของ function ทั้งหมด แทนที่จะเป็นเพียงรายละเอียดทางเทคนิค ซึ่งการทดสอบจะเป็น automated regression tests + +### กรณีการใช้งาน +BDD ช่วยให้เกิดการมีส่วนร่วมระหว่าทีม business และ tech ซึ่งช่วยให้ทุกคนเข้าใจ requirement ร่วมกันล่วงหน้าก่อนที่จะเริ่ม coding + +#### BDD เหมาะสำหรับ +- Web App/Mobile App หรือส่วนที่ติดต่อกับ Users +- Project ที่มี requirement ไม่นิ่ง +- Project ที่ผู้มีส่วนร่วมใน project ไม่เชี่ยวชาญด้านเทคนิคทุกคน + +#### ข้อจำกัดของ BDD +- ต้องใช้เวลาล่วงหน้ามากขึ้นสำหรับการทำงานร่วมกันและการเขียน test +- ดูจะยุ่งยากถ้าเอาไปใช้กับงาน back-end + +## สรุป +- DDD มุ่งเน้นไปที่การสร้างแบบจำลองโดเมนธุรกิจที่ซับซ้อนให้ออกมาเป็น model ที่เหมาะกับการ dev +- TDD มุ่งเน้นไปที่ coding และคุณภาพของงาน ไม่ให้ dev ของใหม่แล้วของเก่าพัง +- BDD มุ่งเน้นไปที่การกำหนดลักษณะการของระบบ ไม่ให้ dev หลงทาง + +วิธีการทั้ง 3 สามารถเอามาใช้ร่วมกันได้ขึ้นอยู่กับว่าจะปรับใช้กับเทคนิคแบบไหนให้เหมาะสมกับงาน diff --git a/content/posts/dev/messaging-system-comparison/featured-image.jpg b/content/posts/dev/messaging-system-comparison/featured-image.jpg new file mode 100644 index 0000000..4c00fd8 Binary files /dev/null and b/content/posts/dev/messaging-system-comparison/featured-image.jpg differ diff --git a/content/posts/dev/messaging-system-comparison/index.en.md b/content/posts/dev/messaging-system-comparison/index.en.md new file mode 100644 index 0000000..0863c6f --- /dev/null +++ b/content/posts/dev/messaging-system-comparison/index.en.md @@ -0,0 +1,225 @@ +--- +title: "Which Messaging System to Choose? Kafka, Valkey, RabbitMQ, NATS... Oh, So Many!" +date: 2025-07-11T10:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +tags: ["messaging", "kafka", "valkey", "redis", "rabbitmq", "nats"] +categories: ["devops", "programming"] +resources: +- name: "featured-image" + src: "featured-image.jpg" +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image" +--- + +When building highly communicative distributed systems, one common headache is choosing the right messaging system. There are so many options available: Kafka, Valkey (born from Redis), RabbitMQ, and NATS. Each has its pros and cons. So, in this post, I'll jot down some notes to compare them for myself, figuring out which one suits which task. + + + +## Kafka + +### What is it? + +Kafka is a massive data streaming platform designed to handle huge volumes of real-time data. Think of it as a large data pipeline that's fast, durable, and easily scalable. + +{{< mermaid >}} +flowchart LR + %% Define Producer & Consumer + subgraph Clients + direction TB + P[Producer] + C[Consumer] + end + + %% Define Kafka Broker & Topics + subgraph "Kafka Broker" + direction TB + T1[Topic 1] + T2[Topic 2] + end + + %% Wire them up with labeled links + P -- "write → Topic 1" --> T1 + C -- "read ← Topic 1" --> T1 + +{{< /mermaid >}} + +### When should you use it? + +* **For real-time data analytics:** When you need to process continuously flowing data to get immediate results. +* **For centralized log collection:** Gathering logs from multiple services into one place. +* **For Event Sourcing:** Using every event in the system as the primary data source. + +### What I like + +* Very high throughput and low latency. +* Scalable and fault-tolerant. +* Durable message storage. + +### What I don't like as much + +* Installation and maintenance can be quite complex. +* Consumes more system resources than others. +* Not ideal for simple queueing tasks where workers just pick up one job and finish. +* If the JVM and ZooKeeper footprint is a concern, Redpanda is a C++, Kafka-API-compatible alternative with a thread-per-core architecture and no external dependencies. + +## Valkey (Redis) + +### What is it? + +Valkey, also known as Redis (Valkey is a community-driven fork), is an incredibly fast in-memory database. People often use it as a cache or a simple message broker, utilizing data structures like lists for queues. Valkey was created in March 2024 under the Linux Foundation as a BSD-licensed fork of Redis 7.2.4, after Redis Ltd. changed its license to SSPL/RSALv2. It is backed by AWS, Google Cloud, and Oracle. Valkey 8.1 (March 2025) delivers roughly 8% higher throughput, 22% lower P99 latency, and 20% less memory than Redis OSS. + +{{< mermaid >}} +flowchart LR + %% subgraph for Redis/Valkey + subgraph valkey + direction TB + Q["List (LPUSH/BRPOP Queue)"] + C[Pub/Sub Channel] + end + + %% Producers & Consumers + subgraph Producers + direction TB + Prod[Producer] + Pub[Publisher] + end + + subgraph Consumers + direction TB + Cons[Consumer] + Sub[Subscriber] + end + + %% Connections + Prod -->|LPUSH| Q + Cons -->|BRPOP| Q + + Pub -->|PUBLISH| C + Sub -->|SUBSCRIBE| C +{{< /mermaid >}} + +### When should you use it? + +* **For basic queues:** Tasks that don't require advanced features, just a simple queue. +* **For Pub/Sub:** Suitable for chat applications or real-time notifications. +* **For Caching:** This is its specialty. + +### What I like + +* Extremely fast due to in-memory operation. +* Easy to use and install, not complex. +* Versatile with various data structures available. + +### What I don't like as much + +* If not configured properly, data can be lost during a power outage (not durable by default). +* Not suitable for storing very large messages. +* Not designed for heavy data streaming or complex message routing. + +## RabbitMQ + +### What is it? + +RabbitMQ is a mature and robust message broker, primarily using the AMQP protocol. It's known for its comprehensive messaging capabilities, flexible routing, and reliability. + +{{< mermaid >}} +flowchart LR + %% Clients + subgraph Clients + direction TB + P[Producer] + C1[Consumer 1] + C2[Consumer 2] + end + + %% Broker + subgraph "RabbitMQ Broker" + direction TB + EX[Exchange] + Q1[Queue 1] + Q2[Queue 2] + end + + %% Message flow + P -- "publishes → Exchange" --> EX + EX -- "routes → Q1" --> Q1 + EX -- "routes → Q2" --> Q2 + Q1 -- "delivers → Consumer 1" --> C1 + Q2 -- "delivers → Consumer 2" --> C2 +{{< /mermaid >}} + +### When should you use it? + +* **For asynchronous tasks:** Offloading heavy tasks to be processed in the background, so users don't have to wait. +* **For work queues:** When you have a pile of tasks and want multiple workers to process them collaboratively. +* **For conditional message delivery:** When you want to send messages to specific consumers based on various conditions. + +### What I like + +* Rich in features, a veteran in the field. +* Extremely flexible message routing. +* Supports various messaging patterns (fanout, direct, topic). + +### What I don't like as much + +* May not match Kafka's throughput. +* Requires careful configuration for high availability. +* Messages are typically read and then deleted; not designed for long-term storage. + +## NATS + +### What is it? + +NATS is a messaging system built for the Cloud-Native, IoT, and Microservices era. Its key strengths are high speed and being extremely lightweight, focusing on simplicity. + +{{< mermaid >}} +flowchart LR + %% Clients + subgraph Clients + direction TB + Producer + Consumer + end + + %% NATS Server with Topics + subgraph "NATS Server" + direction TB + TopicA["Topic A"] + TopicB["Topic B"] + end + + %% Connections + Producer -- "publishes → Topic A" --> TopicA + Consumer -- "subscribes ← Topic A" --> TopicA +{{< /mermaid >}} + +### When should you use it? + +* **For Microservice communication:** Fast and reliable communication between services. +* **For IoT device data:** Suitable for receiving a massive number of small messages from various devices. +* **For command and control systems:** Sending commands to control distributed systems. + +### What I like + +* Truly fast and lightweight. +* Easy to install and manage. +* Supports both Pub/Sub and Request/Reply. + +### What I don't like as much + +* Core NATS has no built-in message persistence, but JetStream — now built directly into nats-server — adds persistence, message replay, acknowledgments, deduplication, a Key-Value store, and an Object store. (The older NATS Streaming/STAN is deprecated; use JetStream for any durability needs.) +* Routing features are not as sophisticated as RabbitMQ. +* Not designed for long-term message storage like Kafka. + +## So, which one should you choose? + +Ultimately, the choice depends on what you want to achieve: + +* **If you need a massive, durable, and scalable data pipeline:** Go with **Kafka**. You won't be disappointed. +* **If you need fast, simple queues or want to do Pub/Sub or Caching:** **Valkey (Redis)** is the answer. +* **If you need a mature message broker with complex and reliable routing:** It has to be **RabbitMQ**. +* **If you need a fast, lightweight messaging system suitable for Microservices:** **NATS** is a very interesting option. + +Before making your decision, ask yourself what kind of throughput your task requires, how long you need to store data, whether message delivery is complex, and how much effort you can put into maintaining it. \ No newline at end of file diff --git a/content/posts/dev/messaging-system-comparison/index.md b/content/posts/dev/messaging-system-comparison/index.md new file mode 100644 index 0000000..0bdb822 --- /dev/null +++ b/content/posts/dev/messaging-system-comparison/index.md @@ -0,0 +1,225 @@ +--- +title: "เลือก Messaging System ตัวไหนดี? Kafka, Valkey, RabbitMQ, NATS... โอย เยอะไปหมด!" +date: 2025-07-11T10:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +tags: ["messaging", "kafka", "valkey", "redis", "rabbitmq", "nats"] +categories: ["devops", "programming"] +resources: +- name: "featured-image" + src: "featured-image.jpg" +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image" +--- + +เวลาจะทำระบบที่มันคุยกันเยอะๆ (distributed systems) เนี่ย เรื่องปวดหัวอย่างนึงคือจะเลือกระบบ Messaging ตัวไหนดี มันมีให้เลือกเยอะซะเหลือเกิน ทั้ง Kafka, Valkey (ที่เกิดใหม่จาก Redis), RabbitMQ, แล้วก็ NATS อีก แต่ละตัวก็มีดีมีเสียต่างกันไป งั้นโพสต์นี้ขอมาจดโน้ตเทียบให้ตัวเองดูหน่อยละกัน ว่าตัวไหนมันเหมาะกับงานแบบไหน + + + +## Kafka + +### มันคืออะไร? + +Kafka นี่มันคือแพลตฟอร์มสตรีมมิ่งข้อมูลขนาดใหญ่ยักษ์เลยนะ ออกแบบมาเพื่อรองรับข้อมูลแบบเรียลไทม์ที่ไหลเข้ามามหาศาล คิดภาพเหมือนท่อส่งข้อมูลขนาดใหญ่ที่ทั้งเร็ว ทนทาน แล้วก็ขยายระบบได้ง่าย + +{{< mermaid >}} +flowchart LR + %% Define Producer & Consumer + subgraph Clients + direction TB + P[Producer] + C[Consumer] + end + + %% Define Kafka Broker & Topics + subgraph "Kafka Broker" + direction TB + T1[Topic 1] + T2[Topic 2] + end + + %% Wire them up with labeled links + P -- "write → Topic 1" --> T1 + C -- "read ← Topic 1" --> T1 + +{{< /mermaid >}} + +### แล้วจะใช้ตอนไหนดี? + +* **อยากวิเคราะห์ข้อมูลสดๆ:** เวลาที่ต้องการประมวลผลข้อมูลที่ไหลเข้ามาเรื่อยๆ เพื่อให้ได้ผลลัพธ์ทันที +* **เก็บ Log จากทุกที่:** รวบรวม Log จากหลายๆ เซอร์วิสมาไว้ที่เดียวกัน +* **ทำ Event Sourcing:** ใช้เก็บทุกเหตุการณ์ที่เกิดขึ้นในระบบเป็นแหล่งข้อมูลหลัก + +### ที่ชอบ + +* รับส่งข้อมูลได้เยอะและเร็วมาก (High throughput, low latency) +* ขยายระบบง่าย ไม่ต้องกลัวล่ม (Scalable, fault-tolerant) +* เก็บข้อความได้นาน ไม่หาย (Durable message storage) + +### ที่ไม่ค่อยชอบ + +* ติดตั้งกับดูแลรักษายุ่งยากพอตัว +* กินทรัพยากรเครื่องเยอะกว่าตัวอื่น +* ไม่ค่อยเหมาะกับงานคิวแบบง่ายๆ ที่อยากให้ worker มาหยิบงานไปทำทีละชิ้นแล้วจบ +* ถ้ากังวลเรื่อง JVM และ ZooKeeper Redpanda เป็นทางเลือกที่เขียนด้วย C++ และ compatible กับ Kafka API โดยใช้สถาปัตยกรรม thread-per-core ไม่ต้องพึ่งพา dependency ภายนอก + +## Valkey (Redis) + +### มันคืออะไร? + +Valkey หรือที่เรารู้จักกันในนาม Redis นั่นแหละ (Valkey เป็น fork ที่ community ช่วยกันดูแล) มันเป็นฐานข้อมูลในหน่วยความจำที่เร็วมาก คนเลยนิยมเอามาทำเป็น cache หรือ message broker แบบง่ายๆ โดยใช้ data structure อย่าง list มาทำเป็นคิว Valkey เกิดขึ้นในเดือนมีนาคม 2024 ภายใต้ Linux Foundation เป็น fork ของ Redis 7.2.4 แบบ BSD license หลังจากที่ Redis Ltd. เปลี่ยน license เป็น SSPL/RSALv2 โดยได้รับการสนับสนุนจาก AWS, Google Cloud และ Oracle ส่วน Valkey 8.1 (มีนาคม 2025) มี throughput สูงกว่าประมาณ 8%, P99 latency ต่ำกว่า 22% และใช้หน่วยความจำน้อยกว่า 20% เมื่อเทียบกับ Redis OSS + +{{< mermaid >}} +flowchart LR + %% subgraph for Redis/Valkey + subgraph valkey + direction TB + Q["List (LPUSH/BRPOP Queue)"] + C[Pub/Sub Channel] + end + + %% Producers & Consumers + subgraph Producers + direction TB + Prod[Producer] + Pub[Publisher] + end + + subgraph Consumers + direction TB + Cons[Consumer] + Sub[Subscriber] + end + + %% Connections + Prod -->|LPUSH| Q + Cons -->|BRPOP| Q + + Pub -->|PUBLISH| C + Sub -->|SUBSCRIBE| C +{{< /mermaid >}} + +### แล้วจะใช้ตอนไหนดี? + +* **ทำคิวแบบบ้านๆ:** งานที่ไม่ต้องการความสามารถสูงส่งอะไรมาก แค่คิวธรรมดาๆ ก็พอ +* **ทำ Pub/Sub:** เหมาะกับงานแชท หรือส่ง notification แบบ real-time +* **ทำ Cache:** อันนี้งานถนัดเค้าเลย + +### ที่ชอบ + +* เร็วมากเพราะทำงานบน RAM +* ใช้ง่าย ติดตั้งง่าย ไม่ซับซ้อน +* ทำได้หลายอย่างดี มี data structure ให้เล่นเยอะ + +### ที่ไม่ค่อยชอบ + +* ถ้าไม่ตั้งค่าดีๆ ไฟดับทีข้อมูลหายเกลี้ยง (Not durable by default) +* เก็บข้อความขนาดใหญ่มากไม่ได้ +* ไม่ได้เกิดมาเพื่องานสตรีมข้อมูลหนักๆ หรือการ routing ข้อความซับซ้อน + +## RabbitMQ + +### มันคืออะไร? + +RabbitMQ นี่เป็น Message Broker รุ่นใหญ่ที่เก๋าเกมมาก ใช้โปรโตคอล AMQP เป็นหลัก ขึ้นชื่อเรื่องความสามารถในการส่งข้อความที่ครบเครื่อง, การ routing ที่ยืดหยุ่น และความน่าเชื่อถือ + +{{< mermaid >}} +flowchart LR + %% Clients + subgraph Clients + direction TB + P[Producer] + C1[Consumer 1] + C2[Consumer 2] + end + + %% Broker + subgraph "RabbitMQ Broker" + direction TB + EX[Exchange] + Q1[Queue 1] + Q2[Queue 2] + end + + %% Message flow + P -- "publishes → Exchange" --> EX + EX -- "routes → Q1" --> Q1 + EX -- "routes → Q2" --> Q2 + Q1 -- "delivers → Consumer 1" --> C1 + Q2 -- "delivers → Consumer 2" --> C2 +{{< /mermaid >}} + +### แล้วจะใช้ตอนไหนดี? + +* **ทำงานเบื้องหลัง (Async):** แยกงานหนักๆ ออกไปทำเบื้องหลัง ไม่ต้องให้ user รอ +* **กระจายงาน (Work queues):** มีงานกองอยู่ อยากให้ worker หลายๆ ตัวมาช่วยกันทำ +* **ส่งข้อความแบบมีเงื่อนไข:** อยากส่งข้อความไปหา consumer ที่เฉพาะเจาะจงตามเงื่อนไขต่างๆ + +### ที่ชอบ + +* ฟีเจอร์เยอะมาก เป็นผู้ใหญ่ในวงการ +* การ routing ข้อความยืดหยุ่นสุดๆ +* รองรับรูปแบบการส่งข้อความได้หลากหลาย (fanout, direct, topic) + +### ที่ไม่ค่อยชอบ + +* ถ้าเทียบกับ Kafka เรื่อง throughput อาจจะสู้ไม่ได้ +* ถ้าอยากให้มันทนทานต่อความผิดพลาด (high availability) ต้องตั้งค่าดีๆ +* ปกติข้อความจะถูกอ่านแล้วลบทิ้ง ไม่ได้ออกแบบมาให้เก็บไว้นานๆ + +## NATS + +### มันคืออะไร? + +NATS เป็นระบบ Messaging ที่เกิดมาเพื่อยุค Cloud-Native, IoT, และ Microservices เลย จุดเด่นคือความเร็วสูงและเบามาก เน้นความเรียบง่ายเป็นหลัก + +{{< mermaid >}} +flowchart LR + %% Clients + subgraph Clients + direction TB + Producer + Consumer + end + + %% NATS Server with Topics + subgraph "NATS Server" + direction TB + TopicA["Topic A"] + TopicB["Topic B"] + end + + %% Connections + Producer -- "publishes → Topic A" --> TopicA + Consumer -- "subscribes ← Topic A" --> TopicA +{{< /mermaid >}} + +### แล้วจะใช้ตอนไหนดี? + +* **ให้ Microservices คุยกัน:** สื่อสารระหว่างเซอร์วิสแบบเร็วๆ และไว้ใจได้ +* **ส่งข้อมูลจากอุปกรณ์ IoT:** เหมาะกับการรับข้อความเล็กๆ จำนวนมหาศาลจากอุปกรณ์ต่างๆ +* **ระบบสั่งการ (Command and control):** ส่งคำสั่งไปควบคุมระบบที่กระจายอยู่ตามที่ต่างๆ + +### ที่ชอบ + +* เร็วและเบามากจริงๆ +* ติดตั้งและจัดการง่าย +* รองรับทั้ง Pub/Sub และ Request/Reply + +### ที่ไม่ค่อยชอบ + +* Core NATS ไม่มีระบบเก็บข้อความในตัว แต่ JetStream ที่ตอนนี้ฝังมาใน nats-server โดยตรง จะเพิ่มความสามารถในการเก็บข้อความ, replay, acknowledgment, deduplication, Key-Value store และ Object store (NATS Streaming/STAN ตัวเก่าเลิกพัฒนาแล้ว ให้ใช้ JetStream สำหรับงานที่ต้องการความคงทนถาวรของข้อมูล) +* ฟีเจอร์การ routing ไม่ซับซ้อนเท่า RabbitMQ +* ไม่ได้ออกแบบมาให้เก็บข้อความระยะยาวเหมือน Kafka + +## สรุปแล้วจะเลือกตัวไหนดี? + +สุดท้ายแล้ว การจะเลือกตัวไหนมันก็ขึ้นอยู่กับว่าเราจะเอามันไปทำอะไร: + +* **อยากได้ท่อส่งข้อมูลยักษ์ใหญ่ ทนทาน ขยายง่าย:** ไป **Kafka** เลย ไม่ผิดหวัง +* **อยากได้คิวเร็วๆ ง่ายๆ หรือทำ Pub/Sub, Cache:** **Valkey (Redis)** คือคำตอบ +* **อยากได้ Message Broker ที่เก๋าเกม routing ซับซ้อนได้ ไว้ใจได้:** ต้อง **RabbitMQ** +* **อยากได้ระบบส่งข้อความที่เร็ว เบา เหมาะกับ Microservices:** **NATS** คือตัวเลือกที่น่าสนใจมาก + +ก่อนจะตัดสินใจ ก็ลองถามตัวเองดูว่า งานของเราต้องการ throughput ขนาดไหน, ต้องเก็บข้อมูลนานแค่ไหน, การส่งข้อความซับซ้อนรึเปล่า, แล้วเรามีแรงดูแลมันมากน้อยแค่ไหน diff --git a/content/posts/dev/tricks_readable_code/featured-image.webp b/content/posts/dev/tricks_readable_code/featured-image.webp new file mode 100644 index 0000000..1403d4c Binary files /dev/null and b/content/posts/dev/tricks_readable_code/featured-image.webp differ diff --git a/content/posts/dev/tricks_readable_code/index.en.md b/content/posts/dev/tricks_readable_code/index.en.md new file mode 100644 index 0000000..adb81be --- /dev/null +++ b/content/posts/dev/tricks_readable_code/index.en.md @@ -0,0 +1,245 @@ +--- +title: "Simple Tricks for Writing Code That Won't Annoy Your Ancestors" +subtitle: "" +date: 2024-06-09T11:01:28+07:00 +lastmod: 2024-06-09T11:01:28+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Shares simple, team-friendly tricks for writing more readable Go code: flattening nested ifs, extracting complex logic into functions, removing duplication, and using names everyone understands." +license: "" +images: [] + +tags: ["dev"] +categories: ["dev"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +When writing code as a team, a common headache is **"Who wrote this code?!"** To minimize friction, it's essential to have shared principles or team agreements on how to write code. Here are some simple tips that can make our lives better; please consider them. + +## The Problematic Code +Let's start with the problematic code that resulted from a patchwork, "it works" approach. +```go +func main() { + ... + if isLogedIn { + if isStaff { + if isInFinanceDepartment { + for _, v := range products { + switch v.Cat { + case "food": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "drink": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "alcohol": + rwMutex.Lock() + receipt["price"] += v.Amount + receipt["tax"] + rwMutex.Unlock() + default: + rwMutex.Lock() + receipt["price"] += v.Amount + rwMutex.Unlock() + } + } + } else { + slog.Warn("main", "isInFinanceDepartment", false) + return ... + } + } else { + slog.Warn("main", "isStaff", false) + return ... + } + } else { + slog.Warn("main", "isLogedIn", false) + return ... + } + + p(products, total) + ... +} +``` + +## Avoid Deeply Nested Code Blocks +This reduces the complexity of the code when reading it, so you don't have to remember which scope you're in. + +### Flatten nested if statements with inversion +From the original code, you can see many nested `if` statements. Most of the time, if you're not doing separate work between `if` and `else`, you can flatten the `if` statements like this: +```go +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if !isStaff || !isInFinanceDepartment { + slog.Warn("main", "isStaffInFinanceDepartment", false) + return ... + } + + for _, v := range products { + switch v.Cat { + case "food": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "drink": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "alcohol": + rwMutex.Lock() + receipt["price"] += v.Amount + receipt["tax"] + rwMutex.Unlock() + default: + rwMutex.Lock() + receipt["price"] += v.Amount + rwMutex.Unlock() + } + } + + p(products, receipt) + ... +} +``` + +### Extract complex logic into functions +Take difficult-to-read, long logic and put it into a function with a name that describes what the logic does. +```go +func isFinanceStaff() bool { + return isStaff && isInFinanceDepartment +} + +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if isFinanceStaff() { + slog.Warn("main", "isFinanceStaff", false) + return ... + } + + for _, v := range products { + switch v.Cat { + case "food": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "drink": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "alcohol": + rwMutex.Lock() + receipt["price"] += v.Amount + receipt["tax"] + rwMutex.Unlock() + default: + rwMutex.Lock() + receipt["price"] += v.Amount + rwMutex.Unlock() + } + } + + p(products, receipt) + ... +} +``` + +## Make code reusable, reduce code duplication +If there's code that's repeatedly created but doesn't perform differently, consolidate it so it can be reused from a single location. +```go +func isFinanceStaff() bool { + return isStaff && isInFinanceDepartment +} + +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if isFinanceStaff() { + slog.Warn("main", "isFinanceStaff", false) + return ... + } + + rwMutex.Lock() + receipt["price"] := SumProductPrice(products, receipt["tax"]) + rwMutex.Unlock() + + p(products, totalPrice) + ... +} + +func SumProductPrice(products []Product, tax float64) total float64 { + for _, v := range products { + switch v.Cat { + case "food": + total += v.Amount + v.AddedAmount + case "drink": + total += v.Amount + v.AddedAmount + case "alcohol": + total += v.Amount + tax + default: + total += v.Amount + } + } +} +``` + +## Don't use names that only you understand +When naming things in code, use terms that customers understand, teammates understand, and then we'll all understand each other. +```go +func isFinanceStaff() bool { + return isStaff && isInFinanceDepartment +} + +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if isFinanceStaff() { + slog.Warn("main", "isFinanceStaff", false) + return ... + } + + receiptMutex.Lock() + receipt["totalPrice"] := SumProductPrice(orderProducts, receipt["tax"]) + receiptMutex.Unlock() + + PrintReceipt(orderProducts, totalPrice) + ... +} + +func SumProductPrice(orderProducts []Product, tax float64) total float64 { + for _, orderItem := range orderProducts { + switch orderItem.Category { + case "food": + total += orderItem.Price + orderItem.GasPrice + case "drink": + total += orderItem.Price + orderItem.PackagePrice + case "alcohol": + total += orderItem.Price + tax + default: + total += orderItem.Price + } + } +} +``` + +## Conclusion +I hope these tips help you write better code and reduce the number of times people complain about it when they have to work on it. Happy coding! diff --git a/content/posts/dev/tricks_readable_code/index.md b/content/posts/dev/tricks_readable_code/index.md new file mode 100644 index 0000000..31bc4ee --- /dev/null +++ b/content/posts/dev/tricks_readable_code/index.md @@ -0,0 +1,245 @@ +--- +title: "ทริกง่าย ๆ กับการเขียนโค้ดไม่ให้บรรพบุรุษเดือดร้อน" +subtitle: "" +date: 2024-06-09T11:01:28+07:00 +lastmod: 2024-06-09T11:01:28+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ทริกง่าย ๆ สำหรับเขียนโค้ดให้อ่านง่ายขึ้นในทีม: ยุบ nested if, แยก logic ซับซ้อนออกเป็นฟังก์ชัน, ลดการทับซ้อนของโค้ด และตั้งชื่อให้คนอื่นเข้าใจ" +license: "" +images: [] + +tags: ["dev"] +categories: ["dev"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +ในการเขียนโค้ดกันเป็นทีมมันจะมีเรื่องชวนปวดหัวบ่อย ๆ เลยคือ **ใครเขียนโค้ดนี้ว่ะ** เลยจะต้องมีสิ่งพึงละลึกร่วมกัน หรือตกลงกันในทีมในการเขียนโค้ดให้การกระทบกระทั้งกันน้อยลง โดยจะเอาวิธีง่าย ๆ ที่ช่วยให้ชีวิตชาวเราดีขึ้นมาเสนอลองพิจารณาดูครับ + +## โค้ดเจ้าปัญหา +เริ่มจากโค้ดเจ้าปัญหาที่เกิดจากการทำงานแบบปะผุ It works +```go +func main() { + ... + if isLogedIn { + if isStaff { + if isInFinanceDepartment { + for _, v := range products { + switch v.Cat { + case "food": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "drink": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "alcohol": + rwMutex.Lock() + receipt["price"] += v.Amount + receipt["tax"] + rwMutex.Unlock() + default: + rwMutex.Lock() + receipt["price"] += v.Amount + rwMutex.Unlock() + } + } + } else { + slog.Warn("main", "isInFinanceDepartment", false) + return ... + } + } else { + slog.Warn("main", "isStaff", false) + return ... + } + } else { + slog.Warn("main", "isLogedIn", false) + return ... + } + + p(products, total) + ... +} +``` + +## หลีกเลี่ยงการเขียนโค้ดซ้อนกันหลายชั้นใน 1 บล็อค +ก็คือลดความซับซ้อนของโค้ดเวลาอ่าน จะได้ไม่ต้องจำว่ามันอยู่ในวงไหนกันนะ + +### ยุบ nested if ด้วย inversion +จากของเดิมจะเห็นหว่ามี if ครอบอยู่หลายชั้นมาก ซึ่งส่วนมากแล้วถ้าเราไม่ได้ทำงานแยกกันระหว่าง if else เราสามารถยุบ if ได้ตามนี้ +```go +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if !isStaff || !isInFinanceDepartment { + slog.Warn("main", "isStaffInFinanceDepartment", false) + return ... + } + + for _, v := range products { + switch v.Cat { + case "food": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "drink": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "alcohol": + rwMutex.Lock() + receipt["price"] += v.Amount + receipt["tax"] + rwMutex.Unlock() + default: + rwMutex.Lock() + receipt["price"] += v.Amount + rwMutex.Unlock() + } + } + + p(products, receipt) + ... +} +``` + +### ย้ายสิ่งที่เป็น logic ซับซ้อนออกมาเป็นฟังก์ชัน +เอา logic ที่อ่านยาก ๆ ยาว ๆ ออกมาเป็นชื่อที่ระบุว่า logic นั้นทำอะไร +```go +func isFinanceStaff() bool { + return isStaff && isInFinanceDepartment +} + +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if isFinanceStaff() { + slog.Warn("main", "isFinanceStaff", false) + return ... + } + + for _, v := range products { + switch v.Cat { + case "food": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "drink": + rwMutex.Lock() + receipt["price"] += v.Amount + v.AddedAmount + rwMutex.Unlock() + case "alcohol": + rwMutex.Lock() + receipt["price"] += v.Amount + receipt["tax"] + rwMutex.Unlock() + default: + rwMutex.Lock() + receipt["price"] += v.Amount + rwMutex.Unlock() + } + } + + p(products, receipt) + ... +} +``` + +## ทำให้โค้ดใช้ซ้ำได้ ลดความทับซ้อนของโค้ด +โค้ดไหนที่มีการสร้างมาซ้ำ ๆ แต่ไม่ได้ทำงานต่างกันก็ยุบรวมไปให้ใช้งานซ้ำ ๆ กันได้จากที่เดียวกัน +```go +func isFinanceStaff() bool { + return isStaff && isInFinanceDepartment +} + +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if isFinanceStaff() { + slog.Warn("main", "isFinanceStaff", false) + return ... + } + + rwMutex.Lock() + receipt["price"] := SumProductPrice(products, receipt["tax"]) + rwMutex.Unlock() + + p(products, totalPrice) + ... +} + +func SumProductPrice(products []Product, tax float64) total float64 { + for _, v := range products { + switch v.Cat { + case "food": + total += v.Amount + v.AddedAmount + case "drink": + total += v.Amount + v.AddedAmount + case "alcohol": + total += v.Amount + tax + default: + total += v.Amount + } + } +} +``` + +## อย่าตั้งชื่อให้เข้าใจอยู่คนเดียว +การระบุชื่อในโค้ด ให้ใช้คำที่ลูกค้าเข้าใจ เพื่อนร่วมทีมเข้าใจ แล้วเราก็จะเข้าใจกัน +```go +func isFinanceStaff() bool { + return isStaff && isInFinanceDepartment +} + +func main() { + ... + if !isLogedIn { + slog.Warn("main", "isLogedIn", false) + return ... + } + + if isFinanceStaff() { + slog.Warn("main", "isFinanceStaff", false) + return ... + } + + receiptMutex.Lock() + receipt["totalPrice"] := SumProductPrice(orderProducts, receipt["tax"]) + receiptMutex.Unlock() + + PrintReceipt(orderProducts, totalPrice) + ... +} + +func SumProductPrice(orderProducts []Product, tax float64) total float64 { + for _, orderItem := range orderProducts { + switch orderItem.Category { + case "food": + total += orderItem.Price + orderItem.GasPrice + case "drink": + total += orderItem.Price + orderItem.PackagePrice + case "alcohol": + total += orderItem.Price + tax + default: + total += orderItem.Price + } + } +} +``` + +## สรุป +ก็ลองไปปรับใช้กันดูนะครับเผื่อจะมีคนพาดพิงน้อยลงเวลาที่มีคนเอาโค้ดเราไปทำงานต่อ Happy coding กันครับ \ No newline at end of file diff --git a/content/posts/go/.DS_Store b/content/posts/go/.DS_Store new file mode 100644 index 0000000..3bb076f Binary files /dev/null and b/content/posts/go/.DS_Store differ diff --git a/content/posts/go/accept_interfaces_return_structs/featured-image.png b/content/posts/go/accept_interfaces_return_structs/featured-image.png new file mode 100644 index 0000000..ddbc4af Binary files /dev/null and b/content/posts/go/accept_interfaces_return_structs/featured-image.png differ diff --git a/content/posts/go/accept_interfaces_return_structs/index.en.md b/content/posts/go/accept_interfaces_return_structs/index.en.md new file mode 100644 index 0000000..d035305 --- /dev/null +++ b/content/posts/go/accept_interfaces_return_structs/index.en.md @@ -0,0 +1,196 @@ +--- +title: "Accept interfaces, return structs" +subtitle: "" +date: 2025-11-08T12:00:00+07:00 +lastmod: 2025-11-08T12:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Exploring the benefits of designing Go functions with interfaces for input and structs for output. This approach enhances testability, promotes loose coupling, and makes your code more adaptable to change." +aliases: +- /posts/go_interface_struct/ +license: "" +images: [] + +tags: ["Go", "Interfaces", "Structs", "Design Patterns"] +categories: ["Go"] + +featuredImage: "featured-image.png" +featuredImagePreview: "featured-image.png" + +lightgallery: true +--- + +In the world of Go programming, designing flexible and maintainable functions is key to building robust applications. One powerful pattern involves using interfaces as function inputs and structs as function outputs. This approach enhances testability, promotes loose coupling, and makes your code more adaptable to change. + + + +## Why Interface as Input? + +When a function accepts an interface as an input parameter, it means the function can operate on any type that implements that interface. +{{< admonition note "Design Principles" >}} +This adheres to the **Dependency Inversion Principle** and **Open-Closed Principle**, allowing for greater flexibility and extensibility. +{{< /admonition >}} + +Consider a scenario where you need to process different types of documents. Instead of writing separate functions for each document type, you can define an interface. + +{{< admonition example >}} +```go +package main + +import "fmt" + +// DocumentProcessor defines the contract for processing documents. +type DocumentProcessor interface { + Process() string + GetContent() string +} + +// PDFDocument implements DocumentProcessor for PDF files. +type PDFDocument struct { + Content string +} + +func (p PDFDocument) Process() string { + return fmt.Sprintf("Processing PDF document with content: %s", p.Content) +} + +func (p PDFDocument) GetContent() string { + return p.Content +} + +// WordDocument implements DocumentProcessor for Word files. +type WordDocument struct { + Text string +} + +func (w WordDocument) Process() string { + return fmt.Sprintf("Processing Word document with text: %s", w.Text) +} + +func (w WordDocument) GetContent() string { + return w.Text +} + +// HandleDocument accepts any type that implements the DocumentProcessor interface. +func HandleDocument(processor DocumentProcessor) { + fmt.Println(processor.Process()) +} + +func main() { + pdf := PDFDocument{Content: "Go documentation"} + word := WordDocument{Text: "Software design patterns"} + + HandleDocument(pdf) + HandleDocument(word) +} +``` +{{< /admonition >}} + +In this example, `HandleDocument` doesn't care whether it's dealing with a `PDFDocument` or a `WordDocument`. It only cares that the input implements the `DocumentProcessor` interface, which guarantees the `Process()` method is available. +{{< admonition tip "Benefit of Interfaces" >}} +This makes `HandleDocument` highly reusable and easy to test with mock implementations of `DocumentProcessor`. +{{< /admonition >}} + +## Why Struct as Output? + +Returning a struct as output provides a clear and organized way to convey multiple pieces of information from a function. Unlike returning multiple bare values, a struct gives semantic meaning to the grouped data and makes the function signature easier to understand and maintain. + +Let's extend our document processing example to return structured results. + +{{< admonition example >}} +```go +package main + +import "fmt" + +// DocumentProcessor defines the contract for processing documents. +type DocumentProcessor interface { + Process() (ProcessResult, error) + GetContent() string +} + +// ProcessResult represents the structured output of a document processing operation. +type ProcessResult struct { + DocumentType string + Status string + Message string + ProcessedBy string +} + +// PDFDocument implements DocumentProcessor for PDF files. +type PDFDocument struct { + Content string +} + +func (p PDFDocument) Process() (ProcessResult, error) { + // Simulate some processing logic + result := ProcessResult{ + DocumentType: "PDF", + Status: "Success", + Message: "PDF processed successfully", + ProcessedBy: "PDFProcessorV1", + } + return result, nil +} + +func (p PDFDocument) GetContent() string { + return p.Content +} + +// WordDocument implements DocumentProcessor for Word files. +type WordDocument struct { + Text string +} + +func (w WordDocument) Process() (ProcessResult, error) { + // Simulate some processing logic + result := ProcessResult{ + DocumentType: "Word", + Status: "Success", + Message: "Word document processed successfully", + ProcessedBy: "WordProcessorV2", + } + return result, nil +} + +func (w WordDocument) GetContent() string { + return w.Text +} + +// HandleDocument processes a document and returns a structured result. +func HandleDocument(processor DocumentProcessor) (ProcessResult, error) { + return processor.Process() +} + +func main() { + pdf := PDFDocument{Content: "Go documentation"} + word := WordDocument{Text: "Software design patterns"} + + pdfResult, err := HandleDocument(pdf) + if err != nil { + fmt.Printf("Error processing PDF: %v\n", err) + } else { + fmt.Printf("PDF Processing Result: %+v\n", pdfResult) + } + + wordResult, err := HandleDocument(word) + if err != nil { + fmt.Printf("Error processing Word: %v\n", err) + } else { + fmt.Printf("Word Processing Result: %+v\n", wordResult) + } +} +``` +{{< /admonition >}} + +By returning `ProcessResult`, we encapsulate all relevant information about the processing operation. +{{< admonition tip "Structured Output Benefits" >}} +This makes the return value self-describing and easy to extend if more information needs to be added in the future without changing the function signature (`HandleDocument`'s return type remains `(ProcessResult, error)`). +{{< /admonition >}} + +## Conclusion + +{{< admonition summary "Key Takeaway" >}} +Combining interfaces for function inputs and structs for function outputs is a highly effective design pattern in Go. It promotes a modular, testable, and maintainable codebase, allowing your applications to evolve gracefully as requirements change. By focusing on contracts (interfaces) for what a function needs and structured data (structs) for what it provides, you can write Go code that is both powerful and elegant. +{{< /admonition >}} \ No newline at end of file diff --git a/content/posts/go/accept_interfaces_return_structs/index.md b/content/posts/go/accept_interfaces_return_structs/index.md new file mode 100644 index 0000000..7709137 --- /dev/null +++ b/content/posts/go/accept_interfaces_return_structs/index.md @@ -0,0 +1,204 @@ +--- +title: "รับ interface แล้วคืนค่า struct" +subtitle: "" +date: 2025-11-08T12:00:00+07:00 +lastmod: 2025-11-08T12:00:00+07:00 +draft: false +author: "กวิน วิริยะประสพสุข" +authorLink: "https://kawin.dev" +description: "ชวนคุยแนวคิดออกแบบฟังก์ชันใน Go แบบรับ interface เป็น input แล้วคืนค่า struct เป็น output ช่วยให้เทสง่าย ลดการผูกติดกันของโค้ด และรองรับการเปลี่ยนแปลงได้ดีขึ้น" +aliases: +- /posts/go_interface_struct/ +license: "" +images: [] + +tags: ["Go", "Interfaces", "Structs", "Design Patterns"] +categories: ["Go"] + +featuredImage: "featured-image.png" +featuredImagePreview: "featured-image.png" + +lightgallery: true +--- + +ในโลกของการเขียนโปรแกรมด้วย Go การออกแบบฟังก์ชันให้ยืดหยุ่นและดูแลง่ายคือกุญแจสำคัญของการสร้างแอปที่แข็งแรง หนึ่งในแพทเทิร์นที่ทรงพลังมากคือ + +> รับค่าเป็น **interface** แล้วคืนค่าเป็น **struct** + +แนวคิดนี้ช่วยให้เทสง่ายขึ้น ลดการผูกติด (coupling) ระหว่างส่วนต่าง ๆ ในระบบ และทำให้โค้ดของเราปรับตัวตาม requirement ที่เปลี่ยนไปได้ดีขึ้นมาก + + + +## ทำไมควรใช้ Interface เป็น Input? + +เวลาเราทำให้ฟังก์ชัน “รับ interface” เป็นพารามิเตอร์ หมายความว่าฟังก์ชันนั้นสามารถทำงานกับ type อะไรก็ได้ที่ “implements interface นั้น” อยู่ + +{{< admonition note "หลักการออกแบบที่เกี่ยวข้อง" >}} +แนวคิดนี้สอดคล้องกับ **Dependency Inversion Principle** และ **Open-Closed Principle** ช่วยให้ระบบยืดหยุ่นและขยายได้ง่ายขึ้น +{{< /admonition >}} + +ลองนึกภาพเคสที่เราต้องประมวลผลเอกสารหลายประเภท ปกติอาจเผลอไปเขียนฟังก์ชันแยกเป็น `ProcessPDF()`, `ProcessWord()` ฯลฯ แต่จริง ๆ แล้วเราสามารถนิยาม interface กลางขึ้นมาชุดเดียวแล้วใช้ร่วมกันได้ + +{{< admonition example >}} +```go +package main + +import "fmt" + +// DocumentProcessor กำหนดสัญญา (contract) สำหรับตัวที่เอาไว้ประมวลผลเอกสาร +type DocumentProcessor interface { + Process() string + GetContent() string +} + +// PDFDocument implements DocumentProcessor สำหรับไฟล์ PDF +type PDFDocument struct { + Content string +} + +func (p PDFDocument) Process() string { + return fmt.Sprintf("Processing PDF document with content: %s", p.Content) +} + +func (p PDFDocument) GetContent() string { + return p.Content +} + +// WordDocument implements DocumentProcessor สำหรับไฟล์ Word +type WordDocument struct { + Text string +} + +func (w WordDocument) Process() string { + return fmt.Sprintf("Processing Word document with text: %s", w.Text) +} + +func (w WordDocument) GetContent() string { + return w.Text +} + +// HandleDocument รับค่าอะไรก็ได้ที่ implements DocumentProcessor +func HandleDocument(processor DocumentProcessor) { + fmt.Println(processor.Process()) +} + +func main() { + pdf := PDFDocument{Content: "Go documentation"} + word := WordDocument{Text: "Software design patterns"} + + HandleDocument(pdf) + HandleDocument(word) +} +``` +{{< /admonition >}} + +ในตัวอย่างนี้ `HandleDocument` ไม่สนใจเลยว่ากำลังทำงานกับ `PDFDocument` หรือ `WordDocument` สิ่งเดียวที่มันสนใจคือ object ที่ส่งเข้ามา “ต้อง” implements `DocumentProcessor` ซึ่งการันตีว่าอย่างน้อยมีเมธอด `Process()` ให้เรียกแน่นอน + +{{< admonition tip "ข้อดีของการใช้ Interface" >}} +ทำให้ `HandleDocument` นำกลับมาใช้ซ้ำได้สูง และเทสง่ายมาก เพราะเราสามารถสร้าง mock ที่ implements `DocumentProcessor` ขึ้นมาทดแทนของจริงได้เลย +{{< /admonition >}} + +## ทำไมควรใช้ Struct เป็น Output? + +การคืนค่าเป็น struct ทำให้เราสื่อสาร “หลาย ๆ ข้อมูล” ออกจากฟังก์ชันได้อย่างมีความหมาย ชัดเจน และเป็นระบบมากกว่าแค่คืนค่าหลาย ๆ ตัวแบบกระจัดกระจาย เพราะ struct จะบอก semantic ของข้อมูลแต่ละฟิลด์ได้ชัดเจนกว่า และทำให้ signature ของฟังก์ชันอ่านง่าย ดูแลต่อได้ไม่ปวดหัว + +ลองขยายตัวอย่างการประมวลผลเอกสารให้คืนค่าแบบมีโครงสร้างมากขึ้น + +{{< admonition example >}} +```go +package main + +import "fmt" + +// DocumentProcessor กำหนดสัญญา (contract) สำหรับตัวที่เอาไว้ประมวลผลเอกสาร +type DocumentProcessor interface { + Process() (ProcessResult, error) + GetContent() string +} + +// ProcessResult แทนผลลัพธ์แบบมีโครงสร้างของการประมวลผลเอกสาร +type ProcessResult struct { + DocumentType string + Status string + Message string + ProcessedBy string +} + +// PDFDocument implements DocumentProcessor สำหรับไฟล์ PDF +type PDFDocument struct { + Content string +} + +func (p PDFDocument) Process() (ProcessResult, error) { + // สมมติว่ามี logic การประมวลผลบางอย่าง + result := ProcessResult{ + DocumentType: "PDF", + Status: "Success", + Message: "PDF processed successfully", + ProcessedBy: "PDFProcessorV1", + } + return result, nil +} + +func (p PDFDocument) GetContent() string { + return p.Content +} + +// WordDocument implements DocumentProcessor สำหรับไฟล์ Word +type WordDocument struct { + Text string +} + +func (w WordDocument) Process() (ProcessResult, error) { + // สมมติว่ามี logic การประมวลผลบางอย่าง + result := ProcessResult{ + DocumentType: "Word", + Status: "Success", + Message: "Word document processed successfully", + ProcessedBy: "WordProcessorV2", + } + return result, nil +} + +func (w WordDocument) GetContent() string { + return w.Text +} + +// HandleDocument ประมวลผลเอกสารแล้วคืนผลลัพธ์แบบมีโครงสร้าง +func HandleDocument(processor DocumentProcessor) (ProcessResult, error) { + return processor.Process() +} + +func main() { + pdf := PDFDocument{Content: "Go documentation"} + word := WordDocument{Text: "Software design patterns"} + + pdfResult, err := HandleDocument(pdf) + if err != nil { + fmt.Printf("Error processing PDF: %v\n", err) + } else { + fmt.Printf("PDF Processing Result: %+v\n", pdfResult) + } + + wordResult, err := HandleDocument(word) + if err != nil { + fmt.Printf("Error processing Word: %v\n", err) + } else { + fmt.Printf("Word Processing Result: %+v\n", wordResult) + } +} +``` +{{< /admonition >}} + +พอเราใช้ `ProcessResult` เป็น struct สำหรับผลลัพธ์ เราก็ห่อข้อมูลทุกอย่างที่เกี่ยวกับการประมวลผลใส่เข้าไปในที่เดียว + +{{< admonition tip "ข้อดีของการคืนค่าแบบ Struct" >}} +ค่าที่คืนออกมาจะ “อธิบายตัวเอง” ได้ดี (self-describing) และขยายเพิ่มฟิลด์ใหม่ในอนาคตได้ง่าย โดยไม่ต้องไปแก้ function signature ของ `HandleDocument` (ยังคงเป็น `(ProcessResult, error)` เหมือนเดิม) +{{< /admonition >}} + +## สรุป + +{{< admonition summary "Key Takeaway" >}} +การใช้ interface เป็น input และ struct เป็น output เป็นแพทเทิร์นที่เวิร์กมากใน Go +มันช่วยให้โค้ดแยกส่วนกันดี (modular), เทสง่าย, และดูแลรักษาในระยะยาวได้สบายขึ้น พอเราโฟกัสที่ “สัญญา” (interface) ว่าฟังก์ชันต้องการอะไร และใช้ “ข้อมูลแบบมีโครงสร้าง” (struct) บอกสิ่งที่ฟังก์ชันส่งกลับ โค้ดที่ได้จะทั้งยืดหยุดและอ่านง่ายในเวลาเดียวกัน +{{< /admonition >}} diff --git a/content/posts/go/automaxprocs/featured-image.webp b/content/posts/go/automaxprocs/featured-image.webp new file mode 100644 index 0000000..d445ba0 Binary files /dev/null and b/content/posts/go/automaxprocs/featured-image.webp differ diff --git a/content/posts/go/automaxprocs/img/concurrency_vs_parallelism.webp b/content/posts/go/automaxprocs/img/concurrency_vs_parallelism.webp new file mode 100644 index 0000000..38d140d Binary files /dev/null and b/content/posts/go/automaxprocs/img/concurrency_vs_parallelism.webp differ diff --git a/content/posts/go/automaxprocs/img/cpu_throttling.webp b/content/posts/go/automaxprocs/img/cpu_throttling.webp new file mode 100644 index 0000000..78f7bf1 Binary files /dev/null and b/content/posts/go/automaxprocs/img/cpu_throttling.webp differ diff --git a/content/posts/go/automaxprocs/img/docker_from_auto.webp b/content/posts/go/automaxprocs/img/docker_from_auto.webp new file mode 100644 index 0000000..5798a1f Binary files /dev/null and b/content/posts/go/automaxprocs/img/docker_from_auto.webp differ diff --git a/content/posts/go/automaxprocs/img/docker_from_manual.webp b/content/posts/go/automaxprocs/img/docker_from_manual.webp new file mode 100644 index 0000000..38d743a Binary files /dev/null and b/content/posts/go/automaxprocs/img/docker_from_manual.webp differ diff --git a/content/posts/go/automaxprocs/img/docker_from_start.webp b/content/posts/go/automaxprocs/img/docker_from_start.webp new file mode 100644 index 0000000..d31556c Binary files /dev/null and b/content/posts/go/automaxprocs/img/docker_from_start.webp differ diff --git a/content/posts/go/automaxprocs/img/from_start.webp b/content/posts/go/automaxprocs/img/from_start.webp new file mode 100644 index 0000000..3f424ad Binary files /dev/null and b/content/posts/go/automaxprocs/img/from_start.webp differ diff --git a/content/posts/go/automaxprocs/img/system.webp b/content/posts/go/automaxprocs/img/system.webp new file mode 100644 index 0000000..af4cbaa Binary files /dev/null and b/content/posts/go/automaxprocs/img/system.webp differ diff --git a/content/posts/go/automaxprocs/index.en.md b/content/posts/go/automaxprocs/index.en.md new file mode 100644 index 0000000..f4112b2 --- /dev/null +++ b/content/posts/go/automaxprocs/index.en.md @@ -0,0 +1,108 @@ +--- +title: "GOMAXPROCS, a good friend of DevOps" +subtitle: "" +date: 2023-08-13T00:19:58+07:00 +lastmod: 2023-08-13T00:19:58+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Let's configure GOMAXPROCS to match the environment." +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Programing", "DevOps"] +categories: ["Go"] + +lightgallery: true +--- + +Since Go 1.5, the number of `User-Level` threads that a Go App can run concurrently in each instance is determined by GOMAXPROCS. The value of GOMAXPROCS is defined as follows: +{{< admonition note "Note" >}} +GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting. It defaults to the value of runtime.NumCPU. If n < 1, it does not change the current setting. This call will go away when the scheduler improves. +{{< /admonition >}} + + +## Let's try it out +![concurrency_vs_parallelism](img/concurrency_vs_parallelism.webp "concurrency vs parallelism") +It seems good that it's set to allow `Parallelism` (which is different from `Concurrency`. Concurrency is about dealing with multiple things at the same time, **but not necessarily simultaneously**. Parallelism is about **doing one or more things simultaneously**) according to the number of `logical CPUs`. But the problem arises when the app is running in a `Container` environment with a `CPU quota` set (which is normal for Ops to set to avoid interfering with other services). Our app doesn't set the GOMAXPROCS value based on the CPU quota from the container. Let's look at an example. Let's create a Go Fiber app that is [Prefork](https://github.com/gofiber/fiber/issues/180). + +```go +func main() { + + // init fiber app + app := fiber.New(fiber.Config{ + Prefork: true, + }) + + // simple get current CPU and Go MaxProcs + app.Get("/", func(ctx *fiber.Ctx) error { + respBody := fiber.Map{ + "NumCPU": runtime.NumCPU(), + "GOMAXPROCS": runtime.GOMAXPROCS(0), + } + return ctx.JSON(respBody) + }) + + // listen HTTP + go func() { + if err := app.Listen(":8080"); err != nil { + log.Panic(err) + } + }() + + // listen for end signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + // block before gracefully shutdown + <-quit + fmt.Println("Gracefully shutting down...") + _ = app.Shutdown() +} +``` + +The app runs on my machine with 16 logical CPU cores. +![system](img/system.webp "System Info") + +The Fiber app will spawn itself and its children into 16 processes. +![from_start](img/from_start.webp "Running on the machine") + +Now let's try it through a Container with a CPU quota set. +![docker_from_start](img/docker_from_start.webp "Running on a Container with a CPU quota") + +What the... it still has 16 processes. It turns out that it still spawns processes beyond the given quota. + +## What's the problem? +So what's the problem? It doesn't seem like a big deal, just a lot of processes. But imagine this: you have 2 workers, but you give them a task for 16 people to do at the same time. What will happen? That's right, latency. Because when there's more work than the workforce can handle, something called `CPU throttling` occurs, which slows everything down. The graph below shows an example of when the CPU is throttled after working beyond its quota, being pushed down far below the limit. +![cpu_throttling](img/cpu_throttling.webp "cpu throttling") + +## The solution +### Set it via GOMAXPROCS +The straightforward way is to set GOMAXPROCS directly in the code. +```go +func main() { + runtime.GOMAXPROCS(4) + + ... +} +``` +After setting the value in `runtime.GOMAXPROCS()`, let's run it in the container. +![docker_from_manual](img/docker_from_manual.webp "Running on a Container with a CPU quota") +We find that the number of processes spawned is as set in the code, but it still doesn't match the container's value. + +### Set it via uber-go/automaxprocs +So, a village representative came to solve this problem, and that is Uber's [uber-go/automaxprocs](https://github.com/uber-go/automaxprocs). It's very easy to use, just add the import pkg to the main function. +```go +import ( + _ "go.uber.org/automaxprocs" +) + +func main() { + ... +} +``` + +The result will be as set in the CPU quota. Now our app will not spawn processes beyond the container's CPU quota, and our app will run smoothly without unintentional CPU throttling. +![docker_from_auto](img/docker_from_auto.webp "Running on a Container with a CPU quota") diff --git a/content/posts/go/automaxprocs/index.md b/content/posts/go/automaxprocs/index.md new file mode 100644 index 0000000..16aee7b --- /dev/null +++ b/content/posts/go/automaxprocs/index.md @@ -0,0 +1,108 @@ +--- +title: "GOMAXPROCS เพื่อนที่ดีของ DevOps" +subtitle: "" +date: 2023-08-13T00:19:58+07:00 +lastmod: 2023-08-13T00:19:58+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ตั้งค่า GOMAXPROCS ให้เข้ากับสภาพแวดล้อมกันเถิด" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Programing", "DevOps"] +categories: ["Go"] + +lightgallery: true +--- + +ตั้งแต่ Go 1.5 เป็นต้นมานั้น ค่าของจำนวน `User-Level` ที่ Go App จะสามารถให้ทำงานพร้อมกันนั้นในแต่ละ Instance จะถูกกำหนดด้วย GOMAXPROCS แล้วทีนี้ค่าของ GOMAXPROCS จะมีค่าตามนิยามนี้ +{{< admonition note "Note" >}} +GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting. It defaults to the value of runtime.NumCPU. If n < 1, it does not change the current setting. This call will go away when the scheduler improves. +{{< /admonition >}} + + +## ลองใช้งานดู +![concurrency_vs_parallelism](img/concurrency_vs_parallelism.webp "concurrency vs parallelism") +ซึ่งก็เหมือนจะดูดีที่กำหมดมาให้ว่าสามารถทำงานแบบ `Parallelism` (คนละอันกับ `Concurrency` นะ Concurrency คือ จัดการกับงานหลายอย่างในช่วงเวลาเดียวกัน**แต่ไม่จำเป็นต้องพร้อมกัน** Parallelism คือ **ทำงาน งานเดียวหรือหลายงาน พร้อมกัน**) ได้ตามจำนวน `logical CPU` แต่ปัญหาจะเกิดเมื่อแอปไม่ได้ทำงานอยู่บนสภาพแวดล้อมที่เป็น `Container` แล้วกำหนด `CPU quota` ไว้ (ซึ่งก็ปกติ Ops จะกำหนดไว้อยู่แล้วเพื่อไม่ให้ไปเบียดกับ services อื่น ๆ) แอปเรามันดันไม่ได้กำหนดค่า GOMAXPROCS ด้วย CPU quota ที่กำหนดมาจาก container นี่สิ ดูได้จากตัวอย่าง ลองสร้าง Go fiber ขึ้มมาแอปนึง ที่เป็นแบบ [Prefork](https://github.com/gofiber/fiber/issues/180) + +```go +func main() { + + // init fiber app + app := fiber.New(fiber.Config{ + Prefork: true, + }) + + // simple get current CPU and Go MaxProcs + app.Get("/", func(ctx *fiber.Ctx) error { + respBody := fiber.Map{ + "NumCPU": runtime.NumCPU(), + "GOMAXPROCS": runtime.GOMAXPROCS(0), + } + return ctx.JSON(respBody) + }) + + // listen HTTP + go func() { + if err := app.Listen(":8080"); err != nil { + log.Panic(err) + } + }() + + // listen for end signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + // block before gracefully shutdown + <-quit + fmt.Println("Gracefully shutting down...") + _ = app.Shutdown() +} +``` + +แอปรันบนเครื่องผมที่มี 16 logical CPU cores +![system](img/system.webp "ข้อมูลระบบ") + +Fiber app ก็จะงอกตัวเองและลูก ๆ ขึ้นมา 16 processes +![from_start](img/from_start.webp "รันบนเครื่อง") + +แล้วมาลองแบบผ่าน Container แบบกำหนด CPU quota ดู +![docker_from_start](img/docker_from_start.webp "รันบน Container ที่กำหนด CPU quota") + +ว้อททท มันก็ยังคงมี 16 processes ปรากฎว่ามันก็ยังงอก process ออกมาจนเกินที่ให้ quota ไว้ + +## ปัญหาคืออะไร +แล้วทีนี้ปัญหาคืออะไร มันก็ไม่เห็นเป็นไรนิก็แค่งอก process ออกมาเยอะเฉย ๆ แต่ลองนึกสภาพดูนะครับว่ามีคนทำงาน 2 คน แต่เอางานขนาด 16 คน มาให้ทำทีเดียว**พร้อมกัน**จะเกิดอะไรขึ้น ใช่แล้วครับ มันความล่าช้าหรือ latency นั่นแหละ เพราะว่าเมื่อมีงานที่ต้องทำเกินกว่าแรงงานที่ทำได้จะเกิดสิ่งที่เรียกว่า `CPU throttling` ทำให้ทุกสิ่งอย่างล่าช้าลงไป จากกราฟด้านล่าง เป็นตัวอย่างเวลาที่ CPU โดนบีบลงมา หลังจากทำงานจนล้น quota โดยโดนกดลงมาจนต่ำกว่า limit เยอะเลย +![cpu_throttling](img/cpu_throttling.webp "cpu throttling") + +## วิธีแก้ +### กำหนดผ่าน GOMAXPROCS +วิธีตรงไปตรงมาก็คือ กำหนด GOMAXPROCS มันเลยในโค้ดนี่แหละ +```go +func main() { + runtime.GOMAXPROCS(4) + + ... +} +``` +หลังจากกำหนดค่าใน `runtime.GOMAXPROCS()` แล้วลองรันใน container ดู +![docker_from_manual](img/docker_from_manual.webp "รันบน Container ที่กำหนด CPU quota") +ก็พบว่ามี process งอกออกมาเท่าที่ตามตั้งไว้ในโค้ดแล้ว แต่ก็ยังไม่ตรงกับค่าของ container อยู่ดี + +### กำหนดผ่าน uber-go/automaxprocs +จึงมีตัวแทนหมู่บ้านมาแก้ขัดปัญหานี้ นั้นคือ uber นั่นเอง [uber-go/automaxprocs](https://github.com/uber-go/automaxprocs) วิธีใช้ก็ง่ายมากเพียงแค่เพิ่ม import pkg เข้ามาใน main เท่านั้น +```go +import ( + _ "go.uber.org/automaxprocs" +) + +func main() { + ... +} +``` + +ก็จะได้ผลออกมาตามที่กำหนดไว้ใน CPU quota เลย ทีนี้แอปของเราก็จะงอก process ไม่เกิน CPU quota ของ container แอปเราก็จะทำงานได้โดยสะดวกแล้ว แบบไม่ต้องโดน CPU throttling แบบไม่ตั้งใจ +![docker_from_auto](img/docker_from_auto.webp "รันบน Container ที่กำหนด CPU quota") \ No newline at end of file diff --git a/content/posts/go/avoid_locks/featured-image.jpg b/content/posts/go/avoid_locks/featured-image.jpg new file mode 100644 index 0000000..920aa19 Binary files /dev/null and b/content/posts/go/avoid_locks/featured-image.jpg differ diff --git a/content/posts/go/avoid_locks/index.en.md b/content/posts/go/avoid_locks/index.en.md new file mode 100644 index 0000000..e70b21b --- /dev/null +++ b/content/posts/go/avoid_locks/index.en.md @@ -0,0 +1,226 @@ +--- +title: "How to Write Go Code That Accesses Shared Resources Without Using Exclusive Locks" +subtitle: "" +date: 2024-12-21T18:07:21+07:00 +lastmod: 2024-12-21T18:07:21+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Explains why to avoid exclusive mutex locks in Go and shows four lock-free alternatives: channels, sync.Map, atomic operations, and sync.RWMutex." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +In Go, using a Mutex (Mutual Exclusion) is one way to manage access to shared data across multiple goroutines to prevent race conditions, which can lead to data corruption. However, sometimes using a Mutex can cause performance issues and program complexity, and it can hurt performance. So let's try to write code that avoids unnecessary Mutex locks and see how it's done. + + + +## Reasons to Avoid Exclusive Locks +- Deadlock: Occurs when two or more goroutines are waiting for each other to release a misplaced mutex, causing the program to lock up and stop working. +- Livelock: Occurs when two or more goroutines repeatedly release and lock a mutex, preventing any goroutine from making progress. +- Overhead: Locking and unlocking a mutex has a cost in terms of context switching and managing the state of the mutex, which can affect program performance, especially in cases of high contention. +- Complexity: Managing mutexes in complex programs can make the code harder to read and understand. +- Additional: [Dmitry Vyukov — Go scheduler: Implementing language with lightweight concurrency](https://youtu.be/-K11rY57K7k?si=t8vKOjBWpcJ7YwJA) + +## Ways to Avoid Exclusive Locks (as far as I know) +### 1. Use Channels Instead of Mutexes + +Channels are the primary tool for communication between goroutines in Go and can be used instead of mutexes to safely manage data access. + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +func main() { + const ( + items = int[]{1, 2, 3, 4, 5, 6, 7} + producerSleep = 100 * time.Millisecond + consumerSleep = 150 * time.Millisecond + channelBuffer = 2 // The size of the channel buffer for the producer and consumer + ) + + dataChannel := make(chan int, channelBuffer) + var wg sync.WaitGroup + + wg.Add(2) // Create a wait queue for 2 goroutines + + go producer(dataChannel, items, producerSleep, &wg) + go consumer(dataChannel, consumerSleep, &wg) + + wg.Wait() // Wait until both goroutines are done +} + +func veryLongTask(input int, sleepDuration time.Duration) (output int, err error) { + output = input * 2 + time.Sleep(sleepDuration) + return +} + +func producer(ch chan<- int, items []int, sleepDuration time.Duration, wg *sync.WaitGroup) { + defer wg.Done() + for _, item := range items { + // Send to work + output, _ := veryLongTask(item, sleepDuration) + ch <- output // Send data to the channel after the work is done + fmt.Println("Produced:", item) + } + close(ch) // Close the channel after all data has been sent +} + +func consumer(ch <-chan int, sleepDuration time.Duration, wg *sync.WaitGroup) { + defer wg.Done() + for data := range ch { + // Receive data from the channel + fmt.Println("Consumed:", data) + time.Sleep(sleepDuration) + } +} +``` +{{< /admonition >}} + +### 2. Use Lock-Free Data Structures + +Using data structures designed to avoid mutexes, such as `sync.Map`, allows for safe data access without having to manage mutexes yourself. + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + var m sync.Map + + // Store data + m.Store("key1", "value1") + m.Store("key2", "value2") + + // Read data + m.Range(func(key, value interface{}) bool { + fmt.Println(key, value) + return true + }) +} +``` +{{< /admonition >}} + +### 3. Use Atomic Operations + +Go has the `sync/atomic` package, which provides functions for atomic operations (similar to the Atomicity property in databases), allowing for safe incrementing or decrementing of variables from concurrent access by multiple goroutines. + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" + "sync/atomic" +) + +func main() { + var counter int64 + var wg sync.WaitGroup + + for i := 0; i < 1000; i++ { + wg.Add(1) + go func() { + defer wg.Done() + atomic.AddInt64(&counter, 1) // Increment the counter variable + }() + } + + wg.Wait() // Wait for all goroutines to finish + fmt.Println("Final Counter:", counter) +} +``` +{{< /admonition >}} + +### 4. Use a Read/Write Mutex + +If you want to allow multiple goroutines to read data but only one goroutine to write data, and you need to use a mutex, you can use `sync.RWMutex`, which allows for concurrent reads without waiting for writes. + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +type SafeData struct { + mu sync.RWMutex + data int +} + +func (s *SafeData) Read() int { + s.mu.RLock() // Use RLock for reading + defer s.mu.RUnlock() + return s.data +} + +func (s *SafeData) Write(value int) { + s.mu.Lock() // Use Lock for writing + defer s.mu.Unlock() + s.data = value +} + +func main() { + safeData := SafeData{} + + var wg sync.WaitGroup + + // Goroutine for writing data + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 5; i++ { + safeData.Write(i) + fmt.Println("Written:", i) + time.Sleep(100 * time.Millisecond) + } + }() + + // Goroutines for reading data + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Read:", safeData.Read()) + time.Sleep(50 * time.Millisecond) + }() + } + + wg.Wait() // Wait for all goroutines to finish +} +``` +{{< /admonition >}} + +## Conclusion + +Avoiding the use of Mutex locks in Go can be done in several ways, such as using channels, lock-free data structures, atomic operations, and read/write mutexes. Choosing the right method will help improve performance and reduce the complexity of your program, making it run smoothly and safely from concurrent data access (race conditions). + +{{< admonition type=quote title="Andrew Gerrand" >}} +Do not communicate by sharing memory; instead, share memory by communicating. +{{< /admonition >}} + +Additional: [How To Avoid Locks (Mutex) In Your Golang Programs?](https://youtu.be/Ya5KRFrwPug?si=_DaVJYNj3uJGq7nz) diff --git a/content/posts/go/avoid_locks/index.md b/content/posts/go/avoid_locks/index.md new file mode 100644 index 0000000..06096f0 --- /dev/null +++ b/content/posts/go/avoid_locks/index.md @@ -0,0 +1,226 @@ +--- +title: "เขียน Go เรียกใช้ resource เดียวกันแต่ไม่อยากใช้ Exclusive Lock มีทางไหนบ้าง" +subtitle: "" +date: 2024-12-21T18:07:21+07:00 +lastmod: 2024-12-21T18:07:21+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "อธิบายเหตุผลที่ควรหลีกเลี่ยง exclusive mutex lock ใน Go และแนะนำ 4 ทางเลือก: channel, sync.Map, atomic operations และ sync.RWMutex พร้อมตัวอย่างใช้งานจริง" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +ในภาษา Go การใช้ Mutex (Mutual Exclusion) เป็นวิธีการหนึ่งในการจัดการกับการเข้าถึงข้อมูลที่ใช้ร่วมกันในหลายๆ โค้ดหรือ Goroutine เพื่อป้องกันการเกิด Race Conditions ซึ่งอาจทำให้ข้อมูลเสียหายได้ แต่ในบางครั้งการใช้ Mutex ก็อาจทำให้เกิดปัญหาด้านประสิทธิภาพและความซับซ้อนของโปรแกรม และเสีย performance ดังนั้นเราลองมาเขียนโดยการหลีกเลี่ยง Mutex Lock โดยไม่จำเป็นกันดูว่าทำยังไงบ้าง + + + +## เหตุผลที่ควรหลีกเลี่ยง Exclusive Lock +- Deadlock: เกิดขึ้นเมื่อ goroutine สองตัวขึ้นไป รอให้กันและกันปลดล็อก mutex ที่วางผิดที่ผิดทางจะทำให้โปรแกรมติด lock แล้วหยุดทำงาน +- Livelock: เกิดขึ้นเมื่อ goroutine สองตัวขึ้นไปสลับกันปลดล็อกและล็อก mutex ซ้ำๆ ทำให้ไม่มี goroutine ใดสามารถดำเนินการต่อได้ +- Overhead: การล็อกและปลดล็อก mutex มีค่าใช้จ่ายในระหว่าง context switch และการจัดการสถานะของ mutex ซึ่งอาจส่งผลต่อประสิทธิภาพของโปรแกรมโดยเฉพาะอย่างยิ่งในกรณีที่มีการ contention สูง +- ซับซ้อน: การจัดการ mutex ในโปรแกรมที่ซับซ้อนอาจทำให้โค้ดอ่านยากและเข้าใจยากขึ้น +- เพิ่มเติม: [Dmitry Vyukov — Go scheduler: Implementing language with lightweight concurrency](https://youtu.be/-K11rY57K7k?si=t8vKOjBWpcJ7YwJA) + +## วิธีหลีกเลี่ยง Exclusive Lock (เท่าที่ผมรู้) +### 1. ใช้ Channel แทน Mutex + +Channel เป็นเครื่องมือหลักในการสื่อสารระหว่าง Goroutine ใน Go และสามารถใช้แทน Mutex เพื่อจัดการการเข้าถึงข้อมูลได้อย่างปลอดภัย + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +func main() { + const ( + items = int[]{1, 2, 3, 4, 5, 6, 7} + producerSleep = 100 * time.Millisecond + consumerSleep = 150 * time.Millisecond + channelBuffer = 2 // ขนาดของ channel buffer สำหรับ producer กับ consumer + ) + + dataChannel := make(chan int, channelBuffer) + var wg sync.WaitGroup + + wg.Add(2) // สร้างคิวรอ 2 goroutine + + go producer(dataChannel, items, producerSleep, &wg) + go consumer(dataChannel, consumerSleep, &wg) + + wg.Wait() // รอจนกว่า goroutine ทั้ง 2 จะทำงานเสร็จ +} + +func veryLongTask(input int, sleepDuration time.Duration) (output int, err error) { + output = input * 2 + time.Sleep(sleepDuration, sleepDuration) + return +} + +func producer(ch chan<- int, items int[], sleepDuration time.Duration, wg *sync.WaitGroup) { + defer wg.Done() + for item := range items { + // ส่งไปทำงาน + output, _ := veryLongTask(item, sleepDuration) + ch <- output // ทำงานเสร็จส่งข้อมูลเข้า channel + fmt.Println("Produced:", i) + } + close(ch) // ส่งข้อมูลหมดแล้วก็ปิด channel +} + +func consumer(ch <-chan int, sleepDuration time.Duration, wg *sync.WaitGroup) { + defer wg.Done() + for data := range ch { + // รับข้อมูลจาก channel + fmt.Println("Consumed:", data) + time.Sleep(sleepDuration) + } +} +``` +{{< /admonition >}} + +### 2. ใช้ Data Structure ที่ไม่ต้อง Lock + +การใช้ Data Structure ที่ออกแบบมาเพื่อหลีกเลี่ยงการใช้ Mutex เช่น `sync.Map` ช่วยให้เข้าถึงข้อมูลได้อย่างปลอดภัยโดยไม่ต้องควบคุม Mutex เอง + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + var m sync.Map + + // เก็บข้อมูล + m.Store("key1", "value1") + m.Store("key2", "value2") + + // อ่านข้อมูล + m.Range(func(key, value interface{}) bool { + fmt.Println(key, value) + return true + }) +} +``` +{{< /admonition >}} + +### 3. ใช้ Atomic Operations + +Go มีแพคเกจ `sync/atomic` ที่ให้ฟังก์ชันสำหรับการดำเนินการเชิงอะตอมิก ( คุณสมบัติคล้าย ๆ Atomicity ใน Databases อะ ) ซึ่งช่วยให้สามารถทำการเพิ่มหรือลดค่าของตัวแปรได้ปลอดภัยจากการเข้าถึงพร้อมกันจากหลาย ๆ Goroutine + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" + "sync/atomic" +) + +func main() { + var counter int64 + var wg sync.WaitGroup + + for i := 0; i < 1000; i++ { + wg.Add(1) + go func() { + defer wg.Done() + atomic.AddInt64(&counter, 1) // เพิ่มค่าตัวแปร counter + }() + } + + wg.Wait() // รอให้ Goroutine ทุกตัวทำงานเสร็จ + fmt.Println("Final Counter:", counter) +} +``` +{{< /admonition >}} + +### 4. ใช้ Read/Write Mutex + +หากต้องการให้มีการอ่านข้อมูลได้หลายๆ Goroutine แต่ต้องการให้เขียนข้อมูลได้เพียง Goroutine เดียว และมีความจำเป็นต้องใช้ mutex เราสามารถใช้ `sync.RWMutex` ซึ่งช่วยให้การอ่านสามารถทำได้พร้อมๆ กันโดยไม่ต้องรอการเขียน + +{{< admonition example >}} +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +type SafeData struct { + mu sync.RWMutex + data int +} + +func (s *SafeData) Read() int { + s.mu.RLock() // ใช้ RLock สำหรับการอ่าน + defer s.mu.RUnlock() + return s.data +} + +func (s *SafeData) Write(value int) { + s.mu.Lock() // ใช้ Lock สำหรับการเขียน + defer s.mu.Unlock() + s.data = value +} + +func main() { + safeData := SafeData{} + + var wg sync.WaitGroup + + // Goroutine สำหรับเขียนข้อมูล + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 5; i++ { + safeData.Write(i) + fmt.Println("Written:", i) + time.Sleep(100 * time.Millisecond) + } + }() + + // Goroutine สำหรับอ่านข้อมูล + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Read:", safeData.Read()) + time.Sleep(50 * time.Millisecond) + }() + } + + wg.Wait() // รอให้ Goroutine ทุกตัวทำงานเสร็จ +} +``` +{{< /admonition >}} + +## สรุป + +การหลีกเลี่ยงการใช้ Mutex Lock ในภาษา Go สามารถทำได้หลายวิธี เช่น การใช้ Channel, Data Structures ที่ไม่ต้อง Lock, Atomic Operations และ Read/Write Mutex การเลือกใช้วิธีการที่เหมาะสมจะช่วยเพิ่มประสิทธิภาพและลดความซับซ้อนในการเขียนโปรแกรม ซึ่งจะทำให้โปรแกรมทำงานได้อย่างราบรื่นและปลอดภัยจากการเข้าถึงข้อมูลพร้อมกัน ( Race conditions ) + +{{< admonition type=quote title="Andrew Gerrand" >}} +Do not communicate by sharing memory; instead, share memory by communicating. +{{< /admonition >}} + +เพิ่มเติม: [How To Avoid Locks (Mutex) In Your Golang Programs?](https://youtu.be/Ya5KRFrwPug?si=_DaVJYNj3uJGq7nz) \ No newline at end of file diff --git a/content/posts/go/faster_json/index.en.md b/content/posts/go/faster_json/index.en.md new file mode 100644 index 0000000..296484e --- /dev/null +++ b/content/posts/go/faster_json/index.en.md @@ -0,0 +1,137 @@ +--- +title: "Give JSON in Go a Jet Engine" +subtitle: "" +date: 2023-12-11T10:00:40+07:00 +lastmod: 2023-12-11T10:00:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Benchmarks goccy/go-json against Go's standard encoding/json and shows roughly 10x speedups, with a 2025-2026 update on bytedance/sonic and the experimental encoding/json/v2." +license: "" +images: [] +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +tags: ["Go", "JSON"] +categories: ["Go"] + +lightgallery: true + +--- + +A common task when working with REST APIs is converting JSON back and forth between services. Typically, `encoding/json` is used, which is the standard library provided in Go. But now, there's something new to try: `goccy/go-json`, which will make our services faster without any extra cost. + + + +## encoding/json +`encoding/json` is the standard library in Go that can be used to convert data between Go data structures (structs, slices, maps) and the JSON we are all familiar with. + +## goccy/go-json +[goccy/go-json](https://github.com/goccy/go-json) is a library developed to provide high speed and efficiency in handling JSON in Go. It is capable of handling larger data and is [optimized to increase the speed of data conversion with a jet engine](https://github.com/goccy/go-json#how-it-works), while still being compatible with `encoding/json`. + +### Writing the test +We will test by benchmarking the reading of a JSON file (you can expand to see what is being tested). +```go +package main_test + +import ( + "encoding/json" + "os" + "testing" + + // You can actually use "github.com/goccy/go-json" directly + // to replace "encoding/json" + goccy "github.com/goccy/go-json" +) + +func BenchmarkGoSTDUnmarshal(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.ReadFile("file.json") + json.Unmarshal(file, &resp) + } + }) +} + +func BenchmarkGoCcyUnmarshal(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.ReadFile("file.json") + goccy.Unmarshal(file, &resp) + } + }) +} + +func BenchmarkGoSTDDecoder(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.Open("file.json") + defer file.Close() + json.NewDecoder(file).Decode(&resp) + } + }) +} + +func BenchmarkGoCcyDecoder(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.Open("file.json") + defer file.Close() + goccy.NewDecoder(file).Decode(&resp) + } + }) +} +``` + +### Test Results +Test results for reading a small 10KB file. + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkGoSTDUnmarshal-8| 65835| 24631 ns/op| 10872 B/op| 11 allocs/op| +|BenchmarkGoCcyUnmarshal-8| 103197| 10113 ns/op| 20973 B/op| 11 allocs/op| +|BenchmarkGoSTDDecoder-8| 27882| 39565 ns/op| 31440 B/op| 17 allocs/op| +|BenchmarkGoCcyDecoder-8| 1219350| 890.3 ns/op| 863 B/op| 9 allocs/op| + + +Test results for reading a medium 2.9MB file. + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkGoSTDUnmarshal-8| 225| 4509768 ns/op| 2925207 B/op| 11 allocs/op| +|BenchmarkGoCcyUnmarshal-8| 5428| 219836 ns/op| 5849958 B/op| 13 allocs/op| +|BenchmarkGoSTDDecoder-8| 232| 4739039 ns/op| 8387297 B/op| 26 allocs/op| +|BenchmarkGoCcyDecoder-8| 1248626| 890.3 ns/op| 871 B/op| 9 allocs/op| + +Test results for reading a large 26MB file. + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkGoSTDUnmarshal-8| 13| 77841000 ns/op| 26150382 B/op| 16 allocs/op| +|BenchmarkGoCcyUnmarshal-8| 264| 4654911 ns/op| 52298626 B/op| 13 allocs/op| +|BenchmarkGoSTDDecoder-8| 16| 63935862 ns/op| 67107820 B/op| 33 allocs/op| +|BenchmarkGoCcyDecoder-8| 1200399| 877.8 ns/op| 863 B/op| 9 allocs/op| + +## Current landscape (2025-2026) + +The JSON library landscape has kept moving since this benchmark was written: + +- **[bytedance/sonic](https://github.com/bytedance/sonic)** — now generally the fastest option for large payloads. It uses SIMD instructions (inspired by simdjson) and is a drop-in replacement for `encoding/json`. +- **[goccy/go-json](https://github.com/goccy/go-json)** — still a solid, easy drop-in; the benchmarks above remain representative. +- **[encoding/json/v2](https://github.com/golang/go/issues/71707)** — Go's own rewrite. It shipped as an **experimental** preview in Go 1.25 (enable with `GOEXPERIMENT=jsonv2`) and is still not stable in Go 1.26. Once finalized it will narrow the gap with the third-party libraries from inside the standard library. + +Pick by workload: `sonic` for the absolute fastest large-payload path, `goccy/go-json` for a zero-config speedup, and keep an eye on `encoding/json/v2` so you can drop the dependency entirely once it stabilizes. + +You will find that `goccy/go-json` is on average 10X++ more performant than `encoding/json`, especially when using `Encoder/Decoder` instead of `Marshal/Unmarshal`, which is not even comparable. diff --git a/content/posts/go/faster_json/index.md b/content/posts/go/faster_json/index.md new file mode 100644 index 0000000..060b495 --- /dev/null +++ b/content/posts/go/faster_json/index.md @@ -0,0 +1,137 @@ +--- +title: "ติดไอพ่นให้ JSON ใน Go" +subtitle: "" +date: 2023-12-11T10:00:40+07:00 +lastmod: 2023-12-11T10:00:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ทำ benchmark เทียบ goccy/go-json กับ encoding/json ของ standard library พบว่าเร็วขึ้นราว 10 เท่า พร้อมอัปเดตสถานการณ์ 2025-2026 ของ bytedance/sonic และ encoding/json/v2 ที่ยังเป็น experimental" +license: "" +images: [] +featuredImage: "/img/featured-image.webp" +featuredImagePreview: "/img/featured-image.webp" + +tags: ["Go", "JSON"] +categories: ["Go"] + +lightgallery: true + +--- + +สิ่งที่ต้องเจอบ่อย ๆ เวลาทำงานกับ REST API นั่นคือการแปลง JSON ไปมาระหว่าง services โดบปกติแล้วก็จะใช้ `encoding/json` กันซึ่งเป็นไลบรารีมาตรฐานที่มีให้ใน Go แต่ตอนนี้มีของจะมาแนะนำให้ลองกัน นั่นคือ `goccy/go-json` ที่จะทำให้ services เราเร็วขึ้นโดยไม่ต้องจ่ายตังเพิ่ม + + + +## encoding/json +`encoding/json` เป็นไลบรารีมาตรฐานที่มีให้ใน Go ที่สามารถใช้ในการแปลงข้อมูลระหว่างโครงสร้างข้อมูล Go (structs, slices, maps) กับ JSON ที่เราคุ้นเคยกันดี + +## goccy/go-json +[goccy/go-json](https://github.com/goccy/go-json) เป็นไลบรารีที่ถูกพัฒนาขึ้นเพื่อให้ความเร็วและประสิทธิภาพสูงในการจัดการ JSON ใน Go โดยเฉพาะ มีความสามารถในการจัดการกับข้อมูลที่ใหญ่มากขึ้น และมีการ [Optimize เพื่อเพิ่มประสิทธิภาพในการแปลงข้อมูลให้เร็วขึ้นอย่างมาก](https://github.com/goccy/go-json#how-it-works) โดยที่ยัง complatible กับ `encoding/json` อยู่ + +### เขียนการทดสอบ +โดยจะทดสอบด้วยการทำ Benchmark เทียบการอ่านไฟล์ JSON (กดขยายดูได้นะว่าทดสอบไรมั้ง) +```go +package main_test + +import ( + "encoding/json" + "os" + "testing" + + // จริง ๆ ใช้ "github.com/goccy/go-json" เฉย ๆ + // แทนที่ "encoding/json" ได้เลย + goccy "github.com/goccy/go-json" +) + +func BenchmarkGoSTDUnmarshal(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.ReadFile("file.json") + json.Unmarshal(file, &resp) + } + }) +} + +func BenchmarkGoCcyUnmarshal(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.ReadFile("file.json") + goccy.Unmarshal(file, &resp) + } + }) +} + +func BenchmarkGoSTDDecoder(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.Open("file.json") + defer file.Close() + json.NewDecoder(file).Decode(&resp) + } + }) +} + +func BenchmarkGoCcyDecoder(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + resp := make(map[string]interface{}) + file, _ := os.Open("file.json") + defer file.Close() + goccy.NewDecoder(file).Decode(&resp) + } + }) +} +``` + +### ผลการทดสอบ +ผลการทดสอบอ่านไฟล์ small 10KB + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkGoSTDUnmarshal-8| 65835| 24631 ns/op| 10872 B/op| 11 allocs/op| +|BenchmarkGoCcyUnmarshal-8| 103197| 10113 ns/op| 20973 B/op| 11 allocs/op| +|BenchmarkGoSTDDecoder-8| 27882| 39565 ns/op| 31440 B/op| 17 allocs/op| +|BenchmarkGoCcyDecoder-8| 1219350| 890.3 ns/op| 863 B/op| 9 allocs/op| + + +ผลการทดสอบอ่านไฟล์ medium 2.9MB + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkGoSTDUnmarshal-8| 225| 4509768 ns/op| 2925207 B/op| 11 allocs/op| +|BenchmarkGoCcyUnmarshal-8| 5428| 219836 ns/op| 5849958 B/op| 13 allocs/op| +|BenchmarkGoSTDDecoder-8| 232| 4739039 ns/op| 8387297 B/op| 26 allocs/op| +|BenchmarkGoCcyDecoder-8| 1248626| 890.3 ns/op| 871 B/op| 9 allocs/op| + +ผลการทดสอบอ่านไฟล์ large 26MB + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkGoSTDUnmarshal-8| 13| 77841000 ns/op| 26150382 B/op| 16 allocs/op| +|BenchmarkGoCcyUnmarshal-8| 264| 4654911 ns/op| 52298626 B/op| 13 allocs/op| +|BenchmarkGoSTDDecoder-8| 16| 63935862 ns/op| 67107820 B/op| 33 allocs/op| +|BenchmarkGoCcyDecoder-8| 1200399| 877.8 ns/op| 863 B/op| 9 allocs/op| + +## สถานการณ์ปัจจุบัน (2025-2026) + +วงการ JSON library เคลื่อนไหวต่อเนื่องตั้งแต่ที่เขียน benchmark นี้: + +- **[bytedance/sonic](https://github.com/bytedance/sonic)** — ตอนนี้เร็วที่สุดโดยทั่วไปสำหรับ payload ขนาดใหญ่ ใช้ SIMD instructions (ได้แรงบันดาลใจจาก simdjson) เป็น drop-in replacement ของ `encoding/json` +- **[goccy/go-json](https://github.com/goccy/go-json)** — ยังเป็นตัวเลือก drop-in ที่ดี ใช้ง่าย benchmark ด้านบนยังเป็นตัวแทนที่เชื่อถือได้ +- **[encoding/json/v2](https://github.com/golang/go/issues/71707)** — การเขียนใหม่ของ Go เอง ออกมาเป็นตัว **experimental** ใน Go 1.25 (เปิดด้วย `GOEXPERIMENT=jsonv2`) และยังไม่ stable ใน Go 1.26 เมื่อเสร็จสมบูรณ์จะช่วยลดช่องว่างกับ library ของ third-party จากภายใน standard library เลย + +เลือกตามลักษณะงาน: `sonic` สำหรับ path ที่เร็วที่สุดบน payload ขนาดใหญ่, `goccy/go-json` สำหรับ speed up แบบ zero-config และติดตาม `encoding/json/v2` เพื่อจะได้ถอด dependency ออกได้เลยเมื่อมัน stable + +จะพบว่า `goccy/go-json` จะประสิทธิภาพดีกว่า `encoding/json` เฉลี่ยที่ 10X++ เลยทีเดียว โดยเฉพาะการใช้ `Encoder/Decoder` แทนการใช้ `Marshal/Unmarshal` ที่สามารถเรียกได้ว่าเทียบกันกันไม่ติดเลย \ No newline at end of file diff --git a/content/posts/go/go_kube_resource_limits/featured-image.webp b/content/posts/go/go_kube_resource_limits/featured-image.webp new file mode 100644 index 0000000..69204a1 Binary files /dev/null and b/content/posts/go/go_kube_resource_limits/featured-image.webp differ diff --git a/content/posts/go/go_kube_resource_limits/index.en.md b/content/posts/go/go_kube_resource_limits/index.en.md new file mode 100644 index 0000000..97b5db4 --- /dev/null +++ b/content/posts/go/go_kube_resource_limits/index.en.md @@ -0,0 +1,69 @@ +--- +title: "GOMAXPROCS, GOMEMLIMIT and Kubernetes" +subtitle: "" +date: 2024-03-13T00:45:21+07:00 +lastmod: 2024-03-13T00:45:21+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "How Go's GOMAXPROCS (with automaxprocs) and GOMEMLIMIT align with Kubernetes CPU and memory limits so the runtime stays within resource boundaries." +license: "" +images: [] + +tags: ["Go", "kubernetes", "DevOps"] +categories: ["Go"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +The [Go runtime](https://pkg.go.dev/runtime) has a few environment variables that can be configured, but there are two that DevOps folks often use as a starting point: **GOMAXPROCS** and **GOMEMLIMIT**. Let's take a look at what each of them does. + + +## Recap +From the [Go runtime](https://pkg.go.dev/runtime) documentation: + +### GOMEMLIMIT (Go 1.19+) +{{< admonition note "Note" >}} +The `GOMEMLIMIT` variable sets a soft memory limit for the runtime. This memory limit includes the Go heap and all other memory managed by the runtime, and excludes external memory sources such as mappings of the binary itself, memory managed in other languages, and memory held by the operating system on behalf of the Go program. `GOMEMLIMIT` is a numeric value in bytes with an optional unit suffix. The supported suffixes include B, KiB, MiB, GiB, and TiB. These suffixes represent quantities of bytes as defined by the IEC 80000-13 standard. That is, they are based on powers of two: KiB means 2^10 bytes, MiB means 2^20 bytes, and so on. The default setting is [math.MaxInt64](https://pkg.go.dev/runtime/internal/math#MaxInt64), which effectively disables the memory limit. [runtime/debug.SetMemoryLimit](https://pkg.go.dev/runtime/debug#SetMemoryLimit) allows changing this limit at run time. +{{< /admonition >}} + +This is the value that tells the Go runtime how much memory we have available. The default is to not enable the memory limit. + +### GOMAXPROCS +{{< admonition note "Note" >}} +The `GOMAXPROCS` variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the `GOMAXPROCS` limit. This package's [GOMAXPROCS](https://pkg.go.dev/runtime#GOMAXPROCS) function queries and changes the limit. +{{< /admonition >}} + +From the above and **([GOMAXPROCS, a good friend of DevOps]({{< ref "/posts/go/automaxprocs" >}} "GOMAXPROCS, a good friend of DevOps"))**, a key point to note is that if we set a pod's CPU limit to **1 CPU** but it's running on a machine with **16 cores**, our app will default to `GOMAXPROCS=16`. This can lead to unintentional performance degradation when the workload exceeds the limit. + +## Configuration when working with Kubernetes +The previous article introduced the [automaxprocs](https://github.com/uber-go/automaxprocs) package, which saves us from having to manually adjust `GOMAXPROCS`. However, we still need to manually adjust `GOMEMLIMIT`. When we deploy in Kubernetes, we can use the value from `resourceFieldRef` as shown in the example below. + +```yaml +... + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 1000m + memory: 512Mi + env: + - name: GOMEMLIMIT + valueFrom: + resourceFieldRef: + resource: limits.memory + - name: GOMAXPROCS + valueFrom: + resourceFieldRef: + resource: limits.cpu +``` + +In this example, the values will be calculated and put into the `GOMAXPROCS` and `GOMEMLIMIT` environment variables for our app as follows: +```yaml +GOMAXPROCS: 1 +GOMEMLIMIT: 536870912 +``` +These set values will be used by the Go runtime automatically. The Go runtime uses a `0` (zero value) as the default when no value is passed. This means that even if we don't set resource limits for the Pod, the Go runtime will fall back to its default values, so we don't have to worry about the service failing to run. diff --git a/content/posts/go/go_kube_resource_limits/index.md b/content/posts/go/go_kube_resource_limits/index.md new file mode 100644 index 0000000..22f4d6c --- /dev/null +++ b/content/posts/go/go_kube_resource_limits/index.md @@ -0,0 +1,70 @@ +--- +title: "GOMAXPROCS, GOMEMLIMIT กับ Kubernetes" +subtitle: "" +date: 2024-03-13T00:45:21+07:00 +lastmod: 2024-03-13T00:45:21+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "วิธีที่ GOMAXPROCS (พร้อม automaxprocs) และ GOMEMLIMIT ของ Go ทำงานร่วมกับขีดจำกัด CPU และ memory ของ Kubernetes เพื่อให้ runtime อยู่ในกรอบของ resource" +license: "" +images: [] + +tags: ["Go", "kubernetes", "DevOps"] +categories: ["Go"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +[Go runtime](https://pkg.go.dev/runtime) มีค่า ENV ที่สามารถตั้งค่าได้อยู่ไม่กี่ตัว แต่มันจะมีอยู่ 2 ค่าที่ชาว DevOps จะเอามาใช้เป็นค่าเริ่มต้นเลย คือ **GOMAXPROCS** และ **GOMEMLIMIT** โดยแต่ละค่าจะใช้ทำอะไรได้บ้างเรามาลองดูกัน + + +## Recap +จาก [Go runtime](https://pkg.go.dev/runtime) ได้อธิบายไว้ว่า + +### GOMEMLIMIT (Go 1.19+) +{{< admonition note "Note" >}} +The `GOMEMLIMIT` variable sets a soft memory limit for the runtime. This memory limit includes the Go heap and all other memory managed by the runtime, and excludes external memory sources such as mappings of the binary itself, memory managed in other languages, and memory held by the operating system on behalf of the Go program. `GOMEMLIMIT` is a numeric value in bytes with an optional unit suffix. The supported suffixes include B, KiB, MiB, GiB, and TiB. These suffixes represent quantities of bytes as defined by the IEC 80000-13 standard. That is, they are based on powers of two: KiB means 2^10 bytes, MiB means 2^20 bytes, and so on. The default setting is [math.MaxInt64](https://pkg.go.dev/runtime/internal/math#MaxInt64), which effectively disables the memory limit. [runtime/debug.SetMemoryLimit](https://pkg.go.dev/runtime/debug#SetMemoryLimit) allows changing this limit at run time. +{{< /admonition >}} + +ซึ่งก็คือค่าที่บอก Go runtime ว่าเรามี memory ให้ใช้งานเท่าไหร่ ซึ่งค่า default จะเป็นไม่เปิดใช้ memory limit นั่นเอง + +### GOMAXPROCS +{{< admonition note "Note" >}} +The `GOMAXPROCS` variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the `GOMAXPROCS` limit. This package's [GOMAXPROCS](https://pkg.go.dev/runtime#GOMAXPROCS) function queries and changes the limit. +{{< /admonition >}} + +จากข้างต้นและ **([GOMAXPROCS เพื่อนที่ดีของ DevOps]({{< ref "/posts/go/automaxprocs" >}} "GOMAXPROCS เพื่อนที่ดีของ DevOps"))** แล้วจะมีจุดสังเกตุว่าถ้าเราตั้ง limit cpu ของ pod เป็น **1 CPU** แต่ทำงานอยู่บนเครื่องที่มี CPU **16 core** ค่าเริ่มต้นของแอปเราจะทำงานด้วย `GOMAXPROCS=16` ส่งผลให้ performance ตกแบบไม่ตั้งใจเมื่อมีการทำงานที่เกิน limit + +## การตั้งค่าเมื่อทำงานกับ kubernetes +จากบทความก่อนหน้าได้แนะนำ package [automaxprocs](https://github.com/uber-go/automaxprocs) ไป ซึ่งทำให้เราไม่ต้องมานั่งปรับค่า `GOMAXPROCS` เอง แต่ว่าตอนนี้เรายังต้องมานั่งปรับ `GOMEMLIMIT` เองอยู่ ซึ่งพอเรา deploy ใน kube เราสามารถใช้ค่าจาก `resourceFieldRef` ได้ ด้วยตัวอย่างด้านล่างนี้ + +```yaml +... + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 1000m + memory: 512Mi + env: + - name: GOMEMLIMIT + valueFrom: + resourceFieldRef: + resource: limits.memory + - name: GOMAXPROCS + valueFrom: + resourceFieldRef: + resource: limits.cpu +``` + +ซึ่งค่าตัวอย่าง ในแอปเราจะโดนคำนวนเอาไปใส่ใน ENV `GOMAXPROCS` และ `GOMEMLIMIT` ให้เลยตามนี้ +```yaml +GOMAXPROCS: 1 +GOMEMLIMIT: 536870912 +``` +ซึ่งค่าที่ตั้งเข้าไปจะถูกนำไปใช้กับ Go runtime ให้เอง โดย Go runtime จะใช้ค่า `0` +(zero value) เป็น default เมื่อไม่ได้มีการส่งค่าเข้าไป ทำให้ถึงเราจะไม่ได้ตั้ง resources limit ให้กับ Pod ไว้ Go runtime ก็จะ fallback ไปใช้ค่า default ให้เอง เราก็ไม่ต้องกลัวว่า service จะรันไม่ขึ้น \ No newline at end of file diff --git a/content/posts/go/messging_with_nats/featured-image.jpg b/content/posts/go/messging_with_nats/featured-image.jpg new file mode 100644 index 0000000..e60d6df Binary files /dev/null and b/content/posts/go/messging_with_nats/featured-image.jpg differ diff --git a/content/posts/go/messging_with_nats/index.en.md b/content/posts/go/messging_with_nats/index.en.md new file mode 100644 index 0000000..fb194ac --- /dev/null +++ b/content/posts/go/messging_with_nats/index.en.md @@ -0,0 +1,188 @@ +--- +title: "Simple Messaging with Go + NATS" +subtitle: "" +date: 2024-09-28T10:55:17+07:00 +lastmod: 2024-09-28T10:55:17+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Walks through building a simple Go publisher and subscriber with NATS — Docker setup, graceful shutdown with OS signals, and a note on enabling JetStream for durability." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +While waiting for a friend at a coffee shop, I had some free time and it occurred to me that we travel with asynchronous tasks. Whoever leaves first, does their work first, and arrives first. In the meantime, others are on their own journey, and eventually, we all meet at the destination. So, I decided to write a simple Go + NATS Pub/Sub to document how it's done. + + +# Prerequisites + +## Go +Since we're writing in Go, you can install it from the official Go website: [Download and install](https://go.dev/doc/install) + +## Docker +For ease of use with NATS, let's also install Docker. +```bash +curl https://get.docker.com | bash +``` + +## NATS +Next up is NATS, which will be the intermediary for our messaging. We'll open a channel on `port 4222` by default, and also `8222` in case you want to send API requests for monitoring or other purposes. Here's the `compose.yaml` file: +```yaml +services: + nats: + image: nats + container_name: nats-server + command: ["--http_port", "8222"] + networks: + - nats-net + ports: + - 4222:4222 + - 8222:8222 + +networks: + nats-net: + external: false + name: nats-net +``` + +# Let's start coding + +## Publisher +Let's start with the job creator, the `publisher.go`. It will publish announcements on the `messages` topic every 5 seconds using a time Ticker until it's told to stop. +```go +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "log" + "time" + + "github.com/nats-io/nats.go" +) + +func main() { + // Create a channel to listen for OS signals + closeChan := make(chan os.Signal, 1) + + // Notify the channel when SIGINT (Ctrl+C) or SIGTERM is received + signal.Notify(closeChan, os.Interrupt, syscall.SIGTERM) + + // Create a ticker that ticks every 5 seconds + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Connect to NATS server + nc, err := nats.Connect(nats.DefaultURL) + if err != nil { + log.Fatalf("Error connecting to NATS server: %v", err) + } + defer nc.Close() + + fmt.Println("Press Ctrl+C to exit...") + + // Publish messages every 5 seconds until terminate signal + for { + select { + case <-ticker.C: + // Publish messages every 5 seconds + msg := fmt.Sprintf("Publishing %d", time.Now().Unix()) + err := nc.Publish("messages", []byte(msg)) + if err != nil { + log.Fatalf("Error publishing message: %v", err) + } + fmt.Printf("Published: %s\n", msg) + case sig := <-closeChan: + // This block executes when a termination signal is received + fmt.Printf("Received signal: %s. Exiting...\n", sig) + return // Exit the loop and terminate the program + } + } +} +``` + +## Subscriber +The slave worker, `subscriber.go`, waits to receive jobs by subscribing to the `messages` topic until it's told to stop. + +```go +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "log" + + "github.com/nats-io/nats.go" +) + +func main() { + // Create a channel to listen for OS signals + closeChan := make(chan os.Signal, 1) + + // Notify the channel when SIGINT (Ctrl+C) or SIGTERM is received + signal.Notify(closeChan, os.Interrupt, syscall.SIGTERM) + + // Connect to NATS server + nc, err := nats.Connect(nats.DefaultURL) + if err != nil { + log.Fatalf("Error connecting to NATS server: %v", err) + } + defer nc.Close() + + // Subscribe to the "messages" subject + _, err = nc.Subscribe("messages", func(msg *nats.Msg) { + fmt.Printf("Received message: %s\n", string(msg.Data)) + }) + if err != nil { + log.Fatalf("Error subscribing to subject: %v", err) + } + + // This block executes when a termination signal is received + sig := <-closeChan + fmt.Printf("Received signal: %s. Exiting...\n", sig) + return // Exit the loop and terminate the program +} +``` + +# Putting it all together + +## Start NATS +```bash +docker compose up -d +``` + +## Start Publisher +```bash +go run publisher.go +``` + +## Start Subscriber +```bash +go run subscriber.go +``` + +### +Now, just watch the console for the results every 5 seconds until you terminate the process. + +## A note on durability + +The example above uses **NATS Core**, which delivers messages at-most-once with no persistence — if no subscriber is connected when a message is published, it is lost. When you need durability (replay, acknowledgments, exactly-once), enable **JetStream**, which is built directly into `nats-server`: + +```go +js, err := nc.JetStream() +// create a stream, then publish/subscribe against js instead of nc +``` + +JetStream also provides a Key-Value store and an Object store on top of the same persistence layer. The older NATS Streaming (STAN) is deprecated; use JetStream for any new work. diff --git a/content/posts/go/messging_with_nats/index.md b/content/posts/go/messging_with_nats/index.md new file mode 100644 index 0000000..da9ba70 --- /dev/null +++ b/content/posts/go/messging_with_nats/index.md @@ -0,0 +1,188 @@ +--- +title: "Messaging อย่างง่าย Go + NATS" +subtitle: "" +date: 2024-09-28T10:55:17+07:00 +lastmod: 2024-09-28T10:55:17+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "พาทำ publisher และ subscriber แบบง่าย ๆ ด้วย Go + NATS ตั้งแต่ติดตั้ง NATS ผ่าน Docker, graceful shutdown ด้วย OS signals ไปจนถึงทิ้งท้ายเรื่อง JetStream สำหรับความคงทนถาวร" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +ระหว่างที่รอเพื่อนร่วมทางที่ร้านกาแฟ พอมีเวลาว่างเลยนึกขึ้นได้ว่าเราเดินทางกับแบบ asynchronous task เลยนี่หว่า ใครออกมาก่อนทำก่อนถึงก่อน ในระหว่างที่คนอื่นก็เดินทางของตัวเองไป แล้วสุดท้ายมาเจอกันปลายทาง ก็เลยลองเขียน Go + NATS ทำ Pub/Sub แบบง่าย ๆ บันทึกไว้ว่ามันทำยังไง + + +# ของที่ต้องมีก่อนจะเริ่ม + +## Go +ก็เขียนด้วย Go ละนะ Install ได้ตาม official ของ Go ได้เลยจ้า [Download and install](https://go.dev/doc/install) + +## Docker +เพื่อความง่ายในการใช้ NATS ก็ลง Docker ไว้กันด้วยนะ +```bash +curl https://get.docker.com | bash +``` + +## NATS +ต่อมาที่ NATS ที่จะใช้เป็นตัวกลางของ Messaging ของเราโดยจะเปิดช่องทำงานผ่าน `port 4222` by default ละก็ 8222 เปิดไว้เผื่ออยากยิง API เข้าไปเช็ค monitoring อะไรก็ว่ากันไป ด้วยไฟล์ `compose.yaml` +```yaml +services: + nats: + image: nats + container_name: nats-server + command: ["--http_port", "8222"] + networks: + - nats-net + ports: + - 4222:4222 + - 8222:8222 + +networks: + nats-net: + external: false + name: nats-net +``` + +# เริ่ม coding + +## Publisher +เริ่มจากคนสร้างงานสร้างอาชีพก่อนเลยคือ `publisher.go` เป็นคนลงประกาศ Publish ผ่านทางหัวข้อ `messages` ทุก ๆ 5 วินาทีด้วย time Ticker เรื่อย ๆ จนกว่าได้โดนสั่งให้หยุด +```go +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "log" + "time" + + "github.com/nats-io/nats.go" +) + +func main() { + // Create a channel to listen for OS signals + closeChan := make(chan os.Signal, 1) + + // Notify the channel when SIGINT (Ctrl+C) or SIGTERM is received + signal.Notify(closeChan, os.Interrupt, syscall.SIGTERM) + + // Create a ticker that ticks every 5 seconds + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Connect to NATS server + nc, err := nats.Connect(nats.DefaultURL) + if err != nil { + log.Fatalf("Error connecting to NATS server: %v", err) + } + defer nc.Close() + + fmt.Println("Press Ctrl+C to exit...") + + // Publish messages every 5 seconds until terminate signal + for { + select { + case <-ticker.C: + // Publish messages every 5 seconds + msg := fmt.Sprintf("Publishing %d", i) + err := nc.Publish("messages", []byte(msg)) + if err != nil { + log.Fatalf("Error publishing message: %v", err) + } + fmt.Printf("Published: %s\n", msg) + case sig := <-closeChan: + // This block executes when a termination signal is received + fmt.Printf("Received signal: %s. Exiting...\n", sig) + return // Exit the loop and terminate the program + } + } +} +``` + +## Subscriber +แรงงานทาส `subscriber.go` ที่รอรับงานผ่านการ subscribe หัวข้อ `messages` เรื่อย ๆ จนกว่าได้โดนสั่งให้หยุด + +```go +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "log" + + "github.com/nats-io/nats.go" +) + +func main() { + // Create a channel to listen for OS signals + closeChan := make(chan os.Signal, 1) + + // Notify the channel when SIGINT (Ctrl+C) or SIGTERM is received + signal.Notify(closeChan, os.Interrupt, syscall.SIGTERM) + + // Connect to NATS server + nc, err := nats.Connect(nats.DefaultURL) + if err != nil { + log.Fatalf("Error connecting to NATS server: %v", err) + } + defer nc.Close() + + // Subscribe to the "messages" subject + _, err = nc.Subscribe("messages", func(msg *nats.Msg) { + fmt.Printf("Received message: %s\n", string(msg.Data)) + }) + if err != nil { + log.Fatalf("Error subscribing to subject: %v", err) + } + + // This block executes when a termination signal is received + sig := <-closeChan: + fmt.Printf("Received signal: %s. Exiting...\n", sig) + return // Exit the loop and terminate the program +} +``` + +# รวมร่าง + +## Start NATS +```bash +docker compose up -d +``` + +## Start Pubplisher +```bash +go run publisher.go +``` + +## Start Subscriber +```bash +go run subscriber.go +``` + +### +จบรอดูผลงานผ่าน console ทุก ๆ 5 วินาทีจนกว่าจะสั่ง terminate + +## ทิ้งท้ายเรื่องความคงทนถาวร + +ตัวอย่างด้านบนใช้ **NATS Core** ซึ่งส่งข้อความแบบ at-most-once ไม่มีการเก็บข้อความ — ถ้าตอนที่ publish ไม่มี subscriber อยู่ ข้อความจะหายไป เมื่อต้องการความคงทนถาวร (replay, acknowledgment, exactly-once) ให้เปิดใช้ **JetStream** ที่ฝังมาใน `nats-server` โดยตรง: + +```go +js, err := nc.JetStream() +// สร้าง stream แล้ว publish/subscribe ผ่าน js แทน nc +``` + +JetStream ยังมี Key-Value store และ Object store อยู่บน persistence layer ตัวเดียวกันด้วย ส่วน NATS Streaming (STAN) ตัวเก่าเลิกพัฒนาแล้ว งานใหม่ให้ใช้ JetStream \ No newline at end of file diff --git a/content/posts/go/readall_vs_buffer/featured-image.webp b/content/posts/go/readall_vs_buffer/featured-image.webp new file mode 100644 index 0000000..da8859d Binary files /dev/null and b/content/posts/go/readall_vs_buffer/featured-image.webp differ diff --git a/content/posts/go/readall_vs_buffer/index.en.md b/content/posts/go/readall_vs_buffer/index.en.md new file mode 100644 index 0000000..1240674 --- /dev/null +++ b/content/posts/go/readall_vs_buffer/index.en.md @@ -0,0 +1,95 @@ +--- +title: "Go: io.ReadAll vs io.Copy" +subtitle: "" +date: 2023-12-10T18:33:40+07:00 +lastmod: 2023-12-10T21:33:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Compares io.ReadAll and io.Copy in Go, with benchmarks across small, medium, and large JSON files showing io.Copy is roughly 40% faster on average." +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Buffer"] +categories: ["Go"] + +lightgallery: true + +--- + +When writing Go, we often encounter situations where we need to read files or responses from API calls. Generally, `io.ReadAll` and `io.Copy` are used for this. This post will explore the differences between them. + + + +## io.ReadAll +`io.ReadAll` is a function used to read all data from a Reader and return it as a byte slice (`[]byte`) of a fixed size containing all the data read into memory. Using `io.ReadAll` might be suitable for reading small amounts of data because you get a byte slice to work with directly. However, since it loads all the data into memory, it's not suitable for reading large files. +### Example +```go +file, _ := os.OpenFile("small-file.json", os.O_RDONLY, 0664) +defer file.Close() +bodyBytes, err := io.ReadAll(file) +``` + +## io.Copy +`io.Copy` is used for copying data from a Reader to a Writer without needing to store all the data in memory. When data is received from the Reader, it copies it to the Writer in chunks, allowing it to handle very large amounts of data without burdening memory. +### Example +```go +file, _ := os.OpenFile("small-file.json", os.O_RDONLY, 0664) +defer file.Close() +bytesBuff := new(bytes.Buffer) +_,err := io.Copy(bytesBuff, file) +bodyBytes := bytesBuff.Bytes() +``` + +## Performance Comparison + +### Writing the test +We will test by benchmarking the reading of a JSON file. +```go +import ( + "os" + "bytes" + "testing" +) + +func BenchmarkReadAll(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + file, _ := os.Open("file.json") + defer file.Close() + io.ReadAll(file) + } + }) +} + +func BenchmarkCopy(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + file, _ := os.Open("file.json") + defer file.Close() + bytesBuff := new(bytes.Buffer) + io.Copy(bytesBuff, file) + } + }) +} +``` + +### Test Results +Test results for reading small 10KB, medium 2.9MB, and large 26MB files. + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkReadAllSmall-8| 45658| 24383 ns/op| 46296 B/op| 14 allocs/op| +|BenchmarkCopySmall-8| 64654| 16235 ns/op| 30938 B/op| 11 allocs/op| +|BenchmarkReadAllMedium-8| 1510| 734884 ns/op| 16792061 B/op| 37 allocs/op| +|BenchmarkCopyMedium-8| 3433| 333917 ns/op| 8388372 B/op| 20 allocs/op| +|BenchmarkReadAllLarge-8| 171| 6335655 ns/op| 160741794 B/op| 46 allocs/op| +|BenchmarkCopyLarge-8| 237| 7064719 ns/op| 67108578 B/op| 22 allocs/op| + +We can see that `io.Copy` is, on average, about 40% more performant than `io.ReadAll`. However, this comes at the cost of writing more code to create a buffer to receive the data, which might not appeal to the lazy programmer. diff --git a/content/posts/go/readall_vs_buffer/index.md b/content/posts/go/readall_vs_buffer/index.md new file mode 100644 index 0000000..acfe942 --- /dev/null +++ b/content/posts/go/readall_vs_buffer/index.md @@ -0,0 +1,95 @@ +--- +title: "GO: io.ReadAll vs io.Copy" +subtitle: "" +date: 2023-12-10T18:33:40+07:00 +lastmod: 2023-12-10T21:33:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เปรียบเทียบ io.ReadAll กับ io.Copy ใน Go พร้อมผล benchmark ไฟล์ JSON ขนาดเล็ก กลาง ใหญ่ พบว่า io.Copy เร็วกว่าเฉลี่ยราว 40%" +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Buffer"] +categories: ["Go"] + +lightgallery: true + +--- + +การเขียน Go บ่อยครั้งเราจะเจอการที่เราต้องเปิดอ่านไฟล์หรืออ่าน Response จากการดึง API ซึ่งส่วนใหญ่ โดยทั่วไปแล้วก็จะใช้ io.ReadAll และ io.Copy กัน ตอนนี้จะพาไปดูว่ามันแตกต่างกันอย่างไร + + + +## io.ReadAll +`io.ReadAll` เป็นฟังก์ชันที่ใช้สำหรับอ่านข้อมูลจากที่อ่านได้ (Readers) ทั้งหมดและส่งกลับข้อมูลในรูปแบบของ byte slice (เช่น []byte) ที่มีขนาดคงที่ของข้อมูลทั้งหมดที่ถูกอ่านออกมาในหน่วยความจำ โดยการใช้ `io.ReadAll` อาจเหมาะสำหรับการอ่านข้อมูลที่มีขนาดเล็ก เพราะจะได้ byte slice มาใช้งานเลย แต่เนื่องจากการโยนข้อมูลทั้งหมดเก็บไว้ในหน่วยความจำ ทำให้ไม่เหมาะกับการอ่านข้อมูลขนาดใหญ่ +### ตัวอย่าง +```go +file, _ := os.OpenFile("small-file.json", os.O_RDONLY, 0664) +defer file.Close() +bodyBytes, err := io.ReadAll(file) +``` + +## io.Copy +`io.Copy` ใช้สำหรับการคัดลอกข้อมูลจาก Reader ไปยัง Writer โดยไม่จำเป็นต้องเก็บข้อมูลทั้งหมดในหน่วยความจำ เมื่อมีข้อมูลถูกส่งมาจาก Reader มันจะทำการคัดลอกข้อมูลนั้นไปยัง Writer ทีละส่วน ซึ่งทำให้สามารถทำงานกับข้อมูลที่มีขนาดใหญ่มาก ๆ ได้โดยไม่เป็นภาระต่อหน่วยความจำ +### ตัวอย่าง +```go +file, _ := os.OpenFile("small-file.json", os.O_RDONLY, 0664) +defer file.Close() +bytesBuff := new(bytes.Buffer) +_,err := io.Copy(bytesBuff, file) +bodyBytes := bytesBuff.Bytes() +``` + +## เปรียบเทียบการทำงาน + +### เขียนการทดสอบ +โดยจะทดสอบด้วยการทำ Benchmark เทียบการอ่านไฟล์ JSON +```go +import ( + "os" + "bytes" + "testing" +) + +func BenchmarkReadAll(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + file, _ := os.Open("file.json") + defer file.Close() + io.ReadAll(file) + } + }) +} + +func BenchmarkCopy(b *testing.B) { + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + file, _ := os.Open("file.json") + defer file.Close() + bytesBuff := new(bytes.Buffer) + io.Copy(bytesBuff, file) + } + }) +} +``` + +### ผลการทดสอบ +ผลการทดสอบอ่านไฟล์ small 10KB, medium 2.9MB, large 26MB + +|Name|Loops Executed|Time Taken per Iteration|Bytes Allocated per Operation|Allocations per Operation| +|---|---|---|---|---| +|BenchmarkReadAllSmall-8| 45658| 24383 ns/op| 46296 B/op| 14 allocs/op| +|BenchmarkCopySmall-8| 64654| 16235 ns/op| 30938 B/op| 11 allocs/op| +|BenchmarkReadAllMedium-8| 1510| 734884 ns/op| 16792061 B/op| 37 allocs/op| +|BenchmarkCopyMedium-8| 3433| 333917 ns/op| 8388372 B/op| 20 allocs/op| +|BenchmarkReadAllLarge-8| 171| 6335655 ns/op| 160741794 B/op| 46 allocs/op| +|BenchmarkCopyLarge-8| 237| 7064719 ns/op| 67108578 B/op| 22 allocs/op| + +จะพบว่า `io.Copy` จะประสิทธิภาพดีกว่า `io.ReadAll` เฉลี่ยที่ 40% เลยทีเดียว แต่ก็แลกมากับการที่ต้องเขียนโค้ดเพิ่ม Buffer มารับข้อมูล ซึ่งอาจจะไม่ถูกจริตสายขี้เกียจสักเท่าไหร่ \ No newline at end of file diff --git a/content/posts/go/rust_ffi/featured-image.webp b/content/posts/go/rust_ffi/featured-image.webp new file mode 100644 index 0000000..be2b6b6 Binary files /dev/null and b/content/posts/go/rust_ffi/featured-image.webp differ diff --git a/content/posts/go/rust_ffi/index.en.md b/content/posts/go/rust_ffi/index.en.md new file mode 100644 index 0000000..5154f47 --- /dev/null +++ b/content/posts/go/rust_ffi/index.en.md @@ -0,0 +1,107 @@ +--- +title: "Why Go vs Rust When You Can Have Go + Rust?" +subtitle: "" +date: 2023-10-13T15:38:13+07:00 +lastmod: 2023-10-13T15:38:13+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Calling Rust from Go via FFI" +license: "" +images: [] + +tags: ["Go", "Rust"] +categories: ["Go", "Rust"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- + +We often encounter the question of whether to use Go or Rust. After trying both Go and Rust for a while, I found that we can use cgo + Rust FFI. Since both have different advantages, we can just use both! + + + +# What is Rust FFI? +Rust FFI (Foreign Function Interface) is a tool that allows us to call functions and code in Rust from other languages such as C, C++, and Go smoothly and safely. + +# Let's try it out +- Prerequisites + - Install Rust from the [official website](https://www.rust-lang.org/tools/install) + - Install Go from the [official website](https://go.dev/dl/) + +## Create a Go Project +```md +go-rust-ffi +├── lib <--- Rust lib +│   └── rs-add +│      ├── src +│      │   └── lib.rs <--- Rust lib src +│      ├── build.rs <--- Rust build script +│      ├── Cargo.lock +│      └── Cargo.toml +├── go.mod +├── LICENSE +├── main.go <--- Go file +├── Makefile <--- Make script +└── README.md +``` + +In `lib`, we can use `cargo` to create a Rust project for us. +```bash +cd lib +cargo new --lib rs-add +``` + +We'll start with something simple like this in the `src/lib.rs` file: +```rust +#[no_mangle] +pub extern "C" fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +Then we use `cbindgen` to generate a header file for us (or you can write it by hand). +```bash +cargo install cbindgen +cbindgen --lang c --output rs-add.h +``` + +Let's build the Rust lib. +```bash +cargo build --release +``` +We will get `librs_add.so` and `librs_add.a`. For now, let's move them to `./lib` to avoid confusion. + +In `main.go`, we can call it via cgo like this: +```go +package main + +/* +#cgo LDFLAGS: -L./lib -l:librs_add.so +#include "./lib/rs-add.h" +*/ +import "C" +import "fmt" + +func main() { + a := 10 + b := 20 + result := int(C.add(C.int(a), C.int(b))) + fmt.Printf("Result: %d\n", result) +} +``` + +In the Go code, we use the `import "C"` statement to call the Rust function via FFI. This will use the header file to specify the `add` function and link the Rust lib to our Go code. + +When building Go, we also need to include the lib. +```bash +CURRENT_DIR=$(pwd || echo ${PWD}) +go build -ldflags="-r $(CURRENT_DIR)lib" -o dist/ ./... +``` + +## Conclusion +Now that we know about Rust FFI and how we can use Rust in our Go code via FFI + cgo to make Go and Rust communicate, we don't have to argue about whether to use Go or Rust anymore. We can take advantage of both, for example, using Go for the Router/Thread controller and Rust to help with hot functions. I have created an example that generates a QR code with Rust at [go-rust-ffi](https://github.com/bouroo/go-rust-ffi). You can go and play with it. + +``` \ No newline at end of file diff --git a/content/posts/go/rust_ffi/index.md b/content/posts/go/rust_ffi/index.md new file mode 100644 index 0000000..f31b862 --- /dev/null +++ b/content/posts/go/rust_ffi/index.md @@ -0,0 +1,105 @@ +--- +title: "จะ Go vs Rust ทำไม ในเมื่อ Go + Rust ได้" +subtitle: "" +date: 2023-10-13T15:38:13+07:00 +lastmod: 2023-10-13T15:38:13+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เรียกใช้ Rust จาก Go ผ่าน FFI" +license: "" +images: [] + +tags: ["Go", "Rust"] +categories: ["Go", "Rust"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- + +เราจะเจอคำถามประเภทที่ว่า จะใช้อะไรดีระหว่าง Go กับ Rust หลังจากที่ได้ลองเขียนทั้ง Go กับ Rust มาสักพักแล้ว พบว่าเราสามารถใช้ cgo + Rust FFI ได้ ซึ่งในเมื่อทั้งสองมีข้อดีต่างกัน เราก็ใช้มันทั้งสองไปเล๊ยยย + + + +# Rust FFI คืออะไร? +Rust FFI (Foreign Function Interface) คือตัวช่วยให้เราเรียกใช้ฟังก์ชันและโค้ดในภาษา Rust จากภาษาอื่น ๆ เช่น C, C++, และ Go ได้อย่างราบรื่นและปลอดภัย + +# มาลองดูกัน +- สิ่งที่ต้องเตรียม + - ติดตั้ง Rust จาก [เว็บไซต์หลัก](https://www.rust-lang.org/tools/install) + - ติดตั้ง Go จาก [เว็บไซต์หลัก](https://go.dev/dl/) + +## สร้าง Go Project +```md +go-rust-ffi +├── lib <--- Rust lib +│   └── rs-add +│      ├── src +│      │   └── lib.rs <--- Rust lib src +│      ├── build.rs <--- Rust build script +│      ├── Cargo.lock +│      └── Cargo.toml +├── go.mod +├── LICENSE +├── main.go <--- Go file +├── Makefile <--- Make script +└── README.md +``` + +ซึ่งใน `lib` ใช้ `cargo` สร้าง Rust project ให้เราได้ +```bash +cd lib +cargo new --lib rs-add +``` + +เราก็จะลองเริ่มจากอะไรที่ง่าย ๆ แบบนี้ที่ไฟล์ `src/lib.rs` +```rust +#[no_mangle] +pub extern "C" fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +แล้วเราก็ใช้ `cbindgen` สร้างไฟล์ header ให้เรา (หรือเขียนมือก็ได้) +```bash +cargo install cbindgen +cbindgen --lang c --output rs-add.h +``` + +build Rust lib กันเลย +```bash +cargo build --release +``` +เราก็จะได้ `librs_add.so` กับ `librs_add.a` ในตอนนี้ให้ย้ายมาไว้ที่ `./lib` เพื่อความไม่งง + +ส่วนใน `main.go` เราก็เรียกใช้ผ่าน cgo แบบนี้ได้เลย +```go +package main + +/* +#cgo LDFLAGS: -L./lib -l:librs_add.so +#include "./lib/rs-add.h" +*/ +import "C" +import "fmt" + +func main() { + a := 10 + b := 20 + result := int(C.add(C.int(a), C.int(b))) + fmt.Printf("Result: %d\n", result) +} +``` + +ในโค้ด Go เราใช้คำสั่ง import "C" เพื่อเรียกใช้ฟังก์ชัน Rust ผ่าน FFI. ซึ่งจะใช้ไฟล์ header เพื่อระบุฟังก์ชัน add และ lib Rust เข้ากับ Go ของเรา + +เวลา build Go เราก็เพิ่ม lib เข้าไปด้วย +```bash +CURRENT_DIR=$(pwd || echo ${PWD}) +go build -ldflags="-r $(CURRENT_DIR)lib" -o dist/ ./... +``` + +## สรุป +เมื่อเราได้รู้จักกับ Rust FFI และวิธีที่เราสามารถใช้ Rust ใน Go ของเราผ่าน FFI + cgo เพื่อให้ Go และ Rust สื่อสารกันได้ เราก็ไม่ต้องเถียงกันแล้วว่า จะใช้อะไรดีระหว่าง Go กับ Rust ในเมื่อเราก็ใช้ประโยชน์จากทั้งสองได้ เช่น ใช้ Go ทำ Router/Thread controller แล้วเอา Rust มาช่วยทำในส่วน hot functions ซึ่งผมได้ลองทำตัวอย่างที่สร้าง QR ด้วย Rust ไว้ที่ [go-rust-ffi](https://github.com/bouroo/go-rust-ffi) แล้วไปยำกันได้เด้อ \ No newline at end of file diff --git a/content/posts/go/solid/featured-image.jpg b/content/posts/go/solid/featured-image.jpg new file mode 100644 index 0000000..ffaaa98 Binary files /dev/null and b/content/posts/go/solid/featured-image.jpg differ diff --git a/content/posts/go/solid/index.en.md b/content/posts/go/solid/index.en.md new file mode 100644 index 0000000..0934465 --- /dev/null +++ b/content/posts/go/solid/index.en.md @@ -0,0 +1,275 @@ +--- +title: "When Go Meets SOLID" +subtitle: "" +date: 2023-07-29T09:16:31+07:00 +lastmod: 2023-07-29T09:16:31+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Let's see how we can write Go code following the SOLID principles." +aliases: +- /posts/go_solid/ +license: "" +images: [] + +tags: ["Go", "SOLID", "Programming"] +categories: ["Go"] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +In modern software development, focusing on getting things done quickly for initial use and then going back to clean up the details later (which is often never fully completed) usually results in difficulties when trying to add new features. This is because the code might be too tightly coupled, or if the demand for use increases, the system might not be able to scale as needed due to a lack of clear planning or structure in the software development process. + +Today, we will introduce a set of guidelines for software development that helps create high-quality, easy-to-maintain, and scalable code. A popular and easy-to-understand set of guidelines is SOLID. In today's examples, we will be using the Go language (because Go is my strongest language). + + + +## Let's get to know SOLID first +SOLID is a set of software development guidelines for creating high-quality code (making it a pleasure for others to read), easy to maintain (changes in one place don't have unexpected effects on other parts), and easily scalable to meet increasing demands (can be extended without worrying about breaking existing code). + +## SOLID consists of + +### **S**ingle Responsibility Principle (SRP) +This principle means that each set of code should have only one responsibility and not perform multiple tasks at the same time. This helps keep the code clean and easy to maintain, as structural changes can be made in one place. + +Example: Let's start with a payment processing structure: +```go +type Payment struct {} + +func (k Payment) MakePayment() { + // do payment stuff +} + +func (k Payment) CreateInvoice() { + // do invoice stuff +} + +func (k Payment) SendBill() { + // do bill mailing stuff +} +``` + +As you can see, `Payment` is responsible for making payments (`Payment`), creating invoices (`Invoice`), and sending bills (`Bill`). According to SRP, we should separate these responsibilities into their own structures as follows: + +```go +type PaymentInfo struct {} + +func (k PaymentInfo) MakePayment() { + // do payment stuff +} + + +type InvoiceInfo struct {} + +func (k InvoiceInfo) CreateInvoice() { + // do invoice stuff +} + +type Billing struct {} + +func (k Billing) SendBill() { + // do bill mailing stuff +} +``` + +Separating the structures like this ensures that the code doesn't mix unrelated responsibilities. A clear example of this can also be seen in the `Go standard library`, such as `hash/crc64` and `hash/crc32`, which are separated into different packages instead of being combined in `hash/crc`. + +### **O**pen-Closed Principle (OCP) +This principle means that code should be extensible without modifying the original code. This helps make the code flexible and adaptable to changing requirements without breaking existing code. + +Example: Let's start with a payment channel and a payment process that requires `Pay()` +```go +type PaymentChannel interface { + Pay() +} + +type Payment struct {} + +func (k Payment) Process(payCh PaymentChannel) { + payCh.Pay() +} +``` + +When we want to add a new payment channel, such as KKU PaymentHub: +```go +type KkuPayment struct { + amount float64 +} + +func (kkupay KkuPayment) Pay() { + fmt.Printf("Paid %.2f via KKU PaymentHub", kkupay.amount) +} + +func main() { + payment := Payment{} + kkuPay := KkuPayment{12.23} + payment.Process(kkuPay) +} +``` + +Later, if we want to allow payment via credit card, we can do it like this: +```go +type CreditCard struct { + amount float64 +} + +func (cc CreditCard) Pay() { + fmt.Printf("Paid %.2f via CreditCard", cc.amount) +} + +func main() { + payment := Payment{} + // KKU PaymentHub + kkuPay := KkuPayment{60.43} + payment.Process(kkuPay) + // Credit Card + creditPay := CreditCard{11.12} + payment.Process(creditPay) +} +``` + +As you can see, we can use `Process()` with both `KkuPayment` and `CreditCard`, as well as any future payment channels, without modifying the existing code. + +### **L**iskov Substitution Principle (LSP) +This principle means that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This helps ensure that the relationship between classes is clear and that the original structure can be maintained. + +Example: We have a bitter-tasting coffee: +```go +type Coffee struct {} + +func (c Coffee) Taste() { + fmt.Println("Bitter") +} +``` + +Now we have a latte, which is a sweet-tasting coffee: +```go +type Late struct { + Coffee +} + +func (a Late) Taste() { + fmt.Println("Sweet") +} +``` + +According to the LSP, we can override the superclass's function without affecting the existing system: +```go +type CoffeeTaste interface { + Taste() +} + +func TasteOf(cTaste CoffeeTaste) { + cTaste.Taste() +} + +coffee := Coffee{} +late := Late{} +TasteOf(coffee) // Bitter +TasteOf(late) // Sweet +``` + +As you can see, the `TasteOf` function, which acts as an intermediary, can accept a parameter of type `CoffeeTaste`. This allows `Coffee` and other subclasses that extend `Coffee` to work in the same function because they all have the `Taste()` function, and it ensures that any variable passed to this function will always have a `Taste()` function. + +### **I**nterface Segregation Principle (ISP) +This principle means that interfaces should be designed to be as small and specific as possible. This helps make the code flexible and avoids unnecessary relationships between classes. + +Example: We have an interface that handles ordering: +```go +type Order interface { + GetOrder() + CreateOrder() + GetItems() + AddItems() + Pay() +} +``` + +As you can see, there are various tasks in the same interface. According to the ISP, we should split it into: +```go +type Order interface { + GetOrder() + CreateOrder() +} + +type OrderItem interface { + GetItems() + AddItems() +} + +type Payment interface { + Pay() +} +``` + +Separating them into interfaces that are responsible for their own tasks makes it easier to modify or track down problems. + +### **D**ependency Inversion Principle (DIP) +This principle states that high-level modules should not depend directly on low-level modules, but both should depend on a common abstraction. This reduces the coupling between components and makes the code more flexible and maintainable. + +Example: A drink shop's menu has both tea and coffee: +```go +type Menu struct { + TeaList []Tea + CoffeeList []Coffee +} +``` + +As you can see, `Menu` depends directly on the `Tea` and `Coffee` structures. When a new type is added or `Tea` or `Coffee` is modified, the `Menu` structure will also change. According to the DIP, we can change this by using a common interface, `Drink`: +```go +type Menu struct { + Drinks []Drink +} + +type Drink interface { + GetCategory() string + GetName() string + GetPrice() float64 +} + +type Coffee struct { + Category string + Name string + AddOn float64 + Price float64 +} + +func (c Coffee) GetCategory() string { + return c.Category +} + +func (c Coffee) GetName() string { + return c.Name +} + +func (c Coffee) GetPrice() float64 { + return c.Price + c.AddOn +} + +type Tea struct { + Category string + Name string + Price float64 +} + +func (t Tea) GetCategory() string { + return t.Category +} + +func (t Tea) GetName() string { + return t.Name +} + +func (t Tea) GetPrice() float64 { + return t.Price +} +``` + +As you can see, with this approach, no matter how the internal structure of `Tea` or `Coffee` differs or changes, they can still be stored in the `Menu` without affecting each other because they are both still `Drink`s. + +## Conclusion + +SOLID is one of the guidelines for software development to prevent us from getting lost in the cycle of technical debt. There are also other widely used guidelines. If you are interested, you can follow the next article. Thank you. diff --git a/content/posts/go/solid/index.md b/content/posts/go/solid/index.md new file mode 100644 index 0000000..305ab36 --- /dev/null +++ b/content/posts/go/solid/index.md @@ -0,0 +1,274 @@ +--- +title: "เมื่อ Go กับ SOLID มาเจอกัน" +subtitle: "" +date: 2023-07-29T09:16:31+07:00 +lastmod: 2023-07-29T09:16:31+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เรามาดูกันว่าเราจะเขียน Go ตามแนวทาง SOLID ได้ยังไงกันนะ" +aliases: +- /posts/go_solid/ +license: "" +images: [] + +tags: ["Go", "SOLID", "Programming"] +categories: ["Go"] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +การพัฒนาซอฟต์แวร์ในยุคปัจจุบัน หากเน้นการเร่งทำให้เสร็จเพื่อใช้งานก่อน แล้วค่อยกลับไปเก็บรายละเอียดทีหลัง (ซึ่งส่วนใหญ่ก็ไม่เคยทำได้หมด) มักจะส่งผลให้เมื่อต้องการเพิ่มฟีเจอร์ใหม่ ๆ จะเริ่มรู้สึกว่าการแก้ไขหรือเพิ่มเติมเป็นเรื่องยากลำบาก เพราะโค้ดที่เขียนขึ้นมาอาจจะเชื่อมโยงกันมากเกินไป หรือหากมีความต้องการใช้งานเพิ่มขึ้น ก็อาจจะไม่สามารถขยายระบบได้ตามความต้องการ เนื่องจากการพัฒนาซอฟต์แวร์นั้นขาดการวางแผนหรือโครงสร้างที่ชัดเจน + +วันนี้เราจะมาแนะนำแนวทางในการพัฒนาซอฟต์แวร์ที่ช่วยให้โค้ดมีคุณภาพดี, ง่ายต่อการดูแลรักษา และสามารถขยายได้ตามความต้องการ แนวทางที่นิยมและเข้าใจง่ายคือ SOLID ซึ่งในตัวอย่างวันนี้จะใช้ภาษา Go เป็นหลัก (เพราะ Go เป็นภาษาที่ผมถนัดที่สุด) + + + +## มารู้จัก SOLID กันก่อน +SOLID เป็นแนวทางการพัฒนาซอฟต์แวร์เพื่อให้ได้โค้ดที่มีคุณภาพดี (ทำให้คนอ่านโค้ดรู้สึกสบายใจ), ง่ายต่อการดูแลรักษา (แก้ไขที่หนึ่งแล้วไม่ส่งผลกระทบไปยังส่วนอื่น ๆ ที่ไม่คาดคิด), และสามารถขยายได้ง่ายตามความต้องการที่เพิ่มขึ้น (สามารถต่อเติมได้โดยไม่ต้องกังวลว่าจะแก้ไขของเก่าพัง) + +## SOLID ประกอบด้วย + +### **S**ingle Responsibility Principle (SRP) +หลักการนี้หมายถึงโค้ดในแต่ละชุดควรมีความรับผิดชอบเพียงอย่างเดียว ไม่ทำงานหลายอย่างในเวลาเดียวกัน สิ่งนี้จะช่วยให้โค้ดสะอาดและบำรุงรักษาได้ง่าย เนื่องจากการเปลี่ยนแปลงโครงสร้างสามารถทำได้ในที่เดียว + +ตัวอย่าง เริ่มจากเรามีโครงสร้างการรับชำระเงิน: +```go +type Payment struct {} + +func (k Payment) MakePayment() { + // do payment stuff +} + +func (k Payment) CreateInvoice() { + // do invoice stuff +} + +func (k Payment) SendBill() { + // do bill mailing stuff +} +``` + +จะเห็นว่า `Payment` รับผิดชอบทั้งการชำระเงิน (`Payment`), การสร้างใบแจ้งหนี้ (`Invoice`), และการส่งบิล (`Bill`) ฉะนั้นจาก SRP เราควรแยกให้โครงสร้างมีความรับผิดชอบเฉพาะตัวดังนี้: + +```go +type PaymentInfo struct {} + +func (k PaymentInfo) MakePayment() { + // do payment stuff +} + +type InvoiceInfo struct {} + +func (k InvoiceInfo) CreateInvoice() { + // do invoice stuff +} + +type Billing struct {} + +func (k Billing) SendBill() { + // do bill mailing stuff +} +``` + +การแยกโครงสร้างเช่นนี้ทำให้โค้ดไม่มีการปะปนกันในการรับผิดชอบงานที่ไม่เกี่ยวข้อง นอกจากนี้ยังเห็นตัวอย่างที่ชัดเจนใน `Go standard library` เช่น `hash/crc64` และ `hash/crc32` ที่แยก package ออกจากกัน แทนที่จะรวมกันใน `hash/crc` + +### **O**pen-Closed Principle (OCP) +หลักการนี้หมายถึงโค้ดควรสามารถขยายได้โดยไม่ต้องแก้ไขโค้ดเดิม สิ่งนี้ช่วยให้โค้ดมีความยืดหยุ่นและปรับให้เข้ากับความต้องการที่เปลี่ยนแปลงได้โดยไม่ทำให้โค้ดที่มีอยู่เสียหาย + +ตัวอย่าง เริ่มจากเรามีช่องทางการชำระเงินและกระบวนการชำระเงินที่ต้องใช้ `Pay()` +```go +type PaymentChannel interface { + Pay() +} + +type Payment struct {} + +func (k Payment) Process(payCh PaymentChannel) { + payCh.Pay() +} +``` + +เมื่อเราต้องการเพิ่มช่องทางการชำระเงินใหม่ เช่น KKU PaymentHub: +```go +type KkuPayment struct { + amount float64 +} + +func (kkupay KkuPayment) Pay() { + fmt.Printf("Paid %.2f via KKU PaymentHub", kkupay.amount) +} + +func main() { + payment := Payment{} + kkuPay := KkuPayment{12.23} + payment.Process(kkuPay) +} +``` + +ต่อมา หากเราต้องการให้สามารถชำระผ่านบัตรเครดิตได้ เราสามารถทำได้แบบนี้: +```go +type CreditCard struct { + amount float64 +} + +func (cc CreditCard) Pay() { + fmt.Printf("Paid %.2f via CreditCard", cc.amount) +} + +func main() { + payment := Payment{} + // KKU PaymentHub + kkuPay := KkuPayment{60.43} + payment.Process(kkuPay) + // Credit Card + creditPay := CreditCard{11.12} + payment.Process(creditPay) +} +``` + +จะเห็นว่าเราสามารถใช้ `Process()` ร่วมกันได้ทั้ง `KkuPayment` และ `CreditCard` รวมถึงช่องทางการชำระเงินที่จะเพิ่มเติมในอนาคต โดยไม่ต้องปรับเปลี่ยนโค้ดที่ทำงานอยู่แล้ว + +### **L**iskov Substitution Principle (LSP) +หลักการนี้หมายถึงอ็อบเจกต์ของ super class ควรสามารถแทนที่ด้วยอ็อบเจ็กต์ของ sub class โดยไม่กระทบต่อความถูกต้องของโปรแกรม สิ่งนี้ช่วยให้แน่ใจว่าความสัมพันธ์ระหว่างคลาสนั้นชัดเจนและสามารถรักษาโครงสร้างเดิมไว้ได้ + +ตัวอย่าง เรามีกาแฟรสชาติขม: +```go +type Coffee struct {} + +func (c Coffee) Taste() { + fmt.Println("Bitter") +} +``` + +ทีนี้เรามีลาเต้ซึ่งเป็นกาแฟที่รสชาติหวาน: +```go +type Late struct { + Coffee +} + +func (a Late) Taste() { + fmt.Println("Sweet") +} +``` + +ตามหลัก LSP เราสามารถเขียนทับฟังก์ชันของ super class ได้โดยไม่กระทบกับระบบเดิม: +```go +type CoffeeTaste interface { + Taste() +} + +func TasteOf(cTaste CoffeeTaste) { + cTaste.Taste() +} + +coffee := Coffee{} +late := Late{} +TasteOf(coffee) // Bitter +TasteOf(late) // Sweet +``` + +จะเห็นว่า `TasteOf` ที่สร้างขึ้นเป็นตัวกลางสามารถรับพารามิเตอร์ที่เป็น `CoffeeTaste` ได้ ทำให้ `Coffee` และ sub class อื่น ๆ ที่ขยายมาจาก `Coffee` สามารถทำงานได้ในฟังก์ชันเดียวกัน เนื่องจากมีฟังก์ชัน `Taste()` เหมือนกัน และรับรองว่าตัวแปรที่จะเข้ามาในฟังก์ชันนี้ต้องมีฟังก์ชัน `Taste()` เสมอ + +### **I**nterface Segregation Principle (ISP) +หลักการนี้หมายถึงอินเทอร์เฟซควรได้รับการออกแบบให้มีขนาดเล็กและเฉพาะเจาะจงมากที่สุดเท่าที่จะเป็นไปได้ สิ่งนี้ช่วยให้โค้ดมีความยืดหยุ่นและหลีกเลี่ยงความสัมพันธ์ระหว่างคลาสโดยไม่จำเป็น + +ตัวอย่าง เรามีอินเทอร์เฟซที่ดูแลด้านการสั่งซื้อ: +```go +type Order interface { + GetOrder() + CreateOrder() + GetItems() + AddItems() + Pay() +} +``` + +จะเห็นว่ามีงานหลากหลายอยู่ในอินเทอร์เฟซเดียวกัน จากนิยาม ISP เราจะแบ่งออกเป็น: +```go +type Order interface { + GetOrder() + CreateOrder() +} + +type OrderItem interface { + GetItems() + AddItems() +} + +type Payment interface { + Pay() +} +``` + +การแยกออกเป็นอินเทอร์เฟซที่รับผิดชอบงานของตัวเองจะช่วยให้การแก้ไขหรือติดตามปัญหาทำได้ง่ายขึ้น + +### **D**ependency Inversion Principle (DIP) +หลักการนี้ระบุว่าโมดูลระดับบนไม่ควรขึ้นอยู่กับโมดูลระดับล่างโดยตรง แต่ทั้งสองอย่างควรขึ้นอยู่กับสิ่งที่เป็นตัวกลางร่วมกัน สิ่งนี้ช่วยลดความสัมพันธ์ระหว่างส่วนประกอบต่าง ๆ และทำให้โค้ดมีความยืดหยุ่นและบำรุงรักษาได้มากขึ้น + +ตัวอย่าง เมนูร้านน้ำแห่งหนึ่งมีทั้งชาและกาแฟอยู่ในเมนู: +```go +type Menu struct { + TeaList []Tea + CoffeeList []Coffee +} +``` + +จะเห็นว่า `Menu` ขึ้นตรงกับโครงสร้างของ `Tea` และ `Coffee` เมื่อต้องการเพิ่มประเภทใหม่หรือมีการแก้ไข `Tea` หรือ `Coffee` ก็จะทำให้โครงสร้างของ `Menu` เปลี่ยนไป จากนิยามของ DIP เราจะเปลี่ยนได้ด้วยการใช้ Interface ร่วมกันคือ `Drink`: +```go +type Menu struct { + Drinks []Drink +} + +type Drink interface { + GetCategory() string + GetName() string + GetPrice() float64 +} + +type Coffee struct { + Category string + Name string + AddOn float64 + Price float64 +} + +func (c Coffee) GetCategory() string { + return c.Category +} + +func (c Coffee) GetName() string { + return c.Name +} + +func (c Coffee) GetPrice() float64 { + return c.Price + c.AddOn +} + +type Tea struct { + Category string + Name string + Price float64 +} + +func (t Tea) GetCategory() string { + return t.Category +} + +func (t Tea) GetName() string { + return t.Name +} + +func (t Tea) GetPrice() float64 { + return t.Price +} +``` + +จะเห็นได้ว่าเมื่อใช้แนวทางนี้ ไม่ว่า `Tea` หรือ `Coffee` จะมีโครงสร้างภายในแตกต่างกันหรือเปลี่ยนไปอย่างไร ก็ยังสามารถจัดเก็บไว้ใน `Menu` ได้โดยไม่กระทบกัน เพราะทั้งคู่ยังคงเป็น `Drink` อยู่ + +## สรุป + +SOLID เป็นแนวทางหนึ่งในการพัฒนาซอฟต์แวร์เพื่อไม่ให้เราหลงทางไปสู่วังวนของ Technical Debt และยังมีแนวทางอื่น ๆ ที่ใช้กันอย่างแพร่หลาย หากสนใจสามารถติดตามบทความถัดไปได้ สวัสดีครับ diff --git a/content/posts/go/sse/featured-image.jpg b/content/posts/go/sse/featured-image.jpg new file mode 100644 index 0000000..dfd24fe Binary files /dev/null and b/content/posts/go/sse/featured-image.jpg differ diff --git a/content/posts/go/sse/img/example.gif b/content/posts/go/sse/img/example.gif new file mode 100644 index 0000000..70059b1 Binary files /dev/null and b/content/posts/go/sse/img/example.gif differ diff --git a/content/posts/go/sse/index.en.md b/content/posts/go/sse/index.en.md new file mode 100644 index 0000000..799d682 --- /dev/null +++ b/content/posts/go/sse/index.en.md @@ -0,0 +1,346 @@ +--- +title: "Let's Build a Voting System with Go and Svelte" +subtitle: "" +date: 2025-01-01T00:00:00+07:00 +lastmod: 2025-01-01T00:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "A walkthrough of building a simple real-time online voting system with Go on the backend and Svelte on the frontend, using Server-Sent Events (SSE) to push score updates from server to client." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +Lately, I've been seeing a lot of local government election campaigns, so I took the opportunity to pick up a web technology that seems to be overlooked, Server-Sent Events (SSE), to create a simple online voting system that supports real-time score display using **Go** for the backend and **Svelte** for the frontend. + + + +## Server-Sent Events + +Normally, a web page has to send a request to the server to get new data. That is, the web page requests data from the server. But with Server-Sent Events (SSE), the server can send data to the client in real-time via the HTTP protocol (PUSH) without the client having to request data every time. This is different from WebSockets, which open a two-way (full-duplex) connection. SSE sends data from the server to the client in one direction only (one-way). + +{{< mermaid >}} +graph TD; + A[Client] -->|HTTP Request| B[Server] + B -->|HTTP Header| F[Content-Type: text/event-stream] + F -->|HTTP Response| A + B -->|Sends Events| C[Event Stream] + C -->|Updates| A + A -->|Handles Events| D[JavaScript Event Listener] + D -->|Processes Data| E[Update UI] +{{< /mermaid >}} + +### How SSE works +1. Establishing a connection: When a client wants to receive data from the server, it sends an HTTP GET request to the server. +2. Sending data: The server responds by sending data in the form of an event stream using the `Content-Type: text/event-stream` header. +3. Updating data: The server continuously sends data (events) to the client when new data becomes available. +4. Handling received data: The client uses JavaScript to wait for events (event listener) and process the received data to update the UI or perform other tasks. + +## Explaining the functions in Go + +### 1. `NewVoteManager()` + +This function is a constructor for creating a new `VoteManager`, which will have default values for candidates and the voting channel. + +```go +func NewVoteManager() *VoteManager { + vm := &VoteManager{ + candidates: map[string]*Candidate{ + "Candidate A": {Name: "Candidate A", Votes: 0}, + "Candidate B": {Name: "Candidate B", Votes: 0}, + }, + voteChannel: make(chan string, runtime.NumCPU()*2), + clients: make(map[chan string]struct{}), + cliRequests: make(chan cliRequest), + } + go vm.manageClients() + return vm +} +``` + +**How it works**: When the server starts, it creates a `VoteManager` with two candidates, "Candidate A" and "Candidate B", with an initial score of 0. + +--- + +### 2. `Start()` + +This function starts the `VoteManager` by opening a `goroutine` to wait for votes from the `voteChannel`. + +```go +func (vm *VoteManager) Start(ctx context.Context) { + vm.wg.Add(1) + go func() { + defer vm.wg.Done() + for { + select { + case candidateName, ok := <-vm.voteChannel: + if !ok { + return + } + vm.processVote(candidateName) + case <-ctx.Done(): + return + } + } + }() +} +``` + +**How it works**: When this function is called, the server starts receiving votes from users. If a user votes for "Candidate A", the server will send this candidate's name to the `processVote` function. + +--- + +### 3. `processVote()` + +This function checks if the voted candidate exists in the system and increments the score for the corresponding candidate. + +```go +func (vm *VoteManager) processVote(candidateName string) { + if candidate, exists := vm.candidates[candidateName]; exists { + candidate.Votes++ + vm.notifyClients(candidate) + } else { + log.Printf("Received vote for unknown candidate: %s", candidateName) + } +} +``` + +**How it works**: If a vote is cast for "Candidate A", the score will increase from 0 to 1, and all users will be notified of the new score. The score will continue to increase with each vote for the candidate. + +--- + +### 4. `notifyClients()` + +This function sends the updated score data to all connected users. + +```go +func (vm *VoteManager) notifyClients(candidate *Candidate) { + message, err := json.Marshal(candidate) + if err != nil { + log.Printf("Failed to marshal candidate: %v", err) + return + } + + for clientChan := range vm.clients { + select { + case clientChan <- string(message): + default: + log.Println("Skipping sending to a slow client") + } + } +} +``` + +**How it works**: When the score of "Candidate A" increases, this function sends the new score data to all users through the connected channel, indicating the current score of "Candidate A". + +--- + +### 5. `voteHandler()` + +This function is responsible for handling vote requests from users. It receives the candidate's name from the URL parameters. + +```go +func (vm *VoteManager) voteHandler(w http.ResponseWriter, r *http.Request) { + candidateName := r.URL.Query().Get("candidate") + if candidateName == "" { + http.Error(w, "Candidate name is required", http.StatusBadRequest) + return + } + select { + case vm.voteChannel <- candidateName: + w.WriteHeader(http.StatusAccepted) + default: + http.Error(w, "Server is busy, try again later", http.StatusServiceUnavailable) + } +} +``` + +**How it works**: If a user sends a `GET` request to `/vote?candidate=Candidate A`, this function will send the name "Candidate A" to the `voteChannel` and return a 202 (Accepted) status. If the server cannot accept the vote, it will return a 503 (Service Unavailable) status. + +--- + +### 6. `resultsHandler()` + +This function returns the current scores of all candidates in JSON format. + +```go +func (vm *VoteManager) resultsHandler(w http.ResponseWriter, r *http.Request) { + candidateList := make([]*Candidate, 0, len(vm.candidates)) + for _, candidate := range vm.candidates { + c := &Candidate{ + Name: candidate.Name, + Votes: candidate.Votes, + } + candidateList = append(candidateList, c) + } + if err := json.NewEncoder(w).Encode(candidateList); err != nil { + http.Error(w, "Failed to encode results", http.StatusInternalServerError) + } +} +``` + +**How it works**: When a user accesses `/results`, the server will send a JSON object of all candidates and their scores, for example: + +```json +[ + {"name": "Candidate A", "votes": 1}, + {"name": "Candidate B", "votes": 0} +] +``` + +--- + +### 7. `sseHandler()` + +This function is used to handle Server-Sent Events (SSE) connections, allowing users to receive real-time data. + +```go +func (vm *VoteManager) sseHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + clientChan := make(chan string, runtime.NumCPU()*2) + vm.AddClient(clientChan) + defer vm.RemoveClient(clientChan) + + initialData, err := json.Marshal(vm.candidates) + if err == nil { + w.Write([]byte("data: " + string(initialData) + "\n\n")) + flusher.Flush() + } + + notify := r.Context().Done() + pingTicker := time.NewTicker(1 * time.Minute) + defer pingTicker.Stop() + + for { + select { + case msg, ok := <-clientChan: + if !ok { + return + } + if _, err := w.Write([]byte("data: " + msg + "\n\n")); err != nil { + log.Println("Error writing to client:", err) + return + } + flusher.Flush() + + case <-notify: + return + + case <-pingTicker.C: + if _, err := w.Write([]byte(":\n\n")); err != nil { + log.Println("Error during ping:", err) + return + } + flusher.Flush() + } + } +} +``` + +**How it works**: When a client establishes a connection to `/events`, the server sends all candidate data in JSON format to the client and also sends updated score data when a new vote is cast. + +--- + +## Explaining the functions in Svelte + +### 1. `fetchResults()` + +This function is used to fetch the candidates' score data from the server. + +```javascript +async function fetchResults() { + loading = true; + try { + const response = await axios.get("http://localhost:8080/results"); + candidates = response.data.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } catch (error) { + errorMessage = "Error fetching results. Please try again later."; + console.error("Error fetching results:", error); + } finally { + loading = false; + } +} +``` + +**How it works**: When the page loads, this function is called to fetch the candidates' score data and store it in the `candidates` variable for display. + +--- + +### 2. `vote()` + +This function is called when the user clicks the vote button. + +```javascript +async function vote(candidate) { + voting = true; + errorMessage = ""; + try { + await axios.get(`http://localhost:8080/vote?candidate=${candidate}`); + voted = true; + setTimeout(() => { + voted = false; + }, 5000); + } catch (error) { + errorMessage = "Error voting. Please try again."; + console.error("Error voting:", error); + } finally { + voting = false; + } +} +``` + +**How it works**: If the user clicks to vote for "Candidate A", this function sends a request to the server and sets the `voted` variable to `true` to prevent repeated voting within a 5-second period, simulating a new user casting a vote. + +--- + +### 3. `setupSSE()` + +This function is used to set up an SSE connection to receive real-time score data. + +```javascript +function setupSSE() { + const eventSource = new EventSource("http://localhost:8080/events"); + + eventSource.onmessage = function (event) { + const updatedCandidate = JSON.parse(event.data); + const index = candidates.findIndex(c => c.name === updatedCandidate.name); + if (index !== -1) { + candidates[index].votes = updatedCandidate.votes; + } + }; + + eventSource.onerror = function (err) { + console.error("EventSource failed:", err); + eventSource.close(); + }; +} +``` + +**How it works**: When the score changes, this function receives the updated score data from the server and automatically updates the `candidates` in the UI. + +--- + +### Example of how it works + +![example](img/example.gif "example") + +## Conclusion + +We can apply SSE to simple real-time display applications, such as updating scores by pushing new data from the server, which eliminates the need to refresh data from the client and does not require opening a WebSocket. You can see the full example code at [bouroo/sse-voting-app](https://github.com/bouroo/sse-voting-app) diff --git a/content/posts/go/sse/index.md b/content/posts/go/sse/index.md new file mode 100644 index 0000000..412a389 --- /dev/null +++ b/content/posts/go/sse/index.md @@ -0,0 +1,346 @@ +--- +title: "ลองสร้างระบบลงคะแนนเลือกตั้งด้วย Go และ Svelte" +subtitle: "" +date: 2025-01-01T00:00:00+07:00 +lastmod: 2025-01-01T00:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "แนวทางการสร้างระบบลงคะแนนเสียงออนไลน์แบบเรียลไทม์อย่างง่ายด้วย Go สำหรับ backend และ Svelte สำหรับ frontend โดยใช้ Server-Sent Events (SSE) ส่งคะแนนอัปเดตจาก server ไปยัง client" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +ช่วงนี้เห็นมีหาเสียงเลือกตั้งองค์กรปกครองส่วนท้องถิ่นกัน เลยถือโอกาสหยิบเว็บเทคโนโลยีนึงที่เหมือนจะถูกมองข้ามเช่น Server-Sent Events (SSE) มาสร้างเป็นระบบลงคะแนนเสียงแบบออนไลน์ที่รองรับการแสดงผลคะแนนแบบเรียลไทม์อย่างง่ายโดยใช้ภาษา **Go** สำหรับ backend และ **Svelte** สำหรับ frontend ดู + + + +## Server-Sent Events + +ตามปกติแล้ว หน้าเว็บจะต้องส่งคำขอไปยัง Server เพื่อรับข้อมูลใหม่ นั่นคือ หน้าเว็บจะขอข้อมูลจาก Server แต่ด้วย Server-Sent Events (SSE) ช่วยให้ Server สามารถส่งข้อมูลไปยัง Client แบบ Real-Time ผ่านโปรโตคอล HTTP (PUSH) โดยไม่ต้องมีการร้องขอข้อมูลในทุกครั้งจาก Client ซึ่งแตกต่างจาก WebSocket ที่เปิดการเชื่อมต่อแบบสองทาง (full-duplex) SSE จะส่งข้อมูลจาก Server ไปยัง Client เพียงทางเดียว (one-way) + +{{< mermaid >}} +graph TD; + A[Client] -->|HTTP Request| B[Server] + B -->|HTTP Header| F[Content-Type: text/event-stream] + F -->|HTTP Response| A + B -->|Sends Events| C[Event Stream] + C -->|Updates| A + A -->|Handles Events| D[JavaScript Event Listener] + D -->|Processes Data| E[Update UI] +{{< /mermaid >}} + +### หลักการทำงานของ SSE +1. การสร้างการเชื่อมต่อ: เมื่อ Client ต้องการรับข้อมูลจาก Server ก็จะส่งคำขอ HTTP แบบ GET ไปยัง Server +2. การส่งข้อมูล: Server จะตอบสนองด้วยการส่งข้อมูลในรูปแบบของ event stream โดยใช้ header `Content-Type: text/event-stream` +3. การอัปเดตข้อมูล: Server จะส่งข้อมูล (events) ไปยัง Client อย่างต่อเนื่อง เมื่อมีข้อมูลใหม่เกิดขึ้น +4. การจัดการข้อมูลที่ได้รับ: Client จะใช้ JavaScript ในการรอรับ event (event listener) และทำการประมวลผลข้อมูลที่ได้รับ เพื่อนำไปอัปเดต UI หรือทำงานอื่น ๆ ต่อไป + +## อธิบายฟังก์ชันใน Go + +### 1. `NewVoteManager()` + +ฟังก์ชันนี้เป็นตัวสร้าง (`constructor`) สำหรับสร้าง `VoteManager` ใหม่ ซึ่งจะมีค่าเริ่มต้นสำหรับผู้สมัครและช่องทางการลงคะแนนเสียง + +```go +func NewVoteManager() *VoteManager { + vm := &VoteManager{ + candidates: map[string]*Candidate{ + "Candidate A": {Name: "Candidate A", Votes: 0}, + "Candidate B": {Name: "Candidate B", Votes: 0}, + }, + voteChannel: make(chan string, runtime.NumCPU()*2), + clients: make(map[chan string]struct{}), + cliRequests: make(chan cliRequest), + } + go vm.manageClients() + return vm +} +``` + +**การทำงาน**: เมื่อเซิร์ฟเวอร์เริ่มทำงาน จะสร้าง `VoteManager` ซึ่งจะมีผู้สมัครสองคนคือ "Candidate A" และ "Candidate B" ที่มีคะแนนเริ่มต้นเป็น 0 + +--- + +### 2. `Start()` + +ฟังก์ชันนี้เริ่มต้นการทำงานของ `VoteManager` โดยเปิด `goroutine` เพื่อรอรับการลงคะแนนเสียงจากช่องทาง `voteChannel` + +```go +func (vm *VoteManager) Start(ctx context.Context) { + vm.wg.Add(1) + go func() { + defer vm.wg.Done() + for { + select { + case candidateName, ok := <-vm.voteChannel: + if !ok { + return + } + vm.processVote(candidateName) + case <-ctx.Done(): + return + } + } + }() +} +``` + +**การทำงาน**: เมื่อมีการเรียกใช้ฟังก์ชันนี้ เซิร์ฟเวอร์จะเริ่มรับการลงคะแนนเสียงจากผู้ใช้ หากผู้ใช้ลงคะแนนเสียง "Candidate A" เซิร์ฟเวอร์จะส่งชื่อผู้สมัครนี้ไปยังฟังก์ชัน `processVote` + +--- + +### 3. `processVote()` + +ฟังก์ชันนี้ทำหน้าที่ตรวจสอบว่าผู้สมัครที่ได้รับการลงคะแนนเสียงนั้นมีอยู่ในระบบหรือไม่ และจะเพิ่มคะแนนให้กับผู้สมัครที่ตรงกับชื่อที่ได้รับ + +```go +func (vm *VoteManager) processVote(candidateName string) { + if candidate, exists := vm.candidates[candidateName]; exists { + candidate.Votes++ + vm.notifyClients(candidate) + } else { + log.Printf("Received vote for unknown candidate: %s", candidateName) + } +} +``` + +**การทำงาน**: หากมีการลงคะแนนเสียงให้กับ "Candidate A" คะแนนจะเพิ่มจาก 0 เป็น 1 และจะมีการแจ้งเตือนผู้ใช้ทั้งหมดเกี่ยวกับคะแนนใหม่ ซึ่งจะบวกค่าไปเรื่อย ๆ หากมีการลงคะแนนเสียงให้กับผู้สมัคร + +--- + +### 4. `notifyClients()` + +ฟังก์ชันนี้ทำหน้าที่ส่งข้อมูลคะแนนที่อัปเดตให้กับผู้ใช้ทุกคนที่เชื่อมต่ออยู่ + +```go +func (vm *VoteManager) notifyClients(candidate *Candidate) { + message, err := json.Marshal(candidate) + if err != nil { + log.Printf("Failed to marshal candidate: %v", err) + return + } + + for clientChan := range vm.clients { + select { + case clientChan <- string(message): + default: + log.Println("Skipping sending to a slow client") + } + } +} +``` + +**การทำงาน**: เมื่อคะแนนของ "Candidate A" เพิ่มขึ้น ฟังก์ชันนี้จะส่งข้อมูลคะแนนใหม่ไปยังผู้ใช้ทุกคนผ่านช่องทางที่เชื่อมต่ออยู่ว่าปัจจุบัน "Candidate A" มีผลคะแนะนเป็นเท่าไร + +--- + +### 5. `voteHandler()` + +ฟังก์ชันนี้รับผิดชอบในการจัดการคำขอการลงคะแนนเสียงจากผู้ใช้ โดยจะรับชื่อผู้สมัครจากพารามิเตอร์ใน URL + +```go +func (vm *VoteManager) voteHandler(w http.ResponseWriter, r *http.Request) { + candidateName := r.URL.Query().Get("candidate") + if candidateName == "" { + http.Error(w, "Candidate name is required", http.StatusBadRequest) + return + } + select { + case vm.voteChannel <- candidateName: + w.WriteHeader(http.StatusAccepted) + default: + http.Error(w, "Server is busy, try again later", http.StatusServiceUnavailable) + } +} +``` + +**การทำงาน**: หากผู้ใช้ส่งคำขอ `GET` ไปที่ `/vote?candidate=Candidate A` ฟังก์ชันนี้จะส่งชื่อ "Candidate A" ไปยังช่อง `voteChannel` และส่งกลับสถานะ 202 (Accepted) หากเซิร์ฟเวอร์ไม่สามารถรับคะแนนได้ จะส่งกลับสถานะ 503 (Service Unavailable) + +--- + +### 6. `resultsHandler()` + +ฟังก์ชันนี้ทำหน้าที่ส่งคืนผลคะแนนที่ปัจจุบันของผู้สมัครทั้งหมดในรูปแบบ JSON + +```go +func (vm *VoteManager) resultsHandler(w http.ResponseWriter, r *http.Request) { + candidateList := make([]*Candidate, 0, len(vm.candidates)) + for _, candidate := range vm.candidates { + c := &Candidate{ + Name: candidate.Name, + Votes: candidate.Votes, + } + candidateList = append(candidateList, c) + } + if err := json.NewEncoder(w).Encode(candidateList); err != nil { + http.Error(w, "Failed to encode results", http.StatusInternalServerError) + } +} +``` + +**การทำงาน**: เมื่อผู้ใช้เข้าถึง `/results` เซิร์ฟเวอร์จะส่งข้อมูล JSON ของผู้สมัครทั้งหมดและคะแนนของพวกเขา เช่น: + +```json +[ + {"name": "Candidate A", "votes": 1}, + {"name": "Candidate B", "votes": 0} +] +``` + +--- + +### 7. `sseHandler()` + +ฟังก์ชันนี้ใช้สำหรับจัดการการเชื่อมต่อ Server-Sent Events (SSE) เพื่อให้ผู้ใช้สามารถรับข้อมูลแบบเรียลไทม์ + +```go +func (vm *VoteManager) sseHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + clientChan := make(chan string, runtime.NumCPU()*2) + vm.AddClient(clientChan) + defer vm.RemoveClient(clientChan) + + initialData, err := json.Marshal(vm.candidates) + if err == nil { + w.Write([]byte("data: " + string(initialData) + "\n\n")) + flusher.Flush() + } + + notify := r.Context().Done() + pingTicker := time.NewTicker(1 * time.Minute) + defer pingTicker.Stop() + + for { + select { + case msg, ok := <-clientChan: + if !ok { + return + } + if _, err := w.Write([]byte("data: " + msg + "\n\n")); err != nil { + log.Println("Error writing to client:", err) + return + } + flusher.Flush() + + case <-notify: + return + + case <-pingTicker.C: + if _, err := w.Write([]byte(":\n\n")); err != nil { + log.Println("Error during ping:", err) + return + } + flusher.Flush() + } + } +} +``` + +**การทำงาน**: เมื่อลูกค้าสร้างการเชื่อมต่อกับ `/events` เซิร์ฟเวอร์จะส่งข้อมูลผู้สมัครทั้งหมดในรูปแบบ JSON ให้กับลูกค้าและยังส่งข้อมูลคะแนนที่อัปเดตเมื่อมีการลงคะแนนเสียงใหม่ + +--- + +## อธิบายฟังก์ชันใน Svelte + +### 1. `fetchResults()` + +ฟังก์ชันนี้ใช้สำหรับดึงข้อมูลคะแนนของผู้สมัครจากเซิร์ฟเวอร์ + +```javascript +async function fetchResults() { + loading = true; + try { + const response = await axios.get("http://localhost:8080/results"); + candidates = response.data.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + } catch (error) { + errorMessage = "Error fetching results. Please try again later."; + console.error("Error fetching results:", error); + } finally { + loading = false; + } +} +``` + +**การทำงาน**: เมื่อโหลดหน้าเว็บ ฟังก์ชันนี้จะถูกเรียกเพื่อดึงข้อมูลคะแนนผู้สมัคร และเก็บในตัวแปร `candidates` เพื่อแสดงผล + +--- + +### 2. `vote()` + +ฟังก์ชันนี้จะถูกเรียกเมื่อผู้ใช้คลิกปุ่มลงคะแนนเสียง + +```javascript +async function vote(candidate) { + voting = true; + errorMessage = ""; + try { + await axios.get(`http://localhost:8080/vote?candidate=${candidate}`); + voted = true; + setTimeout(() => { + voted = false; + }, 5000); + } catch (error) { + errorMessage = "Error voting. Please try again."; + console.error("Error voting:", error); + } finally { + voting = false; + } +} +``` + +**การทำงาน**: หากผู้ใช้คลิกลงคะแนนเสียงให้กับ "Candidate A" ฟังก์ชันนี้จะส่งคำขอไปยังเซิร์ฟเวอร์และตั้งค่าตัวแปร `voted` เป็น `true` เพื่อป้องกันการลงคะแนนซ้ำในช่วงเวลา 5 วินาที จำลองว่ามี user ใหม่เข้ามาลงคะแนน + +--- + +### 3. `setupSSE()` + +ฟังก์ชันนี้ใช้สำหรับตั้งค่าการเชื่อมต่อ SSE เพื่อรับข้อมูลคะแนนแบบเรียลไทม์ + +```javascript +function setupSSE() { + const eventSource = new EventSource("http://localhost:8080/events"); + + eventSource.onmessage = function (event) { + const updatedCandidate = JSON.parse(event.data); + const index = candidates.findIndex(c => c.name === updatedCandidate.name); + if (index !== -1) { + candidates[index].votes = updatedCandidate.votes; + } + }; + + eventSource.onerror = function (err) { + console.error("EventSource failed:", err); + eventSource.close(); + }; +} +``` + +**การทำงาน**: เมื่อมีการเปลี่ยนแปลงคะแนน ฟังก์ชันนี้จะได้รับข้อมูลคะแนนที่อัปเดตจากเซิร์ฟเวอร์และทำการอัปเดต `candidates` ใน UI โดยอัตโนมัติ + +--- + +### ตัวอย่างการทำงาน + +![example](img/example.gif "example") + +## สรุป + +เราสามารถเอา SSE มาประยุกต์ใช้งานกับการแสดงผลแบบ Real-time แบบง่าย เช่นการอัปเดตผลคะแนนด้วยการอาศัย Push ข้อมูลใหม่มาจากฝั่ง Server ทำให้ไม่ต้องวน Refresh ข้อมูลใหม่มาจากฝั่ง Client และไม่ต้องเปิด Websocket ด้วย สามารถดูโค้ด ตัวอย่าง เต็ม ๆ ได้ที่ [bouroo/sse-voting-app](https://github.com/bouroo/sse-voting-app) \ No newline at end of file diff --git a/content/posts/go/struct_memory/featured-image.webp b/content/posts/go/struct_memory/featured-image.webp new file mode 100644 index 0000000..49479d2 Binary files /dev/null and b/content/posts/go/struct_memory/featured-image.webp differ diff --git a/content/posts/go/struct_memory/img/benchmark.webp b/content/posts/go/struct_memory/img/benchmark.webp new file mode 100644 index 0000000..008c978 Binary files /dev/null and b/content/posts/go/struct_memory/img/benchmark.webp differ diff --git a/content/posts/go/struct_memory/img/normal_struct.webp b/content/posts/go/struct_memory/img/normal_struct.webp new file mode 100644 index 0000000..b98c51b Binary files /dev/null and b/content/posts/go/struct_memory/img/normal_struct.webp differ diff --git a/content/posts/go/struct_memory/img/optimized_struct.webp b/content/posts/go/struct_memory/img/optimized_struct.webp new file mode 100644 index 0000000..c580a98 Binary files /dev/null and b/content/posts/go/struct_memory/img/optimized_struct.webp differ diff --git a/content/posts/go/struct_memory/index.en.md b/content/posts/go/struct_memory/index.en.md new file mode 100644 index 0000000..166988e --- /dev/null +++ b/content/posts/go/struct_memory/index.en.md @@ -0,0 +1,145 @@ +--- +title: "Designing Go structs with knowledge of Computer Architecture and Data Structures" +subtitle: "" +date: 2023-07-30T13:01:50+07:00 +lastmod: 2023-07-30T13:01:50+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "We can optimize Go programs with knowledge of Computer Architecture and Data Structures." +aliases: +- /posts/go_struct_memory/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Computer Architecture", "Data Structure", "Programing"] +categories: ["Go"] + +lightgallery: true +--- + +When I was studying computer engineering, a question that always came up was why we needed to study subjects like Computer Architecture and Data Structures. It wasn't until I graduated and started writing in Go that I realized why Go has data types with specific sizes, such as `int8`, `int16`, `int32`, `int64`, and others. Why not just have `int` or `number` like in lazy languages like TypeScript? It was only after reading about [sizes in Go](https://go.dev/src/go/types/sizes.go) that I realized we can use our knowledge of Computer Architecture and Data Structures to help us write Go code that is as efficient as it should be. + + + +## Reviewing System Architecture Knowledge + +### Word size +Word size is the amount of data that a CPU's registers can store and process in one cycle, which varies depending on the CPU architecture. +- 32-bit has a word size of 4 bytes. +- 64-bit has a word size of 8 bytes. + +### Memory allocation +Memory allocation is the reservation of space in memory. This involves reserving the actual space needed plus additional space to fill up the word size. + +### Sizes of Data Types in Go +In Go, each data type has a different memory size. You can check this with `unsafe.Sizeof()`. + +## Go struct +A Go struct is a way to create a data structure in Go, for example: + +```go +type Customer struct { + Id uint64 // 8 bytes + FaceId uint32 // 4 bytes + Name string // 16 bytes + Age uint8 // 1 byte + Address string // 16 bytes + PhoneId uint16 // 2 bytes + PassportId string // 16 bytes + IsActive bool // 1 byte +} +``` + +The total size of all the types in the struct is 64 bytes. Let's see how much memory the entire struct takes up. + +```go +cusA = Customer{} +fmt.Printf("custA size: %d bytes\n", unsafe.Sizeof(custA)) +``` + +![normal_struct](img/normal_struct.webp "normal_struct") + +The result is 88 bytes. What happened? Let's take a look. + +| **word / byte** | **1** | **2** | **3** | **4** | **5** | **6** | **7** | **8** | +|-----------------|------------|------------|------------|------------|------------|------------|------------|------------| +| **word 1** | Id | Id | Id | Id | Id | Id | Id | Id | +| **word 2** | FaceId | FaceId | FaceId | FaceId | | | | | +| **word 3** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 4** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 5** | Age | | | | | | | | +| **word 6** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 7** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 8** | PhoneId | PhoneId | | | | | | | +| **word 9** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 10** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 11** | IsActive | | | | | | | | + +### optimized +We can see that there is padding in each word. We can optimize this like so: + +```go +type CustomerOptimized struct { + Id uint64 // 8 bytes + Name string // 16 bytes + Address string // 16 bytes + PassportId string // 16 bytes + FaceId uint32 // 4 bytes + PhoneId uint16 // 2 bytes + Age uint8 // 1 byte + IsActive bool // 1 byte +} + +custA := Customer{} +fmt.Printf("custA size: %d bytes\n", unsafe.Sizeof(custA)) + +custB := CustomerOptimized{} +fmt.Printf("custB size: %d bytes\n", unsafe.Sizeof(custB)) +``` + +Let's see the results before and after. + +![optimized_struct](img/optimized_struct.webp "optimized_struct") + +Done! We got 64 bytes. The memory layout looks something like this: + +| **word / byte** | **1** | **2** | **3** | **4** | **5** | **6** | **7** | **8** | +|-----------------|------------|------------|------------|------------|------------|------------|------------|------------| +| **word 1** | Id | Id | Id | Id | Id | Id | Id | Id | +| **word 2** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 3** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 4** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 5** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 6** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 7** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 8** | FaceId | FaceId | FaceId | FaceId | PhoneId | PhoneId | Age | IsActive | +| **word 9** | | | | | | | | | +| **word 10** | | | | | | | | | +| **word 11** | | | | | | | | | + +We saved 3 words! + +## benchmark + +Let's see how much of a difference it makes. + +![benchmark](img/benchmark.webp "benchmark") + +| | **iteration (round/5s)** | **exec time (ns/op)** | +|----------------------|--------------------------|-----------------------| +| **Normal struct** | 7,383 | 752,353 | +| **Optimized struct** | 12,250 | 486,464 | + +## fieldalignment +So, do we have to arrange our structs ourselves? The answer is yes, but we have a convenient tool for that: [govet/fieldalignment](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment). Here's how to use it: + +```bash +go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest + +fieldalignment -fix ./... +``` + +Finally, don't forget to always arrange your Go structs and use data types only as needed (using a fieldalignment in a pre-commit script is also convenient). diff --git a/content/posts/go/struct_memory/index.md b/content/posts/go/struct_memory/index.md new file mode 100644 index 0000000..83de84b --- /dev/null +++ b/content/posts/go/struct_memory/index.md @@ -0,0 +1,145 @@ +--- +title: "ออกแบบ Go struct ด้วยความรู้วิชา Computer Architecture และ Data Structure" +subtitle: "" +date: 2023-07-30T13:01:50+07:00 +lastmod: 2023-07-30T13:01:50+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เราสามารถ Optimize โปรแกรมภาษา Go ด้วยความรู้ Computer Architecture และ Data Structure" +aliases: +- /posts/go_struct_memory/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Computer Architecture", "Data Structure", "Programing"] +categories: ["Go"] + +lightgallery: true +--- + +ตอนสมัยเรียนวิศวคอมพิวเตอร์มีคำถามนึงโผล่มาเสมอว่าวิชาอย่าง Computer Architecture กับ Data Structure เรียนไปทำไมกันนะ จนกระทั้งจบออกมาได้เขียนภาษา Go ถึงได้เอะใจว่า ทำไม Go ถึงได้มี data type แบบกำหนดขนาด เช่น `int8` `int16` `int32` `int64` และอื่น ๆ ทำไมถึงไม่เป็น `int` หรือ `number` เฉย ๆ ไปเลยแบบภาษาขี้เกียจอย่าง TypeScript กันนะ จนได้มานั่งอ่านเกี่ยวกับ [sizes in Go](https://go.dev/src/go/types/sizes.go) ถึงได้รู้ว่าเราสามารถใช้ความรู้ในวิชา Computer Architecture และ Data Structure มาช่วยให้เราเขียน Go ออกมาได้ประสิทธิภาพอย่างที่ควรจะเป็น + + + +## ทบทวนความรู้ System Architecture + +### Word size +word size คือ ปริมาณของข้อมูลที่ registers ของ CPU สามารถเก็บและนำมาประมวลผลได้ในหนึ่งรอบซึ่งจะต่างกันตามแต่สถาปัตยกรรมของ CPU +- 32 bit มี word size ที่ 4 bytes +- 64 bit มี word size ที่ 8 bytes + +### Memory allocation +Memory allocation คือ การจองพื้นที่ในหน่วยความจำนั่นเอง ซึ่งจะเป็นการจองพื้นที่ใช้งานจริง + พื้นที่ส่วนเพิ่มเพื่อให้เต็ม word size + +### Sizes ของ Data Type ใน Go +ใน Go แต่ละ data type จะมีขนาดที่ใช้หน่วยความจำแตกต่างกัน [sizes in Go](https://go.dev/src/go/types/sizes.go) ซึ่งเราสามารถดูได้จาก `unsafe.Sizeof()` + +## Go struct +Go struct คือการสร้าง structure ของ data ใน Go เช่น + +```go +type Customer struct { + Id uint64 // 8 bytes + FaceId uint32 // 4 bytes + Name string // 16 bytes + Age uint8 // 1 byte + Address string // 16 bytes + PhoneId uint16 // 2 bytes + PassportId string // 16 bytes + IsActive bool // 1 byte +} +``` + +จาก type ใน struct ทั้งหมดก็ 64 bytes แล้วเรามาดูขนาดของของหน่วยความจำทั้ง struct กันว่าเป็นเท่าไหร่ + +```go +cusA = Customer{} +fmt.Printf("custA size: %d bytes\n", unsafe.Sizeof(custA)) +``` + +![normal_struct](img/normal_struct.webp "normal_struct") + +ผลคือ 88 bytes ว้อททท เกิดอะไรขึ้นมาดูกัน + +| **word / byte** | **1** | **2** | **3** | **4** | **5** | **6** | **7** | **8** | +|-----------------|------------|------------|------------|------------|------------|------------|------------|------------| +| **word 1** | Id | Id | Id | Id | Id | Id | Id | Id | +| **word 2** | FaceId | FaceId | FaceId | FaceId | | | | | +| **word 3** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 4** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 5** | Age | | | | | | | | +| **word 6** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 7** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 8** | PhoneId | PhoneId | | | | | | | +| **word 9** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 10** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 11** | IsActive | | | | | | | | + +### optimized +จะเห็นว่ามีช่อง padding ของในแต่ละ word ทีนี้เราก็สามารถ optimize ได้แบบนี้ + +```go +type CustomerOptimized struct { + Id uint64 // 8 bytes + Name string // 16 bytes + Address string // 16 bytes + PassportId string // 16 bytes + FaceId uint32 // 4 bytes + PhoneId uint16 // 2 bytes + Age uint8 // 1 byte + IsActive bool // 1 byte +} + +custA := Customer{} +fmt.Printf("custA size: %d bytes\n", unsafe.Sizeof(custA)) + +custB := CustomerOptimized{} +fmt.Printf("custB size: %d bytes\n", unsafe.Sizeof(custB)) +``` + +มาดูผลงานกันระหว่างก่อนและหลังกันนน + +![optimized_struct](img/optimized_struct.webp "optimized_struct") + +เรียบร้อยโรงเรียน KKU ได้ 64 bytes แล้ว ซึ่งหน้าตาใน memory ก็จะประมาณนี้ + +| **word / byte** | **1** | **2** | **3** | **4** | **5** | **6** | **7** | **8** | +|-----------------|------------|------------|------------|------------|------------|------------|------------|------------| +| **word 1** | Id | Id | Id | Id | Id | Id | Id | Id | +| **word 2** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 3** | Name | Name | Name | Name | Name | Name | Name | Name | +| **word 4** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 5** | Address | Address | Address | Address | Address | Address | Address | Address | +| **word 6** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 7** | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | PassportId | +| **word 8** | FaceId | FaceId | FaceId | FaceId | PhoneId | PhoneId | Age | IsActive | +| **word 9** | | | | | | | | | +| **word 10** | | | | | | | | | +| **word 11** | | | | | | | | | + +ประหยัดกันไป 3 words กันเลยทีเดียว + +## benchmark + +แล้วมาดูกันว่ามันจะแตกต่างกันขนาดไหน + +![benchmark](img/benchmark.webp "benchmark") + +| | **iteration (round/5s)** | **exec time (ns/op)** | +|----------------------|--------------------------|-----------------------| +| **Normal struct** | 7,383 | 752,353 | +| **Optimized struct** | 12,250 | 486,464 | + +## fieldalignment +แล้วงี้เราก็ต้องมานั่งเรียง struct ของเราเองเลยหรอ คำตอบคือใช่ แต่เรามี tools อำนวยความสะดวกไว้ให้เราใช้แล้วก็คือ [govet/fieldalignment](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment) นั่นเอง ซึ่งวิธีใช้งานก็ + +```bash +go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest + +fieldalignment -fix ./... +``` + +สุดท้ายนี้ก็อย่าลืมจัดเรียง Go struct และใช้ data type เท่าที่จำเป็นเสมอนะครับ (จะใช้วิธีเพิ่ม fieldalignment ใน precommit script ก็สะดวกนะเอ้อ) diff --git a/content/posts/go/sync_pool/featured-image.webp b/content/posts/go/sync_pool/featured-image.webp new file mode 100644 index 0000000..ebfdba5 Binary files /dev/null and b/content/posts/go/sync_pool/featured-image.webp differ diff --git a/content/posts/go/sync_pool/img/basic_pool.webp b/content/posts/go/sync_pool/img/basic_pool.webp new file mode 100644 index 0000000..925fdf8 Binary files /dev/null and b/content/posts/go/sync_pool/img/basic_pool.webp differ diff --git a/content/posts/go/sync_pool/index.en.md b/content/posts/go/sync_pool/index.en.md new file mode 100644 index 0000000..1493aab --- /dev/null +++ b/content/posts/go/sync_pool/index.en.md @@ -0,0 +1,147 @@ +--- +title: "Go and Reusing Memory with sync.Pool" +subtitle: "" +date: 2023-08-01T20:31:34+07:00 +lastmod: 2023-08-02T20:31:34+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Reduce memory allocation in Go by reusing memory with sync.Pool" +aliases: +- /posts/go_sync_pool/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Memory Pooling"] +categories: ["Go"] + +lightgallery: true +--- + +If you've written goroutines, you've probably come across `sync.WaitGroup` for waiting on a group of goroutines. But in the `sync` package, there's another useful type I'd like to introduce: `sync.Pool`, which helps reuse memory and reduce allocations for things that are used repeatedly. + + + +## How sync.Pool{} works +Let's look at how `sync.Pool{}` works. The pool has two baskets: the Pool and the Victim, for caching before being cleared by the garbage collector (GC). + +### basic + +Starting from nothing. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| | 0 | 0 | | | + +Getting from an empty Pool will allocate a new one. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| get | 0 | 0 | allocate | 1 | + +Then put it back into the Pool. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| put | 1 | 0 | ← | 0 | + +The next user will get from the existing Pool. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| get | 0 | 0 | → | 1 | + +### garbage collector + +Before the garbage collector, everything is in the Pool. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| | 3 | 0 | | | + +After the garbage collector, it will be moved to the Victim. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| GC | 0 | 3 | | | + +If there is a get after the garbage collector, it will be taken from the Victim. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| get | 0 | 2 | → | 1 | + +When putting it back into the Pool. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| put | 1 | 2 | ← | 0 | + +After another garbage collector cycle, what's left in the Victim will be cleared out. What's in the Pool will wait in the Victim, and so on. +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| GC | 0 | 1 | | | + +## The structure of sync.Pool{} + +This example is adapted from [sync example-Pool](https://pkg.go.dev/sync#example-Pool). `DoSomethingWithOutPool` is the traditional way without using a pool. `DoSomethingWithPool` uses a pool by putting it back into the pool after use. `DoSomethingWithPoolDefer` uses a pool by deferring putting it back into the pool after the function ends. + +```go +var bufPool = sync.Pool{ + New: func() any { + // The Pool's New function should generally only return pointer + // types, since a pointer can be put into the return interface + // value without an allocation: + return new(bytes.Buffer) + }, +} + +// timeNow is a fake version of time.Now for tests. +func timeNow() time.Time { + return time.Unix(1690909200, 0) +} + +func DoSomethingWithOutPool() { + buff := new(bytes.Buffer) + // write to buffer + buff.WriteString(timeNow().UTC().Format(time.RFC3339)) + // discard for test + io.Discard.Write(buff.Bytes()) + // clear buffer before return + buff.Reset() +} + +func DoSomethingWithPool() { + buff := bufPool.Get().(*bytes.Buffer) + // write to buffer + buff.WriteString(timeNow().UTC().Format(time.RFC3339)) + // write from buffer to discard for test + io.Discard.Write(buff.Bytes()) + // clear buffer before return to pool + buff.Reset() + bufPool.Put(buff) +} + +func DoSomethingWithPoolDefer() { + buff := bufPool.Get().(*bytes.Buffer) + // clear buffer before return to pool after the end of function + defer func() { + buff.Reset() + bufPool.Put(buff) + }() + // write to buffer + buff.WriteString(timeNow().UTC().Format(time.RFC3339)) + // write from buffer to discard for test + io.Discard.Write(buff.Bytes()) +} +``` +Comparing the results using `(*testing.B).RunParallel` to test in parallel. +![Basic sync.Pool{}](img/basic_pool.webp "Basic sync.Pool{}") +As you can see, the time per operation, memory per operation, and allocations per operation are all about half. The lazy, play-it-safe way with defer takes slightly longer than putting it back manually because it has to wait for the function to finish before resetting and putting the buffer back in the pool. + +## Advantages +- Concurrently safe, so it can be used in goroutines at the same time. +- Saves memory allocation by borrowing from the cache in the pool. + +## Cautions +- Data in the pool is lost with each GC cycle (it's not there forever). +- If you don't `put` it back into the pool, every time you `get`, a new instance will be created, making the overall system more wasteful (because it has to allocate more heap space). A middle ground is to use `defer put` immediately after `get` to help you not forget to `put` it back into the pool. + +## When should you use sync.Pool{}? +- As is typical for the sync package, use it in goroutines that repeatedly use the same methods so you don't have to waste memory allocating every time you loop. +- Use it for tasks that have a high initialization cost and are used frequently, such as `parser`, `reader`, `writer`, `buffer`, `network connection`, etc. (It's like, it's expensive to create, so I'll just borrow it and return it when I'm done). + - For example, [ParserPool of valyala/fastjson](https://pkg.go.dev/github.com/valyala/fastjson#ParserPool) diff --git a/content/posts/go/sync_pool/index.md b/content/posts/go/sync_pool/index.md new file mode 100644 index 0000000..c8625c9 --- /dev/null +++ b/content/posts/go/sync_pool/index.md @@ -0,0 +1,147 @@ +--- +title: "Go กับการ re-use memory ด้วย sync.Pool" +subtitle: "" +date: 2023-08-01T20:31:34+07:00 +lastmod: 2023-08-02T20:31:34+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ลดการจอง memory ใน Go ด้วยการ re-use memory ผ่าน sync.Pool" +aliases: +- /posts/go_sync_pool/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Memory Pooling"] +categories: ["Go"] + +lightgallery: true +--- + +ถ้าใครเขียน goroutine ก็คงจะเคยพบเจอกับ `sync.WaitGroup` ที่ใช้ในการทำ wait group ให้ goroutine กันแล้ว แต่ใน pkg `sync` มี type นึงที่มีประโยชน์ในงานที่อยากจะมาแนะนำ คือ `sync.Pool` ที่จะมาช่วย re-use memory ลดการ allocate ที่ต้องใช้ของเดิมซ้ำ ๆ ได้ + + + +## การทำงานของ sync.Pool{} +มาดูการทำงานของ `sync.Pool{}` ว่าทำงานยังไง โดย pool จะมี 2 ตะกร้าคือ Pool และ Victim สำหรับ cache ก่อนจะโดน garbage collector (GC) เคลียร์ออกไป + +### basic + +เริ่มต้น จากไม่มีอะไรเลย +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| | 0 | 0 | | | + +get จาก Pool เปล่า จะเป็นการ allocate ให้ใหม่ +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| get | 0 | 0 | allocate | 1 | + +จากนั้นก็ put กลับคืน Pool +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| put | 1 | 0 | ← | 0 | + +คนที่ใช้ต่อก็จะ get จาก Pool ที่มีอยู่แล้ว +| Operation | Pool | Victim | | caller | +|-----------|------|--------|----------|--------| +| get | 0 | 0 | → | 1 | + +### garbage collector + +ก่อน garbage collector ทุกอย่างอยู่ใน Pool +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| | 3 | 0 | | | + +หลังจาก garbage collector จะโดนย้ายไปพักที่ Victim +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| GC | 0 | 3 | | | + +ถ้ามี get หลังจากจาก garbage collector ก็จะเอาจาก Victim ไปใช้ +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| get | 0 | 2 | → | 1 | + +ตอนที่ put กลับคืน Pool +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| put | 1 | 2 | ← | 0 | + +หลังจาก garbage collector อีกรอบ ที่ค้างใน Victim ก็จะโดนล้างออกไป ส่วนที่อยู่ใน Pool ก็จะมารอใน Victim แบบนี้วนไปเรื่อย ๆ +| Operation | Pool | Victim | | caller | +|-----------|------|--------|------|--------| +| GC | 0 | 1 | | | + +## หน้าตาของ sync.Pool{} + +ตัวอย่างดัดแปลงจาก [sync example-Pool](https://pkg.go.dev/sync#example-Pool) โดย `DoSomethingWithOutPool` เป็นท่าดั้งเดิมแบบไม่ใช้ pool `DoSomethingWithPool` ใช้ pool โดยการ put กลับเข้า pool หลังจากใช้งานเสร็จ `DoSomethingWithPoolDefer` ใช้ pool โดยการ defer put กลับเข้า pool หลังจากจบ function + +```go +var bufPool = sync.Pool{ + New: func() any { + // The Pool's New function should generally only return pointer + // types, since a pointer can be put into the return interface + // value without an allocation: + return new(bytes.Buffer) + }, +} + +// timeNow is a fake version of time.Now for tests. +func timeNow() time.Time { + return time.Unix(1690909200, 0) +} + +func DoSomethingWithOutPool() { + buff := new(bytes.Buffer) + // write to buffer + buff.WriteString(timeNow().UTC().Format(time.RFC3339)) + // discard for test + io.Discard.Write(buff.Bytes()) + // clear buffer before return + buff.Reset() +} + +func DoSomethingWithPool() { + buff := bufPool.Get().(*bytes.Buffer) + // write to buffer + buff.WriteString(timeNow().UTC().Format(time.RFC3339)) + // write from buffer to discard for test + io.Discard.Write(buff.Bytes()) + // clear buffer before return to pool + buff.Reset() + bufPool.Put(buff) +} + +func DoSomethingWithPoolDefer() { + buff := bufPool.Get().(*bytes.Buffer) + // clear buffer before return to pool after the end of function + defer func() { + buff.Reset() + bufPool.Put(buff) + }() + // write to buffer + buff.WriteString(timeNow().UTC().Format(time.RFC3339)) + // write from buffer to discard for test + io.Discard.Write(buff.Bytes()) +} +``` +เปรียบเทียบผลงาน ผ่านการใช้ `(*testing.B).RunParallel` เพื่อทดสอบแบบเป็น Parallel +![Basic sync.Pool{}](img/basic_pool.webp "Basic sync.Pool{}") +จะเห็นได้ว่า เวลาที่ใช้ต่อ operation และหน่วยความจำที่ใช้ต่อ operation รวมถึงการ allocate ต่อ operation ก็ต่างกัน ครึ่ง ๆ เลย ส่วนท่าขี้เกียจ play safe ด้วย defer จะใช้เวลามากกว่า แบบ put เองเล็กน้อยเพราะว่าต้องรอจบ function ก่อนถึงจะ reset กับ put buffer คืน pool + +## ข้อดี +- concurrent safe ทำให้สามารถใช้งานใน goroutine ได้แบบพร้อม ๆ กัน +- ประหยัดการ allocate memory จากการยืม cache ใน pool มาใช้ + +## ข้อควรระวัง +- ข้อมูลใน pool หายไปตามรอบของ GC (ไม่ได้อยู่ตลอดไปนะ) +- ถ้าไม่ `put` กลับคืน pool จะทำให้ทุกครั้งที่เรา `get` จะเป็นการสร้าง instance ใหม่ไปเรื่อย ๆ ทำให้ระบบโดยรวมสิ้นเปลืองมากขึ้น (เพราะต้องไป allocate heap เพิ่มขึ้นเรื่อย ๆ ) ทางสายกลางก็สามารถใช้ `defer put` ทันทีไว้หลังจาก `get` ช่วยให้ไม่ลืม `put` กลับคืน pool ได้ + +## แล้วควรใช้ sync.Pool{} ตอนไหนดี +- ตามปกติของ pkg sync เลย นั้นก็คือใช้ใน goroutine ที่มีการใช้งาน method เดิม ๆ ซ้ำ ๆ จะได้ไม่ต้องเปลือง allocate memory ทุกครั้งที่วนรอบการทำงาน +- ใช้กับงานที่มีค่าใช้จ่ายในการ initialize เยอะ และใช้ซ้ำบ่อย ๆ เช่น `parser` `reader` `writer` `buffer` `network connection` ฯลฯ (ประมาณว่าค่าปั้นตัวมันแพง ขอยืมมาใช้งานละกัน จบงานก็ส่งคืน อะไรแบบนี้) + - ตัวอย่าง เช่น [ParserPool ของ valyala/fastjson](https://pkg.go.dev/github.com/valyala/fastjson#ParserPool) diff --git a/content/posts/go/value_or_pointer/featured-image.webp b/content/posts/go/value_or_pointer/featured-image.webp new file mode 100644 index 0000000..eb45255 Binary files /dev/null and b/content/posts/go/value_or_pointer/featured-image.webp differ diff --git a/content/posts/go/value_or_pointer/img/benchmark_pass_to_func.webp b/content/posts/go/value_or_pointer/img/benchmark_pass_to_func.webp new file mode 100644 index 0000000..e203722 Binary files /dev/null and b/content/posts/go/value_or_pointer/img/benchmark_pass_to_func.webp differ diff --git a/content/posts/go/value_or_pointer/img/benchmark_receiver.webp b/content/posts/go/value_or_pointer/img/benchmark_receiver.webp new file mode 100644 index 0000000..bcb7fac Binary files /dev/null and b/content/posts/go/value_or_pointer/img/benchmark_receiver.webp differ diff --git a/content/posts/go/value_or_pointer/img/benchmark_return_from_func.webp b/content/posts/go/value_or_pointer/img/benchmark_return_from_func.webp new file mode 100644 index 0000000..7ba7a6d Binary files /dev/null and b/content/posts/go/value_or_pointer/img/benchmark_return_from_func.webp differ diff --git a/content/posts/go/value_or_pointer/img/cpu_cache.webp b/content/posts/go/value_or_pointer/img/cpu_cache.webp new file mode 100644 index 0000000..41c20fe Binary files /dev/null and b/content/posts/go/value_or_pointer/img/cpu_cache.webp differ diff --git a/content/posts/go/value_or_pointer/index.en.md b/content/posts/go/value_or_pointer/index.en.md new file mode 100644 index 0000000..d5c8887 --- /dev/null +++ b/content/posts/go/value_or_pointer/index.en.md @@ -0,0 +1,120 @@ +--- +title: "Go and the Age-Old Question: Value or Pointer?" +subtitle: "" +date: 2023-07-31T18:33:40+07:00 +lastmod: 2023-07-31T21:33:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "When writing Go, a question that always comes up is whether to use a value or a pointer for a function. Let's look at the difference." +aliases: +- /posts/go_value_or_pointer/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Computer Architecture", "Data Structure", "Programing"] +categories: ["Go"] + +lightgallery: true + +--- + +When writing Go, a question that always comes up is whether to use a value or a pointer for a function. Let's look at the difference. + + + +## tl;dr +Go is a copy-by-default language, so you can use pass-by-value as the default (with some exceptions, which will be explained below). + +## Reviewing the basics + +### stack +A stack is a data structure where elements are arranged in a last-in, first-out (LIFO) manner. + +### heap +A heap is a priority-based data structure with the properties of a binary tree, making it more flexible than a stack for adding, removing, and accessing elements (but at the cost of access time). + +### Summary +- A stack is fast but can only access the most recently added data. +- A heap is slower but can access any stored data at any time. + +## Go is copy by default +From [stack_or_heap](https://go.dev/doc/faq#stack_or_heap) on the official Go website: +{{< admonition note "Note" >}} +From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language. + +The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack. + +In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack. +{{< /admonition >}} + +In summary, the Go compiler will use the **stack** for `local variables` first. Any variable that cannot be identified as local or is a `pointer` will be on the **heap**. + +## Common use cases + +### pass to function +This is a common scenario where you pass a value to a function to do something and then it returns. +```go +func PassByValue(s SomeStruct) { +} + +func PassByPointer(s *SomeStruct) { +} +``` +Let's benchmark it. +![benchmark_pass_to_func](img/benchmark_pass_to_func.webp "benchmark_pass_to_func") +As you can see, pass-by-value takes more time because it has to copy the value to the destination function, while a pointer doesn't need to copy data, so it's faster. + +### return from function +Now, returning a value from a function can also be done in both ways. +```go +func ReturnByValue() SomeStruct { + return SomeStruct{} +} + +func ReturnByPointer() *SomeStruct { + return &SomeStruct{} +} +``` +Let's benchmark it. +![benchmark_return_from_func](img/benchmark_return_from_func.webp "benchmark_return_from_func") +This is very clear. Returning a pointer takes much less time per operation and also involves memory allocation. As recapped above, a value returned locally will use the stack, which is faster than the heap and doesn't require memory allocation. + +### method receiver +This is a common scenario when defining a function for a type, and you can set the receiver in both ways. +```go +func (s SomeStruct) ReceiveByValue() SomeStruct { + return s +} + +func (s *SomeStruct) ReceiveByPointer() { +} +``` +Let's benchmark it. +![benchmark_receiver](img/benchmark_receiver.webp "benchmark_receiver") +Here, a value receiver wastes time copying before calling the method, but a pointer receiver can call the method directly by referencing the memory address. + +## When should you use a pointer? +- For a method receiver, as the name suggests, because of [Choosing a value or pointer receiver](https://go.dev/tour/methods/8) + - A pointer allows the method to modify the value in the receiver. + - It avoids copying the value every time the method is called. +- For a function that needs to use and modify the original value. +- For a struct that uses `sync.Mutex`, because the purpose is to have a lock during use to prevent data overwriting between goroutines. +- For a large struct ([how to calculate struct size]({{< ref "/posts/go/struct_memory" >}} "how to calculate struct size")). How large is large? Compare the size with the CPU's L2 cache. If it's larger, it's considered large (actually, trying to keep variables passed between each other within the size of the L1 cache will be very fast). + - You can check the L cache size with `sudo lscpu | grep cache`. For example, on my machine, the L2 cache size is 512 KiB * 8 CPU Cores = 4MiB. Therefore, a struct larger than 512KiB should be a pointer (for my machine). + +![cpu_cache](img/cpu_cache.webp "cpu_cache") + +## When should you use a value? +- When none of the conditions for **[When should you use a pointer?]({{}} "When should you use a pointer?" )}}** apply. +- For general data types like `int`, `string`, `float`, etc. +- For a function return that is not used as a global variable, because if it's used as a local variable, it will use the value from the stack, which will be fast as benchmarked above. +- For a value passed to a function, because even though using a pointer is faster, the main drawback is that it's not concurrently safe. For example, if we pass a pointer to a function and another goroutine writes to the same pointer (race condition), the result will not be pretty. +- For other cases where you are not sure (because Go is a copy-by-default language). + +## Special cases +- `map`, `slice`, `func`, `chan`, `interface` are all values that reference a pointer internally, so you can use them as values. + +There are still many details in writing Go that I have encountered. I recommend that you read the documentation of the language you are using (not just Go), at least the effective guides for each language. Because if we write well according to the guidelines, not only will we not fall into anti-patterns, but we will also get good app performance and not waste resources on deploying our app for no reason (don't just blame the resources and scale up, look at yourself and see if you have written it well or not). diff --git a/content/posts/go/value_or_pointer/index.md b/content/posts/go/value_or_pointer/index.md new file mode 100644 index 0000000..621e8cb --- /dev/null +++ b/content/posts/go/value_or_pointer/index.md @@ -0,0 +1,121 @@ +--- +title: "Go กับปัญหาโลกแตก Value หรือ Pointer" +subtitle: "" +date: 2023-07-31T18:33:40+07:00 +lastmod: 2023-07-31T21:33:40+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ในเวลาที่เขียนภาษา Go มักจะมีคำถามนึงโผล่มาเสมอ คือตกลง func นี้จะใช้ Value หรือ Pointer ดีนะ เดี๋ยวเรามาดูความแตกต่างกัน" +aliases: +- /posts/go_value_or_pointer/ +license: "" +images: [] +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +tags: ["Go", "Computer Architecture", "Data Structure", "Programing"] +categories: ["Go"] + +lightgallery: true + +--- + +ในเวลาที่เขียนภาษา Go มักจะมีคำถามนึงโผล่มาเสมอ คือตกลง func นี้จะใช้ Value หรือ Pointer ดีนะ เดี๋ยวเรามาดูความแตกต่างกัน + + + +## tl;dr +Go เป็นภาษาที่ copy by default เพราะฉะนั้นใช้ pass by value เป็น default ได้เลย (ยกเว้น เคสบางอย่างซึ่งจะอธิบายต่อด้านล่างครับ) + +## ทบทวนความรู้ + +### stack +คือ โครงสร้างข้อมูลแบบเรียงซ้อนต่อกันซึ่งเวลาทำงานมันก็ทำงานแบบ **มาทีหลัง ออกไปก่อน** (มันถึงได้ชื่อ stack แหละ) + +### heap +คือ โครงสร้างข้อมูลแบบลำดับตามความสำคัญซึ่งมีคุณสมบัติของ binary tree ทำให้สามารถเพิ่มลด เรียกใช้งานได้ยืดหยุ่นกว่า stack (แต่ก็แลกมาด้วย access time ละนะ) + +### สรุป +- stack เร็วแต่จะใช้ข้อมูลได้จากที่เพิ่มเข้าไปล่าสุดก่อน +- heap ช้ากว่าแต่ก็จะเรียกใช้ข้อมูลที่เก็บไว้ตอนไหนก็ได้ + +## Go copy by default +จาก [stack_or_heap](https://go.dev/doc/faq#stack_or_heap) ในเว็บหลักของ Go ระบุไว้แบบนี้ +{{< admonition note "Note" >}} +From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language. + +The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack. + +In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack. +{{< /admonition >}} + +สรุปคือ Go compiler จะใช้ **stack** สำหรับ `local variable` ก่อน ส่วน variable ไหนที่ระบุไม่ได้ว่ามาจากไหนหรือเป็น `pointer` จะอยู่ใน **heap** + +## การใช้งานที่เจอบ่อย ๆ + +### pass to function +อันนี้ก็ท่าปกติที่จะเจอกันบ่อย ๆ คือ ส่งค่าเข้าไปทำอะไรสักอย่างใน function แล้วก็ออกมา +```go +func PassByValue(s SomeStruct) { +} + +func PassByPointer(s *SomeStruct) { +} +``` +benchmark ดูหน่อย +![benchmark_pass_to_func](img/benchmark_pass_to_func.webp "benchmark_pass_to_func") +จะเห็นได้ว่า pass by value จะใช้เวลามากกว่า ก็เพราะว่าจะต้อง copy value ไปใช้ใน function ปลายทางแต่ pointer ไม่ต้องมีการ copy data ก็เลยไวกว่า + +### return from function +ทีนี้การส่งค่าออกจาก function ก็สามารถส่งออกมาได้ทั้งสองแบบ +```go +func ReturnByValue() SomeStruct { + return SomeStruct{} +} + +func ReturnByPointer() *SomeStruct { + return &SomeStruct{} +} +``` +benchmark ดูหน่อย +![benchmark_return_from_func](img/benchmark_return_from_func.webp "benchmark_return_from_func") +อันนี้ ชัดเจนเลยว่า return pointer ใช้เวลาต่อ operation ต่างกันมาก แถมยังมีการจอง memory อีก ก็ตามที่ recap ไปด้านบนเลยว่าค่าที่ return กลับมาแบบ local จะใช้ stack ซึ่งเร็วกว่า heap และไม่ต้องจอง memory ด้วย + +### method receiver +ท่านี้จะเจอในเวลาที่กำหนด function ให้ type ซึ่งก็สามารถตั้ง receiver ได้ทั้ง 2 แบบอีก +```go +func (s SomeStruct) ReceiveByValue() SomeStruct { + return s +} + +func (s *SomeStruct) ReceiveByPointer() { +} +``` +benchmark ดูหน่อย +![benchmark_receiver](img/benchmark_receiver.webp "benchmark_receiver") +อันนี้ receiver แบบ value เสียเวลาไปกับการ copy ก่อนถึงจะเรียกใช้ method แต่ receiver แบบ pointer เรียกใช้ method ได้เลยจากการอ้าง memory address + +## เมื่อไหร่ควรใช้ pointer +- method receiver ก็ตามชื่อเลยเหตุก็เพราะว่า [Choosing a value or pointer receiver +](https://go.dev/tour/methods/8) + - pointer ทำให้ method สามารถแก้ไขค่าใน receiver ได้ + - เป็นการหลีกเลี่ยงการ copy value ทุกครั้งที่มีการเรียก method +- function ที่ต้องใช้และแก้ค่าที่ origin value +- struct ที่ใช้ `sync.Mutex` เพราะเหตุที่ต้องการใช้งานก็เพื่อให้มีการ Lock ในระหว่างที่ใช้งานอยู่ ป้องกันการซ้อนทับของข้อมูลระหว่าง goroutine +- struct ขนาดใหญ่ ([วิธีคำนวนขนาด struct]({{< ref "/posts/go/struct_memory" >}} "วิธีคำนวนขนาด struct")) ส่วนเท่าไหร่ที่เรียกว่าใหญ่นั้น ก็เทียบขนาดกับ L2 cache ของ CPU ถ้าเกินก็ถือว่าใหญ่ (จริง ๆ พยายามให้ variable ที่ส่งระหว่างกันอยู่ในขนาดของ L1 ได้จะเร็วมาก) + - ดูขนาด L cache ได้จาก `sudo lscpu | grep cache` ตัวอย่างจากเครื่องผมคือ L2 cache ขนาด 512 KiB * 8 CPU Cores = 4MiB เพราะฉะนั้น struct ที่เกิน 512KiB ก็ใช้เป็น pointer ไป (สำหรับเครื่องผมนะ) + +![cpu_cache](img/cpu_cache.webp "cpu_cache") + +## เมื่อไหร่ควรใช้ value +- เมื่อไม่ตรงกับเงื่อนไขของ **[เมื่อไหร่ควรใช้ pointer]({{}} "เมื่อไหร่ควรใช้ pointer" )}** +- พวก data type ทั่วไป เช่น `int`, `string`, `float` ฯลฯ +- function return ที่ไม่ได้ใช้งานระดับ global variable เพราะถ้าใช้งานระดับ local variable มันจะเป็นการใช้ค่าจาก stack ซึ่งก็จะเร็วตามที่ได้ benchmark ไปด้านบนละนะ +- ค่าที่ส่งเข้า function เพราะถึงแม้ว่าการใช้ pointer จะเร็วกว่า แต่ข้อเสียหลักเลยคือ มันไม่ concurrent safe ยกตัวอย่างถ้าเราส่ง pointer เข้าไปทำงานใน function แล้วดันมีอีก goroutine นึงมาเขียนข้อมูลทับ pointer เดียวกันไป (race condition) result ที่ได้ก็อาจไม่ถูกต้อง +- อื่น ๆ ที่ไม่แน่ใจ (ก็เพราะ Go เป็นภาษาที่ copy by default ละนะ) + +## เคสพิเศษ +- `map` `slice` `func` `chan` `interface` พวกนี้จะเป็น value ที่อ้างอิงไป pointer ในตัวอยู่แล้ว เวลาใช้ก็ใช้เป็น value ได้เลย + +ทั้งนี้ยังมีรายละเอียดในการเขียน Go ที่เจอมาอีกเยอะ แนะนำว่าควรอ่าน Doc ของภาษาที่ใช้ (ไม่เฉพาะ Go นะ) อย่างน้อยก็ในส่วนที่เป็น Effective guides ของแต่ละภาษา เพราะถ้าเราเขียนออกมาดีตาม guideline นอกจากจะไม่เข้าสู่ anti pattern แล้ว ยังทำให้ได้ perfomance ของแอปออกมาดี ไม่ต้องเปลืองค่า resources ที่เอาแอปเราไป deploy โดยใช่เหตุอีกด้วย (ไม่ใช่เอะอะก็โทษ resources ไม่พอต้อง scale up อย่างเดียว กลับมาดูตัวเราเองด้วยว่า เขียนดีแล้วหรือยัง) diff --git a/content/posts/go/zero_allocation/featured-image.jpg b/content/posts/go/zero_allocation/featured-image.jpg new file mode 100644 index 0000000..c28ba33 Binary files /dev/null and b/content/posts/go/zero_allocation/featured-image.jpg differ diff --git a/content/posts/go/zero_allocation/index.en.md b/content/posts/go/zero_allocation/index.en.md new file mode 100644 index 0000000..bb3cc7b --- /dev/null +++ b/content/posts/go/zero_allocation/index.en.md @@ -0,0 +1,239 @@ +--- +title: "Let's Try to Understand Zero-Allocation Programming in Go" +subtitle: "" +date: 2024-12-21T11:47:24+07:00 +lastmod: 2024-12-21T11:47:24+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Techniques for reducing heap allocations in Go to ease the garbage collector's workload, using arrays, sync.Pool, slice capacity management, strings.Builder, and stack-based returns." +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +One of the features of Go is its garbage collector (GC), which saves us developers from the headache of having to manage memory. However, this convenience comes at a cost: the time it takes for the GC to run. Although it's not a full stop-the-world event, [you can learn more about the Go GC here](https://tip.golang.org/doc/gc-guide). But for some latency-critical tasks, we need to help the GC work less, which means reducing heap allocations. This is where the question of how to reduce heap allocations without going overboard comes in. + + + +## Why avoid heap allocation? +Although Go's GC is designed for efficient and fast memory management, there are some things to keep in mind: +- Latency: Every GC cycle always has a slight delay, which can be a problem in systems that require very consistent response times. +- CPU resource: The GC needs the CPU to run, so some resources will be allocated to the GC. +- ~~Stop-The-World~~: Even though it's not a full-blown stop-the-world event, there will still be a slight delay, which can affect complex or very large systems. + +We can help reduce the GC's workload by ~~not littering~~ reducing heap allocations. + +## How to reduce heap allocation (as far as I know) +### 1. Use an array instead of a slice if you know the size in advance +Dynamic memory allocation (e.g., during runtime) often leads to heap allocations, which the GC will eventually have to reclaim. Instead of creating new slices or buffers at runtime, pre-allocating a reusable array can help reduce heap allocations. + +{{< admonition example >}} +```go +package main + +// Force inputs to have 10 elements and create an output with 10 elements +func doubleTenValue(inputs [10]int) (result [10]int) { + for i, input := range inputs { + // Process the buffer + result[i] = input * 2 + } + + return +} + +func main() { + inputs := [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + result := doubleTenValue(inputs) + + // Print the result + for _, v := range result { + println(v) + } +} +``` +{{< /admonition >}} +#### Benefits: +- Reduces repeated heap allocations. +- The buffer is reused, not recreated, which reduces the garbage collector's workload. + +### 2. Use sync.Pool for reuse +sync.Pool is an effective tool for managing expensive, temporary objects that are frequently used and discarded. By using a pool, we can reuse them and reduce the amount of garbage that needs to be collected. + +{{< admonition example >}} +```go +package main + +import ( + "bytes" + "sync" +) + +var bufPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func buildString(s ...string) string { + // Get a buffer from the pool + buf := bufPool.Get().(*bytes.Buffer) + // Return it to the pool when done + defer func() { + buf.Reset() + bufPool.Put(buf) + }() + + for _, str := range s { + buf.WriteString(str) + } + + return buf.String() +} + +func main() { + result := buildString("a", "b", "c") + println(result) +} +``` +{{< /admonition >}} +#### Benefits: +- Significantly reduces the number of memory allocations based on the frequency of creation. +- The buffer is reused, which can greatly reduce the GC's workload. + +### 3. Manage slice capacity +Slices in Go are very useful, but their ability to grow dynamically often results in repeated memory allocations and moves to accommodate the slice's size at runtime. By managing the slice's capacity to avoid unnecessary resizing, we can keep slices on the stack instead of the heap. + +{{< admonition example >}} +```go +package main + +func appendData(inputs []int) []int { + // Create a slice with enough capacity in advance + result := make([]int, 0, len(inputs)) + + for _, val := range inputs { + // Add a value to the slice without expanding the capacity + result = append(result, val * 2) + } + + return result +} + +func main() { + inputs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + result := appendData(inputs) + + // Print the result + for _, v := range result { + println(v) + } +} +``` +{{< /admonition >}} +#### Benefits: +- By initializing the result with a pre-defined capacity (len(inputs)), +- we avoid new memory allocations and reduce the chance of allocations from an increasing heap size. + +### 4. Strings in Go are immutable +This means that every time a string is modified, a new string is created. To avoid frequent memory allocations for strings, we should: +- Use strings.Builder for string concatenation. +- Avoid using `+` for concatenating multiple strings in a loop. + +{{< admonition example >}} +```go +package main + +import "strings" + +func buildMessage(parts []string) string { + var builder strings.Builder + // Allocate enough memory for the intended use + builder.Grow(len(parts)) + + for _, part := range parts { + builder.WriteString(part) + } + + return builder.String() +} + +func main() { + result := buildMessage([]string{"Hello", " ", "World"}) + println(result) +} +``` +{{< /admonition >}} +#### Benefits: +- strings.Builder replaces string concatenation with `+`, which helps reduce the expansion of memory behind the string. +- Pre-allocating space for the Builder helps avoid unnecessary new memory allocations. + +### 5. Use stack returns when possible +One of Go's most effective optimizations is the [compiler's analysis](https://go.dev/doc/faq#stack_or_heap) that determines which variables can be returned on the stack or need to be placed on the heap. I recommend watching [Understanding Allocations: the Stack and the Heap - GopherCon SG 2019](https://youtu.be/ZMZpH4yT7M0?si=S6ECgkU8mDkGhNFD) for a better understanding. + +{{< admonition example >}} +```go +type Data struct { + value int +} + +func processData() Data { + d := Data{value: 42} + // Return the value on the stack + return d +} + +func processStackPointer(d *Data) err { + // Use the original pointer on the stack + d = &Data{value: 42} + return nill +} + +func processPointer() *Data { + d := Data{value: 42} + // Return the value on the heap, which requires memory allocation + return &d +} +``` +{{< /admonition >}} +#### Guidelines: +- Avoid returning pointers to internal variables unless necessary. See more at ([Go and the Age-Old Question: Value or Pointer?]({{< ref "/posts/go/value_or_pointer" >}} "Go and the Age-Old Question: Value or Pointer?")) +- Prioritize values over pointers when the size of the returned object is small, such as primitive data types. + +### 6. Reduce repeated variable creation in each operation +In code sections that are executed frequently (e.g., request handlers, loops), eliminating memory allocations will have a performance impact that increases with the number of calls. + +{{< admonition example >}} +```go +package main + +func sumAll(inputs []int) int { + sum := 0 + for _, val := range inputs { + sum += val // Use the same memory for the operation + } + return sum +} + +func main() { + sum := sumAll([]int{1, 2, 3, 4, 5}) + println(sum) +} +``` +{{< /admonition >}} +#### Explanation: +- We declare the `sum` variable in advance and use it within the loop without having to declare a new variable every time the loop iterates, which helps save on additional memory allocations. + +## Conclusion +Zero allocation is an important technique for writing efficient Go programs. Avoiding frequent new memory allocations helps reduce the garbage collector's workload and makes programs run faster. However, the choice of techniques depends on the context of the problem and the system's constraints. + +**Note**: The use of zero allocation should also consider the complexity and readability of the code. Using overly complex techniques can make the code difficult to read and a burden for future generations. + +Additional: [Zero Allocations And Benchmarking In Golang](https://youtu.be/QFGbTOsk-Bk?si=1bejzN__HsLH7fmu) diff --git a/content/posts/go/zero_allocation/index.md b/content/posts/go/zero_allocation/index.md new file mode 100644 index 0000000..52203a3 --- /dev/null +++ b/content/posts/go/zero_allocation/index.md @@ -0,0 +1,239 @@ +--- +title: "มาลองรู้จักการพยายามเขียนแบบ zero-allocation บน Go" +subtitle: "" +date: 2024-12-21T11:47:24+07:00 +lastmod: 2024-12-21T11:47:24+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เทคนิคการลด heap allocation ใน Go เพื่อช่วยลดภาระของ garbage collector ด้วยการใช้ array, sync.Pool, การจัดการความจุของ slice, strings.Builder และการคืนค่าผ่าน stack" +license: "" +images: [] + +tags: [] +categories: [] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image.jpg" + +lightgallery: true +--- + +คุณสมบัติอย่างหนึ่งของ Go คือ มันมี Garbage collector (GC) ทำให้ชีวิต Dev อย่างเรา ๆ ไม่ต้องมาปวดหัวกับการที่ต้องมาคอยจัดการหน่วยความจำ ซึ่งความสะดวกนี้มันก็มีสิ่งที่ต้องแลกมานั่นคือ ช่วงเวลาที่ต้องให้ GC ทำงานนั่นเอง ถึงจะไม่ขนาด stop-the-world ก็เถอะนะ [เพิ่มเติม Go GC ได้ตามลิ้งก์นี้เลย](https://tip.golang.org/doc/gc-guide) แต่สำหรับบางงานที่ latency คือสิ่งสำคัญเราก็ต้องมาช่วยให้ GC ทำงานน้อยลงนั่นคือลด heap allocation ลงนั่นเอง เป็นที่มาว่าทำยังไงเราจะลด heap allocation ให้ได้มาและไม่ฝืนจนเกินไป + + + +## ทำไมต้องหลีกเลี่ยง heap allocation? +แม้ว่า GC ของ Go จะถูกออกแบบมาเพื่อความมีประสิทธิภาพในการจัดการการหน่วยความจำได้อย่างรวดเร็วแต่ก็มีข้อสังเกตุอยู่: +- Latency: ทุก ๆ รอบของการทำงาน GC จะมีความหน่วงเล็กน้อยเสมอ ซึ่งอาจเป็นปัญหาในระบบที่ต้องการ response time ที่สม่ำเสมอมาก ๆ +- CPU resource: GC จะทำงานได้ก็ต้องใช้ CPU เพราะฉะนั้นจะมีการแบ่ง resource ไปใช้กับ GC +- ~~Stop-The-World~~: ถึงแม้ว่าจะไม่ถึงขั้น stop-the-world แบบจริงจังแต่มันก็จะมีช่วงที่ต้องหน่วงเล็กน้อยอยู่ดีซึ่งก็จะมีผลกับระบบที่ซับซ้อนหรือมีขนาดใหญ่ มาก ๆ อยู่ดี + +ซึ่งเราสามารถช่วยลดภาระงานของ GC ลงได้ด้วยการ~~ไม่ทิ้งขยะเรี่ยราด~~ลดการจอง heap ไปทั่ว + +## วิธีลด heap allocation (เท่าที่ผมรู้) +### 1. ใช้ Array แทน Slice ถ้ารู้ขนาดล่วงหน้า +การ allocate หน่วยความจำแบบไดนามิก (เช่น ในระหว่างการทำงาน) มักจะนำไปสู่การ allocate heap ซึ่ง GC จะต้องเรียกคืนในที่สุด แทนที่จะสร้าง slices หรือ buffer ใหม่ในขณะที่ทำงาน การ allocate array ที่นำกลับมาใช้ใหม่ล่วงหน้าจะช่วยลด heap allocation ได้ + +{{< admonition example >}} +```go +package main + +// บังคับว่า inputs ต้องมี 10 ตัว และสร้าง output ไว้รอ 10 ตัว +func doubleTenValue(inputs [10]int) (result [10]int) { + for i, input := range inputs { + // ประมวลผล buffer + result[i] = input * 2 + } + + return +} + +func main() { + inputs := [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + result := doubleTenValue(inputs) + + // แสดงผลลัพธ์ + for _, v := range result { + println(v) + } +} +``` +{{< /admonition >}} +#### ประโยชน์: +- ลดการ allocate heap ที่เกิดขึ้นซ้ำ ๆ +- บัฟเฟอร์ถูกนำกลับมาใช้ใหม่ ไม่ใช่สร้างใหม่เรื่อย ๆ ซึ่งช่วยลดภาระงานของ garbage collector + +### 2. การใช้ sync.Pool สำหรับการนำกลับมาใช้ใหม่ +sync.Pool เป็นเครื่องมือที่มีประสิทธิภาพสำหรับการจัดการกับสิ่งที่มีราคาแพงเวลาสร้างแล้วใช้งานแบบชั่วคราวที่ใช้บ่อยและถูกทิ้ง ถ้าเราทำเป็น pool จะเกิดวนนำมาใช้ซ้ำลดขยะที่ต้องเก็บกวาดนั่นเอง + +{{< admonition example >}} +```go +package main + +import ( + "bytes" + "sync" +) + +var bufPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func buildString(s ...string) string { + // ดึงเอา buffer มาจาก pool + buf := bufPool.Get().(*bytes.Buffer) + // ทำงานเสร็จก็เอากลับคืน pool + defer func() { + buf.Reset() + bufPool.Put(buf) + }() + + for _, str := range s { + buf.WriteString(str) + } + + return buf.String() +} + +func main() { + result := buildString("a", "b", "c") + println(result) +} +``` +{{< /admonition >}} +#### ประโยชน์: +- ลดจำนวนการจองหน่วยความจำอย่างมากตามความบ่อยในการสร้าง +- บัฟเฟอร์ถูกนำกลับมาใช้ใหม่ ซึ่งสามารถลดงานของ GC ได้อย่างมาก + +### 3. การจัดการความจุของ Slice +Slices ใน Go มีประโยชน์มาก แต่การขยายได้เรื่อย ๆ ของ slice มักจะส่งผลให้เกิดการจองและย้ายหน่วยความจำไปเรื่อย ๆ เพื่อให้ขนาดของ slice พอกับ runtime โดยการจัดการความจุของ slice ให้หลีกเลี่ยงการปรับขนาดที่ไม่จำเป็น ทำให้เราสามารถเก็บ slices ไว้ใน stack แทนที่จะเป็น heap ได้ + +{{< admonition example >}} +```go +package main + +func appendData(inputs []int) []int { + // สร้าง slice ที่มีความจุพอใช้งานไว้ล่วงหน้า + result := make([]int, 0, len(inputs)) + + for _, val := range inputs { + // เพิ่มค่าใน slice โดยไม่มีการขยายความจุ + result = append(result, val * 2) + } + + return result +} + +func main() { + inputs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + result := appendData(inputs) + + // แสดงผลลัพธ์ + for _, v := range result { + println(v) + } +} +``` +{{< /admonition >}} +#### ประโยชน์: +- โดยการเริ่มต้น result ด้วยความจุที่กำหนดไว้ล่วงหน้า (len(inputs)) +- เราหลีกเลี่ยงการจองหน่วยความจำใหม่และลดโอกาสในการ allocate จากขนาดของ heap ที่เพิ่มขึ้น + +### 4. String ใน Go เป็น imutable +หมายความว่าการแก้ไข String แต่ละครั้งจะสร้าง String ใหม่ เพื่อเป็นการหลีกเลี่ยงการจองหน่วยความจำภายใต้ String บ่อย ๆ เราควร +- ใช้ strings.Builder สำหรับการเชื่อมต่อ String +- หลีกเลี่ยงการใช้ `+` สำหรับการเชื่อมต่อ String หลาย ๆ ตัวในลูป + +{{< admonition example >}} +```go +package main + +import "strings" + +func buildMessage(parts []string) string { + var builder strings.Builder + // allocate memory ให้พอดีกับที่จะใช้งาน + builder.Grow(len(parts)) + + for _, part := range parts { + builder.WriteString(part) + } + + return builder.String() +} + +func main() { + result := buildMessage([]string{"Hello", " ", "World"}) + println(result) +} +``` +{{< /admonition >}} +#### ประโยชน์: +- strings.Builder ใช้แทนการต่อ string ด้วย `+` ช่วยลดการขยายของหน่วยความจำด้านหลังของ string ได้ +- การเตรียมพื้นที่ล่วงหน้าของ Builder ช่วยหลีกเลี่ยงการจองหน่วยความจำใหม่ที่ไม่จำเป็น + +### 5. ใช้การส่งค่ากลับใน Stack เมื่อเป็นไปได้ +หนึ่งในการเพิ่มประสิทธิภาพที่มีประสิทธิภาพที่สุดของ Go คือ[การวิเคราะห์โดยคอมไพเลอร์](https://go.dev/doc/faq#stack_or_heap)ที่กำหนดว่าตัวแปรใดสามารถส่งกลับได้ใน stack หรือจำเป็นต้องฝากไว้ที่ heap แนะนำดู [Understanding Allocations: the Stack and the Heap - GopherCon SG 2019](https://youtu.be/ZMZpH4yT7M0?si=S6ECgkU8mDkGhNFD) เพิ่มเติมเพื่อให้เข้าใจมากขึ้นครับ + +{{< admonition example >}} +```go +type Data struct { + value int +} + +func processData() Data { + d := Data{value: 42} + // ส่งค่ากลับทาง stack + return d +} + +func processStackPointer(d *Data) err { + // ใช้งานจาก pointer เดิมบน stack + d = &Data{value: 42} + return nill +} + +func processPointer() *Data { + d := Data{value: 42} + // ส่งค่ากลับทาง heap ทำให้ต้อง allocation memory + return &d +} +``` +{{< /admonition >}} +#### แนวทางการทำงาน: +- หลีกเลี่ยงการส่งคืน pointers ไปยังตัวแปรภายในเว้นแต่จำเป็นต้องทำดูเพิ่มได้ที่ ([Go กับปัญหาโลกแตก Value หรือ Pointer]({{< ref "/posts/go/value_or_pointer" >}} "Go กับปัญหาโลกแตก Value หรือ Pointer")) +- ให้ความสำคัญกับ value มากกว่า pointers เมื่อขนาดของ Object ที่ return นั้นเล็ก เช่น พวก Primitive data type + +### 6. ลดการสร้างตัวแปรซ้ำ ๆ ในแต่ละการทำงาน +ในส่วนของโค้ดที่ถูกดำเนินการซ้ำบ่อย ๆ (เช่น ตัวจัดการคำขอ, การวนลูป) การกำจัดการจองหน่วยความจำ จะมีผลต่อมีประสิทธิภาพเพิ่มขึ้นตามจำนวนการเรียกใช้งาน + +{{< admonition example >}} +```go +package main + +func sumAll(inputs []int) int { + sum := 0 + for _, val := range inputs { + sum += val // ใช้หน่วยความจำเดิมในการทำงาน + } + return sum +} + +func main() { + sum := sumAll([]int{1, 2, 3, 4, 5}) + println(sum) +} +``` +{{< /admonition >}} +#### คำอธิบาย: +- เราประกาศค่า sum ไว้ล่วงหน้าแล้วนำไปใช้งานภายในลูปโดยที่ไม่ต้องประกาศตัวแปรใหม่ทุกครั้งที่มีการวนลูปช่วยให้ประหยัดการจองหน่วยความจำเพิ่มได้ + +## สรุป +Zero allocation เป็นเทคนิคที่สำคัญในการเขียนโปรแกรมภาษา Go ที่มีประสิทธิภาพ การหลีกเลี่ยงการจองหน่วยความจำใหม่บ่อยครั้งจะช่วยลดภาระของ garbage collector และทำให้โปรแกรมทำงานได้เร็วขึ้น อย่างไรก็ตาม การเลือกใช้เทคนิคต่าง ๆ ขึ้นอยู่กับบริบทของปัญหาและข้อจำกัดของระบบ + +**หมายเหตุ**: การใช้ zero allocation ควรพิจารณาถึงความซับซ้อนของโค้ดและความอ่านง่ายของโค้ดด้วย การใช้เทคนิคที่ซับซ้อนเกินไปอาจทำให้โค้ดอ่านยากและลำบากคนรุ่นหลัง + +เพิ่มเติม: [Zero Allocations And Benchmarking In Golang](https://youtu.be/QFGbTOsk-Bk?si=1bejzN__HsLH7fmu) diff --git a/content/posts/linux/.DS_Store b/content/posts/linux/.DS_Store new file mode 100644 index 0000000..a013d60 Binary files /dev/null and b/content/posts/linux/.DS_Store differ diff --git a/content/posts/linux/sysctl_tuning/featured-image.jpg b/content/posts/linux/sysctl_tuning/featured-image.jpg new file mode 100644 index 0000000..07322af Binary files /dev/null and b/content/posts/linux/sysctl_tuning/featured-image.jpg differ diff --git a/content/posts/linux/sysctl_tuning/index.en.md b/content/posts/linux/sysctl_tuning/index.en.md new file mode 100644 index 0000000..910e011 --- /dev/null +++ b/content/posts/linux/sysctl_tuning/index.en.md @@ -0,0 +1,48 @@ +--- +title: "Linux Sysctl Tuning" +subtitle: "" +date: 2023-07-19T23:56:31+07:00 +lastmod: 2023-07-19T23:56:31+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Tune sysctl settings to make Linux servers run smoothly under increased load." +aliases: +- /posts/linux_sysctl_tuning/ +license: "" +images: [] +resources: +- name: "featured-image" + src: "featured-image.jpg" + +tags: ["Linux", "DevOps", "PVE", "K3S", "K8S"] +categories: ["Linux", "DevOps"] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image" +--- + +We can adjust sysctl settings to make Linux servers run smoothly under increased load. Normally, each Linux distribution has a default sysctl configuration. For example, RHEL might be optimized for server use, while DEB might be balanced for general performance. In this article, I will introduce the settings I use in production for each type of workload: + + + +## Sysctl for General Servers `60-sysctl.conf` +> You can place these settings in `/etc/sysctl.d/60-sysctl.conf` + +{{< gist bouroo bc52ad58a6e75d44e5235b229e9ca988 60-sysctl.conf >}} + +## Additional Sysctl for Proxmox VE `80-pve.conf` +> You can place these settings in `/etc/sysctl.d/80-pve.conf` + +{{< gist bouroo bc52ad58a6e75d44e5235b229e9ca988 80-pve.conf >}} + +## Additional Sysctl for K3S, K8S `80-k8s-ipvs.conf` +> You can place these settings in `/etc/sysctl.d/80-k8s-ipvs.conf` + +{{< gist bouroo bc52ad58a6e75d44e5235b229e9ca988 80-k8s-ipvs.conf >}} + +## Apply Sysctl Settings +```bash +sysctl --system +``` +> For machines running containerd, k3s, or k8s, you must also restart the containerd, k3s, or k8s services. diff --git a/content/posts/linux/sysctl_tuning/index.md b/content/posts/linux/sysctl_tuning/index.md new file mode 100644 index 0000000..fa8eaa6 --- /dev/null +++ b/content/posts/linux/sysctl_tuning/index.md @@ -0,0 +1,48 @@ +--- +title: "Linux Sysctl Tuning" +subtitle: "" +date: 2023-07-19T23:56:31+07:00 +lastmod: 2023-07-19T23:56:31+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ปรับตั้งค่าใน sysctl เพื่อให้ Linux server ทำงานได้ราบลื่นเมื่อมีโหลดมากขึ้น" +aliases: +- /posts/linux_sysctl_tuning/ +license: "" +images: [] +resources: +- name: "featured-image" + src: "featured-image.jpg" + +tags: ["Linux", "DevOps", "PVE", "K3S", "K8S"] +categories: ["Linux", "DevOps"] + +featuredImage: "featured-image.jpg" +featuredImagePreview: "featured-image" +--- + +เราสามารถปรับตั้งค่าใน sysctl เพื่อให้ Linux server ทำงานได้ราบลื่นเมื่อมีโหลดมากขึ้น โดยปกติแล้ว Linux ในแต่ละ Distro จะมีการตั้งค่า sysctl มาให้กลางอยู่แล้วเช่นสาย RHEL อาจจะปรับมาเพื่อให้บริการเป็นเครื่องแม่ข่ายเป็นพิเศษ DEB อาจจะปรับมาเพื่อให้ทำงานได้อย่างบาลานซ์ เป็นต้น ซึ่งในบทความนี้ผมจะมาแนะนำค่าที่ผมใช้งานอยู่ใน Production ของงานแต่ละประเภทดังนี้ครับ + + + +## Sysctl สำหรับเซิฟเวอร์ทั่วไป `60-sysctl.conf` +> สามารถนำค่าไปไว้ที่ `/etc/sysctl.d/60-sysctl.conf` + +{{< gist bouroo bc52ad58a6e75d44e5235b229e9ca988 60-sysctl.conf >}} + +## Sysctl เพิ่มเติมสำหรับ Promox VE `80-pve.conf` +> สามารถนำค่าไปไว้ที่ `/etc/sysctl.d/80-pve.conf` + +{{< gist bouroo bc52ad58a6e75d44e5235b229e9ca988 80-pve.conf >}} + +## Sysctl เพิ่มเติมสำหรับ K3S, K8S `80-k8s-ipvs.conf` +> สามารถนำค่าไปไว้ที่ `/etc/sysctl.d/80-k8s-ipvs.conf` + +{{< gist bouroo bc52ad58a6e75d44e5235b229e9ca988 80-k8s-ipvs.conf >}} + +## Apply ค่า Sysctl +```bash +sysctl --system +``` +> สำหรับเครื่องที่มีการใช้งาน containerd, k3s, k8s อยู่ต้อง restart containerd, k3s, k8s service ด้วย \ No newline at end of file diff --git a/content/posts/linux/tcp_vs_socket/featured-image.webp b/content/posts/linux/tcp_vs_socket/featured-image.webp new file mode 100644 index 0000000..14e0226 Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/featured-image.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/compose_up.webp b/content/posts/linux/tcp_vs_socket/img/compose_up.webp new file mode 100755 index 0000000..0902843 Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/compose_up.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/mariadb_prepare.webp b/content/posts/linux/tcp_vs_socket/img/mariadb_prepare.webp new file mode 100755 index 0000000..295eb77 Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/mariadb_prepare.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/mariadb_socket.webp b/content/posts/linux/tcp_vs_socket/img/mariadb_socket.webp new file mode 100755 index 0000000..d04872d Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/mariadb_socket.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/mariadb_tcp.webp b/content/posts/linux/tcp_vs_socket/img/mariadb_tcp.webp new file mode 100755 index 0000000..14011be Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/mariadb_tcp.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/redis_socket.webp b/content/posts/linux/tcp_vs_socket/img/redis_socket.webp new file mode 100755 index 0000000..c0b9ee2 Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/redis_socket.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/redis_tcp.webp b/content/posts/linux/tcp_vs_socket/img/redis_tcp.webp new file mode 100755 index 0000000..bcda6d4 Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/redis_tcp.webp differ diff --git a/content/posts/linux/tcp_vs_socket/img/socket_files.webp b/content/posts/linux/tcp_vs_socket/img/socket_files.webp new file mode 100755 index 0000000..a780c68 Binary files /dev/null and b/content/posts/linux/tcp_vs_socket/img/socket_files.webp differ diff --git a/content/posts/linux/tcp_vs_socket/index.en.md b/content/posts/linux/tcp_vs_socket/index.en.md new file mode 100644 index 0000000..3e971bb --- /dev/null +++ b/content/posts/linux/tcp_vs_socket/index.en.md @@ -0,0 +1,129 @@ +--- +title: "Comparing the Performance of Apps with Databases Between TCP and Unix Sockets" +subtitle: "" +date: 2023-07-28T19:52:51+07:00 +lastmod: 2023-07-28T19:52:51+07:00 +draft: fasle +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Did you know that you can improve performance between apps and databases in containers using Unix sockets?" +aliases: +- /posts/db_tcp_vs_socket/ +license: "" +images: [] + +tags: ["DevOps", "Unix Socket", "TCP/IP", "DBA"] +categories: ["DevOps", "Network"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +Normally, when using a database for small-scale tasks in a container, we usually connect via TCP/IP, right? But did you know that you can easily improve performance by removing the TCP overhead by using Unix sockets instead? Let's see what the results are. + + + +## `docker-compose.yml` +Let's start by spinning up a database in Docker using Docker Compose, keeping it as default as possible, as shown below: +```yaml +x-default: &deafult-env + TZ: Asia/Bangkok +x-mariadb: &mariadb-env + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true + MARIADB_AUTO_UPGRADE: true + +services: + mariadb: + image: mariadb:lts + environment: + <<: [*deafult-env, *mariadb-env] + volumes: + - mariadb_data:/var/lib/mysql + - ./tmp/run:/run/mysqld + ports: + - 3306:3306 + + redis: + image: redis:alpine + environment: + <<: [*deafult-env] + volumes: + - redis_data:/data + - ./tmp/run:/data/run + command: [ + "redis-server", + "--unixsocket /data/run/redis.sock", + ] + ports: + - 6379:6379 + +volumes: + mariadb_data: + redis_data: +``` + +After `docker compose up -d`, it will look something like this: +![compose_up](img/compose_up.webp "compose_up") +Unix socket files: +![socket_files](img/socket_files.webp "socket_files") + +## Redis +Let's start with the fastest and simplest database in the example. We will test read and write operations. + +### TCP/IP + +```bash +redis-benchmark -n 1000000 -t set,get -P 16 -q -h 127.0.0.1 -p 6379 +``` + +Results: +![redis_tcp](img/redis_tcp.webp "redis_tcp") + +### UNIX socket + +```bash +redis-benchmark -n 1000000 -t set,get -P 16 -q -s tmp/run/redis.sock +``` + +Results: +![redis_socket](img/redis_socket.webp "redis_socket") + +## MariaDB +Next, let's test a popular database. We will test read and write operations as before. + +### Prepare data for testing +First, create a database and table for sysbench. +```bash +sysbench oltp_read_write --db-driver=mysql --mysql-host=127.0.0.1 --mysql-user=root --mysql-db=sysbenchtest --threads=16 prepare +``` + +### TCP/IP + +```bash +sysbench oltp_read_write --db-driver=mysql --mysql-host=127.0.0.1 --mysql-user=root --mysql-db=sysbenchtest --threads=16 run +``` + +Results: +![mariadb_tcp](img/mariadb_tcp.webp "mariadb_tcp") + +### UNIX socket + +```bash +sysbench oltp_read_write --db-driver=mysql --mysql-socket=tmp/run/mysqld.sock --mysql-user=root --mysql-db=sysbenchtest --threads=16 run +``` + +Results: +![mariadb_socket](img/mariadb_socket.webp "mariadb_socket") + +## Conclusion + +| | **read (req/s)** | **write (req/s)** | **latency avg (ms)** | +|--------------------|------------------|-------------------|----------------------| +| Redis TCP | 475,511.19 | 443,655.72 | 1.519 / 1.639 | +| Redis UnixSocket | 1,555,209.88 | 1,270,648.00 | 0.455 / 0.567 | +| MariaDB TCP | 75,432 | 21,540 | 29.76 | +| MariaDB UnixSocket | 212,688 | 60,731 | 10.54 | + +As you can see, by reducing the overhead of TCP and using Unix sockets instead, we can handle a much higher load without having to significantly scale up our resources. Or, in Kubernetes workloads with sidecars, you can use Unix sockets for inter-process communication instead of TCP/IP. +> Note: This only works on nodes that share the same volume. diff --git a/content/posts/linux/tcp_vs_socket/index.md b/content/posts/linux/tcp_vs_socket/index.md new file mode 100644 index 0000000..0bb23d3 --- /dev/null +++ b/content/posts/linux/tcp_vs_socket/index.md @@ -0,0 +1,129 @@ +--- +title: "ลองเทียบประสิทธิภาพของ Apps กับ Database ระหว่าง TCP กับ Unix socket" +subtitle: "" +date: 2023-07-28T19:52:51+07:00 +lastmod: 2023-07-28T19:52:51+07:00 +draft: fasle +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "รู้หรือไม่ว่าเราสามารถได้ประสิทธิภาพเพิ่มขึ้นจาก Apps กับ DB ใน container ได้ด้วย Unix socket" +aliases: +- /posts/db_tcp_vs_socket/ +license: "" +images: [] + +tags: ["DevOps", "Unix Socket", "TCP/IP", "DBA"] +categories: ["DevOps", "Network"] + +featuredImage: "featured-image.webp" +featuredImagePreview: "featured-image.webp" + +lightgallery: true +--- +ปกติแล้วเวลาใช้งาน database ในระดับงานที่ไม่ใหญ่มาก ใน container เราก็มักจะเชื่อมต่อกันด้วย TCP/IP กันถูกไหมครับ แต่รู้หรือไม่ว่าเราสามารถเพิ่มประสิทธิภาพง่าย ๆ ด้วยการลด overhead ของ TCP ออกด้วยการใช้ Unix socket แทนผลจะเป็นยังไง ลองมาดูกันครับ + + + +## `docker-compose.yml` +เรามาเริ่มจากเปิด database ใน docker ด้วย docker compose ซึ่งพยายามให้ default ที่สุด ตามด้านล่างนี้ +```yaml +x-default: &deafult-env + TZ: Asia/Bangkok +x-mariadb: &mariadb-env + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true + MARIADB_AUTO_UPGRADE: true + +services: + mariadb: + image: mariadb:lts + environment: + <<: [*deafult-env, *mariadb-env] + volumes: + - mariadb_data:/var/lib/mysql + - ./tmp/run:/run/mysqld + ports: + - 3306:3306 + + redis: + image: redis:alpine + environment: + <<: [*deafult-env] + volumes: + - redis_data:/data + - ./tmp/run:/data/run + command: [ + "redis-server", + "--unixsocket /data/run/redis.sock", + ] + ports: + - 6379:6379 + +volumes: + mariadb_data: + redis_data: +``` + +หลังจาก `docker compose up -d` ก็จะได้ประมาณนี้ +![compose_up](img/compose_up.webp "compose_up") +หน้าตาของ Unix socket +![socket_files](img/socket_files.webp "socket_files") + +## Redis +เริ่มจาก database ที่เร็วและง่ายที่สุดในตัวอย่างกันก่อน โดยจะทดสอบ read, write + +### TCP/IP + +```bash +redis-benchmark -n 1000000 -t set,get -P 16 -q -h 127.0.0.1 -p 6379 +``` + +ผลที่ได้ +![redis_tcp](img/redis_tcp.webp "redis_tcp") + +### UNIX socket + +```bash +redis-benchmark -n 1000000 -t set,get -P 16 -q -s tmp/run/redis.sock +``` + +ผลที่ได้ +![redis_socket](img/redis_socket.webp "redis_socket") + +## MariaDB +มาต่อด้วย database ยอดนิยมตัวนึง โดยจะทดสอบ read, write เหมือนเดิม + +### เตรียมข้อมูลทำหรับทดสอบ +เริ่มจากการสร้าง database และ table สำหรับ sysbench +```bash +sysbench oltp_read_write --db-driver=mysql --mysql-host=127.0.0.1 --mysql-user=root --mysql-db=sysbenchtest --threads=16 prepare +``` + +### TCP/IP + +```bash +sysbench oltp_read_write --db-driver=mysql --mysql-host=127.0.0.1 --mysql-user=root --mysql-db=sysbenchtest --threads=16 run +``` + +ผลที่ได้ +![mariadb_tcp](img/mariadb_tcp.webp "mariadb_tcp") + +### UNIX socket + +```bash +sysbench oltp_read_write --db-driver=mysql --mysql-socket=tmp/run/mysqld.sock --mysql-user=root --mysql-db=sysbenchtest --threads=16 run +``` + +ผลที่ได้ +![mariadb_socket](img/mariadb_socket.webp "mariadb_socket") + +## สรุป + +| | **read (req/s)** | **write (req/s)** | **latency avg (ms)** | +|--------------------|------------------|-------------------|----------------------| +| Redis TCP | 475,511.19 | 443,655.72 | 1.519 / 1.639 | +| Redis UnixSocket | 1,555,209.88 | 1,270,648.00 | 0.455 / 0.567 | +| MariaDB TCP | 75,432 | 21,540 | 29.76 | +| MariaDB UnixSocket | 212,688 | 60,731 | 10.54 | + +จะเห็นได้ว่าหากเราลด overhead ของ TCP ออกด้วยการใช้ Unix socket แทน จะทำให้เราไม่ต้องไป up scale ทรัพยากรของเราให้ใหญ่โต ก็สามารถรับโหลดได้เพิ่มมาขึ้นอีกเยอะเลย หรือในงานบน K8S ที่มีการทำ sidecar ก็สามารถใช้ Unix socket สื่อสารระหว่างกันแทน TCP/IP ได้นะครับ +> ปล. ใช้ได้ใน node ที่ใช้งาน volume ร่วมกันนะจร๊ะ \ No newline at end of file diff --git a/content/posts/rust/rust-async-tokio/featured-image.jpeg b/content/posts/rust/rust-async-tokio/featured-image.jpeg new file mode 100644 index 0000000..4829d4e Binary files /dev/null and b/content/posts/rust/rust-async-tokio/featured-image.jpeg differ diff --git a/content/posts/rust/rust-async-tokio/index.en.md b/content/posts/rust/rust-async-tokio/index.en.md new file mode 100644 index 0000000..679d72c --- /dev/null +++ b/content/posts/rust/rust-async-tokio/index.en.md @@ -0,0 +1,563 @@ +--- +title: "Async Rust with Tokio" +subtitle: "" +date: 2026-07-12T09:00:00+07:00 +lastmod: 2026-07-12T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "async/await, the Tokio runtime, spawning tasks, channels, and async I/O for network applications" +license: "" +images: [] +tags: ["Rust", "Tutorial", "Tokio"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + + + +# Async Rust with Tokio + +Asynchronous programming is essential for building high-performance network applications that can handle many concurrent connections without the overhead of spawning a thread for each connection. In this tutorial, we'll explore async/await syntax, the Tokio runtime, spawning tasks, channels for communication between tasks, asynchronous I/O for network programming, using `tokio::select!` for handling multiple futures, graceful shutdown mechanisms, and how these concepts apply to a real-world WebSocket proxy project (rs-wsProxy). + +## Why Async? + +Traditional network servers often use a thread-per-connection model, where each incoming connection gets its own thread. While simple to understand and implement, this approach doesn't scale well to thousands of concurrent connections because each thread consumes significant memory (typically 1-2MB stack space) and involves expensive context switching costs imposed by the operating system. + +Asynchronous programming offers a more efficient alternative. Instead of blocking an entire thread while waiting for I/O operations (like reading from a socket), async code allows the runtime to suspend the current task and switch to another task that is ready to make progress. This enables a single thread to manage thousands of concurrent connections with minimal overhead. + +However, Rust's async/await syntax alone doesn't provide a runtime to execute asynchronous code. Unlike languages with built-in runtimes (like Go or Node.js), Rust's async/await is a low-level feature that requires an external runtime to drive the asynchronous tasks to completion. This is where Tokio comes in – it provides a mature, feature-rich runtime for writing asynchronous applications in Rust. + +## async/await + +The `async` and `await` keywords are the foundation of asynchronous programming in Rust. When you mark a function with `async`, it returns a `Future` – a value that represents a computation that may not have completed yet. The `.await` keyword is used to wait for a future to complete, yielding control back to the runtime so other tasks can run while waiting. + +Here's a basic example: + +```rust +async fn hello_world() { + println!("Hello, world!"); +} + +#[tokio::main] +async fn main() { + hello_world().await; +} +``` + +In this example: +- `hello_world` is an asynchronous function that returns a `Future` +- When we call `hello_world().await`, we're telling the runtime to execute this future until it completes +- The `#[tokio::main]` attribute sets up the Tokio runtime and runs our async `main` function + +You can also use `async` blocks for inline asynchronous code: + +```rust +#[tokio::main] +async fn main() { + let future = async { + println!("Inside async block"); + 42 + }; + + let result = future.await; + println!("Result: {}", result); +} +``` + +Futures are lazy – they don't do anything until you `.await` them. This allows you to combine multiple futures and execute them concurrently. + +## Tokio Runtime + +Tokio provides multiple runtime configurations to suit different workloads. The most common way to start a Tokio application is with the `#[tokio::main]` attribute macro: + +```rust +#[tokio::main] +async fn main() { + println!("Hello from Tokio!"); +} +``` + +By default, this creates a multi-threaded runtime with worker threads equal to the number of CPU cores. You can customize the runtime using `tokio::runtime::Builder`: + +```rust +use tokio::runtime::Builder; + +fn main() { + let runtime = Builder::new_multi_thread() + .worker_threads(4) // Use 4 worker threads + .enable_all() // Enable all features (timers, I/O, etc.) + .build() + .unwrap(); + + runtime.block_on(async { + println!("Running on custom Tokio runtime"); + }); +} +``` + +The runtime builder offers various configurations: +- `new_current_thread()`: Single-threaded runtime (good for debugging or when you want deterministic behavior) +- `new_multi_thread()`: Multi-threaded runtime (default for `#[tokio::main]`) +- `worker_threads(n)`: Set the number of worker threads +- `enable_io()`, `enable_time()`, etc.: Enable specific features +- `thread_name()`: Set custom thread names for easier debugging + +For I/O-heavy applications like network proxies, the multi-threaded runtime typically provides the best performance. + +## Spawning Tasks + +While async functions allow you to write asynchronous code, you often need to run multiple concurrent tasks independently. Tokio provides `tokio::spawn` for this purpose: + +```rust +use tokio::{self, time::{sleep, Duration}}; + +#[tokio::main] +async fn main() { + // Spawn a new task + let handle = tokio::spawn(async { + println!("Task started"); + sleep(Duration::from_secs(2)).await; + println!("Task completed"); + 42 + }); + + // Do other work while the task runs + println!("Doing other work..."); + + // Wait for the task to complete and get its result + let result = handle.await.unwrap(); + println!("Task returned: {}", result); +} +``` + +Key points about `tokio::spawn`: +- It returns a `JoinHandle` where `T` is the output type of the spawned future +- The `JoinHandle` allows you to await the task's completion and retrieve its result +- Spawned tasks run independently on the Tokio runtime +- Use the `move` keyword to capture variables from the surrounding scope: + +```rust +let data = vec![1, 2, 3]; +let handle = tokio::spawn(move || { + // `data` is moved into the task + println!("Data: {:?}", data); +}); +``` + +## Channels + +Tasks often need to communicate with each other. Tokio provides several channel types for asynchronous communication: + +### Multi-Producer, Single-Consumer (mpsc) + +Ideal for scenarios where multiple producers send messages to a single consumer: + +```rust +use tokio::sync::mpsc; +use tokio::{self, time::{sleep, Duration}}; + +#[tokio::main] +async fn main() { + // Create a channel with buffer capacity of 32 + let (tx, mut rx) = mpsc::channel(32); + + // Spawn three producer tasks + for i in 0..3 { + let tx = tx.clone(); // Clone the sender for each task + tokio::spawn(async move { + for j in 0..3 { + let msg = format!("Task {} message {}", i, j); + tx.send(msg).await.unwrap(); + sleep(Duration::from_millis(100)).await; + } + }); + } + + // Consumer task + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + println!("Received: {}", msg); + } + println!("All messages received"); + }); + + // Give time for messages to be processed + sleep(Duration::from_secs(2)).await; +} +``` + +### One-Shot Channels + +Useful for one-time communication between two tasks: + +```rust +use tokio::sync::oneshot; +use tokio::{self, time::{sleep, Duration}}; + +#[tokio::main] +async fn main() { + let (tx, rx) = oneshot::channel(); + + // Spawn a task that will send a value + tokio::spawn(async move { + sleep(Duration::from_secs(1)).await; + let _ = tx.send(42); // Ignore send error if receiver dropped + }); + + // Wait for the value + match rx.await { + Ok(value) => println!("Received: {}", value), + Err(_) => println!("Sender dropped"), + } +} +``` + +## Async I/O + +Tokio provides asynchronous versions of standard I/O types like `TcpListener`, `TcpStream`, `UdpSocket`, etc. These types implement the `AsyncReadExt` and `AsyncWriteExt` traits from the `tokio::io` module, providing async versions of read/write operations. + +Here's a simple async echo server: + +```rust +use tokio::net::{TcpListener, TcpStream}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listener = TcpListener::bind("127.0.0.1:8080").await?; + println!("Listening on 127.0.0.1:8080"); + + loop { + let (socket, addr) = listener.accept().await?; + println!("Accepted connection from: {}", addr); + + // Handle each connection in a separate task + tokio::spawn(async move { + if let Err(e) = process_socket(socket).await { + eprintln!("Error processing socket: {}", e); + } + }); + } +} + +async fn process_socket(mut socket: TcpStream) -> Result<(), Box> { + let mut buffer = [0; 1024]; + + loop { + let n = socket.read(&mut buffer).await?; + if n == 0 { + // Connection closed + break; + } + + // Echo the data back + socket.write_all(&buffer[..n]).await?; + } + + Ok(()) +} +``` + +This server: +1. Binds to localhost:8080 +2. Accepts incoming connections in a loop +3. Spawns a new task for each connection to handle it concurrently +4. Each task reads data from the socket and writes it back (echo) + +## tokio::select! + +The `tokio::select!` macro allows you to wait on multiple asynchronous operations simultaneously, proceeding as soon as one of them completes. This is particularly useful for implementing timeouts, handling multiple communication channels, or implementing select-like behavior for network I/O. + +Here's a timeout example: + +```rust +use tokio::{self, time::{sleep, Duration, timeout}}; + +#[tokio::main] +async fn main() { + let future = async { + sleep(Duration::from_secs(5)).await; + println!("This takes 5 seconds"); + }; + + // Wait for the future to complete, but timeout after 2 seconds + match timeout(Duration::from_secs(2), future).await { + Ok(_) => println!("Completed"), + Err(_) => println!("Timed out!"), + } +} +``` + +And here's an example using `select!` directly for more complex scenarios: + +```rust +use tokio::{self, sync::mpsc, time::{sleep, Duration, interval}}; + +#[tokio::main] +async fn main() { + let (tx, mut rx) = mpsc::channel(32); + let mut interval = interval(Duration::from_secs(2)); + + // Spawn a sender that sends messages every second + tokio::spawn(async move { + let mut i = 0; + loop { + let _ = tx.send(i).await; + i += 1; + sleep(Duration::from_secs(1)).await; + } + }); + + // Use select! to handle both incoming messages and periodic ticks + loop { + tokio::select! { + msg = rx.recv() => { + match msg { + Some(value) => println!("Received: {}", value), + None => { + println!("Sender disconnected"); + break; + } + } + } + _ = interval.tick() => { + println!("Periodic tick"); + } + } + println!("Interval tick"); + } + } + } +} +``` + +In network applications like proxies, it's crucial to handle shutdown signals gracefully to avoid dropping connections abruptly. Tokio provides utilities for handling Unix signals like SIGINT (Ctrl+C) and SIGTERM. + +Here's an example of graceful shutdown handling: + +```rust +use tokio::{self, net::TcpListener, signal}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listener = TcpListener::bind("127.0.0.1:8080").await?; + println!("Listening on 127.0.0.1:8080"); + + // Create a future that resolves when shutdown signal is received + let shutdown_signal = async { + signal::ctrl_c().await + .expect("Failed to install Ctrl+C handler"); + }; + + // Main accept loop + loop { + tokio::select! { + // Accept new connections + res = listener.accept() => { + match res { + Ok((socket, addr)) => { + println!("Accepted connection from: {}", addr); + tokio::spawn(async move { + if let Err(e) = handle_connection(socket).await { + eprintln!("Connection error: {}", e); + } + }); + } + Err(e) => { + eprintln!("Accept error: {}", e); + } + } + } + // Wait for shutdown signal + _ = shutdown_signal => { + println!("Shutdown signal received, stopping acceptance of new connections"); + break; + } + } + } + + println!("Server shutting down"); + Ok(()) +} + +async fn handle_connection(mut socket: tokio::net::TcpStream) -> Result<(), Box> { + let mut buffer = [0; 1024]; + + loop { + let n = socket.read(&mut buffer).await?; + if n == 0 { + break; // Connection closed + } + + socket.write_all(&buffer[..n]).await?; + } + + Ok(()) +} +``` + +This pattern uses `tokio::select!` to wait for either a new connection or a shutdown signal. When a shutdown signal is received, we break out of the accept loop, allowing existing connections to complete before the application exits. + +## Connection to rs-wsProxy + +The rs-wsProxy project (a WebSocket to TCP proxy) demonstrates many of these concepts in a real-world application: + +```rust +use tokio::net::{TcpListener, TcpStream}; +use tokio::runtime::Builder; +use tokio::select; +use tokio::signal; +use tokio::sync::mpsc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() { + // Configure Tokio runtime with custom thread count + let runtime = Builder::new_multi_thread() + .worker_threads(4) // Use 4 worker threads + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let addr = "0.0.0.0:8080"; + let listener = TcpListener::bind(addr).await.unwrap(); + println!("WebSocket proxy listening on {}", addr); + + // Set up shutdown signal handling + let mut shutdown = signal::ctrl_c(); + + loop { + select! { + // Accept new WebSocket connections + res = listener.accept() => { + match res { + Ok((ws_stream, addr)) => { + println!("New WebSocket connection from: {}", addr); + // Spawn a task to handle each WebSocket connection + tokio::spawn(handle_websocket(ws_stream)); + } + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + } + } + } + // Handle shutdown signal + _ = &mut shutdown => { + println!("Shutdown signal received"); + break; + } + } + } + + println!("Proxy shutting down gracefully"); + }); +} + +async fn handle_websocket(mut ws_stream: tokio_tungstenite::WebSocketStream) { + // WebSocket handshake is already complete when we get here + + // Connect to the target TCP server + let target_addr = "127.0.0.1:9000"; // Example target + match TcpStream::connect(target_addr).await { + Ok(mut tcp_stream) => { + println!("Connected to target TCP server at {}", target_addr); + + // Create channels for bidirectional communication + let (ws_to_tx, mut ws_to_rx) = tokio::sync::mpsc::channel(32); + let (tcp_to_tx, mut tcp_to_rx) = tokio::sync::mpsc::channel(32); + + // Spawn tasks for each direction of communication + let ws_to_tcp = tokio::spawn(async move { + while let Some(msg) = ws_to_rx.recv().await { + if let Err(e) = tcp_stream.write_all(&msg).await { + eprintln!("Failed to write to TCP stream: {}", e); + break; + } + } + }); + + let tcp_to_ws = tokio::spawn(async move { + let mut buffer = vec![0; 1024]; + while let Ok(n) = tcp_stream.read(&mut buffer).await { + if n == 0 { + break; // Connection closed + } + let data = buffer[..n].to_vec(); + if let Err(e) = tcp_to_tx.send(data).await { + eprintln!("Failed to send to WebSocket: {}", e); + break; + } + } + }); + + // WebSocket message handling task + let ws_handler = tokio::spawn(async move { + while let Some(Ok(msg)) = ws_stream.next().await { + if let Ok(data) = msg.into_data() { + if let Err(e) = ws_to_tx.send(data).await { + eprintln!("Failed to send to WS->TCP channel: {}", e); + break; + } + } + } + }); + + // TCP to WebSocket forwarding task + let tcp_forwarder = tokio::spawn(async move { + while let Some(data) = tcp_to_rx.recv().await { + if let Err(e) = ws_stream.send(tokio_tungstenite::tungstenite::Message::Binary(data)).await { + eprintln!("Failed to send to WebSocket: {}", e); + break; + } + } + }); + + // Wait for any task to complete (indicating connection end) + select! { + _ = ws_to_tcp => {} + _ = tcp_to_ws => {} + _ = ws_handler => {} + _ = tcp_forwarder => {} + } + + // Clean shutdown of TCP connection + let _ = tcp_stream.shutdown().await; + } + Err(e) => { + eprintln!("Failed to connect to target TCP server: {}", e); + let _ = ws_stream.close().await; + } + } +} +``` + +This example demonstrates: +- Custom Tokio runtime configuration with 4 worker threads +- Using `tokio::select!` to handle multiple concurrent operations (accepting connections, shutdown signal) +- Spawning multiple tasks per WebSocket connection for bidirectional communication +- Using mpsc channels for communication between tasks +- Proper resource cleanup on connection termination + +## Summary + +Asynchronous programming with Tokio enables building high-performance network applications in Rust without the overhead of thread-per-connection models. We've covered: + +1. **Why Async**: Efficiency for I/O-bound applications like network proxies +2. **async/await**: The foundation of async Rust programming +3. **Tokio Runtime**: Configurable runtime options for different workloads +4. **Spawning Tasks**: Running concurrent asynchronous operations +5. **Channels**: Communication between tasks (mpsc and oneshot) +6. **Async I/O**: Non-blocking network operations with TcpListener/TcpStream +7. **tokio::select!**: Handling multiple asynchronous operations concurrently +8. **Graceful Shutdown**: Properly handling shutdown signals for clean termination +9. **Real-world Application**: How these concepts apply to a WebSocket proxy (rs-wsProxy) + +These concepts form the foundation for building efficient, scalable network services in Rust. The ws-proxy project demonstrates how to combine these techniques to create a production-ready WebSocket to TCP proxy that can handle thousands of concurrent connections with minimal resource overhead. + +← Previous: [Traits & Generics](/posts/rust/rust-traits-generics/) +Next: [Building wsProxy — CLI, Config & Server](/posts/rust/rust-wsproxy-server/) \ No newline at end of file diff --git a/content/posts/rust/rust-async-tokio/index.md b/content/posts/rust/rust-async-tokio/index.md new file mode 100644 index 0000000..653996f --- /dev/null +++ b/content/posts/rust/rust-async-tokio/index.md @@ -0,0 +1,681 @@ +--- +title: "Async Rust กับ Tokio" +subtitle: "" +date: 2026-07-12T09:00:00+07:00 +lastmod: 2026-07-12T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: " async/await, Tokio runtime, spawning tasks, channels และ async I/O สำหรับเขียน network applications" +license: "" +images: [] +tags: ["Rust", "Tutorial", "Tokio"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + + + +## บทนำ: Async คืออะไรและทำไมถึงสำคัญ + +การเขียนโปรแกรมแบบ asynchronous (async) เป็นแนวคิดที่ช่วยให้เราสามารถเขียนโค้ดที่ทำงานหลายอย่างพร้อมกันได้โดยไม่ต้องสร้าง thread จำนวนมากๆ ซึ่งเป็นสิ่งสำคัญอย่างยิ่งสำหรับแอปพลิเคชันประเภท network applications เช่นเว็บเซิร์ฟเวอร์, proxy server, หรือไคลเอนต์ที่ต้องจัดการกับการเชื่อมต่อเครือข่ายจำนวนมากพร้อมกัน + +ในภาษา Rust แนวคิดของ async ไม่ได้ถูกฝังมาอยู่ในภาษาโดยตรงเหมือนในบางภาษา เช่น Go หรือ JavaScript แต่ Rust ให้เครื่องมือพื้นฐาน (primitives) สำหรับการเขียนโค้ดแบบ async ผ่าน `async/await` syntax และปล่อยให้ผู้พัฒนาเลือกใช้ runtime ที่เหมาะสมกับความต้องการของตนเอง ซึ่งหนึ่งใน runtime ที่ได้รับความนิยมสูงสุดคือ **Tokio** + +ในบทความก่อนหน้านี้ (ภาค 5: Traits และ Generics) เราได้พูดถึงแนวคิดพื้นฐานของ Rust ที่ช่วยให้เราเขียนโค้ดที่ทั่วถึงและปลอดภัยได้ บทความนี้เราจะต่อยอดด้วยการดูว่าเราจะใช้แนวคิดเหล่านั้นร่วมกับ async/await และ Tokio เพื่อสร้าง network application ที่มีประสิทธิภาพสูงได้อย่างไร + +## ทำไมต้องใช้ Async แทน Thread-per-Connection? + +ในแบบจำลองการเขียนโปรแกรมแบบดั้งเดิมสำหรับ network server เรามักจะสร้าง thread หนึ่งตัวขึ้นมาสำหรับแต่ละการเชื่อมต่อที่เข้ามา (thread-per-connection model) วิธีนี้ทำงานได้ดีเมื่อจำนวนการเชื่อมต่อไม่สูงมาก แต่เมื่อจำนวนการเชื่อมต่อเพิ่มขึ้นเป็นหลักหมื่นหรือหลักแสน การสร้าง thread จำนวนมากจะกินทรัพยากรระบบอย่างมาก เนื่องจากแต่ละ thread มี stack ของตัวเอง (โดยปกติแล้ว 1MB หรือมากกว่า) และการสลับบริบทระหว่าง thread (context switching) มีค่าใช้จ่ายสูง + +ในทางตรงกันข้าม โมเดลแบบ asynchronous ใช้แนวคิดของ event loop และ lightweight tasks (บางครั้งเรียกว่า green threads หรือ coroutines) ซึ่งทำงานอยู่บน thread จำนวนน้อย (เช่น จำนวน CPU cores) แต่ละ task จะถูกระงับ (suspended) เมื่อมันกำลังรอ I/O operation เช่นการอ่านข้อมูลจาก socket หรือการเขียนไฟล์ ทำให้ thread นั้นสามารถไปทำงานอื่นๆ ทำงานอื่นที่พร้อมจะทำงานได้แทน ทำให้เราสามารถจัดการกับการเชื่อมต่อจำนวนมหาศาลได้ด้วยทรัพยากรที่น้อยกว่ามาก + +ใน Rust เราไม่มี runtime async ในตัวภาษา ดังนั้นเราจึงต้องเลือกใช้ external crate เช่น `Tokio` หรือ `async-std` เพื่อให้ได้ runtime ที่พร้อมใช้งาน Tokio ได้กลายเป็นมาตรฐานเด facto สำหรับการเขียน network application ใน Rust เนื่องจากประสิทธิภาพสูง ฟีเจอร์ครบถ้วน และเอกสารที่ดี + +## Async/Await ใน Rust + +ใน Rust เราใช้คำสั่ง `async` เพื่อกำหนดฟังก์ชันที่สามารถทำงานแบบ asynchronous ได้ เมื่อเราประกาศฟังก์ชันด้วย `async fn` คอมไพเลอร์จะแปลงฟังก์ชันนั้นให้กลายเป็นฟังก์ชันที่คืนค่าเป็น `Future` ซึ่งเป็น trait ที่แสดงถึงการคำนวณที่อาจจะยังไม่เสร็จสิ้นในทันที + +ตัวอย่างฟังก์ชัน async ง่ายๆ: + +```rust +async fn fetch_data() -> String { + // จำลองการทำงานที่ใช้เวลา เช่น การดึงข้อมูลจากเครือข่าย + // ในความเป็นจริงเราอาจใช้ tokio::time::sleep หรือ tokio::net::TcpStream ที่นี่ + // แต่เพื่อความเรียบง่ายเราจะใช้ std::thread::sleep ซึ่งเป็น blocking! + // ในการใช้งานจริงเราควรใช้ async sleep จาก tokio + // แต่เพื่อแสดงให้เห็นโครงสร้าง เราจะใช้ blocking ชั่วคราว (ไม่แนะนำในโค้ดจริง) + std::thread::sleep(std::time::Duration::from_secs(1)); + "data from server".to_string() +} +``` + +อย่างไรก็ตาม ตัวอย่างข้างต้นใช้ `std::thread::sleep` ซึ่งเป็น blocking call และไม่ควรใช้ในโค้ด async จริงๆ เราควรใช้ `tokio::time::sleep` แทน แต่เราจะแสดงตัวอย่างที่ถูกต้องในภายหลัง + +เมื่อเรามี `Future` แล้ว เราใช้คำสั่ง `.await` เพื่อรอให้ future นั้นเสร็จสิ้นและได้ผลลัพธ์ออกมา ตัวอย่างการใช้งาน: + +```rust +async fn process_data() -> String { + let data = fetch_data().await; // รอจนกว่า fetch_data จะเสร็จ + format!("Processing: {}", data) +} +``` + +นอกจาก `async fn` แล้ว Rust ยังอนุญาตให้เราใช้ `async block` ได้อีกด้วย: + +```rust +let future = async { + let data = fetch_data().await; + format!("Processing: {}", data) +}; + +// เมื่อเราพร้อมที่จะรอผลลัพธ์: +// let result = future.await; +``` + +สิ่งสำคัญที่ต้องจำไว้คือ `async fn` หรือ `async block` เพียงแค่สร้าง `Future` ขึ้นมาเท่านั้น มันจะไม่เริ่มทำงานจนกว่าเราจะเรียก `.await` บนมัน ซึ่งแตกต่างจากภาษาบางภาษาที่การเรียกฟังก์ชัน async จะเริ่มทำงานทันที + +## Tokio Runtime: หัวใจของการทำงาน Async + +ใน Rust เราต้องมี runtime เพื่อที่จะรัน `Future` ของเรา Tokio เป็นหนึ่งใน runtime ที่ได้รับความนิยมสูงสุด มันให้ฟีเจอร์ครบครันสำหรับการเขียน network application รวมถึง TCP/UDP sockets, timers, สัญญาณ (signals), และ synchronization primitive ต่างๆ + +วิธีที่ง่ายที่สุดในการเริ่มต้นใช้งาน Tokio คือการใช้ attribute macro `#[tokio::main]` บนฟังก์ชัน `main`: + +```rust +use tokio; + +#[tokio::main] +async fn main() { + println!("Hello from Tokio!"); + // ที่นี่เราสามารถเรียกใช้ async function ได้โดยตรง + let data = fetch_data().await; + println!("{}", data); +} +``` + +แมคโครร `#[tokio::main]` จะสร้าง runtime แบบ multi-threaded ตามจำนวน CPU cores ของระบบโดยอัตโนมัติ และรันฟังก์ชัน `main` ของเราบน runtime นั้น + +หากเราต้องการควบคุมการตั้งค่ารายละเอียดของ runtime เราสามารถใช้ `tokio::runtime::Builder` ได้โดยตรง: + +```rust +use tokio::runtime; + +fn main() { + // สร้าง runtime แบบ multi-threaded ที่มี 4 worker threads + let runtime = runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() // เปิดฟีเจอร์ทั้งหมดที่จำเป็นสำหรับ net, time, ฯลฯ + .build() + .expect("Failed to build runtime"); + + // รัน future บน runtime ที่เราสร้างขึ้น + runtime.block_on(async { + println!("Hello from custom Tokio runtime!"); + let data = fetch_data().await; + println!("{}", data); + }); +} +``` + +เราสามารถเลือกใช้ runtime แบบ single-threaded ได้โดยใช้ `Builder::new_current_thread()` ซึ่งเหมาะสำหรับแอปพลิเคชันที่ไม่ต้องการความparallelism มากนัก หรือต้องการหลีกเลี่ยง overhead จากการสลับบริบทระหว่าง thread + +## การสร้าง Task ด้วย Tokio + +ใน Tokio เราไม่ได้สร้าง thread โดยตรง แต่เราสร้าง "task" ซึ่งเป็นหน่วยการทำงานที่เบากว่า thread มาก และถูกจัดการโดย Tokio runtime เราใช้ฟังก์ชัน `tokio::spawn` เพื่อสร้าง task ใหม่: + +```rust +#[tokio::main] +async fn main() { + // สร้าง task ใหม่ที่จะทำงานพร้อมกับ main task + let handle = tokio::spawn(async { + println!("Running in a spawned task!"); + // ทำบางอย่างที่ใช้เวลา เช่น รอเวลา + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + println!("Task completed! + }); + + // รอให้ task ที่เราสร้างเสร็จสิ้น + // handle เป็นประเภท JoinHandle ที่ช่วยให้เราสามารถรอผลลัพธ์ได้ + let result = handle.await; + println!("Task finished with result: {:?}", result); +} +``` + +ความแตกต่างระหว่าง task กับ thread: +- Task น้ำหนักเบากว่า thread มาก (มักจะใช้หน่วยความจำเพียงไม่กี่ร้อยไบต์) +- การสลับบริบทระหว่าง task ทำโดย runtime ไม่ใช่โดย kernel ดังนั้นจึงเร็วกว่า +- Task ทั้งหมดทำงานบนชุด thread เดียวกันที่ถูกจัดการโดย runtime (โดยทั่วไปคือจำนวน CPU cores) + +เมื่อเราเรียก `tokio::spawn` เราจะได้คืนค่าเป็น `JoinHandle` โดยที่ `T` คือประเภทของค่าที่ task นั้นคืนกลับมาเมื่อเสร็จสิ้น เราสามารถใช้ `.await` บน `JoinHandle` เพื่อรอให้ task เสร็จและรับค่าที่มันคืนมาได้ + +สิ่งสำคัญที่ต้องจำไว้เมื่อใช้ `tokio::spawn` คือการใช้ `move` closure หากเราต้องการย้ายค่าจากภายนอกเข้าไปใน task: + +```rust +#[tokio::main] +async fn main() { + let data = vec![1, 2, 3, 4, 5]; + let handle = tokio::spawn(move || async { + // `data` ถูกย้ายเข้าไปใน task นี้ด้วย move + let sum: i32 = data.iter().sum(); + println!("Sum: {}", sum); + sum + }); + + let result = handle.await.unwrap(); + println!("Result from task: {}", result); +} +``` + +หากเราลืม `move` คอมไพเลอร์จะแจ้งข้อผิดพลาดเพราะ `data` ถูกยืมโดย reference แต่ task ที่ถูกสปาวน์อาจมีอายุยาวนานกว่าฟังก์ชันปัจจุบัน + +## ช่องทางสื่อสารระหว่าง Task: Channels + +เมื่อเรามีหลาย task ที่ต้องการสื่อสารกัน เราสามารถใช้ช่องทาง (channels) ที่ให้โดย Tokio ได้ โดยเฉพาะอย่างยิ่ง `mpsc` (multiple producer, single consumer) ช่วยให้เราสามารถส่งข้อความจากหลาย task ไปยัง task เดียวได้อย่างปลอดภัย + +ตัวอย่างการใช้ `mpsc` channel: + +```rust +use tokio::sync::mpsc; +use tokio::time; + +#[tokio::main] +async fn main() { + // สร้าง channel: tx สำหรับส่ง, rx สำหรับรับ + let (tx, mut rx) = mpsc::channel(32); // 32 คือขนาด buffer + + // สร้างหลาย task ที่ส่งข้อความไปยังตัวรับเดียวกัน + for i in 0..3 { + let tx = tx.clone(); // คลอน tx เพื่อให้แต่ละ task มีสำเนาของตัวส่ง + tokio::spawn(async move { + // แต่ละ task ส่งข้อความหลายครั้ง + for j in 0..3 { + let msg = format!("task {} message {}", i, j); + if tx.send(msg).await.is_err() { + // ตัวรับได้ยกเลิกการทำงานแล้ว + eprintln!("Receiver dropped, stopping"); + break; + } + // หน่วงเวลาเล็กน้อยเพื่อให้เห็นการสลับกันทำงาน + time::sleep(time::Duration::from_millis(100)).await; + } + }); + } + + // ตัวรับทำงานใน main task + // เราจะรับข้อความทั้งหมดแล้วพิมพ์ออกมา + while let Some(msg) = rx.recv().await { + println!("Received: {}", msg); + } + println!("All messages received."); +} +``` + +นอกจาก `mpsc` แล้ว Tokio ยังมี `oneshot` channel สำหรับการส่งข้อความเพียงครั้งเดียวจากผู้ส่งหนึ่งไปยังผู้รับหนึ่ง ซึ่งเหมาะสำหรับการส่งสัญญาณว่า task หนึ่งได้เสร็จสิ้นงานแล้ว + +ตัวอย่างการใช้ `oneshot`: + +```rust +use tokio::sync::oneshot; +use tokio::time; + +#[tokio::main] +async fn main() { + // สร้าง oneshot channel + let (tx, rx) = oneshot::channel::(); + + // ส่งค่าจาก task หนึ่ง + tokio::spawn(async move { + // ทำบางอย่างที่ใช้เวลา + time::sleep(time::Duration::from_secs(1)).await; + // ส่งผลลัพธ์ + let _ = tx.send(42); + }); + + // รอรับผลลัพธ์ใน main task + match rx.await { + Ok(value) => println!("Received: {}", value), + Err(_) => eprintln!("Sender dropped without sending a value"), + } +} +``` + +## Async I/O กับ Tokio + +Tokio ให้การสนับสนุนการทำ I/O แบบ asynchronous ผ่าน trait เช่น `AsyncReadExt` และ `AsyncWriteExt` ซึ่งเป็นส่วนขยายของ `tokio::io::AsyncRead` และ `tokio::io::AsyncWrite` ตามลำดับ ทำให้เราสามารถใช้เมธอดเช่น `.read()`, `.write()`, `.shutdown()` ได้ในรูปแบบ async + +ตัวอย่างการเขียน Echo Server แบบง่ายๆ ด้วย Tokio: + +```rust +use tokio::net::{TcpListener, TcpStream}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() -> io::Result<()> { + // ผูกที่อยู่และพอร์ต + let listener = TcpListener::bind("127.0.0.1:8080").await?; + println!("Server listening on 127.0.0.1:8080"); + + loop { + // รอการเชื่อมต่อใหม่เข้ามา (นี้คือ async operation) + let (socket, addr) = listener.accept().await?; + println!("Accepted connection from: {}", addr); + + // สำหรับแต่ละการเชื่อมต่อ เราสร้าง task ใหม่เพื่อจัดการมัน + tokio::spawn(async move { + if let Err(e) = process_socket(socket).await { + eprintln!("Error processing socket: {}", e); + } + }); + } +} + +async fn process_socket(mut socket: TcpStream) -> io::Result<()> { + let mut buffer = [0; 1024]; + + loop { + // อ่านข้อมูลจาก socket (async) + let n = match socket.read(&mut buffer).await { + // ปิดการเชื่อมต่อเมื่ออ่านไม่ได้ข้อมูล (client ปิด connection) + Ok(0) => return Ok(()), + Ok(n) => n, + Err(e) => return Err(e), + }; + + // ส่งข้อมูลที่อ่านได้กลับไปยัง client (echo) + socket.write_all(&buffer[..n]).await?; + } +} +``` + +ในตัวอย่างข้างต้น: +1. เราสร้าง `TcpListener` ที่ฟังอยู่ที่พอร์ต 8080 +2. ในลูปไม่สิ้นสุด เราเรียก `listener.accept().await` เพื่อรอการเชื่อมต่อใหม่เข้ามาโดยไม่บล็อก thread +3. เมื่อมีการเชื่อมต่อใหม่ เราสร้าง task ใหม่ด้วย `tokio::spawn` เพื่อจัดการกับการเชื่อมต่อนั้นโดยเฉพาะ ทำให้เราสามารถจัดการการเชื่อมต่อหลายๆ รายการได้พร้อมกันโดยไม่บล็อกกัน +4. ในแต่ละ task เราอ่านข้อมูลจาก socket ด้วย `socket.read().await` และเขียนกลับไปด้วย `socket.write_all().await` ซึ่งทั้งสองการทำงานนี้เป็นแบบ non-blocking เนื่องจากเราใช้ `.await` + +## การใช้ `tokio::select!` เพื่อรอหลาย Future พร้อมกัน + +บางครั้งเราต้องการรอให้หนึ่งในหลายๆ future เสร็จสิ้นก่อน แล้วจึงดำเนินการต่อ ตัวอย่างเช่น เราอาจต้องการรอทั้งการรับข้อความจาก channel และการหมดเวลา timeout พร้อมกัน และดำเนินการตามอย่างใดอย่างหนึ่งที่เกิดขึ้นก่อน + +Tokio ให้แมโคร `select!` มาเพื่อวัตถุประสงค์นี้โดยเฉพาะ มันทำงานคล้ายกับ `match` แต่สำหรับ future โดยจะทำงานกับตัวแรกที่พร้อม + +ตัวอย่างการใช้ `select!` พร้อม timeout: + +```rust +use tokio::time::{self, Duration}; + +#[tokio::main] +async fn main() { + // สร้าง future ที่จะเสร็จหลังจาก 2 วินาที + let delay = time::sleep(Duration::from_secs(2)); + + // สร้าง future ที่จะเสร็จทันทีด้วยค่า "hello" + let message = async { "hello" }; + + // ใช้ select! เพื่อรอให้ใดๆ ของสอง future นี้เสร็จก่อน + tokio::select! { + msg = message => { + println!("Received message: {}", msg); + } + _ = delay => { + println!("Time is up!"); + } + } +} +``` + +ในตัวอย่างนี้ ข้อความ "hello" จะถูกพิมพ์ออกมาก่อนเพราะ future ของ message เสร็จเร็วกว่า delay 2 วินาที + +ตัวอย่างที่เป็นประโยชน์มากขึ้นคือการใช้ `select!` ในการทำ bidirectional communication ระหว่าง WebSocket และ TCP socket เช่นในโปรเจกต์ wsProxy ของเรา: + +```rust +// สมมติว่าเรามี ws_stream และ tcp_stream +tokio::select! { + // รับข้อความจาก WebSocket และส่งไปยัง TCP + result = ws_stream.next() => { + match result { + Some(Ok(msg)) => { + if let Err(e) = tcp_stream.write_all(&msg.into()).await { + eprintln!("Failed to write to TCP: {}", e); + break; + } + } + Some(Err(e)) => { + eprintln!("WebSocket error: {}", e); + break; + } + None => { + // WebSocket ปิดการเชื่อมต่อ + break; + } + } + } + // รับข้อมูลจาก TCP และส่งไปยัง WebSocket + result = tcp_stream.read(&mut buf) => { + match result { + Ok(0) => { + // TCP ปิดการเชื่อมต่อ + break; + } + Ok(n) => { + if let Err(e) = ws_stream.send(web_socket::Message::Binary( + buf[..n].to_vec() + )).await { + eprintln!("Failed to send to WebSocket: {}", e); + break; + } + } + Err(e) => { + eprintln!("Failed to read from TCP: {}", e); + break; + } + } + } +} +``` + +โครงสร้าง `select!` นี้ช่วยให้เราสามารถจัดการกับการไหลของข้อมูลสองทางได้อย่างมีประสิทธิภาพ โดยไม่ต้องบล็อกที่การอ่านจากทางใดทางหนึ่ง + +## การปิดการทำงานอย่างสงบ (Graceful Shutdown) + +ในแอปพลิเคชันเซิร์ฟเวอร์ที่ดี เราต้องการให้มันสามารถปิดตัวลงได้อย่างสงบเมื่อได้รับสัญญาณเช่น SIGINT (จากการกด Ctrl+C) หรือ SIGTERM แทนที่จะหยุดทำงานทันที ซึ่งอาจทำให้การเชื่อมต่อที่ยังคงอยู่ถูกตัดอย่างกะทันหัน + +Tokio ให้ฟังก์ชัน `tokio::signal::ctrl_ch()` เพื่อรอสัญญาณ Ctrl+C และเราสามารถใช้มันร่วมกับ `select!` เพื่อทำการปิดการทำงานอย่างสงบได้ + +ตัวอย่างการปิดการทำงานอย่างสงบในเซิร์ฟเวอร์ TCP: + +```rust +use tokio::net::{TcpListener, TcpStream}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; +use tokio::signal; + +#[tokio::main] +async fn main() -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:8080").await?; + println!("Server listening on 127.0.0.1:8080"); + + // สร้าง future ที่จะเสร็จเมื่อได้รับสัญญาณ Ctrl+C + let shutdown_signal = async { + signal::ctrl_c().await.expect("Failed to install CTRL+C handler"); + println!("Received shutdown signal, shutting down..."); + }; + + // เก็บตัวจัดการ task ทั้งหมดเพื่อที่เราจะสามารถรอให้พวกมันเสร็จสิ้นได้เมื่อปิด + let mut tasks = Vec::new(); + + loop { + tokio::select! { + // รอการเชื่อมต่อใหม่ + result = listener.accept() => { + match result { + Ok((socket, addr)) => { + println!("Accepted connection from: {}", addr); + // สร้าง task ใหม่สำหรับการเชื่อมต่อนี้ + let handle = tokio::spawn(process_socket(socket)); + tasks.push(handle); + } + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + } + } + } + // รอสัญญาณปิดการทำงาน + _ = shutdown_signal => { + println!("Shutdown signal received, stopping acceptance of new connections."); + break; // ออกจากลูปการรับการเชื่อมต่อใหม่ + } + } + } + + // รอให้ทุก task ที่กำลังทำงานเสร็จสิ้น + println!("Waiting for {} active connections to finish...", tasks.len()); + for task in tasks { + let _ = task.await; + } + + println!("All connections closed. Goodbye!"); + Ok(()) +} + +async fn process_socket(mut socket: TcpStream) -> io::Result<()> { + let mut buffer = [0; 1024]; + + loop { + let n = match socket.read(&mut buffer).await { + Ok(0) => return Ok(()), // Client ปิดการเชื่อมต่อ + Ok(n) => n, + Err(e) => return Err(e), + }; + + if let Err(e) = socket.write_all(&buffer[..n]).await { + return Err(e); + } + } +} +``` + +ในตัวอย่างข้างต้น: +1. เราสร้าง future `shutdown_signal` ที่จะเสร็จเมื่อได้รับสัญญาณ Ctrl+C +2. ในลูปหลัก เราใช้ `select!` เพื่อรอทั้งการเชื่อมต่อใหม่และสัญญาณปิดการทำงาน +3. เมื่อได้รับสัญญาณปิดการทำงาน เราออกจากลูปการรับการเชื่อมต่อใหม่ แต่ยังคงรอให้การเชื่อมต่อที่มีอยู่ทั้งหมดเสร็จสิ้นก่อนที่จะออกจากโปรแกรม +4. เราเก็บ `JoinHandle` ของทุก task ที่เราสร้างไว้ในเวกเตอร์ เพื่อที่เราจะสามารถรอให้พวกมันเสร็จสิ้นได้ด้วย `.await` + +## การประยุกต์ใช้ในโปรเจกต์ wsProxy + +ในโปรเจกต์ wsProxy ของเรา เราได้ใช้แนวคิดทั้งหมดที่กล่าวมาข้างต้นเพื่อสร้าง proxy ที่แปลงระหว่าง WebSocket และ TCP socket อย่างมีประสิทธิภาพ + +### การตั้งค่า Tokio Runtime + +ในฟังก์ชัน `main` ของ wsProxy เราได้ตั้งค่า Tokio runtime แบบ multi-threaded ด้วยจำนวน thread ที่สามารถตั้งค่าได้จากไฟล์การตั้งค่า: + +```rust +fn build_runtime(threads: usize) -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(threads) + .enable_all() + .build() + .expect("Failed to build Tokio runtime") +} +``` + +จากนั้นเราใช้รันไทม์นี้เพื่อรันฟังก์ชันหลักของเรา: + +```rust +fn main() -> Result<(), Box> { + let config = Config::load()?; + let runtime = build_runtime(config.server.worker_threads); + runtime.block_on(async { + if let Err(e) = run_server(config).await { + eprintln!("Server error: {}", e); + std::process::exit(1); + } + }) +} +``` + +### การจัดการการเชื่อมต่อด้วย `tokio::spawn` และ `tokio::select!` + +เมื่อมีการเชื่อมต่อ WebSocket เข้ามา เราจะสร้าง task ใหม่เพื่อจัดการกับการเชื่อมต่อนั้นโดยเฉพาะ: + +```rust +async fn handle_connection(ws_stream: WebSocketStream>, addr: SocketAddr) { + // แยกการเชื่อมต่อ WebSocket ออกเป็นตัวรับและตัวส่ง + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + + // เชื่อมต่อไปยัง TCP server ตามที่ตั้งค่าไว้ + let tcp_stream = TcpStream::connect(&config.server.target_addr).await?; + let (mut tcp_reader, mut tcp_writer) = io::split(tcp_stream); + + // สร้างช่องทางสำหรับส่งสัญญาณยกเลิกระหว่างสองทิศทาง + let (tx_to_ws, mut rx_to_ws) = mpsc::unbounded_channel(); + let (tx_to_tcp, mut rx_to_tcp) = mpsc::unbounded_channel(); + + // งานที่ 1: อ่านจาก WebSocket และส่งไปยัง TCP + let ws_to_tcp_task = tokio::spawn(async move { + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Binary(data)) => { + if tx_to_tcp.send(data).is_err() { + break; // ตัวรับถูกยกเลิกแล้ว + } + } + Ok(Message::Close(_)) => { + // ส่งสัญญาณปิดไปยัง TCP ฝั่งผู้ส่ง + let _ = tx_to_tcp.send(Vec::new()).await; + break; + } + Err(e) => { + eprintln!("WebSocket error: {}", e); + break; + } + _ => {} // ละเลยข้อความประเภทอื่นๆ เช่น Ping, Pong, Text + } + } + }); + + // งานที่ 2: อ่านจาก TCP และส่งไปยัง WebSocket + let tcp_to_ws_task = tokio::spawn(async move { + let mut buffer = vec![0; 4096]; + loop { + let n = match tcp_reader.read(&mut buffer).await { + Ok(0) => break, // TCP ปิดการเชื่อมต่อ + Ok(n) => n, + Err(e) => { + eprintln!("TCP read error: {}", e); + break; + } + }; + + // ส่งข้อมูลไปยัง WebSocket ผ่าน channel เพื่อหลีกเลี่ยงการล็อกบน ws_sender + if tx_to_ws.send(buffer[..n].to_vec()).is_err() { + break; // ตัวรับถูกยกเลิกแล้ว + } + } + }); + + // งานที่ 3: ส่งจาก channel ไปยัง WebSocket + let ws_sender_task = tokio::spawn(async move { + while let Some(data) = rx_to_ws.recv().await { + if let Err(e) = ws_sender.send(Message::Binary(data)).await { + eprintln!("WebSocket write error: {}", e); + break; + } + } + }); + + // งานที่ 4: ส่งจาก channel ไปยัง TCP + let tcp_sender_task = tokio::spawn(async move { + while let Some(data) = rx_to_tcp.recv().await { + if data.is_empty() { + // สัญญาณปิดการเชื่อมต่อ + break; + } + if let Err(e) = tcp_writer.write_all(&data).await { + eprintln!("TCP write error: {}", e); + break; + } + } + }); + + // รอให้งานทั้งสี่เสร็จสิ้น หรือเกิดข้อผิดพลาดขึ้น + tokio::select! { + _ = ws_to_tcp_task => {} + _ = tcp_to_ws_task => {} + _ = ws_sender_task => {} + _ = tcp_sender_task => {} + } + + // ปิดการเชื่อมต่อทั้งสองฝั่งอย่างสงบ + let _ = ws_sender.close().await; + let _ = tcp_writer.shutdown().await; +} +``` + +ในตัวอย่างข้างต้นเราจะเห็นการใช้: +1. `tokio::spawn` เพื่อสร้างงานหลายงานที่ทำงานพร้อมกัน +2. `mpsc::unbounded_channel()` เพื่อส่งข้อมูลระหว่างงานโดยไม่ต้องล็อก +3. `tokio::select!` เพื่อรอให้งานใดงานหนึ่งเสร็จสิ้นก่อน (ในกรณีนี้คือเมื่อมีงานใดงานหนึ่งพบข้อผิดพลาดหรือเสร็จสิ้น) +4. การปิดการเชื่อมต่ออย่างสงบโดยการปิดฝั่งส่งและเรียก `shutdown()` บน TCP writer + +### การจัดการสัญญาณปิดการทำงาน + +ในฟังก์ชัน `run_server` เราได้ตั้งค่าการฟังสัญญาณ Ctrl+C เพื่อทำการปิดการทำงานอย่างสงบ: + +```rust +async fn run_server(config: Config) -> Result<(), Box> { + let addr = format!("0.0.0.0:{}", config.server.port); + let listener = TcpListener::bind(&addr).await?; + println!("WebSocket server listening on {}", addr); + + // สร้าง future ที่จะเสร็จเมื่อได้รับสัญญาณ Ctrl+C + let shutdown_signal = async { + signal::ctrl_c().await.expect("Failed to install CTRL+C handler"); + println!("\nReceived shutdown signal, shutting down gracefully..."); + }; + + // เก็บตัวจัดการ task ของการเชื่อมต่อทั้งหมด + let mut connection_tasks = Vec::new(); + + // ลูปหลักสำหรับการรับการเชื่อมต่อใหม่ + loop { + tokio::select! { + // รอการเชื่อมต่อใหม่ + result = listener.accept() => { + match result { + Ok((ws_stream, addr)) => { + println!("New WebSocket connection from: {}", addr); + // สร้าง task ใหม่สำหรับการเชื่อมต่อนี้ + let handle = tokio::spawn(handle_connection(ws_stream, addr, config.clone())); + connection_tasks.push(handle); + } + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + } + } + } + // รอสัญญาณปิดการทำงาน + _ = shutdown_signal => { + println!("Shutdown signal received, stopping acceptance of new connections."); + break; // ออกจากลูปการรับการเชื่อมต่อใหม่ + } + } + } + + // รอให้ทุกการเชื่อมต่อที่ยังทำงานอยู่เสร็จสิ้น + println!("Waiting for {} active connections to close...", connection_tasks.len()); + for task in connection_tasks { + let _ = task.await; + } + + println!("All connections closed. Server shut down gracefully."); + Ok(()) +} +``` + +โครงสร้างนี้ทำให้เซิร์ฟเวอร์ของเราสามารถ: +1. ยอมรับการเชื่อมต่อใหม่ได้เรื่อยๆ จนกว่าจะได้รับสัญญาณปิดการทำงาน +2. เมื่อได้รับสัญญาณปิดการทำงาน มันจะหยุดการยอมรับการเชื่อมต่อใหม่ แต่ยังคงให้บริการการเชื่อมต่อที่มีอยู่จนกว่าจะเสร็จสิ้น +3. รอให้ทุกงานที่จัดการการเชื่อมต่อเสร็จสิ้นก่อนที่จะออกจากโปรแกรมอย่างสมบูรณ์ + +## สรุป + +ในบทความนี้เราได้เรียนรู้เกี่ยวกับ: + +1. **เหตุผลที่เราต้องการ Async programming** สำหรับ network applications โดยเฉพาะอย่างยิ่งเมื่อเทียบกับโมเดล thread-per-connection แบบดั้งเดิม +2. **พื้นฐานของ `async/await` ใน Rust** ว่ามันทำงานอย่างไรโดยการคืนค่าเป็น `Future` และการใช้ `.await` เพื่อรอผลลัพธ์ +3. **Tokio runtime** วิธีการตั้งค่าโดยใช้ `#[tokio::main]` หรือ `tokio::runtime::Builder` เพื่อควบคุมจำนวน worker threads และฟีเจอร์ที่เปิดใช้งาน +4. **การสร้าง task ด้วย `tokio::spawn`** ความแตกต่างระหว่าง task และ thread และความสำคัญของการใช้ `move` closure เมื่อจำเป็น +5. **การสื่อสารระหว่าง task ด้วยช่องทาง (channels)** ทั้ง `mpsc` สำหรับการสื่อสารหลายผู้ผลิตหนึ่งผู้บริโภค และ `oneshot` สำหรับการสื่อสารหนึ่งครั้ง +6. **การทำ I/O แบบ asynchronous** ด้วย `tokio::net::TcpListener`, `TcpStream`, และ trait อย่าง `AsyncReadExt` และ `AsyncWriteExt` เพื่อสร้าง echo server แบบ async +7. **การใช้ `tokio::select!`** เพื่อรอให้หนึ่งในหลายๆ future เสร็จสิ้นก่อน ซึ่งเป็นประโยชน์อย่างยิ่งสำหรับการจัดการ timeout และการสื่อสารสองทาง +8. **การปิดการทำงานอย่างสงบ (Graceful Shutdown)** ด้วยการใช้ `tokio::signal::ctrl_c()` และการรอให้ทุกงานที่กำลังทำงานเสร็จสิ้นก่อนออกจากโปรแกรม +9. **การประยุกต์ใช้ทั้งหมดนี้ในโปรเจกต์ wsProxy** เพื่อสร้าง WebSocket-to-TCP proxy ที่มีประสิทธิภาพและสามารถจัดการการเชื่อมต่อจำนวนมากได้อย่างมีประสิทธิภาพ + +แนวคิดเหล่านี้เป็นพื้นฐานสำคัญในการสร้าง network application ที่ทันสมัยและมีประสิทธิภาพสูงใน Rust ด้วยการใช้ Tokio เป็น runtime เราสามารถสร้างแอปพลิเคชันที่สามารถจัดการกับการเชื่อมต่อหลายหมื่นหรือหลักแสนการเชื่อมต่อได้โดยใช้ทรัพยากรระบบเพียงเล็กน้อยเท่านั้น + +### ลิงก์ที่เกี่ยวข้อง + +- ก่อนหน้า: [Traits และ Generics](/posts/rust/rust-traits-generics/) +- ถัดไป: [สร้าง wsProxy — CLI, Config และ Server](/posts/rust/rust-wsproxy-server/) \ No newline at end of file diff --git a/content/posts/rust/rust-collections-errors/featured-image.jpeg b/content/posts/rust/rust-collections-errors/featured-image.jpeg new file mode 100644 index 0000000..d358ca5 Binary files /dev/null and b/content/posts/rust/rust-collections-errors/featured-image.jpeg differ diff --git a/content/posts/rust/rust-collections-errors/index.en.md b/content/posts/rust/rust-collections-errors/index.en.md new file mode 100644 index 0000000..7ff10a8 --- /dev/null +++ b/content/posts/rust/rust-collections-errors/index.en.md @@ -0,0 +1,467 @@ +--- +title: "Collections, Iterators & Error Handling" +subtitle: "" +date: 2026-07-10T09:00:00+07:00 +lastmod: 2026-07-10T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Learn Vec, HashMap, iterators, and error handling with Result and the ? operator" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +Collections store multiple values, iterators process them, and error handling keeps code safe. These three concepts form the backbone of practical Rust programming. In Part 3 of our Rust series, we covered structs, enums, and pattern matching. Now we'll build on that foundation to explore how to work with collections of data, process them efficiently with iterators, and handle errors gracefully using Rust's `Result` type and the `?` operator. These concepts are used extensively in real-world Rust code, including in our `rs-wsProxy` project where we see patterns like iterator chains for building allowlists, HashMaps for redirect maps, and `Result` types for network operations. + + + +## Vec: Growable Arrays + +The `Vec` type is Rust's growable array type. It stores elements of type `T` contiguously on the heap, making it efficient for iteration and indexed access. Unlike arrays, vectors can grow and shrink in size at runtime. + +### Creating Vectors + +You can create an empty vector with `Vec::new()` or use the `vec!` macro for initialization: + +```rust +let mut numbers: Vec = Vec::new(); +// or +let numbers = vec![1, 2, 3, 4, 5]; +``` + +The `mut` keyword is necessary if you plan to modify the vector after creation. The type annotation `: Vec` is often unnecessary due to type inference when using `vec!`. + +### Adding and Removing Elements + +Use `push` to add elements to the end and `pop` to remove and return the last element: + +```rust +let mut numbers = vec![1, 2, 3]; +numbers.push(4); // [1, 2, 3, 4] +let last = numbers.pop(); // Some(4), numbers is now [1, 2, 3] +``` + +To insert or remove at specific positions, use `insert` and `remove`: + +```rust +numbers.insert(1, 99); // [1, 99, 2, 3] +let second = numbers.remove(1); // 99, numbers is now [1, 2, 3] +``` + +Note that `insert` and `remove` shift all subsequent elements, which can be slow for large vectors. + +### Accessing Elements + +You can access elements by index using square brackets, but this will panic if the index is out of bounds: + +```rust +let third = numbers[2]; // Panics if numbers has fewer than 3 elements +``` + +For safe access, use the `get` method which returns an `Option<&T>`: + +```rust +match numbers.get(2) { + Some(value) => println!("The third element is {}", value), + None => println!("There is no third element"), +} +``` + +### Iterating Over Vectors + +You can iterate over a vector in three ways, depending on whether you want to take ownership, borrow immutably, or borrow mutably: + +```rust +// Immutable borrow - iter() +for num in &numbers { + println!("{}", num); +} + +// Mutable borrow - iter_mut() +for num in &mut numbers { + *num *= 2; +} + +// Taking ownership - into_iter() +for num in numbers { + println!("{}", num); + // numbers is now empty and cannot be used again +} +``` + +### Ownership Considerations + +When you iterate with `&numbers`, you borrow the vector immutably, so you can't modify it during iteration. With `&mut numbers`, you get mutable references to each element. With `numbers` (into_iter), you take ownership of the vector and consume it, moving each element out. + +## HashMap: Key-Value Stores + +A `HashMap` stores mappings from keys of type `K` to values of type `V`. It provides average O(1) lookup, insertion, and removal times. + +### Creating and Inserting + +Create a new hash map with `HashMap::new()` or collect from an iterator: + +```rust +use std::collections::HashMap; + +let mut scores = HashMap::new(); +scores.insert(String::from("Blue"), 10); +scores.insert(String::from("Yellow"), 50); +``` + +### Accessing Values + +The `get` method returns an `Option<&V>`: + +```rust +match scores.get("Blue") { + Some(score) => println!("Blue's score: {}", score), + None => println!("No team named Blue"), +} +``` + +### Iterating + +You can iterate over keys, values, or key-value pairs: + +```rust +for (key, value) in &scores { + println!("{}: {}", key, value); +} +``` + +### The Entry API + +The `entry` API is useful for inserting a value only if a key doesn't already exist, or for updating a value based on its current value: + +```rust +// Insert if key doesn't exist +scores.entry(String::from("Green")).or_insert(30); + +// Update based on current value +scores.entry(String::from("Blue")).and_modify(|e| *e += 10); +``` + +### Ownership of Keys and Values + +For types that implement `Copy` (like integers), values are copied into the hash map. For owned types like `String`, the hash map takes ownership of the value: + +```rust +let field_name = String::from("Favorite color"); +let field_value = String::from("Blue"); + +let mut map = HashMap::new(); +map.insert(field_name, field_value); +// field_name and field_value are now invalid; they've been moved into the map +``` + +To retain ownership, insert references instead, but then the data you're pointing to must live at least as long as the hash map. + +## String and &str: Owned vs Borrowed Strings + +Rust has two main string types: `String` (owned, growable, heap-allocated) and `&str` (borrowed slice, view into existing string data). + +### String: Owned String Data + +A `String` is a growable, mutable, owned UTF-8 string: + +```rust +let mut s = String::from("hello"); +s.push_str(", world!"); +s.push('!'); +// s is now "hello, world!" +``` + +You can concatenate strings with `+` or the `format!` macro: + +```rust +let s1 = String::from("Hello"); +let s2 = String::from("world!"); +let s3 = s1 + &s2; // s1 is moved, s2 is borrowed +let s4 = format!("{} {}", s1, s2); // s1 and s2 are unchanged +``` + +Note that `+` takes ownership of the left operand and borrows the right, which is why we often see `&s2`. + +### &str: Borrowed String Slices + +A string slice `&str` is a view into a string, typically a UTF-8 slice. String literals are `&'static str`: + +```rust +let hello = "Hello, world!"; // &'static str +let s = String::from("hello world"); +let hello = &s[0..5]; // "hello" +let world = &s[6..11]; // "world" +``` + +String slices must be valid UTF-8. You can create them from `String` or string literals. + +### Why Two Types? + +The distinction exists because of Rust's ownership system. `String` owns its data and can modify it, while `&str` is a view that doesn't own the data. Use `String` when you need to own or modify string data, and `&str` when you just need to read or view string data. + +This distinction appears frequently in APIs: functions that need to own or modify strings take `String`, while functions that only need to read string data take `&str`. + +## Iterators: Lazy, Composable Iteration + +The `Iterator` trait is the foundation of Rust's iterator model. An iterator produces a sequence of values, and you can chain iterator adapters to transform sequences lazily. + +### Creating Iterators + +Vectors, slices, strings, and hash maps all provide iterator methods: + +```rust +let v = vec![1, 2, 3]; +// Immutable iteration +let iter = v.iter(); +// Mutable iteration +let iter_mut = v.iter_mut(); +// Into iteration (takes ownership) +let into_iter = v.into_iter(); +``` + +### Iterator Adaptors + +Iterator adaptors transform iterators into new iterators without consuming them until you call a consuming adaptor: + +```rust +let v = vec![1, 2, 3, 4, 5]; +// Map: transform each element +let doubled: Vec = v.iter().map(|x| x * 2).collect(); +// Filter: keep only elements that match a predicate +let evens: Vec = v.iter().filter(|x| x % 2 == 0).collect(); +// Chain: combine multiple adaptors +let result: Vec = v.iter() + .filter(|x| x % 2 == 0) + .map(|x| x * 3) + .collect(); +// result is [6, 12, 18] +``` + +### Consuming Adaptors + +Consuming adaptors consume the iterator and produce a result: + +- `collect()`: gathers items into a collection +- `fold()`: accumulates items into a single value +- `find()`: finds the first element matching a predicate +- `any()`/`all()`: test if any/all elements match a predicate + +### Zero-Cost Abstractions + +Iterator chains compile down to efficient loops equivalent to hand-written code. The compiler optimizes away the iterator overhead, making chains like `.filter().map().collect()` as fast as a manual loop. + +## Error Handling with Result + +Rust doesn't have exceptions. Instead, it uses the `Result` type for recoverable errors and `panic!` for unrecoverable errors. + +### The Result Type + +`Result` is an enum with two variants: + +```rust +enum Result { + Ok(T), + Err(E), +} +``` + +### Pattern Matching + +You can handle results with pattern matching: + +```rust +fn parse_number(s: &str) -> Result { + s.parse::() +} + +match parse_number("42") { + Ok(n) => println!("Number: {}", n), + Err(e) => println!("Failed to parse: {}", e), +} +``` + +### The ? Operator + +The `?` operator simplifies error propagation. If the result is `Ok`, it returns the inner value; if it's `Err`, it returns the error from the function: + +```rust +fn parse_and_double(s: &str) -> Result { + let n = s.parse::?; // Returns early if Err + Ok(n * 2) +} +``` + +The `?` operator can be used in any function that returns `Result` (or `Option`). + +### unwrap() and expect() + +For quick prototyping, you can use `unwrap()` (panics on `Err`) or `expect()` (panics with a custom message): + +```rust +let n = s.parse::().expect("Failed to parse number"); +``` + +Use these sparingly in production code. + +### Converting Between Error Types + +When calling functions that return different error types, you may need to convert errors: + +```rust +use std::fs::File; +use std::io; +use std::num; + +fn read_number_from_file(path: &str) -> Result> { + let mut file = File::open(path)?; // io::Error + let mut contents = String::new(); + file.read_to_string(&mut contents)?; // io::Error + let n = contents.trim().parse::()?; // num::ParseIntError + Ok(n) +} +``` + +The `?` operator automatically converts errors using `From::from` when the return type is a trait object like `Box`. + +### Custom Error Types + +For larger projects, define your own error type: + +```rust +use std::fmt; + +#[derive(Debug)] +enum AppError { + Io(io::Error), + Parse(num::ParseIntError), + NotFound, +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AppError::Io(e) => write!(f, "IO error: {}", e), + AppError::Parse(e) => write!(f, "Parse error: {}", e), + AppError::NotFound => write!(f, "Item not found"), + } + } +} + +impl std::error::Error for AppError {} + +impl From for AppError { + fn from(e: io::Error) -> Self { + AppError::Io(e) + } +} + +impl From for AppError { + fn from(e: num::ParseIntError) -> Self { + AppError::Parse(e) + } +} +``` + +Then use `?` to convert automatically: + +```rust +fn process_data(path: &str) -> Result<(), AppError> { + let data = std::fs::read_to_string(path)?; // Converts io::Error to AppError + let value = data.trim().parse::()?; // Converts ParseIntError to AppError + println!("Value: {}", value); + Ok(()) +} +``` + +## Connection to rs-wsProxy + +In our `rs-wsProxy` project, we see these concepts applied in real code: + +### Iterator Chains in `build_allowed_list` + +In `src/proxy.rs`, the `build_allowed_list` function uses an iterator chain to process a comma-separated list of origins: + +```rust +fn build_allowed_list(s: &str) -> Vec { + s.split(',') + .map(|s| s.trim()) + .filter(|s| !s.isEmpty()) + .map(|s| s.to_string()) + .collect() +} +``` + +This chain: +1. Splits the input string by commas +2. Trims whitespace from each piece +3. Filters out empty strings +4. Converts each piece to an owned `String` +5. Collects the results into a vector + +### HashMap Construction in `build_redirects` + +The `build_redirects` function creates a `HashMap` from a list of redirect rules: + +```rust +fn build_redirects(rules: &[(&str, &str)]) -> HashMap { + let mut map = HashMap::new(); + for (from, to) in rules { + map.insert(from.to_string(), to.to_string()); + } + map +} +``` + +This could also be written using `collect` on an iterator: + +```rust +fn build_redirects(rules: &[(&str, &str)]) -> HashMap { + rules.iter() + .map(|(from, to)| (from.to_string(), to.to_string())) + .collect() +} +``` + +### Error Handling with Result in `connect_tcp` + +The `connect_tcp` function returns a `Result` and uses the `?` operator for error propagation: + +```rust +fn connect_tcp(addr: &str) -> Result { + TcpStream::connect(addr) + .map_err(|e| format!("Failed to connect to {}: {}", addr, e)) +} +``` + +Here, `TcpStream::connect` returns a `Result`, and we use `map_err` to convert the `io::Error` to a `String` to match our function's error type. + +### Error Handling in `validate_tls_paths` + +The `validate_tls_paths` function returns `Result<(), String>` and uses `?` to propagate errors: + +```rust +fn validate_tls_paths(cert_path: &str, key_path: &str) -> Result<(), String> { + // Check that both files exist + std::fs::metadata(cert_path)?; + std::fs::metadata(key_path)?; + Ok(()) +} +``` + +Each call to `std::fs::metadata` returns a `Result`. The `?` operator converts the `io::Error` to a `String` (via `From::from` implementation we'd need to provide) and returns early if there's an error. + +## Summary + +We've covered Rust's core collection types (`Vec`, `HashMap`), string types (`String` vs `&str`), the powerful iterator system with its zero-cost abstractions, and Rust's approach to error handling with `Result` and the `?` operator. + +These concepts work together to enable safe, efficient, and expressive code. In `rs-wsProxy`, we see them applied in practical ways: processing configuration with iterators, building lookup tables with hash maps, and handling I/O and parsing errors with `Result`. + +Continue your Rust journey with the next topic: [Traits & Generics](/posts/rust/rust-traits-generics/). There you'll learn how to write generic code that works with multiple types and how to define shared behavior with traits. + +← Previous: [Structs, Enums & Pattern Matching](/posts/rust/rust-structs-enums/) \ No newline at end of file diff --git a/content/posts/rust/rust-collections-errors/index.md b/content/posts/rust/rust-collections-errors/index.md new file mode 100644 index 0000000..5ddba10 --- /dev/null +++ b/content/posts/rust/rust-collections-errors/index.md @@ -0,0 +1,443 @@ +--- +title: "Collections, Iterators และ Error Handling" +subtitle: "" +date: 2026-07-10T09:00:00+07:00 +lastmod: 2026-07-10T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เรียนรู้ Vec, HashMap, Iterators และการจัดการ Error ด้วย Result และ ? operator" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +การเขียนโปรแกรมใน Rust ไม่ได้หยุดอยู่แค่การเขียนฟังก์ชันและโครงสร้างข้อมูลพื้นฐานเท่านั้น แต่ยังต้องเข้าใจกลไกการจัดการข้อมูลที่ซับซ้อนขึ้น เช่น คอลเลกชันต่างๆ ที่ช่วยให้เราจัดการข้อมูลหลายๆ ชิ้นได้อย่างมีประสิทธิภาพ รวมถึงการใช้อิเทอร์เรเตอร์เพื่อประมวลผลข้อมูลเหล่านั้นอย่างมีประสิทธิภาพ และที่สำคัญที่สุดคือการจัดการข้อผิดพลาดอย่างเหมาะสมด้วยระบบ Result และตัวดำเนินการ ? ซึ่งเป็นหัวใจสำคัญของการเขียนโค้ดที่ปลอดภัยและน่าเชื่อถือใน Rust บทความนี้เป็นส่วนที่ 4 ของซีรีส์ Rust โดยต่อเนื่องจาก Part 3 ที่พูดถึง Structs, Enums และ Pattern Matching และจะนำไปสู่ Part 5 ที่จะพูดถึง Traits และ Generics ต่อไป เราจะเจาะลึกแต่ละหัวข้อด้วยคำอธิบายอย่างละเอียดและตัวอย่างโค้ดที่สามารถนำไปใช้ได้จริง + + + +## Vec + +`Vec` คือเวกเตอร์แบบไดนามิกที่เก็บข้อมูลหลายชนิดไว้ด้วยกันบนฮีป เหมือนกับ `ArrayList` ใน Java หรือ `slice` ใน Go มันสามารถขยายขนาดได้โดยอัตโนมัติเมื่อต้องการเก็บข้อมูลเพิ่ม และเป็นคอลเลกชันที่ใช้บ่อยที่สุดใน Rust เนื่องจากความยืดหยุ่นและประสิทธิภาพ + +```rust +// สร้างเวกเตอร์ว่างของจำนวนเต็ม i32 +let mut numbers: Vec = Vec::new(); +// เพิ่มค่าเข้าไปในเวกเตอร์ +numbers.push(1); +numbers.push(2); +numbers.push(3); + +// อ่านค่าดัชนีแรก - จะเกิด panic หากอยู่นอกขอบเขต +println!("First: {}", numbers[0]); // First: 1 +// ตรวจสอบความยาวของเวกเตอร์ +println!("Length: {}", numbers.len()); // Length: 3 + +// ใช้ macro vec! เพื่อสร้างเวกเตอร์พร้อมค่าเริ่มต้น +let zeros = vec![0; 5]; // สร้างเวกเตอร์ [0, 0, 0, 0, 0] +``` + +การอ่านค่าจากเวกเตอร์มีสองวิธีหลัก ได้แก่ การใช้ดัชนี `[index]` ซึ่งจะทำให้เกิด panic หากอยู่นอกขอบเขต และการใช้เมธอด `.get()` ซึ่งคืนค่าเป็น `Option<&T>` ทำให้สามารถจัดการกับกรณีที่อยู่นอกขอบเขตได้อย่างปลอดภัยโดยไม่ทำให้โปรแกรมหยุดทำงานอย่างกะทันหัน + +```rust +let fruits = vec!["apple", "banana", "cherry"]; + +// วิธีที่ 1: การเข้าถึงด้วยดัชนี - จะ panic หากดัชนีอยู่นอกขอบเขต +let first = fruits[0]; // "apple" + +// วิธีที่ 2: การใช้ get - ปลอดภัย คืนค่าเป็น Option +match fruits.get(10) { + Some(fruit) => println!("Found: {}", fruit), + None => println!("No fruit at index 10"), +} + +// ยังสามารถใช้ if let เพื่อความกระชับได้อีกด้วย +if let Some(fruit) = fruits.get(1) { + println!("Second fruit: {}", fruit); +} else { + println!("No second fruit"); +} +``` + +การลบค่าออกจากเวกเตอร์สามารถทำได้หลายวิธี เช่น `pop()` เพื่อลบและคืนค่าตัวสุดท้าย `remove(index)` เพื่อลบและคืนค่าตามดัชนีที่กำหนด หรือ `clear()` เพื่อลบทั้งหมด นอกจากนี้ยังสามารถลูปผ่านเวกเตอร์ได้โดยใช้อิเทอร์เรเตอร์ต่างๆ เช่น `.iter()` เพื่ออ่านค่าแบบอ้างอิง `.iter_mut()` เพื่อแก้ไขค่า หรือ `.into_iter()` เพื่อเป็นเจ้าของค่าและทำให้เวกเตอร์ว่างเปล่าหลังการลูป + +```rust +let mut numbers = vec![1, 2, 3, 4, 5]; + +// ลบและคืนค่าตัวสุดท้าย +let last = numbers.pop(); // Some(5) +// ลบและคืนค่าตามดัชนีที่ 0 +let first = numbers.remove(0); // 1 +// ตอนนี้ numbers คือ [2, 3, 4] + +// ลูปผ่านเวกเตอร์เพื่อพิมพ์ค่าทั้งหมด +for number in &numbers { + println!("{}", number); +} + +// ลูปเพื่อแก้ไขค่าในเวกเตอร์ +for number in &mut numbers { + *number *= 2; // คูณแต่ละค่าเป็น 2 เท่า +} +// ตอนนี้ numbers คือ [4, 6, 8] + +// ลูปด้วย into_iter เพื่อเป็นเจ้าของค่า (ทำให้เวกเตอร์ว่างหลังลูป) +let sum: i32 = numbers.into_iter().sum(); +// หลังจากนี้ numbers จะว่างเปล่าเพราะ into_iter ได้ย้ายความเป็นเจ้าของไป +``` + +## HashMap + +`HashMap` คือตารางแฮชที่เก็บข้อมูลเป็นคู่ key-value โดยที่ key จะต้องเป็นแบบที่สามารถแฮชได้ (implements the `Hash` trait) และเทียบเท่าได้ (implements the `Eq` trait) มันให้ประสิทธิภาพในการค้นหา เฉลี่ย O(1) ทำให้เหมาะกับการเก็บข้อมูลที่ต้องการค้นหาโดยใช้ key บ่อยๆ คล้ายกับ Dictionary ใน Python หรือ Object ใน JavaScript แต่มีการรับประกันความปลอดภัยของหน่วยความจำตามแบบฉบับของ Rust + +```rust +use std::collections::HashMap; + +// สร้าง HashMap ว่างที่เก็บ String เป็น key และ i32 เป็น value +let mut scores: HashMap = HashMap::new(); + +// เพิ่มคู่ key-value เข้าไปใน HashMap +scores.insert(String::from("Blue"), 10); +scores.insert(String::from("Yellow"), 50); + +// อ่านค่าจาก key - คืนค่าเป็น Option<&V> +let blue_score = scores.get("Blue"); +// จะพิมพ์ว่า Blue's score: 10 +match blue_score { + Some(score) => println!("Blue's score: {}", score), + None => println!("No score for Blue"), +} + +// อัปเดตค่าของ key ที่มีอยู่แล้ว +scores.insert(String::from("Blue"), 25); +// ตอนนี้คะแนนของ Blue คือ 25 + +// ใช้ entry API เพื่อใส่ค่าเริ่มต้นหาก key ยังไม่มีอยู่ +scores.entry(String::from("Green")).or_insert(30); +// หากไม่มี key "Green" จะใส่ค่า 30 เข้าไป +// หากมีอยู่แล้วจะไม่เปลี่ยนแปลงค่าเดิม +``` + +การทำงานกับคีย์และค่าใน HashMap มีเรื่องของการเป็นเจ้าของ (ownership) ที่สำคัญ เมื่อเราใส่ค่าแบบ `String` ลงใน HashMap HashMap จะเป็นเจ้าของค่านั้น หมายความว่าเราจะไม่สามารถใช้ตัวแปรเดิมที่เคยถือค่านั้นได้อีกต่อไปหลังจากที่ใส่เข้าไปแล้ว หากต้องการรักษาการเป็นเจ้าของไว้เราสามารถใช้อ้างอิง (`&String`) แทนได้ แต่ต้องแน่ใจว่าข้อมูลที่อ้างอิงนั้นมีอายุยืนยาวกว่า HashMap + +```rust +use std::collections ฮ HashMap; + +let field_name = String::from("Favorite color"); +let field_value = String::from("Blue"); + +let mut map = HashMap::new(); +// การใส่ String เข้าไปจะทำให้ HashMap เป็นเจ้าของ field_name และ field_value +map.insert(field_name, field_value); +// field_name และ field_value ไม่สามารถใช้งานได้อีกต่อไปที่นี่ +// println!("{}", field_name); // จะเกิด error เพราะถูกย้ายไปแล้ว + +// หากต้องการใช้ค่าเดิมต่อได้ ให้ใช้การอ้างอิงแทน +let field_name = String::from("Favorite color"); +let field_value = String::from("Blue"); + +let mut map = HashMap::new(); +// ใส่การอ้างอิงเข้าไป - แต่ต้องแน่ใจว่าข้อมูลที่อ้างอิงอยู่นานกว่า HashMap +map.insert(&field_name, &field_value); +// field_name และ field_value ยังสามารถใช้งานได้ต่อไป +println!("{}", field_name); // ปลอดภัย +``` + +การลูปผ่าน HashMap สามารถทำได้สามรูปแบบเหมือนกับเวกเตอร์ แต่จะได้คู่ key-value กลับมาเป็นทูเปิล เราสามารถเลือกลูปแบบอ้างอิงของทั้งคู่ อ้างอิงแบบเปลี่ยนแปลงได้ หรือเป็นเจ้าของทั้งคู่ ขึ้นอยู่กับความต้องการในการใช้งาน + +```rust +use std::collections::HashMap; + +let mut scores = HashMap::new(); +scores.insert(String::from("Blue"), 10); +scores.insert(String::from("Yellow"), 50); + +// ลูปแบบอ้างอิงเพื่ออ่านค่าเท่านั้น +for (key, value) in &scores { + println!("{}: {}", key, value); +} + +// ลูปแบบอ้างอิงที่เปลี่ยนแปลงได้เพื่อแก้ไขค่า +for (key, value) in &mut scores { + if key == "Blue" { + *value += 5; // เพิ่มคะแนน Blue ขึ้น 5 + } +} + +// ลูปแบบเป็นเจ้าของ - จะทำให้ HashMap ว่างหลังจากลูปจบ +for (key, value) in scores { + println!("{}: {}", key, value); +} +// ตอนนี้ scores ว่างเปล่าแล้ว +``` + +## String และ &str + +ใน Rust มีสองประเภทหลักสำหรับการจัดการสตริง ได้แก่ `String` ซึ่งเป็นสตริงที่ถูกเป็นเจ้าของ เก็บไว้บนฮีป สามารถขยายขนาดได้ และสามารถแก้ไขได้ และ `&str` ซึ่งเป็นสตริงสไลซ์ที่เป็นการอ้างอิงไปยังส่วนหนึ่งของสตริงที่อยู่ที่ใดที่หนึ่ง ไม่ว่าจะเป็นในไบนารี่ (string literal) หรือใน `String` ที่ถูกเป็นเจ้าของอยู่ การเข้าใจความแตกต่างระหว่างสองประเภทนี้เป็นสิ่งสำคัญเพื่อหลีกเลี่ยงข้อผิดพลาดเกี่ยวกับการเป็นเจ้าของและการยืมข้อมูล + +```rust +// สร้าง String ใหม่ที่ว่างเปล่า +let mut s = String::new(); +// เพิ่มข้อความเข้าไปใน String +s.push_str("hello"); +s.push('!'); // เพิ่มตัวอักษรเดียว +// ตอนนี้ s คือ "hello!" + +// สร้าง String จาก literal โดยใช้ to_string() หรือ String::from +let hello = String::from("hello"); +// หรือ +let hello = "hello".to_string(); + +// การต่อสตริงด้วย + หรือ format! +let s1 = String::from("Hello, "); +let s2 = String::from("world!"); +let s3 = s1 + &s2; // s1 ถูกย้ายไปและไม่สามารถใช้งานได้อีกต่อไป +// หรือใช้ format! ซึ่งไม่ทำให้เกิดการย้าย +let s1 = String::from("Hello, "); +let s2 = String::from("world!"); +let s3 = format!("{}{}", s1, s2); // s1 และ s2 ยังสามารถใช้งานได้ต่อไป + +// การตัดสตริงเป็นสไลซ์ &str +let hello = String::from("hello world"); +// สร้างสไลซ์จากดัชนี 0 ถึง 5 (ไม่รวม 5) +let hello = &hello[0..5]; // "hello" +// หรือจากดัชนี 6 ถึงสิ้นสุด +let world = &hello[6..]; // "world" +``` + +เหตุผลที่เราต้องมีทั้ง `String` และ `&str` ก็เพื่อให้ Rust สามารถจัดการหน่วยความจำได้อย่างปลอดภัยและมีประสิทธิภาพ `String` เป็นเจ้าของข้อมูลและรับผิดชอบในการปล่อยหน่วยความจำเมื่อหมดขอบเขต ในขณะที่ `&str` เป็นเพียงการอ้างอิงที่ไม่มีความรับผิดชอบในการปล่อยหน่วยความจำ ทำให้เราสามารถส่งต่อการอ้างอิงไปยังฟังก์ชันต่างๆ ได้โดยไม่ต้องกังวลเรื่องการย้ายหรือการคัดลอกข้อมูลที่มีขนาดใหญ่ + +```rust +// ฟังก์ชันที่รับ &str เป็นพารามิเตอร์สามารถรับได้ทั้ง String และ string literal +fn print_str(s: &str) { + println!("{}", s); +} + +// การใช้งาน +let owned = String::from("Hello"); +let literal = "World"; + +print_str(&owned); // ต้องใช้ & เพื่อยืมการอ้างอิง +print_str(literal); // ส่งตรงได้เพราะ string literal เป็น &str อยู่แล้ว +``` + +## Iterators + +อิเทอร์เรเตอร์ใน Rust เป็นวิธีที่ทรงพลังในการประมวลผลชุดข้อมูลแบบลำดับโดยไม่ต้องกังวลเกี่ยวกับการจัดการดัชนีหรือเงื่อนไขการหยุดลูป มันขึ้นอยู่กับทเรท `Iterator` ที่กำหนดเมธอด `next()` ซึ่งจะคืนค่า `Option` โดยเมื่อหมดองค์ประกอบจะคืนค่า `None` ความสวยงามของอิเทอร์เรเตอร์อยู่ที่การเป็น zero-cost abstraction หมายความว่าการใช้งานมันจะเร็วเท่ากับการเขียนลูปด้วยมือ แต่มีความปลอดภัยและความกระชับมากกว่า + +```rust +let v1 = vec![1, 2, 3]; + +// สร้างอิเทอร์เรเตอร์แบบอ้างอิงเพื่ออ่านค่า +let v1_iter = v1.iter(); + +// ลูปผ่านอิเทอร์เรเตอร์ด้วย for loop (ซึ่งจะเรียก .into_iter() ภายใต้ฝากระโปรงสำหรับคอลเลกชัน) +for val in v1_iter { + println!("Got: {}", val); +} + +// หรือใช้เมธอดของอิเทอร์เรเตอร์โดยตรงผ่านการเรียกเมธอดซ้อนกัน (method chaining) +let v2: Vec = v1.iter() + .map(|x| x + 1) // เพิ่มค่าแต่ละตัวขึ้น 1 + .filter(|x| *x % 2 == 0) // กรองเฉพาะเลขคู่ + .collect(); // รวบรวมผลลัพธ์เป็นเวกเตอร์ใหม่ +// v2 จะเป็น [2, 4] +``` + +อิเทอร์เรเตอร์มีเมธอดที่เรียกว่าอะแดปเตอร์ (adapters) ซึ่งสามารถนำมาเรียงกันเป็นลำดับเพื่อทำการแปลงข้อมูลที่ซับซ้อนได้โดยยังคงประสิทธิภาพสูง เมธอดเหล่านี้ได้แก่ `.map()` สำหรับการแปลงแต่ละองค์ประกอบ `.filter()` สำหรับการกรองเงื่อนไข `.take()` สำหรับการจำกัดจำนวนองค์ประกอบ และ `.collect()` สำหรับการรวบรวมผลลัพธ์ลงในคอลเลกชัน เช่น เวกเตอร์ หรือแฮชแมป + +```rust +let numbers = vec![1, 2, 3, 4, 5, 6]; + +// หาผลรวมของเลขคู่ที่ยกกำลังสองที่น้อยกว่า 20 +let sum_of_squares: i32 = numbers.iter() + .filter(|&x| x % 2 == 0) // เลือกเฉพาะเลขคู่ + .map(|x| x * x) // ยกกำลังสอง + .take_while(|&x| x < 20) // หยุดเมื่อค่ามากกว่าหรือเท่ากับ 20 + .sum(); // คำนวณผลรวม +// ขั้นตอน: [2,4,6] -> [4,16,36] -> [4,16] (เพราะ 36 >= 20) -> ผลรวม = 20 +``` + +เมธอด `.into_iter()` จะทำให้เราเป็นเจ้าของค่าในคอลเลกชัน ซึ่งเหมาะสมเมื่อเราต้องการเปลี่ยนแปลงหรือใช้ค่าที่ได้จากการลูปโดยไม่ต้องกังวลเรื่องการยืมอ้างอิง ในขณะที่ `.iter_mut()` จะให้เราสามารถแก้ไขค่าในคอลเลกชันได้โดยตรงผ่านการอ้างอิงที่เปลี่ยนแปลงได้ + +```rust +let mut names = vec!["Alice".to_string(), "Bob".to_string(), "Charlie".to_string()]; + +// ใช้ iter_mut เพื่อเปลี่ยนชื่อทั้งหมดให้เป็นตัวพิมพ์ใหญ่ +for name in names.iter_mut() { + *name = name.to_uppercase(); +} +// ตอนนี้ names คือ ["ALICE", "BOB", "CHARLIE"] + +// ใช้ into_iter เพื่อเป็นเจ้าของชื่อและสร้างเวกเตอร์ใหม่ของความยาวชื่อ +let name_lengths: Vec = names.into_iter() + .map(|name| name.len()) + .collect(); +// หลังจากนี้ names จะไม่สามารถใช้งานได้อีกต่อไปเพราะถูกย้ายไปแล้ว +// name_lengths คือ [5, 3, 7] +``` + +## Error Handling ด้วย Result + +ใน Rust การจัดการข้อผิดพลาดทำผ่านประเภท `Result` ซึ่งเป็นเอ็นัมที่มีสองแขน: `Ok(T)` สำหรับกรณีสำเร็จที่มีค่าผลลัพธ์ของประเภท T และ `Err(E)` สำหรับกรณีล้มเหลวที่มีข้อผิดพลาดของประเภท E วิธีนี้บังคับให้ผู้เขียนโค้ดต้องจัดการกับกรณีที่อาจล้มเหลวอย่างชัดเจน ป้องกันการละเลยข้อผิดพลาดโดยไม่ได้ตั้งใจ ซึ่งแตกต่างจากการใช้ข้อยกเว้นในภาษาอื่นๆ ที่อาจถูกมองข้ามได้ + +```rust +use std::fs::File; + +// ฟังก์ชันที่อ่านไฟล์และคืนค่า Result +fn open_file(filename: &str) -> Result { + let f = File::open(filename); + f +} + +// การใช้งานฟังก์ชันข้างต้นด้วยการจับคู่รูปแบบ (pattern matching) +match open_file("hello.txt") { + Ok(file) => println!("File opened successfully"), + Err(e) => println!("Failed to open file: {}", e), +} +``` + +เพื่อให้การเขียนโค้ดที่จัดการกับข้อผิดพลาดสะดวกุล่ิน Rust มีตัวดำเนินการ `?` ซึ่งสามารถใช้ได้เฉพาะในฟังก์ชันที่คืนค่าเป็น `Result` หรือ `Option` ตัวดำเนินการนี้จะทำหน้าที่เหมือนกับการจับคู่รูปแบบแต่ในรูปแบบที่กระชับกว่า: หากผลลัพธ์เป็น `Ok` จะคืนค่าที่อยู่ภายในออกมา แต่หากเป็น `Err` จะคืนค่าข้อผิดพลาดนั้นออกจากฟังก์ชันทันที (early return) ทำให้โค้ดอ่านและเขียนได้ง่ายขึ้นมาก + +```rust +use std::fs::File; +use std::io::{self, Read}; + +fn read_username_from_file() -> Result { + let mut username = String::new(); + + // เปิดไฟล์ หากล้มเหลวจะคืนค่าข้อผิดพลาดทันที + let mut file = File::open("username.txt")?; + // อ่านข้อมูลจากไฟล์ลงใน string หากล้มเหลวจะคืนค่าข้อผิดพลาดทันที + file.read_to_string(&mut username)?; + // หากทุกอย่างสำเร็จ จะคืนค่า Ok(username) + Ok(username) +} +``` + +แม้ว่า `?` จะสะดวก แต่ก็ยังมีเมธอด `unwrap()` และ `expect()` ที่สามารถใช้ได้เมื่อเรามั่นใจว่าการดำเนินการนั้นจะไม่ล้มเหลว เช่น เมื่อเราแน่ใจว่าไฟล์มีอยู่จริงในช่วงเวลาทำงาน อย่างไรก็ตามการใช้เมธอดเหล่านี้ควรทำด้วยความระมัดระวังเพราะหากเกิดข้อผิดพลาดขึ้นจริง ๆ จะทำให้เกิด panic และหยุดการทำงานของโปรแกรมทันที `expect()` ช่วยให้เราสามารถระบุข้อความข้อผิดพลาดที่ต้องการแสดงได้เมื่อเกิด panic ซึ่งช่วยในการดีบักได้ดีกว่า `unwrap()` ที่ไม่มีข้อความอธิบาย + +```rust +use std::fs::File; + +// สมมติว่าเราแน่ใจว่าไฟล์นี้มีอยู่จริงในระบบไฟล์ของเรา +let f = File::open("essential_config.txt").unwrap(); +// หรือพร้อมข้อความอธิบายเมื่อเกิดข้อผิดพลาด +let f = File::open("essential_config.txt") + .expect("Failed to open essential_config.txt"); +``` + +ในกรณีที่เราต้องการแปลงข้อผิดพลาดจากประเภทหนึ่งไปเป็นอีกประเภทหนึ่ง เช่น จาก `std::io::Error` เป็นข้อผิดพลาดเฉพาะของแอปพลิเคชันของเรา เราสามารถใช้เมธอด `.map_err()` บน `Result` เพื่อแปลงข้อผิดพลาดได้ นอกจากนี้ยังสามารถนิยามประเภทข้อผิดพลาดเฉพาะของตัวเองโดยใช้เอ็นัมและทำการแปลงโดยใช้ `From` trait เพื่อให้สามารถใช้ตัวดำเนินการ `?` ได้อย่างไร้รอยต่อ + +```rust +use std::fmt; +use std::fs::File; +use std::io; + +// นิยามประเภทข้อผิดพลาดเฉพาะของแอปพลิเคชัน +#[derive(Debug)] +enum AppError { + Io(io::Error), + NotFound, +} + +// ทำให้ AppError สามารถแปลงจาก io.::Error ได้โดยอัตโนมัติ +impl From for AppError { + fn from(err: io::Error) -> Self { + AppError::Io(err) + } +} + +// ทำให้ App สามารถแสดงผลได้ด้วย fmt::Display +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AppError::Io(e) => write!(f, "I/O error: {}", e), + AppError::NotFound => write!(f, "Resource not found"), + } + } +} + +// ตอนนี้เราสามารถใช้ ? กับ AppError ได้โดยตรง +fn read_config_file() -> Result { + let mut content = String::new(); + File::open("config.txt")?.read_to_string(&mut content)?; + Ok(content) +} +``` + +## เชื่อมโยงกับ rs-wsProxy + +ในโครงการจริงอย่าง rs-wsProxy เราจะเห็นการประยุกต์ใช้แนวคิดทั้งหมดที่ได้พูดถึงไปแล้ว ตัวอย่างเช่น ในฟังก์ชัน `build_allowed_list` เราจะเห็นการใช้งานอิเทอร์เรเตอร์อย่างต่อเนื่องเพื่อแปลงและกรองข้อมูลจากสตริงที่คั่นด้วยเครื่องหมายจุลภาค รายการที่ว่างเปล่าจะถูกกรองออกและช่องว่างรอบๆ จะถูกตัดออก ก่อนจะเก็บผลลัพธ์ลงในเวกเตอร์ของสตริง + +```rust +fn build_allowed_list(hosts: &str) -> Vec { + hosts.split(',') // แบ่งสตริงด้วยเครื่องหมายจุลภาค + .map(|s| s.trim()) // ตัดช่องว่างที่อยู่ด้านหน้าและด้านหลัง + .filter(|s| !s.is_empty()) // กรองออกสตริงที่ว่างเปล่า + .map(|s| s.to_string()) // แปลงจาก &str เป็น String เพื่อเป็นเจ้าของข้อมูล + .collect() // รวบรวมผลลัพธ์เป็น Vec +} +``` + +ฟังก์ชัน `build_redirects` แสดงให้เห็นถึงการใช้งาน `HashMap` เพื่อสร้างความสัมพันธ์ระหว่างเส้นทางต้นทางและเส้นทางปลายทางในการตั้งค่าการเปลี่ยนเส้นทาง โดยใช้เมธอด `insert` เพื่อเพิ่มคู่ key-value ลงในแฮชแมป ซึ่งแสดงถึงการเป็นเจ้าของข้อมูลของทั้ง key และ value เมื่อพวกเขาเป็นประเภท `String` + +```rust +use std::collections::HashMap; + +fn build_redirects() -> HashMap { + let mut redirects = HashMap::new(); + redirects.insert(String::from("/old-path"), String::from("/new-target")); + redirects.insert(String::from("/another/old"), String::from("/another/new")); + redirects +} +``` + +ในส่วนของการจัดการข้อผิดพลาด ฟังก์ชัน `connect_tcp` แสดงให้เห็นถึงการใช้งาน `Result` และตัวดำเนินการ `?` เพื่อจัดการกับข้อผิดพลาดจากการเชื่อมต่อ TCP โดยฟังก์ชันนี้คืนค่าเป็น `Result` ซึ่งหมายถึงหากสำเร็จจะได้ `TcpStream` หากล้มเหลวจะได้ข้อความข้อผิดพลาดเป็น `String` การใช้ `?` หลังจากการเรียก `TcpStream::connect` จะทำให้หากเกิดข้อผิดพลาดขึ้นฟังก์ชันจะคืนค่าข้อผิดพลาดนั้นทันทีโดยแปลงมันให้เป็น `String` ผ่านการใช้ `to_string()` + +```rust +use std::net::TcpStream; +use std::io; + +fn connect_tcp(addr: &str) -> Result { + // พยายามเชื่อมต่อ TCP หากล้มเหลวจะคืนค่า Err พร้อมข้อความข้อผิดพลาด + TcpStream::connect(addr).map_err(|e| e.to_string()) +} +``` + +สุดท้าย ฟังก์ชัน `validate_tls_paths` แสดงให้เห็นถึงการใช้งาน `Result<(), String>` เพื่อบ่งบอกว่าการตรวจสอบสำเร็จหรือล้มเหลว โดยไม่มีข้อมูลผลลัพธ์ใดๆ คืนกลับมาเมื่อสำเร็จ (ใช้หน่วยย่อย `()`) แต่เมื่อล้มเหลวจะคืนข้อความอธิบายข้อผิดพลาด ฟังก์ชันนี้ตรวจสอบว่ามีไฟล์ใบรับรองและคีย์ส่วนตัวอยู่หรือไม่ โดยใช้การจับคู่รูปแบบบนผลลัพธ์ของ `metadata()` เพื่อตรวจสอบว่าเป็นไฟล์จริงหรือไม่ + +```rust +use std::fs::metadata; +use std::io; + +fn validate_tls_paths(cert_path: &str, key_path: &str) -> Result<(), String> { + // ตรวจสอบไฟล์ใบรับรอง + let cert_meta = metadata(cert_path).map_err(|e| format!("Failed to read cert: {}", e))?; + if !cert_meta.is_file() { + return Err("Cert path is not a file".to_string()); + } + + // ตรวจสอบไฟล์คีย์ส่วนตัว + let key_meta = metadata(key_path).map_err(|e| format!("Failed to read key: {}", e))?; + if !key_meta.is_file() { + return Err("Key path is not a file".to_string()); + } + + Ok(()) +} +``` + +## สรุป + +ในบทความนี้เราได้สำรวจแนวคิดหลักๆ ของ Rust อย่างลึกซึ้ง ตั้งแต่การทำงานกับคอลเลกชันพื้นฐานอย่าง `Vec` และ `HashMap` ซึ่งแสดงให้เห็นถึงการจัดการหน่วยความจำและการเป็นเจ้าของที่ปลอดภัย ไปจนถึงการใช้อิเทอร์เรเตอร์เพื่อประมวลผลข้อมูลแบบลำดับอย่างมีประสิทธิภาพและเป็น zero-cost abstraction เรายังได้พูดถึงความแตกต่างที่สำคัญระหว่าง `String` และ `&str` ซึ่งเป็นหัวใจสำคัญของการทำงานกับข้อความใน Rust อย่างปลอดภัยและมีประสิทธิภาพ + +นอกจากนี้เรายังได้เจาะลึกระบบการจัดการข้อผิดพลาดของ Rust ด้วยประเภท `Result` และตัวดำเนินการ `?` ซึ่งบังคับให้เราต้องจัดการกับข้อผิดพลาดอย่างชัดเจน จึงช่วยลดโอกาสเกิดบั๊กจากการละเลยข้อผิดพลาด เราได้เห็นวิธีการแปลงข้อผิดพลาดและการสร้างประเภทข้อผิดพลาดเฉพาะเพื่อให้เหมาะกับบริบทของแอปพลิเคชัน + +สุดท้ายเราได้เห็นการประยุกต์ใช้แนวคิดเหล่านี้ในโครงการจริงอย่าง rs-wsProxy ซึ่งแสดงให้เห็นว่าแนวคิดเหล่านี้ไม่ใช่เพียงทฤษฎีเท่านั้น แต่สามารถนำไปใช้แก้ปัญหาจริงๆ ได้อย่างมีประสิทธิภาพ ตั้งแต่การประมวลผลรายการโฮสต์ด้วยอิเทอร์เรเตอร์ การจัดการการเปลี่ยนเส้นทางด้วยแฮชแมป ไปจนถึงการจัดการข้อผิดพลาดในการเชื่อมต่อเครือข่ายและการตรวจสอบไฟล์ TLS + +หากต้องการศึกษาต่อในหัวข้อต่อไปของซีรีส์นี้ โปรดดูบทความก่อนหน้าเกี่ยวกับ Structs, Enums และ Pattern Matching ที่ [Structs, Enums และ Pattern Matching](/posts/rust/rust-structs-enums/) และเตรียมตัวพบกับบทความถัดไปเกี่ยวกับ Traits และ Generics ที่ [Traits และ Generics](/posts/rust/rust-traits-generics/) ซึ่งจะพาเราไปสู่ความเข้าใจในระดับที่สูงขึ้นเกี่ยวกับระบบชนิดแบบทั่วไปและพอลิมอร์ฟิซึมใน Rust \ No newline at end of file diff --git a/content/posts/rust/rust-getting-started/featured-image.jpeg b/content/posts/rust/rust-getting-started/featured-image.jpeg new file mode 100644 index 0000000..46d15e0 Binary files /dev/null and b/content/posts/rust/rust-getting-started/featured-image.jpeg differ diff --git a/content/posts/rust/rust-getting-started/index.en.md b/content/posts/rust/rust-getting-started/index.en.md new file mode 100644 index 0000000..b479421 --- /dev/null +++ b/content/posts/rust/rust-getting-started/index.en.md @@ -0,0 +1,490 @@ +--- +title: "Getting Started with Rust" +subtitle: "" +date: 2026-07-07T09:00:00+07:00 +lastmod: 2026-07-07T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Install Rust, learn Cargo and basic syntax — the first step toward building a WebSocket-to-TCP proxy" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +# Getting Started with Rust + +This series started when I saw a Facebook post from [rayrag.com](https://rayrag.com/) showing that you can play **Ragnarok Online (RO)** right in your web browser — connecting through WebSocket to an emulator game server. That got me curious about how it works under the hood, and I wanted to build my own WebSocket-to-TCP proxy using a language that's fast and safe. That's how I came back to revisit Rust and created the [rs-wsProxy](https://github.com/bouroo/rs-wsProxy) project. + + + +Welcome to the first part of our 8-part series "Learning Rust — Building a WebSocket-to-TCP Proxy". In this series, we'll revisit Rust fundamentals and build a production-ready WebSocket-to-TCP proxy that works with [roBrowser](https://github.com/vthibault/roBrowser). + +## Why Rust? + +Rust offers a unique combination of performance, safety, and concurrency without garbage collection. It gives you control over low-level details while preventing entire classes of bugs at compile time. This makes it perfect for building network applications like our WebSocket-to-TCP proxy where performance and reliability are critical. + +## Installing Rust + +The recommended way to install Rust is through `rustup`, which manages Rust versions and associated tools. + +Open your terminal and run: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +This script will download and install `rustup`, which in turn installs: +- `rustc` - the Rust compiler +- `cargo` - Rust's package manager and build tool +- `rust-std` - the standard library +- `rust-docs` - documentation + +After installation, you need to source the environment: + +```bash +source $HOME/.cargo/env +``` + +Verify the installation: + +```bash +rustc --version +cargo --version +``` + +You should see output similar to: +``` +rustc 1.70.0 (90c541806 2023-05-31) +cargo 1.70.0 (aba3780b2 2023-05-15) +``` + +To update Rust to the latest stable version later, simply run: + +```bash +rustup update +``` + +## Hello, Cargo + +Cargo is Rust's build system and package manager. It handles downloading dependencies, compiling your code, and creating distributable packages. + +Let's create a new project: + +```bash +cargo new hello_rust +cd hello_rust +``` + +This creates a directory structure: +``` +hello_rust/ +├── Cargo.toml +└── src/ + └── main.rs +``` + +`Cargo.toml` is the manifest file where you configure your project and dependencies. `src/main.rs` contains the source code. + +Let's look at the generated `main.rs`: + +```rust +fn main() { + println!("Hello, world!"); +} +``` + +Run the program: + +```bash +cargo run +``` + +Output: +``` +Hello, world! +``` + +For optimized builds (suitable for release), use: + +```bash +cargo build --release +``` + +The compiled binary will be in `target/release/hello_rust`. + +Let's examine `Cargo.toml`: + +```toml +[package] +name = "hello_rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +``` + +Key sections: +- `[package]`: Metadata about your project +- `[dependencies]`: External crates your project depends on + +The `edition = "2021"` specifies we're using Rust 2021 edition, which we'll use throughout this tutorial. + +## Variables and Mutability + +In Rust, variables are immutable by default. This helps prevent accidental changes and makes code easier to reason about. + +```rust +let x = 5; // immutable +// x = 6; // This would cause a compile-time error +``` + +To make a variable mutable, use `mut`: + +```rust +let mut y = 5; +y = 6; // This is allowed +println!("y = {}", y); // Prints: y = 6 +``` + +### Shadowing + +You can declare a new variable with the same name as a previous variable. This is called shadowing. + +```rust +let z = 5; +let z = z + 1; // New variable z shadows the previous z +let z = z * 2; +println!("z = {}", z); // Prints: z = 12 +``` + +Shadowing is different from mutability because it creates a new variable, allowing you to change the type if needed. + +### Constants + +Constants are similar to immutable variables but are declared with `const` and must be annotated with a type. They're valid for the entire time a program runs. + +```rust +const MAX_POINTS: u32 = 100_000; +``` + +Constants can be declared in any scope, including global scope, and must be set to a constant expression. + +## Data Types + +Rust is statically typed, meaning types must be known at compile time. However, the compiler can often infer types. + +### Scalar Types + +Scalar types represent a single value. Rust has four primary scalar types: + +#### Integers +Integers come in signed and unsigned variants, with different sizes: + +| Length | Signed | Unsigned | +|--------|--------|----------| +| 8-bit | i8 | u8 | +| 16-bit | i16 | u16 | +| 32-bit | i32 | u32 | +| 64-bit | i64 | u64 | +| 128-bit| i128 | u128 | +| arch | isize | usize | + +`isize` and `usize` depend on the architecture of the computer (64-bit or 32-bit). + +Example: +```rust +let a: i32 = 42; +let b: u64 = 1_000_000_000; +``` + +#### Floating-Point Numbers +Rust has two primitive types for floating-point numbers: + +```rust +let x = 2.0; // f64 by default +let y: f32 = 3.0; // f32 +``` + +#### Boolean +The `bool` type has two possible values: `true` and `false`. + +```rust +let is_rust_fun = true; +let is_java_fun = false; +``` + +#### Character +The `char` type represents a Unicode scalar value, meaning it can represent much more than just ASCII. + +```rust +let c = 'z'; +let z = 'ℤ'; +let heart_eyed_cat = '😻'; +``` + +Note: Char literals are specified with single quotes, unlike string literals which use double quotes. + +### Compound Types + +Compound types can group multiple values into one type. Rust has two primitive compound types: tuples and arrays. + +#### Tuples +Tuples group together a fixed number of values with different types. + +```rust +let tup: (i32, f64, u8) = (500, 6.4, 1); +``` + +To get the individual values out of a tuple, we can use pattern matching to destructure: + +```rust +let (x, y, z) = tup; +println!("The value of y is: {}", y); // Prints: 6.4 +``` + +Alternatively, we can access elements directly with a dot followed by the index: + +```rust +let five_hundred = tup.0; +let six_point_four = tup.1; +let one = tup.2; +``` + +#### Arrays +Arrays in Rust have a fixed length, and every element must have the same type. + +```rust +let a = [1, 2, 3, 4, 5]; +``` + +Arrays are useful when you want your data allocated on the stack rather than the heap, or when you want to ensure you always have a fixed number of elements. + +You can also initialize an array with the same value for each element: + +```rust +let b = [3; 5]; // Creates [3, 3, 3, 3, 3] +``` + +To access array elements, use indexing: + +```rust +let first = a[0]; +let second = a[1]; +``` + +Note: Accessing an index beyond the array's length will cause a runtime panic. + +## Functions + +Functions are the building blocks of Rust code. We've already seen the `main` function, which is the entry point of every executable program. + +Let's look at a simple function: + +```rust +fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +This function takes two `i32` parameters and returns their sum as an `i32`. + +### Expressions vs Statements + +Rust is an expression-based language, which means most things are expressions rather than statements. + +- **Statements** perform actions but don't return a value. +- **Expressions** evaluate to a resulting value. + +In the `add` function above, `a + b` is an expression that evaluates to the sum. The entire function body is an expression, and its value is returned implicitly (note the lack of semicolon). + +If we add a semicolon, it becomes a statement: + +```rust +fn add(a: i32, b: i32) -> i32 { + a + b; // This is a statement, returns () +} +``` + +This would cause a compile error because the function is expected to return an `i32` but returns `()` (unit type) instead. + +### Early Return + +You can return early from a function using the `return` keyword: + +```rust +fn foo(x: i32) -> i32 { + if x < 0 { + return -1; // Early return + } + x * x +} +``` + +However, Rust encourages using expressions and match statements for control flow rather than early returns when possible. + +## Control Flow + +Control flow determines the order in which code executes based on conditions. + +### if/else + +```rust +let number = 6; + +if number % 4 == 0 { + println!("number is divisible by 4"); +} else if number % 3 == 0 { + println!("number is divisible by 3"); +} else if number % 2 == 0 { + println!("number is divisible by 2"); +} else { + println!("number is not divisible by 4, 3, or 2"); +} +``` + +### if as Expression + +Because `if` is an expression, we can use it on the right-hand side of a let statement: + +```rust +let condition = true; +let number = if condition { 5 } else { 6 }; + +println!("The value of number is: {}", number); // Prints: 5 +``` + +Note that all arms of the if must return the same type. + +### loop + +Rust provides a `loop` keyword to execute a block of code repeatedly until you explicitly tell it to stop. + +```rust +let mut counter = 0; + +let result = loop { + counter += 1; + + if counter == 10 { + break counter * 2; // We can return a value from break + } +}; + +println!("The result is {}", result); // Prints: 20 +``` + +### while + +A `while` loop is useful when you want to loop as long as a condition holds. + +```rust +let mut number = 3; + +while number != 0 { + println!("{}!", number); + number -= 1; +} + +println!("LIFTOFF!!!"); +``` + +### for with Ranges + +The `for` loop is commonly used to iterate over a collection. We can use ranges to generate a sequence of numbers. + +```rust +// Range expression: start..end (exclusive of end) +for number in 0..5 { + println!("{}!", number); +} +// Prints: 0! 1! 2! 3! 4! + +// Inclusive range: start..=end +for number in 1..=5 { + println!("{}!", number); +} +// Prints: 1! 2! 3! 4! 5! +``` + +## Comments + +Comments are ignored by the compiler but help humans understand the code. + +### Line Comments + +Line comments start with `//` and continue to the end of the line. + +```rust +// This is a line comment +let x = 5; // This is also a line comment +``` + +### Block Comments + +Block comments start with `/*` and end with `*/`. They can span multiple lines. + +```rust +/* This is a block comment + that spans multiple lines */ +let y = 6; +``` + +Block comments can be nested: + +```rust +/* This is a block comment + /* with a nested block comment */ + and back to the outer block */ +let z = 7; +``` + +### Documentation Comments + +Documentation comments generate HTML documentation. They use three slashes (`///`) or an exclamation mark followed by two slashes (`//!`). + +```rust +/// Adds one to the number given. +/// +/// # Examples +/// +/// ``` +/// let arg = 5; +/// let answer = my_crate::add_one(arg); +/// assert_eq!(6, answer); +/// ``` +pub fn add_one(x: i32) -> i32 { + x + 1 +} +``` + +Module documentation comments (`//!`) are placed at the top of a file to document the entire module: + +```rust +//! # My Crate +//! +//! `my_crate` is a collection of utilities to make performing certain +//! calculations more convenient. +``` + +## Summary + +In this first part of our Rust series, we've covered: +- Why Rust is a great choice for systems programming +- How to install and update Rust using rustup +- Creating and running your first Cargo project +- Variables, mutability, shadowing, and constants +- Rust's data types including scalars (integers, floats, booleans, chars) and compounds (tuples, arrays) +- Writing functions, understanding expressions vs statements, and early returns +- Control flow with if/else, loops, and for loops with ranges +- Different types of comments including documentation comments + +You now have enough knowledge to write basic Rust programs and understand the syntax. In the next part, we'll dive into Rust's ownership system, which is what makes Rust unique in providing memory safety without garbage collection. + +**Next: [Ownership, Borrowing & Lifetimes](/posts/rust/rust-ownership-borrowing/)** + +Continue your Rust journey by learning about ownership, the cornerstone of Rust's safety guarantees. \ No newline at end of file diff --git a/content/posts/rust/rust-getting-started/index.md b/content/posts/rust/rust-getting-started/index.md new file mode 100644 index 0000000..26feab9 --- /dev/null +++ b/content/posts/rust/rust-getting-started/index.md @@ -0,0 +1,424 @@ +--- +title: "เริ่มต้นกับ Rust" +subtitle: "" +date: 2026-07-07T09:00:00+07:00 +lastmod: 2026-07-07T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "ติดตั้ง Rust เรียนรู้ Cargo และไวยากรณ์พื้นฐาน ก้าวแรกสู่การสร้าง WebSocket-to-TCP Proxy" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +# เริ่มต้นกับ Rust + +จุดเริ่มต้นของซีรีส์นี้มาจากโพสต์บน Facebook ที่เห็นจาก [rayrag.com](https://rayrag.com/) ที่แสดงให้เห็นว่าเราสามารถเล่น **Ragnarok Online (RO)** บนเว็บเบราว์เซอร์ได้เลย ผ่านการเชื่อมต่อ WebSocket ไปยัง emulator server ของเกม นั่นทำให้ผมอยากรู้ว่ามันทำงานยังไง และอยากลองสร้าง WebSocket-to-TCP proxy ของตัวเองด้วยภาษาที่เร็วและปลอดภัย — นั่นคือจุดของการกลับมาทบทวน Rust อีกครั้ง และโปรเจกต์ [rs-wsProxy](https://github.com/bouroo/rs-wsProxy) + +ทำไมต้อง Rust? เพราะมันให้ทั้งประสิทธิภาพระดับระบบ (ระดับ C/C++) ความปลอดภัยด้านหน่วยความจำโดยไม่ต้องใช้ garbage collector และรองรับการเขียนโปรแกรมแบบพร้อมกันได้อย่างปลอดภัย นี่เป็นบทแรกของซีรีส์ 8 ตอนที่จะพาคุณทบทวน Rust ไปจนถึงสร้าง WebSocket-to-TCP Proxy ที่ใช้งานจริงกับ roBrowser + + + +## ติดตั้ง Rust + +วิธีที่แนะนำคือการใช้ `rustup` ซึ่งเป็นเครื่องมือจัดการเวอร์ชันและเครื่องมือของ Rust + +เปิดเทอร์มินัลและรันคำสั่งต่อไปนี้: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +สคริปต์นี้จะติดตั้ง `rustup` ซึ่งจะจัดการการติดตั้ง `rustc` (คอมไพเลอร์), `cargo` (ตัวจัดการแพ็กเกจ และเครื่องมืออื่นๆ) + +หลังจากติดตั้งเสร็จ ให้รีสตาร์ทเทอร์มินัลหรือรัน `source $HOME/.cargo/env` เพื่อให้ `cargo` และ `rustc` อยู่ใน PATH ของคุณ + +ตรวจสอบการติดตั้ง: + +```bash +rustc --version +cargo --version +``` + +คุณควรเห็นเวอร์ชันของ Rust และ Cargo ที่ติดตั้งไว้ + +เพื่ออัปเดต Rust เป็นเวอร์ชันล่าสุดในอนาคต ให้รัน: + +```bash +rustup update +``` + +## Hello, Cargo + +Cargo คือระบบสร้างและจัดการแพ็กเกจของ Rust มันจัดการการสร้างโค้ด การดาวน์โหลดไลบรารี่ที่จำเป็น และการอัปโหลดแพ็กเกจของคุณไปยัง crates.io + +มาเริ่มโครงการแรกกัน: + +```bash +cargo new hello_rust +cd hello_rust +``` + +คำสั่ง `cargo new` จะสร้างไดเรกทอรีใหม่ชื่อ `hello_rust` พร้อมด้วยโครงสร้างต่อไปนี้: + +``` +hello_rust +├── Cargo.toml +└── src + └── main.rs +``` + +- `Cargo.toml`: ไฟล์ตั้งค่าโครงการ ซึ่งประกอบด้วยข้อมูลเมทาดาต้าเช่นชื่อโครงการ เวอร์ชัน และขึ้นต่อกับไลบรารี่อื่นๆ +- `src/main.rs`: ไฟล์โค้ดหลักของโปรแกรม + +เปิดไฟล์ `src/main.rs` คุณจะเห็น: + +```rust +fn main() { + println!("Hello, world!"); +} +``` + +รันโปรแกรมด้วยคำสั่ง: + +```bash +cargo run +``` + +คุณจะเห็นผลลัพธ์: + +``` + Compiling hello_rust v0.1.0 (/path/to/hello_rust) + Finished dev [unoptimized + debuginfo] target(s) in 0.50s + Running `target/debug/hello_rust` +Hello, world! +``` + +คำสั่ง `cargo build` จะคอมไพล์โปรแกรมโดยไม่รัน มันจะสร้างไฟล์ exécutable ที่ `target/debug/hello_rust` + +สำหรับการสร้างเวอร์ชันที่ปรับประสิทธิภาพสูงสุด ให้ใช้: + +```bash +cargo build --release +``` + +ไฟล์ exécutable จะถูกวางไว้ที่ `target/release/hello_rust` + +ในไฟล์ `Cargo.toml` คุณจะเห็นเนื้อหาพื้นฐานดังนี้: + +```toml +[package] +name = "hello_rust" +version = "0.1.0" +edition = "2021" + +# ดูเพิ่มเติมเกี่ยวกับคีย์อื่น ๆ ได้ที่ https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +``` + +ส่วน `[package]` มีข้อมูลเกี่ยวกับแพ็กเกจ ส่วน `[dependencies]` คือที่ที่คุณระบุไลบรารี่ภายนอกที่โครงการของคุณต้องการ + +## ตัวแปรและความไม่แน่นอน + +ใน Rust ตัวแปรโดยค่าเริ่มต้นจะไม่สามารถเปลี่ยนแปลงได้ (immutable) หากคุณต้องการให้สามารถเปลี่ยนแปลงได้ คุณต้องใช้คีย์เวิร์ด `mut` + +```rust +let x = 5; // x เป็น immutable ไม่สามารถเปลี่ยนค่าได้ +let mut y = 10; // y เป็น mutable สามารถเปลี่ยนค่าได้ + +y = 15; // ถูกต้อง เพราะ y เป็น mutable +// x = 6; // ผิด! x เป็น immutable ไม่สามารถเปลี่ยนค่าได้ +``` + +Rust ยังสนับสนุนแนวคิดที่เรียกว่า **shadowing** ซึ่งคุณสามารถประกาศตัวแปรใหม่ด้วยชื่อเดียวกันได้ และตัวแปรใหม่นี้จะบังแสง (shadow) ตัวแปรเก่า + +```rust +let x = 5; +let x = x + 1; // ตอนนี้ x คือ 6 +let x = x * 2; // ตอนนี้ x คือ 12 +// ตัวแปร x ตัวแรกและตัวที่สองถูกบังแสงไปแล้ว +``` + +สำหรับค่าคงที่ (constant) ใช้คีย์เวิร์ด `const` ค่าคงที่ต้องมีการระบุประเภทข้อมูลอย่างชัดเจน และค่าของมันต้องเป็นค่าคงที่ที่รู้ได้ในเวลาคอมไพล์ + +```rust +const MAX_POINTS: u32 = 100_000; +// ตัวอย่างการใช้ขีดล่างเพื่อเพิ่มความอ่านง่าย +``` + +ตัวแปรที่ประกาศด้วย `let` มีช่วงอายุ (scope) อยู่ในบล็อกที่มันถูกประกาศ ในขณะที่ `const` มีช่วงอายุตลอดทั้งโปรแกรม + +## ประเภทข้อมูล + +Rust เป็นภาษาที่มีการกำหนดประเภทแบบ static แต่มีการอนุมานประเภท (type inference) ที่ยอดเยี่ยม ซึ่งหมายความว่าคุณไม่จำเป็นต้องระบุประเภทเสมอไป เว้นแต่ว่าคอมไพเลอร์ไม่สามารถอนุมานได้ + +### ประเภทจำนวนเต็ม (Integers) + +| ความยาว | ที่มีเครื่องหมาย | ไม่มีเครื่องหมาย | +|:-------:|:--------------:|:--------------:| +| 8-bit | i8 | u8 | +| 16-bit | i16 | u16 | +| 32-bit | i32 | u32 | +| 64-bit | i64 | u64 | +| arch | isize | usize | + +ตัวอย่าง: +```rust +let a: i8 = -128; +let b: u64 = 4294967295; +``` + +### จำนวนจุดลอยตัว (Floating-Point Numbers) + +Rust มีสองประเภทสำหรับตัวเลขจุดลอยตัวซึ่งเป็นไปตามมาตรฐาน IEEE-754: +- `f32`: จำนวนจุดลอยตัวความแม่นยำเดี่ยว 32 บิต +- `f64`: จำนวนจุดลอยตัวความแม่นยำคู่ 64 บิต (ค่าเริ่มต้น) + +```rust +let x = 2.0; // f64 +let y: f32 = 3.0; // f32 +``` + +### บูลีน (Boolean) + +ประเภท `bool` มีสองค่า: `true` และ `false` + +```rust +let t = true; +let f: bool = false; // ระบุประเภทอย่างชัดเจนได้หากจำเป็น +``` + +### อักขระ (Character) + +ประเภท `char` แทนอักขระ Unicode สกัลลาร์เดียว ซึ่งหมายความว่ามันสามารถแทนอักขระได้มากกว่าแค่ ASCII เช่น ตัวอักษรภาษาไทย อีโมจิ เป็นต้น + +```rust +let c = 'z'; +let z = 'ℤ'; +let heart_eyed_cat = '😻'; +``` + +### ทูเพิล (Tuples) + +ทูเพิลคือวิธีการจัดกลุ่มค่าหลายชนิดเข้าด้วยกันเป็นหนึ่งคอมโพเนนต์หลัก ทูเพิลมีความยาวคงที่: เมื่อประกาศแล้ว พวกมันจะไม่สามารถขยายหรือย่อขนาดได้ + +```rust +let tup: (i32, f64, u8) = (500, 6.4, 1); + +// การแยกส่วนทูเพิล (destructuring) +let (x, y, z) = tup; +println!("The value of y is: {y}"); + +// การเข้าถึงโดยใช้อินเด็กซ์ (จุด) +println!("The value of x is: {}", tup.0); +``` + +### อาร์เรย์ (Arrays) + +ต่างจากทูเพิล อาร์เรย์ใน Rust มีทุกองค์ประกอบเป็นประเภทเดียวกัน และมีความยาวคงที่ + +```rust +let a = [1, 2, 3, 4, 5]; +// ประเภทของ a คือ [i32; 5] หมายถึงอาร์เรย์ของ i32 จำนวน 5 องค์ประกอบ + +// การเข้าถึงองค์ประกอบแรกและที่สอง +let first = a[0]; +let second = a[1]; + +// คุณสามารถกำหนดค่าเริ่มต้นให้กับทุกองค์ประกอบได้ด้วย: +// let a = [3; 5]; // เทียบเท่ากับ [3, 3, 3, 3, 3] +``` + +หมายเหตุ: การเข้าถึงดัชนีที่อยู่นอกขอบเขตจะทำให้เกิด `panic` ในเวลาทำงาน (เพราะ Rust ตรวจสอบขอบเขตของอาร์เรย์) + +## ฟังก์ชัน + +ฟังก์ชันใน Rust ประกาศด้วยคีย์เวิร์ด `fn` พารามิเตอร์ต้องระบุประเภท และถ้าฟังก์ชันคืนค่า ก็ต้องระบุประเภทของค่าที่คืนหลังจาก `->` + +```rust +fn add(a: i32, b: i32) -> i32 { + a + b // นิพจน์โดยไม่มีจุดคั่นจะถูกส่งกลับเป็นค่าที่คืนมา +} +``` + +เรียกใช้ฟังก์ชัน: + +```rust +fn main() { + let sum = add(5, 10); + println!("The sum is {sum}"); +} +``` + +### นิพจน์และคำสั่ง + +- **นิพจน์ (Expression)**: คืนค่าอะไรบางอย่าง และไม่ลงท้ายด้วยจุดคั่น (ยกเว้นเมื่อใช้เป็นส่วนหนึ่งของคำสั่งที่ใหญ่กว่า) +- **คำสั่ง (Statement)**: ทำการกระทำบางอย่าง แต่ไม่คืนค่า + +ตัวอย่างของนิพจน์: `x + 1`, `if condition { 5 } else { 6 }`, `loop { break 5; }` +ตัวอย่างของคำสั่ง: `let x = 5;` (การประกาศตัวแปรเป็นคำสั่ง), `x = 5;` (การกำหนดค่าเป็นคำสั่ง) + +การคืนค่าจากฟังก์ชันโดยใช้คีย์เวิร์ด `return` นั้นทำได้ แต่โดยทั่วไปแล้วเราจะใช้การทิ้งจุดคั่นทิ้งไปที่บรรทัดสุดท้ายเพื่อให้มันเป็นนิพจน์และคืนค่า + +```rust +fn add_with_return(a: i32, b: i32) -> i32 { + return a + b; // ทำได้แต่ไม่จำเป็น +} + +fn add_without_return(a: i32, b: i32) -> i32 { + a + b // นิพจน์นี้จะถูกส่งกลับมา +} +``` + +## การควบคุมการทำงาน + +### นิพจน์ `if` + +`if` เป็นนิพจน์ใน Rust หมายความว่ามันสามารถคืนค่าได้ + +```rust +let condition = true; +let number = if condition { 5 } else { 6 }; + +println!("The value of number is: {number}"); +``` + +คุณสามารถมี `else if` ได้หลายเงื่อนไข + +```rust +let number = 6; +if number % 4 == 0 { + println!("number is divisible by 4"); +} else if number % 3 == 0 { + println!("number is divisible by 3"); +} else if number % 2 == 0 { + println!("number is divisible by 2"); +} else { + println!("number is not divisible by 4, 3, or 2"); +} +``` + +### ลูป + +Rust มีสามประเภทของลูปหลักๆ: `loop`, `while`, และ `for` + +#### `loop` + +ลูป `loop` จะทำงานไปเรื่อยๆ อย่างไม่มีเงื่อนไขจนกว่าจะเจอคำสั่ง `break` + +```rust +let mut counter = 0; + +let result = loop { + counter += 1; + + if counter == 10 { + break counter * 2; // คืนค่าจากลูป + } +}; + +println!("The result is {result}"); +``` + +#### `while` + +ลูป `while` จะทำงานต่อไปตราบเท่าที่เงื่อนไขเป็นจริง + +```rust +let mut number = 3; + +while number != 0 { + println!("{number}!"); + + number -= 1; +} + +println!("LIFTOFF!!!"); +``` + +#### `for` + +ลูป `for` ใช้สำหรับวนซ้ำไอเท็มในคอลเลกชัน เช่น อาร์เรย์ หรือเรนจ์ (range) + +```rust +let a = [10, 20, 30, 40, 50]; + +for element in a { + println!("the value is: {element}"); +} + +// การใช้เรนจ์ (range) +for number in 1..4 { + println!("{number}!"); +} +// ผลลัพธ์: 1, 2, 3 (ไม่รวม 4) + +// เรนจ์แบบปิด (รวมค่าสุดท้าย) +for number in 1..=4 { + println!("{number}!"); +} +// ผลลัพธ์: 1, 2, 3, 4 + +// เรนจ์แบบย้อนกลับ +for number in (1..4).rev() { + println!("{number}!"); +} +// ผลลัพธ์: 3, 2, 1 +``` + +## คอมเมนต์ + +คอมเมนต์ใน Rust คล้ายกับหลายๆ ภาษา + +- `//` สำหรับคอมเมนต์หนึ่งบรรทัด +- `/* */` สำหรับคอมเมนต์หลายบรรทัด + +ตัวอย่าง: + +```rust +// นี่คือคอมเมนต์หนึ่งบรรทัด + +/* +นี่คือคอมเมนต์ +หลายบรรทัด +*/ +``` + +คอมเมนต์สำหรับเอกสารประกอบ (documentation comments) ใช้ `///` สำหรับการอธิบายไอเท็มถัดไป (เช่น ฟังก์ชัน หรือสตรัคต์) และ `//!` สำหรับการอธิบายไอเท็มที่บรรจุ (เช่น โมดูล หรือเครต) + +```rust +/// เพิ่มตัวเลขหนึ่งเข้ากับอีกตัวเลขหนึ่ง. +/// +/// # ตัวอย่าง +/// +/// ``` +/// let result = add(1, 2); +/// assert_eq!(result, 3); +/// ``` +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +//! ไฟล์นี้มีฟังก์ชันที่เป็นประโยชน์สำหรับการคำนวณทางคณิตศาสตร์พื้นฐาน +``` + +## สรุป + +ในบทนี้เราได้: +- ติดตั้ง Rust ด้วย `rustup` +- สร้างและรันโปรเจกต์แรกด้วย Cargo +- เรียนรู้เกี่ยวกับตัวแปร ความไม่แน่นอน และการบังแสง +- สำรวจประเภทข้อมูลพื้นฐาน: จำนวนเต็ม จุดลอยตัว บูลีน อักขระ ทูเพิล และอาร์เรย์ +- เขียนฟังก์ชันและเข้าใจความแตกต่างระหว่างนิพจน์และคำสั่ง +- ควบคุมการทำงานของโปรแกรมด้วย `if`, `loop`, `while`, และ `for` +- เขียนคอมเมนต์และคอมเมนต์สำหรับเอกสารประกอบ + +ในส่วนถัดไป เราจะดำดิ่งลงไปในแนวคิดที่สำคัญที่สุดของ Rust นั่นคือ **การเป็นเจ้าของ (Ownership)** ซึ่งเป็นหัวใจของการรับประกันความปลอดภัยด้านหน่วยความจำโดยไม่ต้องใช้ garbage collector + +ถัดไป: [Ownership, Borrowing และ Lifetimes](/posts/rust/rust-ownership-borrowing/) \ No newline at end of file diff --git a/content/posts/rust/rust-ownership-borrowing/featured-image.jpeg b/content/posts/rust/rust-ownership-borrowing/featured-image.jpeg new file mode 100644 index 0000000..43fbf06 Binary files /dev/null and b/content/posts/rust/rust-ownership-borrowing/featured-image.jpeg differ diff --git a/content/posts/rust/rust-ownership-borrowing/index.en.md b/content/posts/rust/rust-ownership-borrowing/index.en.md new file mode 100644 index 0000000..c41991e --- /dev/null +++ b/content/posts/rust/rust-ownership-borrowing/index.en.md @@ -0,0 +1,237 @@ +--- +title: "Ownership, Borrowing & Lifetimes" +subtitle: "" +date: 2026-07-08T09:00:00+07:00 +lastmod: 2026-07-08T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "The heart of Rust: ownership, borrowing, references, and lifetimes that make Rust memory-safe without a garbage collector" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +Ownership is Rust's most distinctive feature, enabling memory safety guarantees without needing a garbage collector. This system enforces strict rules about how data is accessed and modified, preventing entire classes of bugs at compile time. Understanding ownership is essential for writing safe and efficient Rust code. This tutorial builds upon the concepts introduced in [Getting Started with Rust](/posts/rust/rust-getting-started/). + + + +## The Stack and the Heap + +Memory in Rust is managed through two primary regions: the stack and the heap. The stack stores data with a fixed, known size at compile time and operates in a last-in, first-out manner, making it extremely fast. The heap stores data of unknown or changing size, requiring dynamic allocation and deallocation, which is slower but more flexible. Ownership rules govern how data moves between these regions, ensuring heap data is properly cleaned up when no longer needed. + +Consider a string literal stored on the stack versus a `String` on the heap: + +```rust +let s1 = "hello"; // &str: string literal, stored in binary, stack reference +let mut s2 = String::from("hello"); // String: heap allocation +s2.push_str(", world"); // Modifiable heap data +``` + +Here, `s1` is a string literal stored in the program's read-only memory, accessible via a stack-based reference. `s2` points to heap-allocated memory that can grow. When `s2` goes out of scope, its heap memory is automatically freed. This distinction is crucial: stack data is copied by value, while heap data requires explicit ownership transfers to prevent double-free errors. + +## Ownership Rules + +Rust's ownership system enforces three fundamental rules: each value has exactly one owner, there can only be one owner at a time, and when the owner goes out of scope, the value is dropped. These rules ensure memory safety without runtime overhead. The compiler enforces them at compile time, catching errors before runtime. + +Consider this scope example demonstrating ownership and dropping: + +```rust +{ + let s = String::from("hello"); // s enters scope + // s is valid here +} // s goes out of scope, drop is called, memory freed +``` + +When `s` leaves the scope, Rust automatically calls `drop`, returning the heap memory to the system. If we tried to use `s` after the closing brace, we'd get a compile-time error. This automatic cleanup prevents memory leaks and dangling pointers, common issues in garbage-collected or manually managed languages. + +## Move Semantics + +When assigning a `String` to another variable, Rust moves ownership rather than copying the heap data. This prevents expensive deep copies and ensures only one owner exists. Attempting to use the original variable after a move results in a compile-time error. + +Here's a classic move semantics example: + +```rust +let s1 = String::from("hello"); +let s2 = s1; // Ownership moves to s2 + +println!("{}", s1); // ERROR: borrow of moved value: `s1` +``` + +After `s2 = s1`, `s1` is no longer valid. Unlike types that implement the `Copy` trait (like integers), `String` owns heap data, so moving is more efficient than copying. To explicitly duplicate heap data, use the `clone` method: + +```rust +let s1 = String::from("hello"); +let s2 = s1.clone(); // Deep copy of heap data + +println!("{}", s1); // OK: s1 still valid +println!("{}", s2); // OK: s2 has its own copy +``` + +Functions also follow move semantics when taking ownership of parameters: + +```rust +fn takes_ownership(s: String) { + println!("{}", s); +} // s is dropped here + +let s = String::from("hello"); +takes_ownership(s); +// println!("{}", s); // ERROR: value moved +``` + +To return ownership from a function, simply return the `String`: + +```rust +fn gives_ownership() -> String { + let s = String::from("hello"); + s // s is returned, moving ownership to caller +} + +let s = gives_ownership(); // s owns the returned String +``` + +## References and Borrowing + +Instead of taking ownership, functions can borrow references using `&` (immutable) or `&mut` (mutable). Immutable references allow multiple readers, while mutable references require exclusive access. The borrowing rules prevent data races at compile time: you can have either many immutable references or exactly one mutable reference to a particular piece of data in a particular scope. + +Consider these borrowing examples: + +```rust +let s = String::from("hello"); + +let len1 = calculate_length(&s); // Immutable borrow +let len2 = calculate_length(&s); // Another immutable borrow - OK + +fn calculate_length(s: &String) -> usize { + s.len() +} // s goes out of scope, but &s does not own the data +``` + +Attempting to create a mutable reference while an immutable one exists causes a compile error: + +```rust +let mut s = String::from("hello"); + +let r1 = &s; // Immutable borrow +let r2 = &s; // Another immutable borrow - OK +let r3 = &mut s; // ERROR: cannot borrow as mutable while also borrowed as immutable + +println!("{}, {}, {}", r1, r2, r3); +``` + +To fix this, ensure mutable borrows are exclusive: + +```rust +let mut s = String::from("hello"); + +let r1 = &s; // OK +let r2 = &s; // OK +println!("{} {}", r1, r2); // r1 and r2 no longer used after this point + +let r3 = &mut s; // OK: previous immutable borrows are not used here +println!("{}", r3); +``` + +## Slices + +Slices let you reference a contiguous sequence of elements in a collection without taking ownership. A string slice (`&str`) references part of a `String`, while a slice `[T]` works for arrays or vectors. Slices are fat pointers containing a pointer to the data and a length, enabling safe, efficient access to portions of data. + +String slices are particularly useful for working with string literals and substrings: + +```rust +let s = String::from("hello world"); + +let hello = &s[0..5]; // Reference to bytes 0..5 (not including 5) +let world = &s[6..11]; // Reference to bytes 6..11 + +// String literals are slices +let literal = "hello world"; // &str type + +// String slice of entire string +let whole = &s[..]; // Equivalent to &s[0..s.len()] +``` + +Attempting to create an invalid slice (out-of-bounds or invalid UTF-8 boundary) causes a panic: + +```rust +let s = String::from("hello"); + +// let oops = &s[0..10]; // PANIC: index out of bounds +// For UTF-8, slicing in the middle of a character panics: +let hello = "Здравствуйте"; // Russian for "hello" +// let oops = &hello[0..1]; // PANIC: byte index 1 is not a char boundary +``` + +Array slices work similarly: + +```rust +let a = [1, 2, 3, 4, 5]; + +let slice = &a[1..3]; // Reference to elements at index 1 and 2 +assert_eq!(slice, &[2, 3]); + +// Slices coerce to []T automatically in many contexts +fn print_slice(slice: &[i32]) { + println!("{:?}", slice); +} + +print_slice(&a); // Works because &[i32; 5] coerces to &[i32] +``` + +## Connection to rs-wsProxy + +In the `rs-wsProxy` project, ownership and borrowing principles directly influence design. The `connect_tcp` function takes an address as a string slice (`&str`), borrowing the address without taking ownership, which is efficient for configuration strings that often live for the program's lifetime: + +```rust +// Simplified from rs-wsProxy +fn connect_tcp(addr: &str) -> std::io::Result { + TcpStream::connect(addr) +} + +// Usage: +let addr = "127.0.0.1:8080".to_string(); +connect_tcp(&addr); // Borrow the String as &str +// addr remains valid after the call +``` + +For sharing state across asynchronous tasks, `Arc` provides thread-safe shared ownership. `Arc` (Atomic Reference Counting) allows multiple threads to own a value, incrementing/decrementing an atomic counter to track references. When the count reaches zero, the value is dropped: + +```rust +use std::sync::Arc; + +struct AppState { + config: Config, + stats: Statistics, +} + +// In main or initialization: +let state = Arc::new(AppState { + config: load_config(), + stats: Statistics::new(), +}); + +// When spawning tasks: +let state_clone = Arc::clone(&state); // Increments reference count +tokio::spawn(async move { + // Use state_clone here + // When this task ends, state_clone is dropped, decrementing the count +}); + +// Original state can still be used here +``` + +This pattern ensures `AppState` remains valid as long as at least one task holds a reference, preventing use-after-free in concurrent scenarios. + +## Summary + +Ownership, borrowing, and lifetimes form Rust's guarantee of memory safety without garbage collection. Ownership ensures each value has a single owner responsible for cleanup. Borrowing allows temporary access via references, governed by strict rules preventing data races. Slices provide efficient views into data without ownership transfer. Lifetimes ensure references remain valid for their intended use. Together, these concepts enable fearless concurrency and predictable resource management. + +Understanding these concepts is crucial for effective Rust programming, as evidenced in real-world projects like `rs-wsProxy` where they enable safe, high-performance networking code. Mastering ownership transforms how you think about memory and data relationships, leading to more robust and efficient systems. + +← Previous: [Getting Started with Rust](/posts/rust/rust-getting-started/) +Next: [Structs, Enums & Pattern Matching](/posts/rust/rust-structs-enums/) \ No newline at end of file diff --git a/content/posts/rust/rust-ownership-borrowing/index.md b/content/posts/rust/rust-ownership-borrowing/index.md new file mode 100644 index 0000000..5edc97d --- /dev/null +++ b/content/posts/rust/rust-ownership-borrowing/index.md @@ -0,0 +1,432 @@ +--- +title: "เรียนรู้ Ownership, Borrowing และ Lifetimes" +subtitle: "" +date: 2026-07-08T09:00:00+07:00 +lastmod: 2026-07-08T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "หัวใจของ Rust: Ownership, Borrowing, References และ Lifetimes ที่ทำให้ Rust ปลอดภัยด้านหน่วยความจำโดยไม่ต้องมี Garbage Collector" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- +Ownership คือหัวใจของ Rust ที่ทำให้ปลอดภัยด้านหน่วยความจำโดยไม่ต้องมี Garbage Collector เชื่อมจากภาค 1 (`/posts/rust/rust-getting-started/`) + + + +## Stack และ Heap + +ใน Rust ความจำแบ่งออกเป็นสองส่วนหลัก ได้แก่ Stack และ Heap ซึ่งมีลักษณะการทำงานที่แตกต่างกันอย่างชัดเจน Stack คือหน่วยความจำที่จัดสรรแบบ LIFO (Last In First Out) มีความเร็วสูงและขนาดที่ทราบ ณ เวลาคอมไพล์ เหมาะกับตัวแปรประเภทพื้นฐานเช่น integers, booleans, และข้อมูลที่มีขนาดคงที่ เข้าใช้งานได้เร็วเพราะอยู่ใกล้กับหน่วยประมวลผลกลาง (CPU) มากที่สุด ส่วน Heap คือพื้นที่หน่วยความจำที่ใช้สำหรับข้อมูลที่ขนาดไม่ทราบล่วงหน้าหรืออาจเปลี่ยนแปลงได้ขณะทำงาน เช่น String, Vec หรือโครงสร้างข้อมูลที่ซับซ้อน การจัดสรรและปล่อยคืนหน่วยความจำบน Heap จะมีค่าใช้จ่ายมากกว่าเพราะต้องทำการจัดสรร (allocation) และปล่อยคืน (deallocation) ผ่านระบบจัดการหน่วยความจำ (allocator) ซึ่งอาจเกี่ยวข้องกับการเรียกระบบปฏิบัติการ + +Rust จำเป็นต้องติดตามว่าใครเป็นเจ้าของข้อมูลบน Heap เพื่อที่จะสามารถปล่อยคืนหน่วยความจำได้อย่างถูกต้องและปลอดภัยโดยไม่ต้องพึ่ง Garbage Collector หากไม่มีระบบ Ownership โปรแกรมอาจเกิดปัญหาหน่วยความจำรั่ว (memory leak) หรือการชี้ไปยังหน่วยความจำที่ถูกปล่อยไปแล้ว (dangling pointer) ซึ่งเป็นต้นเหตุของบั๊กร้ายแรงและช่องโหว่ด้านความปลอดภัย โดยการกำหนดว่าตัวแปรใดเป็นเจ้าของข้อมูลและเมื่อเจ้าของออกจากขอบเขตการทำงาน (scope) จะทำการ drop (ปล่อยคืน) หน่วยความจำโดยอัตโนมัติ Rust จึงสามารถรับประกันความปลอดภัยด้านหน่วยความจำได้ในเวลาคอมไพล์ + +ตัวอย่างต่อไปนี้แสดงให้เห็นความแตกต่างระหว่างการจัดสรรบน Stack และ Heap อย่างชัดเจน: + +```rust +fn main() { + // ตัวแปรเหล่านี้อยู่บน Stack เพราะมีขนาดคงที่และทราบ ณ คอมไพล์ + let x: i32 = 42; + let y: bool = true; + + // String เก็บข้อมูลจริงบน Heap เท่านั้น ตัวแปร s อยู่บน Stack แต่เป็นเพียงตัวชี้ (pointer) + let s = String::from("hello, world"); + // ตัวแปร v เช่นกัน เก็บข้อมูลจริงบน Heap + let v = vec![1, 2, 3, 4, 5]; + + // เมื่อออกจากฟังก์ชัน main ตัวแปรทั้งหมดจะถูก drop ในลำดับย้อนกลับ + // s และ v จะทำการปล่อยคืนหน่วยความจำบน Heap โดยอัตโนมัติ + // ส่วน x และ y ที่อยู่บน Stack จะถูกปล่อยคืนโดยไม่ต้องทำงานเพิ่มเติม +} +``` + +ในตัวอย่างข้างต้น ตัวแปร `x` และ `y` ถูกเก็บไว้บน Stack โดยตรง เพราะมีขนาดคงที่และทราบได้ทันทีเมื่อคอมไพล์ ส่วน `String` และ `Vec` นั้น ตัวแปรตัวแปร (`s` และ `v`) ที่อยู่บน Stack จะเก็บเพียงตัวชี้ (pointer), ความยาว (length) และความจุ (capacity) ซึ่งชี้ไปยังพื้นที่บน Heap ที่เก็บข้อมูลจริง เมื่อฟังก์ชันสิ้นสุดลง Rust จะเรียกใช้เมธอด `drop` ของ `String` และ `Vec` เพื่อปล่อยคืนหน่วยความจำบน Heap ที่พวกเขาอ้างอิงอยู่ ทำให้มั่นใจได้ว่าไม่มีการรั่วไหลของหน่วยความจำแม้ไม่มีการเก็บขยะแบบอัตโนมัติ (Garbage Collector) เข้ามาเกี่ยวข้อง + +ต่อไปเราจะพูดถึงกฎพื้นฐานสามข้อของ Ownership ซึ่งเป็นรากฐานสำคัญของระบบนี้ + +## กฎของ Ownership 3 ข้อ + +Rust กำหนดกฎพื้นฐานสามข้อเกี่ยวกับ Ownership ที่คอมไพเลอร์ตรวจสอบอย่างเข้มงวดเพื่อให้แน่ใจว่าหน่วยความจำถูกจัดการอย่างปลอดภัย โดยไม่ต้องพึ่ง Garbage Collector กฎเหล่านี้คือ: + +1. ทุกค่าใน Rust มีเจ้าของ (owner) หนึ่งและเพียงหนึ่งเดียวเท่านั้น +2. เจ้าของสามารถมีได้เพียงหนึ่งคนในเวลาใดเวลาหนึ่ง +3. เมื่อเจ้าของออกจากขอบเขตการทำงาน (scope) ค่าจะถูก drop (ปล่อยคืนหน่วยความจำ) โดยอัตโนมัติ + +กฎเหล่านี้ดูเรียบง่ายแต่มีผลกระทบอย่างลึกซึ้งต่อวิธีการเขียนโปรแกรมใน Rust มาดูตัวอย่างและคำอธิบายแต่ละข้อกัน + +### ข้อที่ 1: ทุกค่ามีเจ้าของหนึ่งและเพียงหนึ่งเดียว + +ทุกชนิดข้อมูลใน Rust ไม่ว่าจะเป็นประเภทพื้นฐานเช่น `i32`, `bool` หรือประเภทที่ซับซ้อนเช่น `String`, `Vec` จะต้องมีตัวแปรที่ทำหน้าที่เป็นเจ้าของ (owner) หนึ่งเดียว เจ้าของนี้มีหน้าที่รับผิดชอบในการปล่อยคืนหน่วยความจำเมื่อค่านั้นไม่จำเป็นอีกต่อไป ตัวอย่างเช่น: + +```rust +fn main() { + let s = String::from("hello"); // s คือเจ้าของของข้อมูล String + // ที่นี่ s มีสิทธิ์แต่เพียงผู้เดียวในการควบคุมข้อมูล "hello" + // เมื่อ s ออกจาก scope มันจะถูก drop +} +``` + +ในตัวอย่างข้างต้น ตัวแปร `s` เป็นเจ้าของเพียงหนึ่งเดียวของข้อมูล String ที่เก็บข้อความ "hello" ไม่สามารถมีตัวแปรอื่นมาเป็นเจ้าของข้อมูลชิ้นนี้ได้พร้อมกัน หากเราพยายามมอบเจ้าของให้กับตัวแปรอื่น จะเกิดการย้าย (move) ซึ่งเราจะพูดถึงในหัวข้อถัดไป + +### ข้อที่ 2: เจ้าของมีได้เพียงหนึ่งคนในเวลาใดเวลาหนึ่ง + +กฎนี้หมายความว่า ณ ช่วงเวลาใดเวลาหนึ่ง จะมีตัวแปรเพียงหนึ่งตัวที่ถือสิทธิ์เป็นเจ้าของของข้อมูลนั้น หากเราพยายามมอบเจ้าของให้กับตัวแปรอื่น (เช่น การกำหนดค่าให้ตัวแปรใหม่) เจ้าของเดิมจะสูญเสียสิทธิ์นั้นไปทันที กระบวนการนี้เรียกว่าการย้าย (move) และเป็นพื้นฐานของความปลอดภัยใน Rust ตัวอย่าง: + +```rust +fn main() { + let s1 = String::from("hello"); + let s2 = s1; // s1 ย้ายกรรมสิทธิ์ไปให้ s2 + // println!("{}", s1); // ข้อผิดพลาด: ค่า s1 ถูกย้ายไปแล้ว + println!("{}", s2); // ใช้งานได้เพราะ s2 คือเจ้าของใหม่ +} +``` + +เมื่อบรรทัด `let s2 = s1;` ทำงาน กรรมสิทธิ์ของข้อมูล String จะถูกย้ายจาก `s1` ไปยัง `s2` ทันที หลังจากนั้น `s1` จะไม่สามารถเข้าถึงข้อมูลนั้นได้อีก ซึ่งช่วยป้องกันปัญหาการชี้ไปยังหน่วยความจำที่ถูกปล่อยไปแล้ว (dangling pointer) หากเราพยายามใช้ `s1` หลังจากการย้าย คอมไพเลอร์จะรายงานข้อผิดพลาดทันที + +### ข้อที่ 3: เมื่อเจ้าของออกจาก scope ค่าจะถูก drop + +เมื่อเจ้าของออกจากขอบเขตการทำงาน (scope) ที่มันถูกกำหนดไว้ Rust จะเรียกใช้ฟังก์ชัน `drop` โดยอัตโนมัติเพื่อปล่อยคืนหน่วยความจำที่เจ้าของครอบครองอยู่ กระบวนการนี้เกิดขึ้นอย่างกำหนดและไม่มีค่าใช้จ่ายแฝง นี่คือเหตุผลที่ Rust ไม่ต้องการ Garbage Collector เราสามารถเห็นได้จากตัวอย่างต่อไปนี้: + +```rust +{ + let s = String::from("hello"); // s เข้าสู่ scope + // ใช้งาน s ได้อย่างอิสระที่นี่ + println!("{}", s); +} // s ออกจาก scope ที่นี่ → เรียก drop โดยอัตโนมัติ +// ที่นี่ s ไม่สามารถใช้งานได้อีกต่อไป +``` + +ในบล็อกโค้ดด้านบน ตัวแปร `s` จะมีอยู่อย่างถูกต้องเฉพาะภายในวงเล็บปีกกา `{}` เมื่อการควบคุมออกจากบล็อกนี้ Rust จะเรียกใช้ `drop(s)` เพื่อปล่อยคืนหน่วยความจำที่ String ถือครองอยู่ หากเราพยายามใช้ `s` หลังจากปิดวงเล็บปีกกา คอมไพเลอร์จะตรวจพบว่าเป็นการยืมค่าที่ถูกปล่อยไปแล้วและรายงานข้อผิดพลาด + +กฎทั้งสามนี้ทำงานร่วมกันเพื่อให้ Rust สามารถรับประกันความปลอดภัยด้านหน่วยความจำได้โดยไม่มีต้นทุนรันไทม์จาก Garbage Collector ต่อไปเราจะดูรายละเอียดของการย้ายกรรมสิทธิ์ (Move Semantics) ซึ่งเป็นผลโดยตรงจากกฎข้อที่สอง + +## Move Semantics + +ใน Rust เมื่อเรากำหนดค่าของตัวแปรหนึ่งให้กับตัวแปรอื่นด้วยการใช้ `let` หรือการส่งผ่านเป็นอาร์กิวเมนต์ให้กับฟังก์ชัน เกิดสิ่งที่เรียกว่าการย้าย (move) แทนการคัดลอก (copy) สำหรับประเภทที่ไม่มีการ implement trait `Copy` เช่น `String`, `Vec` และโครงสร้างข้อมูลส่วนใหญ่ที่จัดสรรหน่วยความจำบน Heap การย้ายนี้หมายถึงการถ่ายโอนกรรมสิทธิ์ (ownership) จากตัวแปรเดิมไปยังตัวแปรใหม่ ทำให้ตัวแปรเดิมไม่สามารถเข้าถึงข้อมูลนั้นได้อีกต่อไป ซึ่งแตกต่างจากภาษาส่วนใหญ่ที่มักจะทำการคัดลอกข้อมูลโดยอัตโนมัติ + +มาดูตัวอย่างพื้นฐานของการย้ายกันก่อน: + +```rust +let s1 = String::from("hello"); +let s2 = s1; // การย้ายกรรมสิทธิ์จาก s1 ไปยัง s2 + +// println!("{}", s1); // ข้อผิดพลาด: ค่า s1 ถูกย้ายไปแล้ว +println!("{}", s2); // ใช้งานได้ปกติ เพราะ s2 คือเจ้าของใหม่ +``` + +ในตัวอย่างข้างต้น หลังจากบรรทัด `let s2 = s1;` ตัวแปร `s1` จะถือว่าว่างเปล่าในแง่ของกรรมสิทธิ์ และไม่สามารถเข้าถึงข้อมูล String ได้อีก หากเราพยายามพิมพ์ค่าของ `s1` คอมไพเลอร์จะแสดงข้อผิดพลาดว่า "borrow of moved value: `s1`" นี่คือกลไกที่ Rust ใช้ป้องกันการเกิด dangling pointer และ double free โดยไม่ต้องมี Garbage Collector + +อย่างไรก็ตาม ไม่ใช่ทุกประเภทที่จะเกิดการย้ายเสมอไป ประเภทที่มีขนาดเล็กและทราบขนาดได้ ณ เวลาคอมไพล์ เช่น `i32`, `bool`, `char` และทูเปิลที่ประกอบด้วยประเภทเหล่านี้ จะทำการคัดลอก (copy) แทนการย้าย เพราะการคัดลอกข้อมูลเหล่านี้มีค่าใช้จ่ายต่ำมาก พฤติกรรมนี้ถูกควบคุมโดย trait `Copy` ซึ่งหากประเภทหนึ่ง implement trait นี้ไว้ การกำหนดค่าจะทำการคัดลอกแทนการย้าย ตัวอย่าง: + +```rust +let x = 5; +let y = x; // การคัดลอก เพราะ i32 implement Copy +println!("x = {}, y = {}", x, y); // ใช้งานได้ทั้ง x และ y +``` + +ในกรณีนี้ หลังจาก `let y = x;` ตัวแปรทั้ง `x` และ `y` ยังคงถือค่า `5` ไว้ได้ทั้งคู่ เพราะข้อมูลถูกคัดลอกไปยังตำแหน่งใหม่บน Stack ไม่มีการย้ายกรรมสิทธิ์เกิดขึ้น + +เราสามารถบังคับให้เกิดการคัดลอกลึก (deep copy) แม้กับประเภทที่ปกติจะย้ายได้ เช่น `String` โดยใช้เมธอด `clone()` ซึ่งจะทำการสำเนาข้อมูลจริงบน Heap ทำให้ได้สองอ็อบเจกต์ที่เป็นอิสระต่อกัน ตัวอย่าง: + +```rust +let s1 = String::from("hello"); +let s2 = s1.clone(); // การสร้างสำเนาลึกของ String + +println!("s1 = {}, s2 = {}", s1, s2); // ใช้งานได้ทั้ง s1 และ s2 +``` + +แม้ว่า `clone()` จะทำให้เราได้สำเนาของข้อมูล แต่ก็มาพร้อมกับค่าใช้จ่ายในการจัดสรรหน่วยความจำใหม่และการคัดลอกข้อมูลจริง ซึ่งอาจมีค่าใช้จ่ายสูงหากข้อมูลมีขนาดใหญ่ ดังนั้น Rust จึงส่งเสริมให้ใช้การยืม (borrowing) แทนการคัดลอกเมื่อเป็นไปได้ เพื่อประสิทธิภาพที่ดีกว่า + +ต่อไปเราจะดูว่าการยืม (borrowing) และการอ้างอิง (references) ทำงานอย่างไรภายใต้กฎของ Ownership + +## References และ Borrowing + +แทนที่จะย้ายกรรมสิทธิ์ Rust อนุญาตให้เราสร้างการอ้างอิง (reference) ไปยังค่าโดยไม่รับกรรมสิทธิ์นั้น การอ้างอิงช่วยให้เราสามารถอ่านหรือแก้ไขข้อมูลได้โดยไม่ต้องถ่ายโอนความเป็นเจ้าของ ซึ่งช่วยหลีกเลี่ยงการคัดลอกข้อมูลที่มีค่าใช้จ่ายสูง การอ้างอิงแบ่งออกเป็นสองประเภทหลัก ได้แก่ การอ้างอิงแบบไม่เปลี่ยนแปลง (`&T`) และการอ้างอิงแบบเปลี่ยนแปลงได้ (`&mut T`) + +กฎการยืม (borrowing rules) มีดังนี้: +1. ณ จุดใดจุดหนึ่ง คุณสามารถมีได้either (a) การอ้างอิงแบบไม่เปลี่ยนแปลงได้หลายอัน หรือ (b) การอ้างอิงแบบเปลี่ยนแปลงได้เพียงหนึ่งอันเท่านั้น +2. การอ้างอิงจะต้องไม่มีอายุยาวนานไปกว่าข้อมูลที่มันอ้างอิง + +มาดูตัวอย่างการอ้างอิงแบบไม่เปลี่ยนแปลงก่อน: + +```rust +fn main() { + let s1 = String::from("hello"); + let len = calculate_length(&s1); // ผ่านการอ้างอิงแบบไม่เปลี่ยนแปลง + + println!("ความยาวของ '{}' คือ {}", s1, len); // s1 ยังใช้งานได้อยู่ +} + +fn calculate_length(s: &String) -> usize { + s.len() // s คือการอ้างอิง ไม่ใช่เจ้าของ +} +``` + +ในฟังก์ชัน `calculate_length` พารามิเตอร์ `s` มีประเภท `&String` ซึ่งหมายถึงการอ้างอิงแบบไม่เปลี่ยนแปลงไปยัง `String` เนื่องจากไม่ได้รับกรรมสิทธิ์ ฟังก์ชันนี้จึงไม่สามารถแก้ไขข้อมูลที่ `s` ชี้ไปได้ หลังจากที่ฟังก์ชันคืนค่า การอ้างอิง `s` จะหมดอายุและเจ้าของเดิม (`s1` ในฟังก์ชัน `main`) จะกลับมาใช้งานได้อย่างเต็มที่ + +หากเราพยายามสร้างการอ้างอิงแบบเปลี่ยนแปลงได้สองอันไปยังข้อมูลเดียวกันในเวลาเดียวกัน คอมไพเลอร์จะปฏิเสธ เนื่องจากละเมิดกฎการยืม ดังตัวอย่าง: + +```rust +let mut s = String::from("hello"); +let r1 = &mut s; +let r2 = &mut s; // ข้อผิดพลาด: ไม่สามารถมีการอ้างอิงแบบ mutable มากกว่าหนึ่งอันได้ +println!("{}, {}", r1, r2); +``` + +ข้อผิดพลาดที่เกิดขึ้นคือ "cannot borrow `s` as mutable more than once at a time" ซึ่งเป็นการป้องกันไม่ให้เกิดการแข่งขันในการเขียนข้อมูล (data race) ที่อาจนำไปสู่พฤติกรรมที่ไม่กำหนด (undefined behavior) หากเราต้องการแก้ไขข้อมูล เราสามารถมีการอ้างอิงแบบเปลี่ยนแปลงได้เพียงหนึ่งอันในเวลาใดเวลาหนึ่ง และเมื่อการอ้างอิงนั้นสิ้นสุดอายุ เราจึงสามารถสร้างการอ้างอิงแบบเปลี่ยนแปลงได้อีกครั้ง ตัวอย่างการใช้งานที่ถูกต้อง: + +```rust +let mut s = String::from("hello"); +{ + let r1 = &mut s; + r1.push_str(", world"); +} // r1 หมดอายุติอายุที่นี่ +let r2 = &mut s; +r2.push_str("!"); +println!("{}", s); // พิมพ์ "hello, world!" +``` + +นอกจากนี้ เรายังไม่สามารถมีการอ้างอิงแบบไม่เปลี่ยนแปลงและแบบเปลี่ยนแปลงได้พร้อมกันไปยังข้อมูลเดียวกันได้ เช่น: + +```rust +let mut s = String::from("hello"); +let r1 = &s; // ไม่เปลี่ยนแปลง +let r2 = &s; // ไม่เปลี่ยนแปลง ได้ +let r3 = &mut s; // ข้อผิดพลาด: ไม่สามารถมี mutable reference พร้อมกับ immutable reference +println!("{}, {}, {}", r1, r2, r3); +``` + +คอมไพเลอร์จะรายงานข้อผิดพลาดว่า "cannot borrow `s` as mutable because it is also borrowed as immutable" ซึ่งเป็นการป้องกันไม่ให้เกิดสถานการณ์ที่ข้อมูลถูกอ่านขณะที่กำลังถูกเขียน ทำให้มั่นใจได้ว่าการเข้าถึงข้อมูลจะเป็นไปอย่างปลอดภัยและมีความสอดคล้องกัน + +การใช้อ้างอิงช่วยให้เราสามารถเขียนฟังก์ชันที่รับข้อมูลมาโดยไม่ต้องรับกรรมสิทธิ์ ซึ่งเป็นประโยชน์อย่างมากเมื่อเราต้องการเพียงแค่อ่านหรือแก้ไขข้อมูลชั่วคราว ตัวอย่างฟังก์ชันที่รับ `&str` แทนการรับ `String`: + +```rust +fn first_word(s: &str) -> &str { + let bytes = s.as_bytes(); + + for (i, &item) in bytes.iter().enumerate() { + if item == b' ' { + return &s[0..i]; + } + } + + &s[..] +} + +fn main() { + let s = String::from("hello world"); + let word = first_word(&s); // ส่งอ้างอิง ไม่ย้ายกรรมสิทธิ์ + println!("คำแรก: {}", word); + // s ยังใช้งานได้หลังจากเรียก first_word +} +``` + +ฟังก์ชัน `first_word` รับพารามิเตอร์ `s: &str` ซึ่งเป็นการอ้างอิงไปยังสตริงสไลซ์ ทำให้เราสามารถทำงานกับสตริงได้โดยไม่ต้องเป็นเจ้าของมัน หลังจากที่ฟังก์ชันคืนค่า การอ้างอิงจะสิ้นสุดอายุและเจ้าของเดิมจะกลับมาใช้งานได้เต็มที่ + +ต่อไปเราจะพูดถึงสไลซ์ (slices) ซึ่งเป็นประเภทของการอ้างอิงที่ชี้ไปยังส่วนหนึ่งของข้อมูลโดยไม่มีกรรมสิทธิ์ + +## Slices: `&str` และ `&[T]` + +สไลซ์ (slice) เป็นประเภทข้อมูลที่ช่วยให้เราสามารถอ้างอิงไปยังส่วนหนึ่งของคอลเลกชันเช่นสตริงหรืออาร์เรย์โดยไม่ต้องเป็นเจ้าของข้อมูลนั้น สไลซ์ถูกแทนที่ด้วยการอ้างอิง (`&`) และประกอบด้วยสองส่วนสำคัญ ได้แก่ ตัวชี้ (pointer) ที่ชี้ไปยังตำแหน่งเริ่มต้นของสไลซ์ และความยาว (length) ที่บอกจำนวนองค์ประกอบในสไลซ์ โครงสร้างนี้ทำให้สไลซ์เป็นประเภทที่มีขนาดคงที่ (fat pointer) และสามารถถูกคัดลอกได้อย่างปลอดภัยโดยไม่ต้องคัดลอกข้อมูลจริง + +เรามาเริ่มที่สตริงสไลซ์ (`&str`) ซึ่งเป็นประเภทที่ใช้บ่อยที่สุดใน Rust สตริงสไลซ์ชี้ไปยังส่วนหนึ่งของสตริงที่เข้ารหัส UTF-8 โดยไม่ต้องเป็นเจ้าของมัน ตัวอย่างการใช้งาน: + +```rust +fn main() { + let s = String::from("hello world"); + let hello = &s[0..5]; // สไลซ์จากดัชนี 0 ถึง 4 (ไม่นับ 5) + let world = &s[6..11]; // สไลซ์จากดัชนี 6 ถึง 10 + + println!("{} {}", hello, world); // พิมพ์ "hello world" + + // สไลซ์ทั้งสตริง + let whole = &s[..]; // เทียบเท่ากับ &s[0..s.len()] + println!("ทั้งหมด: {}", whole); +} +``` + +ในตัวอย่างข้างต้น เราใช้ไวยากรณ์ `[start..end]` เพื่อสร้างสไลซ์ โดยที่ `start` เป็นดัชนีเริ่มต้น (รวม) และ `end` เป็นดัชนีสิ้นสุด (ไม่รวม) หากเราละเว้น `start` จะเริ่มจากดัชนีที่ 0 และหากเราละเว้น `end` จะไปจนถึงความยาวของสตริง สตริงสไลซ์ `&str` เป็นประเภทที่มีขนาดคงที่เนื่องจากประกอบด้วยตัวชี้ไปยังข้อมูลและความยาว ทำให้สามารถส่งผ่านโดยการคัดลอกได้อย่างปลอดภัย + +สไลซ์ยังสามารถใช้กับอาร์เรย์และเวกเตอร์ได้เช่นกัน ตัวอย่างกับอาร์เรย์ของจำนวนเต็ม: + +```rust +fn main() { + let a = [1, 2, 3, 4, 5]; + let slice = &a[1..4]; // ชี้ไปยังองค์ประกอบที่ 2, 3, และ 4 (ดัชนี 1, 2, 3) + + println!("slice: {:?}", slice); // พิมพ์ [2, 3, 4] + + // สามารถทำงานกับสไลซ์เหมือนกับอาร์เรย์ทั่วไป + for i in slice.iter() { + println!("ค่า: {}", i); + } +} +``` + +ในตัวอย่างนี้ `slice` มีประเภท `&[i32]` ซึ่งเป็นการอ้างอิงไปยังส่วนหนึ่งของอาร์เรย์ `a` เช่นเดียวกับสตริงสไลซ์ สไลซ์ของอาร์เรย์ประกอบด้วยตัวชี้ไปยังองค์ประกอบแรกของสไลซ์และความยาวของสไลซ์ ทำให้เราสามารถทำงานกับสไลซ์ได้โดยไม่ต้องเป็นเจ้าของอาร์เรย์ทั้งหมด + +สไลซ์มีประโยชน์อย่างมากเมื่อเราต้องการส่งส่วนหนึ่งของข้อมูลไปให้ฟังก์ชันโดยไม่ต้องคัดลอกหรือย้ายกรรมสิทธิ์ ตัวอย่างฟังก์ชันที่รับสตริงสไลซ์เป็นพารามิเตอร์: + +```rust +fn first_word(s: &str) -> &str { + let bytes = s.as_bytes(); + + for (i, &item) in bytes.iter().enumerate() { + if item == b' ' { + return &s[0..i]; + } + } + + &s[..] +} + +fn main() { + let s = String::from("hello world"); + let word = first_word(&s); // ส่งอ้างอิงไปยังสตริงทั้งหมด + println!("คำแรก: {}", word); + // s ยังคงใช้งานได้หลังจากเรียกฟังก์ชัน +} +``` + +ฟังก์ชัน `first_word` คืนค่าเป็น `&str` ซึ่งเป็นการอ้างอิงไปยังส่วนหนึ่งของสตริงต้นทาง หมายความว่าข้อมูลที่คืนกลับมายังคงเป็นของเจ้าของเดิมและจะถูกต้องตราบเท่าที่เจ้าของเดิมยังมีอายุอยู่ นี่คือเหตุผลที่เราจำเป็นต้องเข้าใจเกี่ยวกับอายุการใช้งาน (lifetimes) ซึ่งเราจะพูดถึงในหัวข้อถัดไป + +ก่อนที่เราจะไปถึงอายุการใช้งาน เราควรสังเกตว่าการใช้สไลซ์ช่วยให้เราหลีกเลี่ยงการคัดลอกข้อมูลที่ไม่จำเป็นและยังคงรักษาความปลอดภัยด้านหน่วยความจำได้อย่างสมบูรณ์ เนื่องจากการอ้างอิงจะถูกตรวจสอบโดยคอมไพเลอร์ว่าชี้ไปยังข้อมูลที่ยังมีอยู่เสมอ + +## Lifetimes + +อายุการใช้งาน (lifetime) ใน Rust เป็นแนวคิดที่คอมไพเลอร์ใช้เพื่อให้แน่ใจว่าการอ้างอิงทั้งหมดมีความถูกต้อง กล่าวคือ ชี้ไปยังข้อมูลที่ยังไม่ถูกปล่อยคืน กล่าวอีกนัยหนึ่ง ลำดับชีวิตอธิบายว่าการอ้างอิงนั้นมีอายุยืนยาวเพียงใดสัมพันธ์กับข้อมูลที่มันอ้างอิง ในกรณีส่วนใหญ่ คอมไพเลอร์สามารถอนุมานอายุการใช้งานได้โดยอัตโนมัติผ่านกลไกที่เรียกว่า lifetime elision แต่ในบางสถานการณ์ โดยเฉพาะเมื่อฟังก์ชันรับการอ้างอิงหลายตัวและคืนค่าเป็นการอ้างอิง เราจำเป็นต้องระบุอายุการใช้งานอย่างชัดเจนด้วยไวยากรณ์ `<'a>` โดยที่ `'` นำหน้าชื่ออายุการใช้งาน (เช่น `'a`, `'b`) และชื่ออายุการใช้งานนั้นสามารถเป็นชื่อใดก็ได้ตามต้องการ + +มาดูตัวอย่างฟังก์ชันที่จำเป็นต้องมีการระบุอายุการใช้งานอย่างชัดเจน: + +```rust +fn longest<'a>(x: &str, y: &str) -> &str { + if x.len() > y.len() { + x + } else { + y + } +} + +fn main() { + let string1 = String::from("abcd"); + let string2 = String::from("xyz"); + + let result = longest(&string1, &string2); + println!("สายยาวที่สุดคือ: {}", result); +} +``` + +ในฟังก์ชัน `longest` พารามิเตอร์ `x` และ `y` มีประเภท `&str` ซึ่งหมายถึงการอ้างอิงไปยังข้อมูลสตริง เนื่องจากฟังก์ชันคืนค่าเป็นการอ้างอิง (`&str`) คอมไพเลอร์จึงต้องทราบว่าการอ้างอิงที่คืนกลับมานั้นมีอายุการใช้งานยาวนานเท่าใด โดยไม่มีการระบุอายุการใช้งานอย่างชัดเจน คอมไพเลอร์จะไม่สามารถทราบได้ว่าการอ้างอิงที่คืนกลับมานั้นชี้ไปยัง `x` หรือ `y` และจึงไม่สามารถรับประกันได้ว่าการอ้างอิงนั้นจะยังคงถูกต้องหลังจากที่ฟังก์ชันสิ้นสุดลง ดังนั้นเราจึงต้องใช้พารามิเตอร์อายุการใช้งาน `'a` เพื่อบอกว่าการอ้างอิงที่ส่งเข้ามาและการอ้างอิงที่คืนกลับไปนั้นมีอายุการใช้งานอย่างน้อยเท่ากับอายุการใช้งาน `'a` กล่าวคือ การอ้างอิงที่คืนกลับมาจะมีอายุการใช้งานไม่น้อยกว่าการอ้างอิงที่สั้นที่สุดระหว่าง `x` และ `y` + +หากเราลองเรียกฟังก์ชันนี้ด้วยอาร์กิวเมนต์ที่มีอายุการใช้งานต่างกัน เช่น: + +```rust +fn main() { + let string1 = String::from("abcd"); + let result; + { + let string2 = String::from("xyz"); + result = longest(&string1, &string2); + // string2 ออกจาก scope ที่นี่ + } + // พยายามใช้ result ซึ่งชี้ไปยัง string2 ที่ถูกปล่อยไปแล้ว + println!("{}", result); // ข้อผิดพลาด: การอ้างอิงชี้ไปยังข้อมูลที่ไม่ถูกต้อง +} +``` + +คอมไพเลอร์จะตรวจพบข้อผิดพลาดเนื่องจาก `result` พยายามชี้ไปยังข้อมูลของ `string2` ซึ่งถูกปล่อยคืนเมื่อออกจาก scope ภายในบล็อก `{}` ระบบอายุการใช้งานจะป้องกันไม่ให้เกิดสถานการณ์เช่นนี้ขึ้น + +นอกจากการกำหนดอายุการใช้งานทั่วไปแล้ว Rust ยังมีอายุการใช้งานพิเศษที่เรียกว่า `'static` ซึ่งหมายถึงการอ้างอิงที่มีอายุการใช้งานตลอดทั้งโปรแกรม ตัวอย่างทั่วไปของ `&'static str` คือสตริงลิเทอรัลเช่น `"hello"` ซึ่งถูกฝังอยู่ในไบนารีของโปรแกรมและมีอยู่ตลอดเวลาที่โปรแกรมทำงาน ตัวอย่าง: + +```rust +let s: &'static str = "ฉันมีอายุการใช้งานตลอดโปรแกรม"; +println!("{}", s); // ปลอดภัยเสมอ +``` + +นอกจากนี้ Rust ยังมีกฎการอนุมานอายุการใช้งาน (lifetime elision) ที่ช่วยให้เราไม่ต้องเขียนอายุการใช้งานอย่างชัดเจนในกรณีที่พบบ่อย ได้แก่: +1. แต่ละพารามิเตอร์ที่เป็นการอ้างอิงจะได้รับอายุการใช้งานของตัวเอง +2. หากมีพารามิเตอร์การอ้างอิงเพียงหนึ่งตัวนั้น อายุการใช้งานนั้นจะถูกส่งต่อไปยังค่าที่คืนกลับ (หากคืนกลับเป็นการอ้างอิง) +3. หากมีพารามิเตอร์หลายตัวแต่หนึ่งในนั้นคือ `&self` หรือ `&mut self` (เป็นเมธอดของโครงสร้างหรืออินเทอร์เฟซ) อายุการใช้งานของ `self` จะถูกใช้สำหรับค่าที่คืนกลับ + +กฎเหล่านี้ช่วยให้เราเขียนโค้ดที่สะอาดและอ่านง่ายขึ้นโดยไม่ต้องเขียนอายุการใช้งานซ้ำซ้อนในกรณีที่คอมไพเลอร์สามารถอนุมานได้อย่างถูกต้อง + +การเข้าใจอายุการใช้งานเป็นสิ่งสำคัญเมื่อทำงานกับการอ้างอิงและการยืมใน Rust โดยเฉพาะเมื่อเราเริ่มสร้างโครงสร้างข้อมูลที่เก็บการอ้างอิงไว้ภายใน เช่น โครงสร้างที่มีฟิลด์เป็น `&str` หรือ `&T` ในกรณีเหล่านี้ เราจำเป็นต้องระบุอายุการใช้งานให้ชัดเจนเพื่อบอกคอมไพเลอร์ว่าการอ้างอิงภายในโครงสร้างนั้นมีอายุการใช้งานสัมพันธ์กับอะไรบ้าง + +ต่อไปเราจะดูว่าความเข้าใจใน Ownership, Borrowing และ Lifetimes นั้นถูกนำไปใช้จริงในโครงการอย่าง rs-wsProxy อย่างไร + +## เชื่อมโยงกับโปรเจกต์ rs-wsProxy + +ในโครงการ rs-wsProxy ซึ่งเป็นเว็บโซเก็ตพร็อกซีที่เขียนด้วย Rust แนวคิดเรื่อง Ownership, Borrowing และ Lifetimes มีบทบาทสำคัญในการออกแบบให้ปลอดภัยและมีประสิทธิภาพ ตัวอย่างเช่น ฟังก์ชันที่จัดการการเชื่อมต่อ TCP มักจะรับที่อยู่เซิร์ฟเวอร์เป็นพารามิเตอร์ประเภท `&str` แทนที่จะเป็น `String` เพื่อหลีกเลี่ยงการคัดลอกข้อมูลที่ไม่จำเป็น เนื่องจากที่อยู่นั้นมักจะเป็นสตริงลิเทอรัลหรือสตริงที่ถูกสร้างขึ้นที่อื่นและมีอายุการใช้งานยาวพอที่จะถูกอ้างอิงได้อย่างปลอดภัย ตัวอย่างการประกาศฟังก์ชันดังกล่าวอาจเป็นดังนี้: + +```rust +fn connect_tcp(addr: &str) -> std::io::Result { + TcpStream::connect(addr) +} +``` + +ฟังก์ชัน `connect_tcp` รับพารามิเตอร์ `addr: &str` ซึ่งเป็นการอ้างอิงไปยังสตริงที่มีข้อมูลที่อยู่เซิร์ฟเวอร์ เช่น `"127.0.0.1:8080"` โดยการใช้ `&str` แทน `String` ทำให้เราไม่ต้องเกิดการอะโลเคทหน่วยความจำใหม่หรือคัดลอกข้อมูลเมื่อเรียกฟังก์ชันนี้ ซึ่งเป็นประโยชน์อย่างยิ่งในบริบทที่มีการเรียกใช้บ่อยครั้ง เช่น ในลูปที่จัดการการเชื่อมต่อหลายครั้งต่อวินาที + +ในทำนองเดียวกัน ฟังก์ชันที่ตรวจสอบความถูกต้องของเป้าหมาย (target) อาจมีหน้าตาเช่นนี้: + +```rust +fn validate_target(target: &str) -> bool { + !target.is_empty() && !target.contains("..") && !target.contains(":") +} +``` + +ฟังก์ชันนี้รับ `&str` เพื่อตรวจสอบว่าสตริงที่ส่งมาไม่ว่างเปล่าและไม่มีลำดับอักขระที่อาจเป็นอันตราย เช่น `..` หรือ `:` ซึ่งอาจใช้ในการโจมตีแบบพาธทราซาลซัล (path traversal) การใช้การอ้างอิงช่วยให้ฟังก์ชันนี้ทำงานได้โดยไม่ต้องเป็นเจ้าของข้อมูล input ทำให้สามารถเรียกใช้กับสตริงที่มาจากแหล่งต่างๆ ได้อย่างยืดหยุ่น ไม่ว่าจะเป็นสตริงจากคอนฟิกิวเรชัน อินพุตจากผู้ใช้ หรือข้อมูลที่ได้จากการถอดรหัสโปรโตคอล + +นอกจากการใช้การอ้างอิงแบบงั้นใน rs-wsProxy ยังมีการใช้ `Arc` เพื่อแชร์สถานะร่วมกันระหว่างงานอะซิงโครนัสหลายงาน (tasks) อย่างปลอดภัย ที่นี่ `Arc` ย่อมาจาก Atomic Reference Counting ซึ่งเป็นประเภทชี้อัจฉริยะที่ช่วยให้หลายๆ คนสามารถเป็นเจ้าของร่วมกันได้โดยใช้การนับอ้างอิงแบบอะโตมิก ส่วน `AppState` คือโครงสร้างที่เก็บข้อมูลร่วมกัน เช่น การตั้งค่าพร็อกซี สถิติการเชื่อมต่อ หรือแหล่งทรัพยากรที่ใช้ร่วมกัน ตัวอย่างการใช้งานอาจเป็นดังนี้: + +```rust +use std::sync::Arc; + +struct AppState { + proxy_addr: String, + timeout: u64, + // ฟิลด์อื่นๆ ที่จำเป็น +} + +#[tokio::main] +async fn main() { + let state = Arc::new(AppState { + proxy_addr: String::from("127.0.0.1:8080"), + timeout: 30, + // เริ่มต้นฟิลด์อื่นๆ + }); + + // สร้างงานหลายงานที่แชร์ state เดียวกัน + let mut handles = vec![]; + for i in 0..4 { + let state_clone = Arc::clone(&state); // เพิ่มจำนวนการอ้างอิง + let handle = tokio::spawn(async move { + // แต่ละงานสามารถเข้าถึง state ได้อย่างปลอดภัย + connect_to_proxy(&state_clone.proxy_addr, state_clone.timeout).await; + }); + handles.push(handle); + } + + // รอให้ทุกงานเสร็จสิ้น + for handle in handles { + let _ = handle.await; + } +} +``` + +ในตัวอย่างข้างต้น เราใช้ `Arc` เพื่อให้หลายงานที่รันบนโทคิโอ (Tokio) สามารถอ้างอิงถึงสถานะเดียวกันได้โดยไม่ต้องกังวลเกี่ยวกับการแข่งขันในการเข้าถึงข้อมูล (data race) เนื่องจาก `Arc` ให้การนับอ้างอิงแบบอะโตมิก เมื่อแต่ละงานทำการ `clone` อาร์ค จะเพิ่มจำนวนการอ้างอิง และเมื่องานสิ้นสุดลง การอ้างอิงจะถูกลดลงโดยอัตโนมัติ เมื่อจำนวนการอ้างอิงลดลงเป็นศูนย์ `AppState` จะถูกทำลายและปล่อยคืนทรัพยากรทั้งหมดอย่างปลอดภัย + +รูปแบบนี้ช่วยให้เราสามารถออกแบบระบบที่มีส่วนประกอบหลายส่วนทำงานพร้อมกันโดยไม่ต้องใช้การล็อก (mutex) ที่อาจทำให้เกิดความล่าช้าหรือความซับซ้อนเพิ่มเติม นอกจากนี้ยังสอดคล้องกับปรัชญาของ Rust ที่ต้องการให้ความปลอดภัยเป็นค่าเริ่มต้นโดยไม่ต้องเสียสละประสิทธิภาพ + +จากตัวอย่างเหล่านี้จะเห็นได้ว่าแนวคิดพื้นฐานของ Ownership, Borrowing และ Lifetimes ไม่ใช่เพียงทฤษฎีเท่านั้น แต่เป็นเครื่องมือเชิงปฏิบัติที่ช่วยให้เราเขียนโค้ดที่ปลอดภัย มีประสิทธิภาพ และบบำรุงรักษาได้จริงในโครงการขนาดใหญ่อย่าง rs-wsProxy + +## สรุป + +Ownership, Borrowing และ Lifetimes คือหัวใจสำคัญที่ทำให้ Rust สามารถรับประกันความปลอดภัยด้านหน่วยความจำได้โดยไม่ต้องพึ่ง Garbage Collector ผ่านกฎที่เข้มงวดแต่ทรงพลังเหล่านี้ Rust ช่วยให้เราสามารถเขียนโค้ดที่มีประสิทธิภาพเทียบเท่ากับภาษาระดับต่ำเช่น C หรือ C++ แต่มีความปลอดภัยในระดับที่สูงกว่ามาก การเข้าใจแนวคิดเหล่านี้จึงเป็นสิ่งจำเป็นสำหรับนักพัฒนาที่ต้องการเชี่ยวชาญภาษานี้อย่างแท้จริง + +หากคุณต้องการทบทวนพื้นฐาน Rust ก่อนเริ่มต้นหัวข้อนี้ โปรดดูบทความก่อนหน้า: [เริ่มต้นกับ Rust](/posts/rust/rust-getting-started/) + +และหากคุณต้องการเรียนรู้ต่อไปในหัวข้อถัดไปเกี่ยวกับการจัดการข้อมูลด้วย Structs, Enums และ Pattern Matching ใน Rust โปรดดูบทความถัดไป: [Structs, Enums และ Pattern Matching](/posts/rust/rust-structs-enums/) \ No newline at end of file diff --git a/content/posts/rust/rust-structs-enums/featured-image.jpeg b/content/posts/rust/rust-structs-enums/featured-image.jpeg new file mode 100644 index 0000000..0e10677 Binary files /dev/null and b/content/posts/rust/rust-structs-enums/featured-image.jpeg differ diff --git a/content/posts/rust/rust-structs-enums/index.en.md b/content/posts/rust/rust-structs-enums/index.en.md new file mode 100644 index 0000000..fe55cc5 --- /dev/null +++ b/content/posts/rust/rust-structs-enums/index.en.md @@ -0,0 +1,621 @@ +--- +title: "Structs, Enums & Pattern Matching" +subtitle: "" +date: 2026-07-09T09:00:00+07:00 +lastmod: 2026-07-09T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Build your own data types with structs and enums, and handle every case safely with pattern matching" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +Structs and enums are the building blocks for creating custom data types in Rust. They allow you to model the data of your domain with precision and safety. In the rs-wsProxy project, you'll see structs like `Args` for command-line configuration and enums like `VerifyResult` for representing the outcome of a verification process. This tutorial builds on the concepts of ownership and borrowing covered in [Part 2](/posts/rust/rust-ownership-borrowing/) and prepares you for [Part 4](/posts/rust/rust-collections-errors/) on collections, collections and error handling. + + + +## Structs + +A struct, or structure, is a custom data type that lets you group together related values of different types. Think of it as a blueprint for creating instances that share the same structure. + +### Named-Field Structs + +The most common kind of struct is the named-field struct. Each field has a name and a type. For example, we can model a `User` with a username and email: + +```rust +struct User { + username: String, + email: String, + sign_in_count: u64, + active: bool, +} +``` + +To create an instance of this struct, we specify values for each field: + +```rust +let user1 = User { + username: String::from("kawin"), + email: String::from("kawin@example.com"), + sign_in_count: 1, + active: true, +}; +``` + +Rust provides a convenient shorthand when the variable names match the field names. If we have variables with the same names as the fields, we can use the field init shorthand: + +```rust +let username = String::from("kawin"); +let email = String::from("kawin@example.com"); +let sign_in_count = 1; +let active = true; + +let user1 = User { + username, + email, + sign_in_count, + active, +}; +``` + +### Tuple Structs + +Sometimes you want a struct that doesn't need named fields but just wants to give a tuple a distinct type. This is where tuple structs come in. They look like tuples but have a struct name. + +```rust +struct Color(i32, i32, i32); +struct Point(i32, i32, i32); + +let black = Color(0, 0, 0); +let origin = Point(0, 0, 0); +``` + +Even though `Color` and `Point` both contain three `i32` values, they are different types and cannot be used interchangeably. + +### Unit Structs + +A unit struct is a struct without any fields. It's useful when you need a type that carries no data but can be used as a marker or for implementing traits. + +```rust +struct AlwaysEqual; + +let subject = AlwaysEqual; +``` + +### Accessing Fields + +To access a field of a struct instance, we use the dot notation: + +```rust +let username = user1.username; +let email = user1.email; +``` + +If the instance is mutable, we can change a field's value: + +```rust +let mut user1 = User { + username: String::from("kawin"), + email: String::from("kawin@example.com"), + sign_in_count: 1, + active: true, +}; + +user1.email = String::from("another@example.com"); +``` + +## Methods with `impl` + +Methods are functions defined within the context of a struct (or enum). They are defined inside an `impl` block. The first parameter of a method is often `self`, which represents the instance the method is called on. + +### Defining Methods + +Let's add some functionality to our `User` struct. We'll create a method to check if the user is active and another to increment the sign-in count. + +```rust +impl User { + fn is_active(&self) -> bool { + self.active + } + + fn increment_sign_in_count(&mut self) { + self.sign_in_count += 1; + } + + // This is an associated function because it doesn't take `self` + fn new(username: String, email: String) -> User { + User { + username, + email, + sign_in_count: 0, + active: true, + } + } +} +``` + +In the `is_active` method, we use `&self` because we only need to read the instance. In `increment_sign_in_count`, we use `&mut self` because we need to modify the instance. The `new` function is an associated function (often used as a constructor) because it doesn't take `self`; it's called with `User::new(...)`. + +### Using Methods + +Here's how we use these methods: + +```rust +let mut user = User::new(String::from("kawin"), String::from("kawin@example.com")); + +println!("Is active? {}", user.is_active()); // Prints: Is active? true + +user.increment_sign_in_count(); +println!("Sign in count: {}", user.sign_in_count); // Prints: Sign in count: 1 +``` + +### Multiple `impl` Blocks + +You can define multiple `impl` blocks for the same struct. This is useful for organizing methods by functionality or for separating associated functions from methods. + +```rust +impl User { + // Methods related to user state + fn is_active(&self) -> bool { + self.active + } +} + +impl User { + // Methods related to user activity + fn increment_sign_in_count(&mut self) { + self.sign_in_count += 1; + } + + fn reset_sign_in_count(&mut self) { + self.sign_in_count = 0; + } +} +``` + +This is equivalent to having all methods in a single `impl` block. + +### Example: Rectangle + +Let's look at a more comprehensive example with a `Rectangle` struct that has methods for calculating area and scaling. + +```rust +struct Rectangle { + width: u32, + height: u32, +} + +impl Rectangle { + fn area(&self) -> u32 { + self.width * self.height + } + + fn scale(&mut self, factor: u32) { + self.width *= factor; + self.height *= factor; + } + + // Associated function to create a square + fn square(size: u32) -> Rectangle { + Rectangle { + width: size, + height: size, + } + } +} + +fn main() { + let mut rect = Rectangle { + width: 30, + height: 50, + }; + + println!("Area: {}", rect.area()); // 1500 + + rect.scale(2); + println!("Scaled area: {}", rect.area()); // 6000 + + let sq = Rectangle::square(10); + println!("Square area: {}", sq.area()); // 100 +} +``` + +## Enums + +Enums, short for enumerations, allow you to define a type by listing its possible variants. Each variant can hold different types and amounts of data. + +### Simple Enums + +A simple enum lists variants that don't hold any additional data. For example, a `Direction` enum for moving in a grid: + +```rust +enum Direction { + Up, + Down, + Left, + Right, +} + +let north = Direction::Up; +let east = Direction::Right; +``` + +### Enums with Data + +Enums become much more powerful when variants can hold data. This is similar to unions or variant types in other languages, but with Rust's safety guarantees. + +Consider an enum for IP addresses, which can be either IPv4 or IPv6: + +```rust +enum IpAddr { + V4(String), + V6(String), +} + +let home = IpAddr::V4(String::from("127.0.0.1")); +let loopback = IpAddr::V6(String::from("::1")); +``` + +Each variant holds a `String`, but we could also hold other types. For instance, we might want to store IPv4 addresses as four `u8` values and IPv6 as six `u16` values: + +```rust +enum IpAddr { + V4(u8, u8, u8, u8), + V6(String), +} + +let home = IpAddr::V4(127, 0, 0, 1); +let loopback = IpAddr::V6(String::from("::1")); +``` + +### Complex Enums with Multiple Data Types + +An enum can have variants that hold different types and amounts of data. This is where enums really shine as algebraic data types. + +Let's define an enum for different kinds of messages we might receive in a network application: + +```rust +enum Message { + Quit, + Move { x: i32, y: i32 }, + Write(String), + ChangeColor(i32, i32, i32), +} + +let msg1 = Message::Quit; +let msg2 = Message::Move { x: 10, y: 20 }; +let msg3 = Message::Write(String::from("hello")); +let msg4 = Message::ChangeColor(255, 0, 255); +``` + +Here: +- `Quit` has no data. +- `Move` holds a struct-like block with `x` and `y` coordinates. +- `Write` holds a `String`. +- `ChangeColor` holds three `i32` values for red, green, and blue. + +This ability to model data with precise variation is why enums are so useful in Rust. + +### Why Enums Are Powerful + +Enums in Rust are algebraic data types, meaning they can represent data that is one of several variants, each with its own data. This is in contrast to structs, which represent data that is a combination of all fields. Enums let you express ideas like "this value is either A with some data, or B with other data, or C with no data" in a type-safe way. + +## The `Option` Enum + +One of the most important enums in the Rust standard library is `Option`. It represents the presence or absence of a value, eliminating the need for null pointers. + +### Definition + +The `Option` enum is defined as follows: + +```rust +enum Option { + Some(T), + None, +} +``` + +- `Some(T)` indicates that a value is present and holds the value of type `T`. +- `None` indicates that there is no value. + +### Using `Option` + +Let's look at a function that searches for a character in a string and returns its index, or `None` if the character isn't found: + +```rust +fn find_char(s: &str, c: char) -> Option { + for (index, ¤t_char) in s.as_bytes().iter().enumerate() { + if current_char as char == c { + return Some(index); + } + } + None +} + +fn main() { + let s = String::from("hello world"); + + match find_char(&s, 'o') { + Some(index) => println!("Found 'o' at index {}", index), + None => println!("Character 'o' not found"), + } + + match find_char(&s, 'z') { + Some(index) => println!("Found 'z' at index {}", index), + None => println!("Character 'z' not found"), // This will print + } +} +``` + +### Why Safer Than Null + +In languages with null, accessing a null pointer often leads to runtime errors. With `Option`, you must explicitly handle both the `Some` and `None` cases. The compiler enforces this handling, preventing accidental null dereferences. + +For example, if we tried to use the result of `find_char` without checking, we'd get a compile-time error: + +```rust +// This won't compile because we're trying to use an Option as a usize +let index = find_char(&s, 'o'); +println!("Index: {}", index); // Error: expected usize, found enum Option +``` + +We must use a `match` statement or methods like `unwrap()` (which panics on `None`) or `expect()` to handle the `Option`. + +## Pattern Matching + +Pattern matching in Rust is done with the `match` keyword. It allows you to compare a value against a series of patterns and execute code based on which pattern matches. Patterns can be literal values, variable names, wildcards, and more. + +### Exhaustive Checking + +One of the key features of `match` is that it is exhaustive. You must cover every possible case, or the compiler will complain. This ensures that you don't accidentally miss a variant. + +### Matching `Option` + +Let's revisit the `find_char` example and see how we handle the `Option` with `match`: + +```rust +fn find_char(s: &str, c: char) -> Option { + for (index, ¤t_char) in s.as_bytes().iter().enumerate() { + if current_char as char == c { + return Some(index); + } + } + None +} + +fn main() { + let s = String::from("hello world"); + + let result = find_char(&s, 'o'); + match result { + Some(index) => println!("Found 'o' at index {}", index), + None => println!("Character 'o' not found"), + } +} +``` + +In this `match`, we have two arms: one for `Some(index)` and one for `None`. The variable `index` is bound to the value inside `Some`. + +### Patterns with Bindings + +You can bind parts of a pattern to variables. This is useful for extracting data from enum variants. + +```rust +enum Message { + Quit, + Move { x: i32, y: i32 }, + Write(String), + ChangeColor(i32, i32, i32), +} + +fn process_message(msg: Message) { + match msg { + Message::Quit => { + println!("Quit command received"); + } + Message::Move { x, y } => { + println!("Move to x: {}, y: {}", x, y); + } + Message::Write(text) => { + println!("Text message: {}", text); + } + Message::ChangeColor(r, g, b) => { + println!( + "Change color to red: {}, green: {}, blue: {}", + r, g, b + ); + } + } +} + +fn main() { + let msg1 = Message::Quit; + let msg2 = Message::Move { x: 10, y: 20 }; + let msg3 = Message::Write(String::from("hello")); + let msg4 = Message::ChangeColor(255, 0, 255); + + process_message(msg1); + process_message(msg2); + process_message(msg3); + process_message(msg4); +} +``` + +### The `_` Wildcard + +The underscore (`_`) is a catch-all pattern that matches any value. It's useful when you want to ignore a value or handle all remaining cases with a single arm. + +```rust +fn main() { + let some_u8_value = 0u8; + + match some_u8_value { + 1 => println!("one"), + 3 => println!("three"), + 5 => println!("five"), + 7 => println!("seven"), + _ => println!("anything else"), // This will print for 0 + } +} +``` + +### `if let` for Single Case + +When you only care about one variant and want to ignore the others, `if let` provides a more concise syntax than `match`. + +```rust +fn main() { + let some_option = Some(5); + + if let Some(i) = some_option { + println!("{}", i); // Prints: 5 + } +} + +// This is equivalent to: +// +// match some_option { +// Some(i) => { +// println!("{}", i); +// } +// _ => (), +// } +``` + +### `while let` for Loops + +Similarly, `while let` allows you to loop as long as a pattern matches. + +```rust +fn main() { + let mut stack = Vec::new(); + + stack.push(1); + stack.push(2); + stack.push(3); + + while let Some(top) = stack.pop() { + println!("{}", top); // Prints 3, 2, 1 + } +} +``` + +## The `Result` Enum (Intro) + +Just like `Option` handles the presence or absence of a value, `Result` handles success or failure. It's used for operations that can fail, such as file I/O or parsing. + +### Definition + +The `Result` enum is defined as: + +```rust +enum Result { + Ok(T), + Err(E), +} +``` + +- `Ok(T)` indicates that the operation succeeded and holds the result of type `T`. +- `Err(E)` indicates that the operation failed and holds an error of type `E`. + +### Brief Example + +Here's a simple example that tries to parse a string into an integer: + +```rust +use std::num::ParseIntError; + +fn double_number(number_str: &str) -> Result { + match number_str.parse::() { + Ok(n) => Ok(2 * n), + Err(e) => Err(e), + } +} + +fn main() { + match double_number("10") { + Ok(n) => println!("Double: {}", n), + Err(e) => println!("Error: {}", e), + } + + match double_number("hello") { + Ok(n) => println!("Double: {}", n), + Err(e) => println!("Error: {}", e), + } +} +``` + +In the next part, we'll dive deeper into `Result`, combinators like `map` and `and_then`, and the `?` operator for error propagation. + +## Connection to rs-wsProxy + +Let's see how these concepts are used in the rs-wsProxy project. The `Args` struct holds command-line configuration, and the `VerifyResult` enum represents the outcome of verifying a connection request. + +```rust +pub struct Args { + pub config: String, + pub verbose: bool, +} + +pub enum VerifyResult { + Accepted(String), + Rejected(RejectReason), +} + +pub struct AppState { + pub allowed_servers: Option>, + pub redirects: HashMap, + pub default_target: Option, +} +``` + +### Understanding `Option` in `AppState` + +The `AppState` struct uses `Option` in two fields: +- `allowed_servers: Option>` +- `default_target: Option` + +These options distinguish between three states: +1. `None`: The field is not set. For `allowed_servers`, this means the proxy is open (allows connections to any server). For `default_target`, it means there is no fallback server. +2. `Some(Vec)`: A list of allowed servers. An empty vector `Some(vec![])` means no servers are allowed (deny all). +3. `Some(String)`: A specific default target server. + +This design makes the semantics explicit and prevents confusion between an empty list and an unset value. + +### Matching on `VerifyResult` + +When handling a connection request, we match on `VerifyResult` to decide what to do: + +```rust +fn handle_verify(result: VerifyResult) { + match result { + VerifyResult::Accepted(server) => { + println!("Connection accepted to {}", server); + // Proceed with establishing the connection + } + VerifyResult::Rejected(reason) => { + println!("Connection rejected: {:?}", reason); + // Send an error response or close the connection + } + } +} +``` + +This match is exhaustive: we must handle both `Accepted` and `Rejected` variants, ensuring we don't miss any possible outcome. + +## Summary + +In this tutorial, we've explored how to define and use structs and enums to create custom data types in Rust. We've seen how to attach methods to structs with `impl` blocks, and how enums like `Option` and `Result` provide powerful ways to handle absence and error cases safely. Pattern matching with `match`, `if let`, and `while let` allows us to handle each variant of an enum clearly and exhaustively. + +These concepts are foundational for writing idiomatic Rust code and are heavily used in real-world projects like rs-wsProxy. By modeling data with structs and enums, and handling all cases with pattern matching, you can write code that is both expressive and robust. + +### Navigation + +← Previous: [Ownership, Borrowing & Lifetimes](/posts/rust/rust-ownership-borrowing/) +Next: [Collections, Iterators & Error Handling](/posts/rust/rust-collections-errors/) \ No newline at end of file diff --git a/content/posts/rust/rust-structs-enums/index.md b/content/posts/rust/rust-structs-enums/index.md new file mode 100644 index 0000000..9ffa4fb --- /dev/null +++ b/content/posts/rust/rust-structs-enums/index.md @@ -0,0 +1,424 @@ +--- +title: "Structs, Enums และ Pattern Matching" +subtitle: "" +date: 2026-07-09T09:00:00+07:00 +lastmod: 2026-07-09T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "สร้างประเภทข้อมูลของเราเองด้วย Structs และ Enums จัดการทุกกรณีอย่างปลอดภัยด้วย Pattern Matching" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + + + +## ทำไมเราต้องสร้างประเภทข้อมูลแบบกำหนดเอง + +ในการเขียนโปรแกรม เรามักพบกับข้อมูลที่มีโครงสร้างซับซ้อนซึ่งไม่สามารถอธิบายได้ด้วยประเภทพื้นฐานเช่น integer หรือ string เพียงอย่างเดียว ตัวอย่างเช่น ในโปรเจกต์ rs-wsProxy เราต้องจัดการกับการตั้งค่าเซิร์ฟเวอร์พร็อกซีและผลลัพธ์จากการตรวจสอบการเชื่อมต่อ ซึ่งต้องการประเภทข้อมูลที่สามารถเก็บค่าหลายประเภทและสถานะที่แตกต่างกันได้ การใช้ structs และ enums ช่วยให้เราสามารถจำลองข้อมูลเหล่านี้ได้อย่างชัดเจนและปลอดภัย พร้อมทั้งใช้ประโยชน์จากระบบ type system ของ Rust เพื่อจับข้อผิดพลาดตั้งแต่เวลาคอมไพล์ นอกจากนี้ pattern matching ยังช่วยให้เราจัดการกับทุกกรณีที่เป็นไปได้ของ enum ได้อย่างครบถ้วนและกระชับ ลดความเสี่ยงจากการละเลยกรณีบางกรณี ในบทความนี้เราจะเรียนรู้การสร้างและใช้งาน structs, enums และ pattern matching อย่างลึกซึ้ง โดยมีตัวอย่างจากโค้ดจริงใน rs-wsProxy เพื่อแสดงให้เห็นการประยุกต์ใช้ในสถานการณ์จริง ก่อนเริ่มต้น แนะนำให้อ่าน Part 2 เกี่ยวกับ Ownership, Borrowing และ Lifetimes เพื่อความเข้าใจที่สมบูรณ์ยิ่งขึ้น + +## Structs: การกำหนดโครงสร้างข้อมูล + +Structs เป็นวิธีการกำหนดประเภทข้อมูลที่มีฟิลด์หลายฟิลด์ โดยแต่ละฟิลด์สามารถมีประเภทต่างกันได้ มันคล้ายกับคลาสในภาษาอื่นๆ แต่ใน Rust structs ใช้เพื่อเก็บข้อมูลเท่านั้น ไม่มีพฤติกรรมติดมาด้วย (พฤติกรรมจะถูกเพิ่มผ่าน impl block แยกต่างหาก) ตัวอย่างเช่น เราสามารถสร้าง struct เพื่อแทนผู้ใช้ในระบบโดยเก็บชื่อ อีเมล และอายุ การสร้าง instance ของ struct ทำได้โดยการระบุค่าของแต่ละฟิลด์ และเราสามารถเข้าถึงฟิลด์ผ่านจุด notation นอกจากนี้ Rust ยังมีฟีเจอร์ที่เรียกว่า field init shorthand ซึ่งช่วยให้เราสามารถกำหนดค่าฟิลด์โดยใช้ชื่อตัวแปรที่ตรงกับชื่อฟิลด์ได้โดยตรง นอกจาก structs ที่มีฟิลด์ที่มีชื่อแล้ว เรายังมี tuple structs ซึ่งเหมาะสำหรับกรณีที่เราต้องการประเภททูเพิลที่มีชื่อ และ unit structs ซึ่งไม่มีข้อมูลใดๆ เลยและมักใช้เป็น marker หรือในการ implement traits + +```rust +// Struct ธรรมดาที่มีฟิลด์ที่มีชื่อ +struct User { + username: String, + email: String, + age: u32, + active: bool, +} + +// Tuple struct - เหมือนทูเพิลแต่มีชื่อ +struct Color(i32, i32, i32); + +// Unit struct - ไม่มีข้อมูลใดๆ +struct AlwaysEqual; + +fn main() { + // สร้าง instance ของ User โดยระบุค่าฟิลด์ทั้งหมด + let user1 = User { + username: String::from("kawin"), + email: String::from("kawin@example.com"), + age: 30, + active: true, + }; + + // การเข้าถึงฟิลด์ด้วยจุด notation + println!("ชื่อผู้ใช้: {}", user1.username); + println!("อีเมล: {}", user1.email); + + // การสร้าง tuple struct + let black = Color(0, 0, 0); + println!("สีดำ: RGB({}, {}, {})", black.0, black.1, black.2); + + // การสร้าง unit struct + let subject = AlwaysEqual; + // เราสามารถใช้ subject เป็น marker ได้ เช่น ในการ implement trait +} +``` + +จากตัวอย่างข้างต้น เราเห็นว่าการสร้าง struct นั้นตรงไปตรงมา เราสามารถกำหนดประเภทของแต่ละฟิลด์ได้อย่างอิสระ เมื่อต้องการสร้าง instance เราต้องระบุค่าสำหรับทุกฟิลด์ (เว้นแต่จะกำหนดค่าเริ่มต้นใน struct definition) การเข้าถึงฟิลด์ทำได้โดยใช้จุด notation เช่น `user1.username` สำหรับ tuple structs เราเข้าถึงฟิลด์โดยใช้ดัชนีเช่น `black.0` ส่วน unit structs นั้นไม่มีข้อมูลให้เข้าถึง แต่ยังมีประโยชน์ในการเป็นประเภทที่แตกต่างกันเพื่อใช้ในการ overloading ฟังก์ชันหรือเป็น marker ใน type system ในการพัฒนาจริง เรามักจะกำหนดค่าเริ่มต้นให้กับบางฟิลด์โดยใช้ Default trait หรือสร้างฟังก์ชัน constructor ผ่าน associated function ใน impl block ซึ่งจะอธิบายในส่วนถัดไป + +## Methods ด้วย `impl` + +ใน Rust เราไม่สามารถกำหนดเมธอดภายใน struct definition ได้โดยตรง แต่เราใช้ `impl` block เพื่อเพิ่มเมธอดและฟังก์ชันที่เกี่ยวข้องกับ struct นั้น เมธอดใน Rust แตกต่างจากภาษาอื่นตรงที่เราต้องระบุประเภทของ `self` อย่างชัดเจน ซึ่งกำหนดว่าเมธอดนั้นยืม instance แบบใด มีสามรูปแบบหลัก ได้แก่ `&self` (ยืมแบบ immutable), `&mut self` (ยืมแบบ mutable) และ `self` (รับ ownership) นอกจากนี้ยังมี associated functions ซึ่งไม่มีพารามิเตอร์ `self` และมักใช้เป็น constructor หรือฟังก์ชันยูทิลิตี้ที่เกี่ยวข้องกับประเภทนั้นๆ เราสามารถมีหลาย `impl` block สำหรับ struct เดียวกันได้ ซึ่งช่วยให้เราจัดกลุ่มเมธอดตามความรับผิดชอบได้ ตัวอย่างต่อไปนี้แสดงการใช้งาน struct `Rectangle` พร้อมเมธอดต่างๆ เพื่อคำนวณพื้นที่และปรับขนาด + +```rust +struct Rectangle { + width: f64, + height: f64, +} + +impl Rectangle { + // Associated function (constructor) - ไม่มี self + fn new(width: f64, height: f64) -> Self { + Rectangle { width, height } + } + + // Method ที่ยืม self แบบ immutable + fn area(&self) -> f64 { + self.width * self.height + } + + // Method ที่ยืม self แบบ mutable + fn scale(&mut self, factor: f64) { + self.width *= factor; + self.height *= factor; + } + + // Method ที่รับ ownership ของ self + fn square(size: f64) -> Self { + Rectangle { width: size, height: size } + } +} + +fn main() { + // ใช้ associated function เพื่อสร้าง instance + let mut rect = Rectangle::new(10.0, 5.0); + println!("พื้นที่เริ่มต้น: {}", rect.area()); // พื้นที่เริ่มต้น: 50 + + // ใช้ method ที่ยืม self แบบ mutable เพื่อปรับขนาด + rect.scale(2.0); + println!("พื้นที่หลังปรับขนาด: {}", rect.area()); // พื้นที่หลังปรับขนาด: 200 + + // ใช้ associated function อีกตัวเพื่อสร้างสี่เหลี่ยมจัตุรัส + let sq = Rectangle::square(7.0); + println!("พื้นที่สี่เหลี่ยมจัตุรัส: {}", sq.area()); // พื้นที่สี่เหลี่ยมจัตุรัส: 49 +} +``` + +จากตัวอย่าง เราเห็นว่า `new` เป็น associated function ที่ใช้สร้าง instance ของ Rectangle โดยไม่ต้องมี `self` ซึ่งเป็นรูปแบบที่พบบ่อยสำหรับ constructor ใน Rust เมธอด `area` ใช้ `&self` หมายถึงมันยืม instance แบบ immutable จึงสามารถอ่านค่าฟิลด์ได้แต่ไม่สามารถแก้ไขได้ เมธอด `scale` ใช้ `&mut self` ซึ่งอนุญาตให้แก้ไขค่าฟิลด์ได้ นอกจากนี้เรายังมี associated function `square` ที่สร้างสี่เหลี่ยมจัตุรัสจากขนาดด้านเดียว เราสามารถมีหลาย `impl` block ได้ ตัวอย่างเช่น เราอาจแยกเมธอดที่เกี่ยวข้องกับการแสดงผลออกไปอยู่ใน `impl` block อื่นเพื่อความชัดเจน ในการใช้งานจริง เราควรเลือกประเภทของ `self` ตามว่าเมธอดนั้นต้องการอ่านข้อมูลเท่านั้น เปลี่ยนแปลงข้อมูล หรือต้องการทำลาย instance หลังใช้งาน การใช้ `&self` และ `&mut self` ช่วยป้องกันข้อผิดพลาดทั่วไปเช่นการแก้ไขข้อมูลขณะที่มีการอ่านอยู่จากที่อื่น เนื่องจากระบบ ownership และ borrowing ของ Rust จะตรวจสอบสิ่งเหล่านี้ที่เวลาคอมไพล์ + +## Enums: การกำหนดประเภทข้อมูลแบบสหภาพ + +Enums ใน Rust ช่วยให้เราสามารถกำหนดประเภทข้อมูลที่สามารถเป็นหนึ่งในหลายๆ ตัวเลือกได้ ซึ่งแตกต่างจาก structs ที่ enum ในหลายภาษาอาจเป็นเพียงตัวแปรจำนวนเต็มที่มีชื่อ enum ใน Rust สามารถเก็บข้อมูลที่แตกต่างกันได้ในแต่ละตัวเลือก ทำให้มีประสิทธิภาพและแสดงเจตนาได้ชัดเจน ตัวอย่างที่ง่ายที่สุดคือ enum ที่แสดงทิศทางสี่ทิศ โดยแต่ละตัวเลือกไม่มีข้อมูลเพิ่มเติม อย่างไรก็ตาม ความแข็งแกร่งที่แท้จริงของ enum ใน Rust ปรากฏเมื่อเราให้แต่ละตัวเลือกสามารถเก็บข้อมูลประเภทต่างกันได้ ตัวอย่างเช่น เราอาจต้องการแสดงที่อยู่ IP ซึ่งอาจเป็นแบบ IPv4 (สี่ตัวเลข) หรือ IPv6 (แปดกลุ่มของเลขฐานสิบหก) นอกจากนี้ enum ยังสามารถใช้เพื่อแสดงข้อความหรือคำสั่งต่างๆ ในการประมวลผลข้อความได้อย่างปลอดภัย เนื่องจากแต่ละตัวเลือกมีประเภทข้อมูลที่ชัดเจน จึงเป็นไปไม่ได้ที่จะสับสนระหว่างประเภทข้อมูลที่แตกต่างกัน การจัดการกับ enum จะทำผ่าน pattern matching ซึ่งจะบังคับให้เราจัดการกับทุกตัวเลือกที่เป็นไปได้ จึงช่วยป้องกันข้อผิดพลาดจากการละเลยกรณีบางกรณี + +```rust +// Enum ที่ไม่มีข้อมูลเพิ่มเติมในตัวเลือก +enum Direction { + Up, + Down, + Left, + Right, +} + +// Enum ที่มีข้อมูลประเภทต่างกันในแต่ละตัวเลือก +enum IpAddr { + V4(String), + V6(String), +} + +// Enum ที่ซับซ้อนมากขึ้นที่แสดงข้อความหรือคำสั่งต่างๆ +enum Message { + NewLine, // ไม่มีข้อมูลเพิ่มเติม + Text(String), // เก็บข้อความหนึ่งสตริง + Move { x: i32, y: i32 }, // เก็บพิกัดเป็น struct แบบมีชื่อ + Write(String), // เก็บข้อความที่ต้องเขียน + ChangeColor(i32, i32, i32), // เก็บสีเป็นสามค่าจำนวนเต็ม +} + +fn main() { + // การใช้งาน Direction + let dir = Direction::Up; + match dir { + Direction::Up => println!("กำลังเคลื่อนที่ขึ้น"), + Direction::Down => println!("กำลังเคลื่อนที่ลง"), + Direction::Left => println!("กำลังเคลื่อนที่ซ้าย"), + Direction::Right => println!("กำลังเคลื่อนที่ขวา"), + } + + // การสร้างและใช้งาน IpAddr + let home = IpAddr::V4(String::from("127.0.0.1")); + let loopback = IpAddr::V6(String::from("::1")); + + match home { + IpAddr::V4(addr) => println!("ที่อยู่ IPv4: {}", addr), + IpAddr::V6(addr) => println!("ที่อยู่ IPv6: {}", addr), + } + + // การใช้งาน Message ที่ซับซ้อน + let msg = Message::Move { x: 10, y: 20 }; + match msg { + Message::NewLine => println!("ขึ้นบรรทัดใหม่"), + Message::Text(t) => println!("ข้อความ: {}", t), + Message::Move { x, y } => println!("เคลื่อนที่ไปที่ ({}, {})", x, y), + Message::Write(t) => println!("เขียนข้อความ: {}", t), + Message::ChangeColor(r, g, b) => println!("เปลี่ยนสีเป็น RGB({}, {}, {})", r, g, b), + } +} +``` + +จากตัวอย่างข้างต้น เราเห็นว่า enum นั้นมีความยืดหยุ่นสูงมาก เราสามารถกำหนดตัวเลือกที่ไม่มีข้อมูลเพิ่มเติมเช่น `Direction::Up` หรือตัวเลือกที่เก็บข้อมูลประเภทต่างกันเช่น `IpAddr::V4(String)` ซึ่งเก็บที่อยู่ IPv4 เป็นสตริง ในกรณีที่ต้องการเก็บข้อมูลหลายค่าที่มีความหมาย เราสามารถใช้ struct แบบมีชื่อภายในตัวเลือกได้เช่นใน `Message::Move { x: i32, y: i32 }` ซึ่งทำให้โค้ดอ่านเข้าใจได้ง่ายกว่าการใช้ทูเพิลธรรมดา ความแตกต่างที่สำคัญระหว่าง enum ใน Rust และในภาษาอื่นคือ แต่ละตัวเลือกของ enum คือประเภทที่แตกต่างกันอย่างสิ้นเชิง ซึ่งหมายความว่าเราไม่สามารถสับสนระหว่าง `IpAddr::V4` และ `IpAddr::V6` ได้เลย เนื่องจากพวกมันเป็นค่าที่มีประเภทต่างกันโดยสิ้นเชิง นอกจากนี้เมื่อเราใช้ pattern matching กับ enum คอมไพเลอร์จะบังคับให้เราจัดการกับทุกตัวเลือกที่เป็นไปได้ หากเราลืมกรณีใดกรณีหนึ่ง คอมไพเลอร์จะแสดงข้อผิดพลาด ทำให้โค้ดของเรามีความปลอดภัยสูงและครอบคลุมทุกกรณี ในการพัฒนาจริง เช่นในโปรเจกต์ rs-wsProxy เราใช้ enum เพื่อแสดงผลลัพธ์จากการตรวจสอบการเชื่อมต่อ ซึ่งอาจเป็นการยอมรับหรือปฏิเสธพร้อมเหตุผล ซึ่งเราจะเห็นในส่วนถัดไป + +## Option: การจัดการกับค่าที่อาจไม่มีอยู่ + +ในภาษาโปรแกรมหลายภาษา เราใช้ค่า null หรือ nil เพื่อแสดงถึงการไม่มีค่า แต่สิ่งนี้มักนำไปสู่ข้อผิดพลาดที่พบบ่อยที่สุดอย่าง NullPointerException ใน Rust ไม่มีแนวคิดของ null แทนที่จะใช้ประเภท `Option` ซึ่งเป็น enum ที่มีสองตัวเลือก ได้แก่ `Some(T)` ซึ่งบ่งบอกว่ามีค่าอยู่และเก็บค่าที่มีประเภท T ไว้ และ `None` ซึ่งบ่งบอกว่าไม่มีค่า การใช้ `Option` ทำให้เราต้องจัดการกับกรณีที่ไม่มีค่าอย่างชัดเจนผ่าน pattern matching ซึ่งช่วยป้องกันข้อผิดพลาดจากการสมมติว่ามีค่าอยู่เมื่อจริงๆ แล้วไม่มี นอกจากนี้ยังทำให้เจตนาของโค้ดชัดเจนขึ้นว่าเราตระหนักถึงความเป็นไปได้ที่จะไม่มีค่า ตัวอย่างต่อไปนี้แสดงการใช้งาน `Option` ในฟังก์ชันที่ค้นหาตัวอักษรแรกในสตริง ซึ่งอาจคืนค่าไม่พบหากสตริงว่างเปล่า + +```rust +fn first_character(s: &str) -> Option { + // คืนค่า Some(char) หากพบตัวอักษรแรก หรือ None หากสตริงว่าง + s.chars().next() +} + +fn main() { + let text = "สวัสดีครับ"; + match first_character(text) { + Some(ch) => println!("ตัวอักษรแรกคือ: '{}'", ch), + None => println!("สตริงว่างเปล่า ไม่มีตัวอักษรแรก"), + } + + let empty = ""; + match first_character(empty) { + Some(ch) => println!("ตัวอักษรแรกคือ: '{}'", ch), + None => println!("สตริงว่างเปล่า ไม่มีตัวอักษรแรก"), // จะพบกรณีนี้ + } + + // การใช้ if let เพื่อจัดการกรณีเดียวอย่างกระชับ + if let Some(ch) = first_character(text) { + println!("พบตัวอักษรแรก: '{}'", ch); + } else { + println!("ไม่พบตัวอักษรแรก"); + } +} +``` + +จากตัวอย่าง เราเห็นว่าฟังก์ชัน `first_character` คืนค่าเป็น `Option` ซึ่งบ่งบอกว่าอาจคืนค่าตัวอักษรหรือไม่คืนค่าเลยก็ได้ การใช้ `match` เพื่อจัดการกับ `Option` ทำให้เราต้องพิจารณาทั้งสองกรณีอย่างชัดเจน นอกจากนี้เรายังสามารถใช้ `if let` เมื่อเราสนใจเพียงกรณีเดียว (เช่นกรณีที่มีค่า) และต้องการจัดการกรณีที่เหลือในอีกทางหนึ่งได้อย่างกระชับ การใช้ `Option` แทนที่จะใช้ null ช่วยขจัดคลาสของข้อผิดพลาดทั้งหมดที่เกี่ยวข้องกับการอ้างอิงถึงค่าว่าง เนื่องจากคอมไพเลอร์จะบังคับให้เราตรวจสอบว่าเราได้จัดการกับกรณี `None` แล้วหรือไม่ก่อนที่จะเข้าถึงค่าที่อยู่ภายใน `Some` ในการพัฒนาจริง เราจะเห็นการใช้ `Option` อย่างแพร่หลาย เช่นในโครงสร้างข้อมูลของ rs-wsProxy ที่ฟิลด์บางฟิลด์อาจไม่มีค่าที่กำหนดไว้ เช่น `allowed_servers` ซึ่งเป็น `Option>` ที่ใช้แยกแยะระหว่างการไม่มีการจำกัด (None) กับการมีรายการเซิร์ฟเวอร์ที่อนุญาตว่างเปล่า (Some(vec![])) ซึ่งมีความหมายแตกต่างกันอย่างสิ้นเชิงในตรรกะของพร็อกซี + +## Pattern Matching: การจับคู่รูปแบบเพื่อจัดการข้อมูล + +Pattern matching ใน Rust เป็นกลไกที่ทรงพลังสำหรับการทำลายข้อมูลและจัดการกับกรณีต่างๆ ของ enum หรือ struct มันมากกว่าแค่ switch statement ในภาษาอื่นๆ เนื่องจากสามารถทำลายโครงสร้างข้อมูลที่ซับซ้อน พันตรวจสอบเงื่อนไขเพิ่มเติม และผูกค่าที่ตรงกับรูปแบบไว้เป็นตัวแปรใหม่ได้ เราใช้คีย์เวิร์ด `match` เพื่อเริ่มต้นการจับคู่รูปแบบ โดยแต่ละแขนของ match จะประกอบด้วยรูปแบบ (pattern) ที่จะจับคู่กับค่าที่ป้อนเข้ามา และนิพจน์ที่จะดำเนินการเมื่อเกิดการจับคู่ รูปแบบสามารถเป็นได้ตั้งแต่ค่าคงที่ ตัวแปร โครงสร้าง struct หรือ enum ไปจนถึงการผสมผสานที่ซับซ้อน นอกจากนี้ยังมีรูปแบบพิเศษอย่าง `_` ซึ่งทำหน้าที่เป็น wildcard จับคู่กับทุกค่าที่เหลือ และเรายังสามารถใช้ `if let` และ `while let` เมื่อเราต้องการจับคู่เพียงรูปแบบเดียวโดยไม่ต้องเขียน match ทั้งหมด ตัวอย่างต่อไปนี้แสดงการใช้งาน pattern matching กับ enum ต่างๆ รวมถึง Option และผลลัพธ์จากการดำเนินการที่อาจล้มเหลว + +```rust +// ตัวอย่างการจับคู่กับ enum ที่ซับซ้อน +enum Message { + Quit, + Move { x: i32, y: i32 }, + Write(String), + ChangeColor(i32, i32, i32), +} + +fn process_message(msg: Message) { + match msg { + Message::Quit => { + println!("ออกจากโปรแกรม"); + // ในโปรแกรมจริงอาจเรียก std::process::exit หรือส่งสัญญาณออกไป + } + Message::Move { x, y } => { + println!("เคลื่อนที่ไปที่พิกัด ({}, {})", x, y); + } + Message::Write(text) => { + println!("ส่งข้อความ: {}", text); + } + Message::ChangeColor(r, g, b) => { + println!("เปลี่ยนสีเป็น RGB({}, {}, {})", r, g, b); + } + } +} + +// ตัวอย่างการจับคู่กับ Option +fn divide_integers(a: i32, b: i32) -> Option { + if b == 0 { + None + } else { + Some(a / b) + } +} + +fn main() { + // ทดสอบฟังก์ชันหาร + match divide_integers(10, 2) { + Some(result) => println!("10 / 2 = {}", result), + None => println!("ไม่สามารถหารด้วยศูนย์ได้"), + } + + // การใช้ if let เพื่อจัดการกรณีที่สำเร็จเพียงอย่างเดียว + if let Some(result) = divide_integers(10, 3) { + println!("ผลลัพธ์ของการหาร: {}", result); + } + + // การใช้ while let ในลูปเพื่อประมวลผลชุดข้อมูลจนกว่าจะหมด + let mut stack = vec![1, 2, 3, 4, 5]; + while let Some(top) = stack.pop() { + println!("นำออกจากกอง: {}", top); + } + + // ตัวอย่างการใช้แมทช์กับโครงสร้างซ้อนกัน + let complex = ( + Message::Write(String::จาก("สวัสดี")), + Message::ChangeColor(255, 0, 0), + ); + match complex { + (Message::Write(text), Message::ChangeColor(r, g, b)) => { + println!("เขียนข้อความ '{}' ด้วยสี RGB({}, {}, {})", text, r, g, b); + } + _ => println!("รูปแบบไม่ตรงกัน"), + } +} +``` + +จากตัวอย่างข้างต้น เราเห็นว่า pattern matching นั้นมีความยืดหยุ่นและทรงพลังมาก ในการจับคู่กับ enum ที่มีข้อมูล เราสามารถทำลายโครงสร้างเพื่อดึงค่าภายในออกมาใช้ได้โดยตรง เช่นในกรณีของ `Message::Move { x, y }` เราจะได้ตัวแปร `x` และ `y` ที่มีค่าตามที่กำหนดใน enum สำหรับกรณีที่มีข้อมูลหลายประเภทเช่นใน tuple ที่ซ้อนกัน เราสามารถทำลายหลายระดับได้ในครั้งเดียว นอกจากนี้เรายังสามารถใช้เงื่อนไขเพิ่มเติมในรูปแบบได้โดยใช้ `if` guard ตัวอย่างเช่น `Some(x) if x > 0` จะจับคู่เฉพาะเมื่อค่าใน Some มากกว่าศูนย์ รูปแบบ wildcard `_` มีประโยชน์เมื่อเราต้องการจัดการกับทุกกรณีที่เหลือโดยไม่ต้องระบุชื่อตัวแปร การใช้ `if let` และ `while let` ช่วยให้โค้ดกระชับขึ้นเมื่อเราสนใจเพียงรูปแบบเดียว ในการพัฒนาจริง pattern matching เป็นหัวใจสำคัญของการเขียนโค้ดที่ปลอดภัยและอ่านเข้าใจได้ใน Rust เนื่องจากมันบังคับให้เราจัดการกับทุกกรณีที่เป็นไปได้และทำให้เจตนาของโค้ดชัดเจนว่าเราคาดหวังว่าข้อมูลจะมีรูปแบบใดบ้าง + +## Result: การจัดการกับข้อผิดพลาดอย่างมีแบบแผน + +เช่นเดียวกับที่ `Option` ใช้เพื่อแสดงถึงการมีหรือไม่มีค่า `Result` ใช้เพื่อแสดงถึงผลลัพธ์ของการดำเนินการที่อาจสำเร็จหรือล้มเหลว มันเป็น enum ที่มีสองตัวเลือก ได้แก่ `Ok(T)` ซึ่งบ่งบอกว่าการดำเนินการสำเร็จและคืนค่าที่มีประเภท T และ `Err(E)` ซึ่งบ่งบอกว่าการดำเนินการล้มเหลวและคืนค่าข้อผิดพลาดที่มีประเภท E การใช้ `Result` ทำให้เราต้องจัดการกับกรณีความล้มเหลวอย่างชัดเจน ซึ่งแตกต่างจากการใช้ exception ในภาษาอื่นที่อาจถูกมองข้ามหรือจัดการไม่ถูกต้อง นอกจากนี้ `Result` ยังช่วยให้เราสามารถส่งต่อข้อผิดพลาดไปยังผู้เรียกใช้ได้อย่างง่ายดายโดยใช้ตัวดำเนินการ `?` ซึ่งจะคืนค่าข้อผิดพลาดทันทีหากผลลัพธ์เป็น `Err` หรือแตกค่าออกจาก `Ok` หากผลลัพธ์เป็น success แม้ว่าในบทความนี้เราจะให้เพียงการแนะนำเบื้องต้นเท่านั้น เนื่องจากการจัดการกับข้อผิดพลาดอย่างละเอียดจะถูกอธิบายอย่างเต็มที่ใน Part 4 เกี่ยวกับ Collections, Iterators และ Error Handling อย่างไรก็ตาม ตัวอย่างต่อไปนี้แสดงการใช้งานพื้นฐานของ `Result` ในการอ่านไฟล์ซึ่งเป็นการดำเนินการที่มักล้มเหลวได้จากหลายสาเหตุ เช่นไฟล์ไม่มีอยู่หรือไม่มีสิทธิ์อ่าน + +```rust +use std::fs::File; +use std::io::{self, Read}; + +fn read_username_from_file() -> Result { + // เปิดไฟล์และคืนค่า Result ที่อาจเป็น File หรือ io::Error + let mut file = File::open("hello.txt")?; + + // อ่านเนื้อหาไฟล์ลงในสตริงและคืนค่า Result ที่อาจเป็นสตริงหรือ io::Error + let mut username = String::new(); + file.read_to_string(&mut username)?; + + // หากทุกขั้นตอนสำเร็จ เราจะคืนค่า Ok ที่มี username + Ok(username) +} + +fn main() { + match read_username_from_file() { + Ok(username) => println!("ชื่อผู้ใช้: {}", username), + Err(e) => println!("เกิดข้อผิดพลาดในการอ่านไฟล์: {}", e), + } + + // การใช้ตัวดำเนินการ ? เพื่อส่งต่อข้อผิดพลาดอย่างรวดเร็ว + // ฟังก์ชันนี้จะคืนค่า Result ทันทีหากมีข้อผิดพลาดเกิดขึ้นในขั้นตอนใดขั้นตอนหนึ่ง + fn read_username_shortcut() -> Result { + let mut username = String::new(); + File::open("hello.txt")?.read_to_string(&mut username)?; + Ok(username) + } +} +``` + +จากตัวอย่าง เราเห็นว่า `Result` ช่วยให้เราจัดการกับการดำเนินการที่อาจล้มเหลวได้อย่างชัดเจนและปลอดภัย การใช้ตัวดำเนินการ `?` ทำให้การส่งต่อข้อผิดพลาดเป็นไปอย่างสะดวกโดยไม่ต้องเขียน match ซ้ำซ้อนในทุกขั้นตอน ซึ่งช่วยให้โค้ดอ่านเข้าใจได้ง่ายขึ้นและลดความเสี่ยงจากการลืมจัดการกับข้อผิดพลาด ในการพัฒนาจริง เราจะเห็นการใช้ `Result` อย่างแพร่หลายในไลบรารีมาตรฐานของ Rust และในไลบรารีบุคคลที่สาม เกือบทุกการดำเนินการที่เกี่ยวข้องกับ I/O การเครือข่าย หรือการแยกวิเคราะห์ข้อมูลจะคืนค่าเป็น `Result` เพื่อบังคับให้ผู้เรียกใช้ต้องพิจารณากรณีความล้มเหลว ในโปรเจกต์ rs-wsProxy เราใช้ `Result` อย่างมากในการจัดการกับข้อผิดพลาดจากการเชื่อมต่อเครือข่าย การวิเคราะห์ข้อความ และการเข้าถึงระบบไฟล์ ซึ่งจะอธิบายอย่างละเอียดใน Part 4 + +## เชื่อมโยงกับ rs-wsProxy: การประยุกต์ใช้ในโครงการจริง + +ในโปรเจกต์ rs-wsProxy ซึ่งเป็นเซิร์ฟเวอร์พร็อกซ์ WebSocket ที่เขียนด้วย Rust เราได้ประยุกต์ใช้แนวคิดของ structs, enums และ pattern matching อย่างเต็มที่เพื่อสร้างระบบที่ปลอดภัยและบำรุงรักษาได้ง่าย ตัวอย่างที่เห็นได้ชัดเจนคือการกำหนดประเภทข้อมูลสำหรับการตั้งค่าและผลลัพธ์จากการตรวจสอบการเชื่อมต่อ เราใช้ struct `AppState` เพื่อเก็บสถานะของแอปพลิเคชันซึ่งประกอบด้วยฟิลด์ต่างๆ เช่น รายการเซิร์ฟเวอร์ที่อนุญาต (ซึ่งใช้ `Option` เพื่อแยกแยะระหว่างไม่มีการจำกัดและการอนุญาตรายการว่าง) ตารางการเปลี่ยนเส้นทาง และเป้าหมายเริ่มต้น นอกจากนี้เรายังใช้ enum `VerifyResult` เพื่อแสดงผลลัพธ์จากการตรวจสอบว่าไคลเอนต์ควรได้รับอนุญาตให้เชื่อมต่อไปยังเซิร์ฟเวอร์เป้าหมายหรือไม่ โดยมีสองตัวเลือก ได้แก่ `Accepted` ที่เก็บข้อความยืนยันและ `Rejected` ที่เก็บเหตุผลการปฏิเสธ ต่อไปนี้แสดงโครงสร้างข้อมูลจริงจากโค้ดของ rs-wsProxy + +```rust +use std::collections::HashMap; + +// ผลลัพธ์จากการตรวจสอบการเชื่อมต่อ +pub enum VerifyResult { + // การเชื่อมต่อได้รับการยอมรับพร้อมข้อความยืนยัน (เช่นเซิร์ฟเวอร์เป้าหมาย) + Accepted(String), + // การเชื่อมต่อถูกปฏิเสธพร้อมเหตุผล + Rejected(RejectReason), +} + +// เหตุผลในการปฏิเสธการเชื่อมต่อ +pub enum RejectReason { + Unauthorized, // ไม่ได้รับอนุญาต + ServerNotFound, // เซิร์ฟเวอร์เป้าหมายไม่พบในรายการที่อนุญาต + InternalError, // ข้อผิดพลาดภายในของพร็อกซี่ +} + +// สถานะของแอปพลิเคชันที่แบ่งปันระหว่างการเชื่อมต่อต่างๆ +pub struct AppState { + // รายการเซิร์ฟเวอร์ที่อนุญาตให้เชื่อมต่อได้ หากเป็น None หมายถึงไม่มีการจำกัด (เปิดพร็อกซี่) + // หากเป็น Some(vec![]) หมายถึงอนุญาตรายการว่าง (ปฏิเสธการเชื่อมต่อทั้งหมด) + pub allowed_servers: Option>, + // ตารางการเปลี่ยนเส้นทางจากเส้นทางต้นทางไปยังเส้นทางปลายทาง + pub redirects: HashMap, + // เป้าหมายเริ่มต้นที่จะใช้เมื่อไม่มีการระบุเฉพาะเจาะจง หากเป็น None หมายถึงไม่มีเป้าหมายเริ่มต้น + pub default_target: Option, +} + +impl AppState { + // เมธอดสำหรับตรวจสอบว่าเซิร์ฟเวอร์เป้าหมายได้รับอนุญาตให้เชื่อมต่อหรือไม่ + pub fn is_server_allowed(&self, server: &str) -> bool { + match &self.allowed_servers { + Some(servers) => servers.contains(server), + None => true, // None หมายถึงไม่มีการจำกัด อนุญาตทุกเซิร์ฟเวอร์ + } + } +} + +// ตัวอย่างการใช้งาน VerifyResult ในตัวจัดการการเชื่อมต่อ +async fn handle_connection(state: AppState, target_server: String) -> VerifyResult { + // ตรวจสอบว่าเซิร์ฟเวอร์เป้าหมายได้รับอนุญาตหรือไม่ + if !state.is_server_allowed(&target_server) { + return VerifyResult::Rejected(RejectReason::ServerNotFound); + } + + // ในโค้ดจริงอาจมีการตรวจสอบเพิ่มเติม เช่นการเชื่อมต่อไปยังเซิร์ฟเวอร์จริง + // ที่นี่เราสมมติว่าการตรวจสอบผ่านทั้งหมด + VerifyResult::Accepted(format!("เชื่อมต่อไปยัง {} สำเร็จ", target_server)) +} + +fn main() { + // ตัวอย่างการสร้าง AppState และการใช้งาน + let state = AppState { + allowed_servers: Some(vec![ + String::from("เซิร์ฟเวอร์A.example.com"), + String::from("เซิร์ฟเวอร์B.example.com"), + ]), + redirects: HashMap::from([ + (String::from("/เก่า"), String::from("/ใหม่")), + ]), + default_target: Some(String::from("เซิร์ฟเวอร์C.example.com")), + }; + + // ตรวจสอบว่าเซิร์ฟเวอร์หนึ่งได้รับอนุญาตหรือไม่ + println!( + "เซิร์ฟเวอร์A ได้รับอนุญาต: {}", + state.is_server_allowed("เซิร์ฟเวอร์A.example.com") + ); // จะพิมพ์ true + + println!( + "เซิร์ฟเวอร์C ได้รับอนุญาต: {}", + state.is_server_allowed("เซิร์ฟเวอร์C.example.com") + ); // จะพิมพ์ false เนื่องจากไม่อยู่ในรายการ + + // จำลองการจัดการการเชื่อมต่อ + let result = handle_connection(state, String::from("เซิร์ฟเวอร์A.example.com")); + match result { + VerifyResult::Accepted(msg) => println!("ผลลัพธ์: {}", msg), + VerifyResult::Rejected(reason) => { + println!("การเชื่อมต่อถูกปฏิเสธด้วยเหตุผล: {:?}", reason); + } + } +} +``` + +จากตัวอย่างข้างต้น เราเห็นว่าการใช้ `Option` ในฟิลด์ `allowed_servers` ช่วยให้เราสามารถแยกแยะสามสถานะได้อย่างชัดเจน: `None` หมายถึงไม่มีการจำกัด (พร็อกซี่เปิดให้เชื่อมต่อไปยังเซิร์ฟเวอร์ใดๆ ก็ได้), `Some(vec![])` หมายถึงอนุญาตรายการว่าง (ปฏิเสธการเชื่อมต่อทั้งหมดเนื่องจากไม่มีเซิร์ฟเวอร์ใดที่ได้รับอนุญาต), และ `Some` ที่มีองค์ประกอบหนึ่งหรือมากกว่าหมายถึงอนุญาตเฉพาะเซิร์ฟเวอร์ในรายการนั้น การออกแบบเช่นนี้ทำให้ตรรกะในการตรวจสอบว่าเซิร์ฟเวอร์ได้รับอนุญาตหรือไม่ (`is_server_allowed`) มีความชัดเจนและปลอดภัย เนื่องจากเราต้องจัดการกับทั้งสองกรณีของ `Option` อย่างชัดเจน นอกจากนี้การใช้ enum `VerifyResult` พร้อมกับ pattern matching ใน `handle_connection` ทำให้เราสามารถจัดการกับผลลัพธ์สองประเภทได้อย่างครบถ้วนโดยไม่มีความเสี่ยงที่จะละเลยกรณีใดกรณีหนึ่ง ในการพัฒนาจริง โครงสร้างข้อมูลเหล่านี้ถูกใช้ทั่วทั้งโค้ดฐานของ rs-wsProxy เพื่อจัดการกับสถานะ การตั้งค่า และผลลัพธ์จากการดำเนินการต่างๆ ซึ่งทำให้โค้ดมีความทนทานต่อข้อผิดพลาดและง่ายต่อการเข้าใจ + +## สรุป + +ในบทความนี้เราได้เรียนรู้เกี่ยวกับการสร้างและใช้งาน structs และ enums ใน Rust เพื่อกำหนดประเภทข้อมูลของเราเอง ซึ่งช่วยให้เราสามารถจำลองข้อมูลที่ซับซ้อนได้อย่างปลอดภัยและชัดเจน เราได้เห็นว่า structs เหมาะสำหรับการเก็บข้อมูลที่มีฟิลด์หลายฟิลด์ที่เกี่ยวข้องกัน ในขณะที่ enums ช่วยให้เราสามารถกำหนดประเภทข้อมูลที่สามารถเป็นหนึ่งในหลายๆ ตัวเลือกได้ โดยแต่ละตัวเลือกสามารถเก็บข้อมูลประเภทต่างกันได้ เราได้เรียนรู้เกี่ยวกับการเพิ่มเมธอดให้กับ structs ผ่าน `impl` block และความแตกต่างระหว่าง `&self`, `&mut self` และ `self` นอกจากนี้เราได้สำรวจ pattern matching ซึ่งเป็นกลไกที่ทรงพลังสำหรับการจัดการกับทุกกรณีที่เป็นไปได้ของ enum หรือ struct อย่างครบถ้วน และเราได้เห็นการแนะนำเบื้องต้นเกี่ยวกับ `Result` สำหรับการจัดการกับข้อผิดพลาด ซึ่งจะถูกอธิบายอย่างละเอียดใน Part 4 สุดท้ายเราได้เห็นการประยุกต์ใช้แนวคิดเหล่านี้ในโครงการจริงอย่าง rs-wsProxy ซึ่งแสดงให้เห็นว่าแนวคิดเหล่านี้ไม่ใช่เพียงทฤษฎีเท่านั้น แต่เป็นเครื่องมือที่จำเป็นในการสร้างซอฟต์แวร์ที่ทนทานและบำรุงรักษาได้ง่ายใน Rust + +หากคุณต้องการทบทวนเนื้อหาก่อนหน้าเกี่ยวกับ Ownership, Borrowing และ Lifetimes โปรดดูที่: [Ownership, Borrowing และ Lifetimes](/posts/rust/rust-ownership-borrowing/) + +หากคุณต้องการเรียนรู้ต่อไปเกี่ยวกับ Collections, Iterators และ Error Handling ใน Rust โปรดดูที่: [Collections, Iterators และ Error Handling](/posts/rust/rust-collections-errors/) \ No newline at end of file diff --git a/content/posts/rust/rust-traits-generics/featured-image.jpeg b/content/posts/rust/rust-traits-generics/featured-image.jpeg new file mode 100644 index 0000000..c38f3b1 Binary files /dev/null and b/content/posts/rust/rust-traits-generics/featured-image.jpeg differ diff --git a/content/posts/rust/rust-traits-generics/index.en.md b/content/posts/rust/rust-traits-generics/index.en.md new file mode 100644 index 0000000..0ef0bb8 --- /dev/null +++ b/content/posts/rust/rust-traits-generics/index.en.md @@ -0,0 +1,579 @@ +--- +title: "Traits & Generics" +subtitle: "" +date: 2026-07-11T09:00:00+07:00 +lastmod: 2026-07-11T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Abstraction in Rust: traits, generics, trait bounds, and trait objects" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +Traits and generics are Rust's primary mechanisms for abstraction and code reuse. Traits define shared behavior, similar to interfaces in other languages, while generics allow code to work with multiple types. Together, they enable powerful abstractions without runtime cost. This tutorial builds on [Part 4: Collections, Iterators & Error Handling](/posts/rust/rust-collections-errors/) and prepares you for [Async Rust with Tokio](/posts/rust/rust-async-tokio/). + + + +## Defining Traits + +Traits define shared behavior in an abstract way. Similar to interfaces in Java or interfaces in Go, Rust traits specify what methods a type must implement to possess certain behavior. + +Let's define a simple `Summary` trait that provides a method to summarize content: + +```rust +pub trait Summary { + fn summarize(&self) -> String; +} +``` + +This trait declaration states that any type implementing `Summary` must provide a `summarize` method that takes an immutable reference to `self` and returns a `String`. + +Now let's implement this trait for two structs: `NewsArticle` and `Tweet`. + +```rust +pub struct NewsArticle { + pub headline: String, + pub location: String, + pub author: String, + pub content: String, +} + +impl Summary for NewsArticle { + fn summarize(&self) -> String { + format!("{}, by {} ({})", self.headline, self.author, self.location) + } +} + +pub struct Tweet { + pub username: String, + pub content: String, + pub reply: bool, + pub retweet: bool, +} + +impl Summary for Tweet { + fn summarize(&self) -> String { + format!("{}: {}", self.username, self.content) + } +} +``` + +Here we've defined two structs and implemented the `Summary` trait for each. The `NewsArticle` implementation returns a formatted string with headline, author, and location, while `Tweet` returns username and content. + +This approach is similar to interfaces in Go or Java, but with key differences: +- Rust traits can be implemented for types you don't own (the orphan rule has restrictions, but it's more flexible than Java's interfaces) +- Traits can have default method implementations (we'll see this next) +- Traits can be used as parameters through trait bounds (also coming up) + +## Default Implementations + +Traits can provide default implementations for their methods. Types implementing the trait can choose to use the default or override it. + +Let's enhance our `Summary` trait with a default implementation: + +```rust +pub trait Summary { + fn summarize(&self) -> String { + String::from("(Read more...)") + } +} +``` + +Now any type implementing `Summary` gets the default `summarize` method that returns "(Read more...)". However, we can still override it: + +```rust +impl Summary for NewsArticle { + // We're overriding the default implementation + fn summarize(&self) -> String { + format!("{}, by {} ({})", self.headline, self.author, self.location) + } +} + +impl Summary for Tweet { + // Using the default implementation + // fn summarize(&self) -> String { + // String::from("(Read more...)") + // } +} +``` + +In this example, `NewsArticle` overrides the default method while `Tweet` uses it. This provides flexibility - common behavior can be defined once in the trait, while specific types can customize when needed. + +Default methods can also call other methods in the same trait, even if those methods don't have default implementations: + +```rust +pub trait Summary { + fn summarize_author(&self) -> String; + + fn summarize(&self) -> String { + format!("(Read more from {}...)", self.summarize_author()) + } +} +``` + +Now any type implementing `Summary` only needs to implement `summarize_author` to get a default `summarize` method. + +## Traits as Parameters + +There are two ways to use traits as parameters in Rust: `impl Trait` syntax and trait bound syntax. + +First, the `impl Trait` syntax - useful for simple cases: + +```rust +pub fn notify(item: &impl Summary) { + println!("Breaking news! {}", item.summarize()); +} +``` + +This function accepts any type that implements the `Summary` trait. It's concise and readable for simple cases. + +For more complex situations, we use trait bound syntax with generics: + +```rust +pub fn notify(item: &T) { + println!("Breaking news! {}", item.summarize()); +} +``` + +Here we've introduced a generic type parameter `T` that is constrained by the `Summary` trait. This means `T` can be any type that implements `Summary`. + +Which should you use? Use `impl Trait` for simple cases where you only need one parameter. Use trait bounds when: +- You need to use the same trait bound multiple times in a function signature +- You need to specify multiple trait bounds +- You want to be explicit about the generic nature of your function + +Both approaches compile to the same code due to monomorphization (which we'll discuss later). + +## Generics + +Generics allow us to write code that works with multiple types. Let's start with a generic function that finds the largest element in a slice: + +```rust +fn largest(list: &[T]) -> T { + let mut largest = &list[0]; + + for item in list { + if item > largest { + largest = item; + } + } + + *largest +} +``` + +This won't compile because we're using the `>` operator on type `T`, and not all types can be compared. We need to add a trait bound: + +```rust +use std::cmp::PartialOrd; + +fn largest(list: &[T]) -> T { + let mut largest = &list[0]; + + for item in list { + if item > largest { + largest = item; + } + } + + *largest +} +``` + +Now `T` must implement `PartialOrd` (for comparison) and `Copy` (so we can return a copy of the value). We could also return a reference (`&T`) to avoid the `Copy` requirement. + +Let's look at generic structs: + +```rust +struct Point { + x: T, + y: T, +} + +impl Point { + fn x(&self) -> &T { + &self.x + } +} + +fn main() { + let integer = Point { x: 5, y: 10 }; + let float = Point { x: 1.0, y: 4.0 }; +} +``` + +Here `Point` is a generic struct that can hold any type for its coordinates, as long as both fields are the same type. We can also constrain the implementation: + +```rust +impl Point { + fn distance_from_origin(&self) -> f32 { + (self.x.powi(2) + self.y.powi(2)).sqrt() + } +} +``` + +This implementation only exists for `Point`, not for other types. + +Generic enums work similarly: + +```rust +enum Option { + Some(T), + None, +} + +enum Result { + Ok(T), + Err(E), +} +``` + +These are fundamental to Rust's standard library. + +### Monomorphization + +When Rust compiles generic code, it performs monomorphization - generating specialized versions of the code for each concrete type used. For example, if we call `largest` with both `i32` and `f64`, the compiler generates two specialized functions: + +```rust +fn largest_i32(list: &[i32]) -> i32 { /* ... */ } +fn largest_f64(list: &[f64]) -> f64 { /* ... */ } +``` + +This means generics have zero runtime cost - they're as fast as if you'd written the type-specific versions yourself. However, it does increase compile time and binary size. + +## Trait Bounds + +Sometimes we need multiple trait bounds. We can specify them with `+` syntax: + +```rust +use std::fmt::Display; + +fn notify(item: &(impl Summary + Display)) { + println!("Breaking news! {}", item.summarize()); +} +``` + +Or with trait bound syntax: + +```rust +fn notify(item: &T) { + println!("Breaking news! {}", item.summarize()); +} +``` + +For longer lists of trait bounds, the `where` clause improves readability: + +```rust +fn some_function(t: &T, u: &U) -> i32 +where + T: Display + Clone, + U: Clone + Debug, +{ + // function body +} +``` + +The `where` clause is particularly useful when trait bounds become complex or when we want to keep the function signature clean. + +## Trait Objects + +So far we've discussed static dispatch - where the compiler knows exactly which method implementation to call at compile time. Rust also supports dynamic dispatch through trait objects. + +A trait object is a pointer to an implementation of a trait, allowing for polymorphic behavior. We create trait objects using `dyn Trait`: + +```rust +fn notify(item: &Box) { + println!("Breaking news! {}", item.summarize()); +} +``` + +Or more commonly with references: + +```rust +fn notify(item: &dyn Summary) { + println!("Breaking news! {}", item.summarize()); +} +``` + +Here's how we might use it: + +```rust +fn main() { + let article = NewsArticle { + headline: String::from("Penguins win the Stanley Cup Championship!"), + location: String::from("Pittsburgh, PA, USA"), + author: String::from("Iceburgh"), + content: String::from( + "The Pittsburgh Penguins once again are the best \ + hockey team in the NHL.", + ), + }; + + let tweet = Tweet { + username: String::from("horse_ebooks"), + content: String::from( + "of course, as you probably already know, people", + ), + reply: false, + retweet: false, + }; + + let items: Vec> = vec![ + Box::new(article), + Box::new(tweet), + ]; + + for item in items { + notify(&item); + } +} +``` + +Notice we had to box our items to create trait objects. This is because trait objects have unknown size at compile time - we need indirection via a pointer. + +### Static vs Dynamic Dispatch + +Static dispatch (via generics and trait bounds) has zero runtime overhead but increases compile time and binary size. Dynamic dispatch (via trait objects) has a small runtime cost due to virtual method dispatch but keeps compile times lower and binary size smaller. + +Choose static dispatch when: +- You know all possible types at compile time +- Performance is critical +- You want zero-cost abstractions + +Choose dynamic dispatch when: +- You need heterogeneous collections (like our `Vec>`) +- The exact types aren't known until runtime +- You prefer faster compilation over minimal binary size + +## Standard Library Traits + +Rust's standard library provides many useful traits. Let's examine some of the most important ones: + +### Display and Debug + +`Display` is for user-facing output, while `Debug` is for debugging: + +```rust +use std::fmt; + +struct Point { + x: i32, + y: i32, +} + +impl fmt::Display for Point { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +impl fmt::Debug for Point { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Point {{ x: {}, y: {} }}", self.x, self.y) + } +} + +fn main() { + let p = Point { x: 1, y: 2 }; + println!("{}", p); // Output: (1, 2) + println!("{:?}", p); +} +``` + +### Clone and Copy + +`Clone` enables explicit duplication, while `Copy`T: Clone + Display>`. + +```rust +#[derive(Debug)] +struct Point { + x: i32, + y: i32, +} + +impl Clone for Point { + fn clone(&self) -> Self { + Self { + x: self.x, + y: self.y, + } + } +} + +// Or simply derive it: +#[derive(Debug, Clone)] +struct Point { + x: i32, + y: i32, +} +``` + +`Copy` is a marker trait for types that can be copied bit-for-wise. All `Copy` types must also be `Clone`: + +```rust +#[derive(Debug, Copy, Clone)] +struct Point { + x: i32, + y: i32, +} +``` + +### From and Into + +These traits provide a consistent way to convert between types: + +```rust +use std::convert::From; + +#[derive(Debug)] +struct Point { + x: i32, + y: i32, +} + +impl From<(i32, i32)> for Point { + fn from(point: (i32, i32)) -> Self { + Point { + x: point.0, + y: point.1, + } + } +} + +fn main() { + let point = Point::from((3, 4)); + println!("{:?}", point); // Point { x: 3, y: 4 } +} +``` + +Thanks to the blanket implementation in the standard library, if `From for U` is implemented, then `Into for T` is automatically available: + +```rust +fn main() { + let point: Point = (3, 4).into(); + println!("{:?}", point); // Point { x: 3, y: 4 } +} +``` + +### Default + +The `Default` trait provides a default value for a type: + +```rust +#[derive(Debug, Default)] +struct Point { + x: i32, + y: i32, +} + +fn main() { + let p = Point::default(); + println!("{:?}", p); // Point { x: 0, y: 0 } +} +``` + +We can also implement it manually: + +```rust +impl Default for Point { + fn default() -> Self { + Point { x: 0, y: 0 } + } +} +``` + +### Derive Macros + +Many standard library traits can be automatically implemented using the `derive` attribute. This reduces boilerplate and ensures correctness. Common derivable traits include: +- `Debug` +- `Clone` +- `Copy` +- `PartialEq` +- `Eq` +- `PartialOrd` +- `Ord` +- `Hash` +- `Default` + +Custom derive macros are also possible through procedural macros, which we'll see in action with frameworks like `clap`. + +## Connection to rs-wsProxy + +In our rs-wsProxy project (built with Axum), traits are used extensively throughout the framework: + +### Axum Traits +- `IntoResponse`: Converts types into HTTP responses. Our handlers return types that implement this trait. +- `FromRequest`: Extracts information from incoming requests (like headers, query parameters, or state). +- `FromRef`: Allows extracting shared state from application state. + +Example from our project: +```rust +async fn handler( + TypedHeader(headers): TypedHeader, + State(app_state): State, +) -> impl IntoResponse { + // ... handler logic + Json(json!({ "status": "ok" })) +} +``` + +Here `TypedHeader` uses `FromRequest` to extract the Authorization header, `State` extracts application state, and `Json` implements `IntoResponse` to produce a JSON response. + +### clap Derive +We use `clap`'s derive feature to generate command-line argument parsing: +```rust +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Args { + #[arg(short, long)] + port: u16, + + #[arg(short, long)] + host: String, +} +``` + +This automatically implements the `Parser` trait for our `Args` struct, generating code to parse command-line arguments into this structure. + +### Future Trait +Asynchronous programming in Rust is built around the `Future` trait. When we write `async fn`, the compiler transforms it into a struct that implements `Future`: + +```rust +async fn handle_connection(mut stream: TcpStream) -> Result<()> { + // ... async logic +} + +// The compiler roughly transforms this to: +struct HandleConnectionFuture { /* fields */ } +impl Future for HandleConnectionFuture { + type Output = Result<()>; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // ... polling logic + } +} +``` + +This allows async functions to be composed and executed efficiently without runtime overhead. + +## Summary + +Traits and generics are fundamental to Rust's approach to abstraction: +- Traits define shared behavior (like interfaces in other languages) +- Generics enable type-agnostic code +- Trait bounds constrain generic types to those implementing specific traits +- Trait objects enable dynamic dispatch when needed +- The standard library provides essential traits like `Display`, `Debug`, `Clone`, `Copy`, `From`/`Into`, and `Default` +- Derive macros reduce boilerplate for common traits +- Real-world frameworks like Axum and clap leverage these concepts extensively + +Understanding these concepts is crucial for writing idiomatic, efficient Rust code. They enable the zero-cost abstractions that make Rust powerful while maintaining safety and performance. + +Next, we'll explore asynchronous programming with Tokio in [Async Rust with Tokio](/posts/rust/rust-async-tokio/). Previously, we covered [Collections, Iterators & Error Handling](/posts/rust/rust-collections-errors/). + +Continue your Rust journey by exploring how these abstractions enable powerful asynchronous patterns in the next tutorial. \ No newline at end of file diff --git a/content/posts/rust/rust-traits-generics/index.md b/content/posts/rust/rust-traits-generics/index.md new file mode 100644 index 0000000..8dc58bc --- /dev/null +++ b/content/posts/rust/rust-traits-generics/index.md @@ -0,0 +1,548 @@ +--- +title: "Traits และ Generics" +subtitle: "" +date: 2026-07-11T09:00:00+07:00 +lastmod: 2026-07-11T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "abstraction ใน Rust: Traits, Generics, Trait bounds และ Trait objects" +license: "" +images: [] +tags: ["Rust", "Tutorial"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +Traits และ Generics คือสองแนวคิดสำคัญที่ทำให้ Rust มีพลังในการเขียนโค้ดที่เป็น generic และสามารถแชร์พฤติกรรมระหว่างประเภทต่าง ๆ ได้อย่างปลอดภัยและมีประสิทธิภาพ บทความนี้เป็นภาคต่อจากภาค 4 ที่เราได้พูดถึง Collections, Iterators และ Error Handling มาแล้ว เราจะเจาะลึกว่า trait ทำหน้าที่เหมือน interface ในภาษาอื่น ๆ เช่น Go หรือ Java อย่างไร และ generics ช่วยให้เราเขียนฟังก์ชันและโครงสร้างข้อมูลที่ทำงานกับหลายประเภทได้โดยไม่สูญเสียประสิทธิภาพ เราจะพูดถึง trait bounds, trait objects รวมถึง trait ที่สำคัญในไลบรารีมาตรฐาน เช่น Display, Debug, Clone, Copy, From, Into และ Default แล้วเชื่อมโยงกับการใช้งานจริงในโปรเจกต์ rs-wsProxy ที่ใช้ Axum, clap และ async/await ด้วย + + + +## Traits คืออะไร + +ใน Rust trait คือการกำหนดชุดของเมธอดที่ประเภทหนึ่งสามารถนำไปใช้งานได้ คล้ายกับ interface ในภาษาอื่น ๆ แต่ trait มีความยืดหยุ่นมากกว่าเพราะสามารถมี default implementation ได้ และสามารถนำไปใช้กับประเภทใดก็ได้ที่เราต้องการ ไม่ว่าจะเป็น struct ที่เราสร้างเองหรือประเภทจากไลบรารีมาตรฐาน เมื่อเราต้องการให้ประเภทหนึ่งมีพฤติกรรมบางอย่าง เราจะทำการ implement trait สำหรับประเภทนั้น ๆ ตัวอย่างเช่นเราอาจต้องการให้โครงสร้างข่าวสารและทวีตสามารถสรุปเนื้อหาได้ เราจึงกำหนด trait ชื่อ `Summary` ที่มีเมธอด `summarize` จากนั้นเราจะ implement trait นี้ให้กับ struct `NewsArticle` และ `Tweet` แต่ละตัวจะให้ผลลัพธ์ที่แตกต่างกันไปตามลักษณะของข้อมูล + +ตัวอย่างการกำหนด trait และการ implement ดังต่อไปนี้ + +```rust +// ประกาศ trait Summary ที่มีเมธอด summarize คืนค่าเป็น String +pub trait Summary { + fn summarize(&self) -> String; +} + +// โครงสร้างข่าวสาร +pub struct NewsArticle { + pub headline: String, + pub location: String, + pub author: String, + pub content: String, +} + +// นำ Summary มาใช้กับ NewsArticle +impl Summary for NewsArticle { + fn summarize(&self) -> String { + format!("{}, โดย {} ({})", self.headline, self.author, self.location) + } +} + +// โครงสร้างทวีต +pub struct Tweet { + pub username: String, + pub content: String, + pub reply: bool, + pub retweet: bool, +} + +// นำ Summary มาใช้กับ Tweet +impl Summary for Tweet { + fn summarize(&self) -> String { + format!("{}: {}", self.username, self.content) + } +} +``` + +เมื่อเรา implement trait ให้กับประเภทใด ๆ แล้ว เราสามารถเรียกใช้เมธอด `summarize` กับอินสแตนซ์ของประเภทนั้นได้เหมือนกับการเรียกเมธอดปกติ ตัวอย่างการใช้งาน: + +```rust +fn main() { + let article = NewsArticle { + headline: String::from("Penguins win the Stanley Cup Championship!"), + location: String::from("Pittsburgh, PA, USA"), + author: String::from("Iceburgh"), + content: String::from("The Pittsburgh Penguins once again are the best \ + hockey team in the NHL."), + }; + + let tweet = Tweet { + username: String::from("horse_ebooks"), + content: String::from( + "of course, as you probably already know, people", + ), + reply: false, + retweet: false, + }; + + println!("บทความใหม่! {}", article.summarize()); + println!("ทวีตใหม่: {}", tweet.summarize()); +} +``` + +การใช้ trait ช่วยให้เราสามารถเขียนฟังก์ชันที่ทำงานกับประเภทต่าง ๆ ได้ตราบใดที่ประเภทนั้น ๆ นำ trait ที่ต้องการมาใช้งาน นี่คือพื้นฐานของการเขียนโค้ดแบบ generic ใน Rust ที่เราจะพูดถึงในส่วนต่อไป + +## Default implementations + +Trait ไม่จำเป็นต้องประกาศเฉพาะเมธอดที่ไม่มีการ implement เท่านั้น เรายังสามารถให้ default implementation สำหรับเมธอดใน trait ได้อีกด้วย ซึ่งหมายความว่าประเภทใด ๆ ที่นำ trait มาใช้จะได้รับการ implement เมธอดนั้นโดยอัตโนมัติ หากประเภทนั้นไม่ได้ให้การ implement ของตัวเอง คุณสมบัตินี้ช่วยลดการเขียนโค้ดซ้ำซ้อนเมื่อหลายประเภทต้องการพฤติกรรมเดียวกัน แต่ยังอนุญาตให้แต่ละประเภท override ได้ถ้าต้องการพฤติกรรมที่แตกต่างออกไป + +ตัวอย่างการเพิ่ม default implementation ใน trait `Summary`: + +```rust +pub trait Summary { + // Default implementation ที่คืนค่า String ว่างเปล่า + fn summarize(&self) -> String { + String::from("(อ่านเพิ่มเติม...)") + } +} +``` + +เมื่อเรา implement trait สำหรับ `NewsArticle` เช่นเดิม แต่เราไม่ได้ override เมธอด `summarize` เมธอดจาก default จะถูกใช้แทน อย่างไรก็ตามหากเราต้องการให้ผลลัพธ์แตกต่างออกไปเราสามารถ override ได้โดยการให้ implementation ของตัวเอง ดังตัวอย่างต่อไปนี้ + +```rust +impl Summary for NewsArticle { + // Override default implementation + fn summarize(&self) -> String { + format!("{}, โดย {} ({})", self.headline, self.author, self.location) + } +} + +// Tweet ไม่ได้ override จะใช้ default implementation +impl Summary for Tweet {} +``` + +เมื่อเราเรียกใช้ `summarize` กับอินสแตนซ์ของ `NewsArticle` จะได้ผลลัพธ์ตามที่เรา override แต่เมื่อเรียกกับ `Tweet` จะได้ default implementation ที่คืนค่า `(อ่านเพิ่มเติม...)` ซึ่งอาจไม่เหมาะสมสำหรับทวีต ดังนั้นในทางปฏิบัติเรามักจะให้แต่ละประเภท override default implementation เพื่อให้เหมาะสมกับข้อมูลของมัน + +Default implementation ยังสามารถเรียกใช้เมธอดอื่นใน trait เดียวกันได้อีกด้วย ทำให้เราสามารถสร้างพฤติกรรมที่ซับซ้อนจากเมธอดพื้นฐานไม่กี่ตัวได้ + +## Traits เป็น parameter + +เมื่อเราต้องการเขียนฟังก์ชันที่สามารถรับพารามิเตอร์ที่นำ trait บางอย่างมาใช้ได้ เรามีสองวิธีหลักในการระบุ trait bound ใน Rust วิธีแรกคือการใช้ `impl Trait` ในตำแหน่งพารามิเตอร์ ซึ่งเหมาะกับกรณีที่เราต้องการพารามิเตอร์เพียงตัวเดียวและไม่ต้องการระบุประเภทอย่างชัดเจน วิธีที่สองคือการใช้ trait bound แบบปกติด้วยรูปแบบ `T: Trait` ภายในวงเล็บเหลี่ยมของ generics ซึ่งให้ความยืดหยุ่นมากกว่าเมื่อต้องการระบุพารามิเตอร์หลายตัวหรือต้องการกำหนดเงื่อนไขเพิ่มเติม + +ตัวอย่างการใช้ `impl Trait` เป็นพารามิเตอร์: + +```rust +use crate::Summary; + +// ฟังก์ชัน notify รับพารามิเตอร์ที่นำ Summary มาใช้ +// โดยใช้รูปแบบ impl Trait +pub fn notify(item: &impl Summary) { + println!("ข่าวด่วน! {}", item.summarize()); +} +``` + +ฟังก์ชันนี้สามารถรับอินสแตนซ์ของ `NewsArticle` หรือ `Tweet` หรือประเภทอื่นใดก็ตามที่นำ `Summary` มาใช้ได้ โดยไม่ต้องระบุประเภทอย่างชัดเจน + +อีกรูปแบบหนึ่งคือการใช้ trait bound แบบปกติ: + +```rust +use crate::Summary; + +// ฟังก์ชัน notify แบบใช้ trait bound ปกติ +pub fn notify(item: &T) { + println!("ข่าวด่วน! {}", item.summarize()); +} +``` + +ที่นี่เราได้กำหนดว่า `T` ต้องนำ `Summary` มาใช้ ซึ่งทำให้เราสามารถระบุเงื่อนไขเพิ่มเติมได้ เช่น `T: Summary + Clone` เพื่อให้ `T` ต้องนำทั้ง `Summary` และ `Clone` มาใช้ หรือใช้ `where` clause เพื่อเพิ่มความอ่านง่ายเมื่อมีเงื่อนไขหลายอย่าง + +การใช้ `impl Trait` เหมาะกับกรณีที่ฟังก์ชันมีพารามิเตอร์เพียงหนึ่งหรือสองตัวและไม่ต้องการเงื่อนไขซับซ้อน ในขณะที่ trait bound แบบปกติให้ความยืดหยุ่นมากกว่าเมื่อต้องการระบุหลายเงื่อนไขหรือต้องการใช้กับหลายพารามิเตอร์ + +## Generics + +Generics คือกลไกที่ช่วยให้เราเขียนฟังก์ชัน โครงสร้างข้อมูล และเอ็นัมที่ทำงานกับหลายประเภทได้โดยไม่ต้องทำซ้ำโค้ด หลักการทำงานของ generics ใน Rust คือ monomorphization ซึ่งหมายถึงคอมไพเลอร์จะสร้างเวอร์ชันเฉพาะของฟังก์ชันหรือโครงสร้างสำหรับแต่ละประเภทที่ใช้จริงในเวลาคอมไพล์ ทำให้ไม่มีค่าใช้จ่ายในการรันไทม์เหมือนกับการใช้ไดนามิกดิสแพตช์ในภาษาอื่น ๆ + +เรามาเริ่มกันด้วยฟังก์ชัน generic ที่หาค่ามากที่สุดในสไลซ์: + +```rust +// ฟังก์ชัน generic ที่หาค่ามากที่สุดในสไลซ์ของประเภท T +// T ต้องนำ trait PartialOrd มาใช้เพื่อให้สามารถเปรียบเทียบได้ +fn largest(list: &[T]) -> T { + let mut largest = list[0]; + + for &item in list.iter() { + if item > largest { + largest = item; + } + } + + largest +} + +// ตัวอย่างการใช้งาน +fn main() { + let number_list = vec![34, 50, 25, 100, 65]; + + let result = largest(&number_list); + println!("จำนวนที่มากที่สุดคือ {}", result); + + let char_list = vec!['y', 'm', 'a', 'q']; + + let result = largest(&char_list); + println!("ตัวอักษรที่มากที่สุดคือ {}", result); +} +``` + +ในตัวอย่างนี้เราได้กำหนด trait bound `T: PartialOrd + Copy` เพื่อให้แน่ใจว่าประเภท T สามารถเปรียบเทียบได้ด้วยoperators `>` และสามารถคัดลอกได้ด้วย `Copy` หากเราลบ `Copy` ออกเราจะต้องใช้การอ้างอิงแทน แต่ตัวอย่างนี้ทำให้เข้าใจได้ง่าย + +ต่อไปเรามาดู struct generic กันบ้าง: + +```rust +// จุดในมิติที่กำหนดโดยประเภท T +struct Point { + x: T, + y: T, +} + +impl Point { + // เมธอดสร้างจุดใหม่ + fn new(x: T, y: T) -> Self { + Self { x, y } + } + + // เมธอดคืนค่าพิกัด x + fn x(&self) -> &T { + &self.x + } +} + +// ตัวอย่างการใช้งาน +fn main() { + let integer = Point { x: 5, y: 10 }; + let float = Point { x: 1.0, y: 4.0 }; + + println!("จุดจำนวนเต็ม: ({}, {})", integer.x(), integer.y()); + println!("จุดจุดทศนิยม: ({}, {})", float.x(), float.y()); +} +``` + +ในตัวอย่างข้างต้น `Point` เป็น struct generic ที่สามารถเก็บพิกัดที่เป็นประเภทใดก็ได้ตราบใดที่ฟิลด์ `x` และ `y` มีประเภทเดียวกัน หากเราต้องการให้ `x` และ `y` เป็นประเภทที่แตกต่างกันเราสามารถใช้พารามิเตอร์แบบหลายตัวได้เช่น `struct Point { x: T, y: U }` + +สุดท้ายเรามาดู enum generic กันบ้าง เช่น `Option` และ `Result` ที่อยู่ในไลบรารีมาตรฐาน: + +```rust +// การกำหนด enum Option ด้วย generic ทั่วไปในไลบรารีมาตรฐาน +// enum Option { +// Some(T), +// None, +// } + +// ตัวอย่างการใช้งาน Option +fn main() { + let some_number = Some(5); + let some_string = Some(String::from("hello")); + + let absent_number: Option = None; +} + +// การกำหนด enum Result ด้วย generic ทั่วไปในไลบรารีมาตรฐาน +// enum Result { +// Ok(T), +// Err(E), +// } + +// ตัวอย่างการใช้งาน Result +fn divide(numerator: f64, denominator: f64) -> Result { + if denominator == 0.0 { + Err("ไม่สามารถหารด้วยศูนย์ได้") + } else { + Ok(numerator / denominator) + } +} + +fn main() { + match divide(10.0, 2.0) { + Ok(result) => println!("ผลลัพธ์คือ {}", result), + Err(e) => println!("ข้อผิดพลาด: {}", e), + } +} +``` + +จากตัวอย่างเหล่านี้เราจะเห็นว่า generics ช่วยให้เราเขียนโค้ดที่เป็น generic ได้อย่างปลอดภัยและมีประสิทธิภาพ เพราะคอมไพเลอร์จะสร้างโค้ดเฉพาะเจาะจงสำหรับแต่ละประเภทที่ใช้จริง + +## Trait bounds + +Trait bounds คือเงื่อนไขที่เรากำหนดให้กับพารามิเตอร์ generic เพื่อให้แน่ใจว่าประเภทนั้นมีความสามารถบางอย่างที่เราต้องการ เราสามารถระบุหนึ่ง trait หรือหลาย trait ได้โดยใช้เครื่องหมาย `+` เพื่อเชื่อมต่อกัน นอกจากนี้เรายังสามารถใช้ `where` clause เพื่อทำให้เงื่อนไขอ่านง่ายขึ้นเมื่อมีหลายเงื่อนไขหรือมีความซับซ้อน + +ตัวอย่างการใช้หลาย trait bounds พร้อมกัน: + +```rust +use std::fmt::Display; + +// ฟังก์ชันที่ต้องการให้ T นำทั้ง Display และ Clone มาใช้ +fn print_and_clone(item: T) { + println!("ค่าคือ: {}", item); + let _copy = item.clone(); +} +``` + +ในตัวอย่างนี้ `T` ต้องนำทั้ง `Display` (เพื่อให้สามารถพิมพ์ได้ด้วย `{}`) และ `Clone` (เพื่อให้สามารถคัดลอกได้) มาใช้ หากเราลบเงื่อนไขใดเงื่อนไขหนึ่งออกคอมไพเลอร์จะแจ้งข้อผิดพลาดเมื่อเราพยายามเรียกใช้เมธอดที่ต้องการเงื่อนไขนั้น + +อีกวิธีหนึ่งคือการใช้ `where` clause ซึ่งมีประโยชน์เมื่อเรามีพารามิเตอร์หลายตัวหรือเงื่อนไขที่ยาวเหยียด: + +```rust +use std::fmt::Display; + +fn some_function(t: T, u: U) +where + T: Display + Clone, + U: Clone + PartialEq, +{ + println!("t คือ: {}", t); + let _t_clone = t.clone(); + let _u_clone = u.clone(); + // เปรียบเทียบ u กับตัวมันเองเพื่อแสดงว่า U ต้อง implement PartialEq + let _ = u == u; +} +``` + +ที่นี่เราได้แยกเงื่อนไขออกเป็นบรรทัดต่าง ๆ ทำให้อ่านง่ายขึ้น นอกจากนี้เรายังสามารถใช้ `where` clause กับ impl block ได้อีกด้วย เพื่อกำหนด trait bounds สำหรับการ implement เมธอดหรือฟังก์ชันที่เกี่ยวข้องกับประเภทนั้น + +ตัวอย่างการใช้ `where` กับ impl block: + +```rust +struct Pair { + x: T, + y: T, +} + +impl Pair { + fn new(x: T, y: T) -> Self { + Self { x, y } + } +} + +// เราจะ implement เมธอด cmp_display เฉพาะเมื่อ T นำ Display และ PartialOrd มาใช้ +impl Pair +where + T: Display + PartialOrd, +{ + fn cmp_display(&self) { + if self.x >= self.y { + println!("สมาชิกที่มากที่สุดคือ x: {}", self.x); + } else { + println!("สมาชิกที่มากที่สุดคือ y: {}", self.y); + } + } +} +``` + +ในตัวอย่างนี้เมธอด `cmp_display` จะพร้อมใช้งานเฉพาะเมื่อประเภท `T` นำทั้ง `Display` และ `PartialOrd` มาใช้ ซึ่งช่วยให้เราสามารถเขียน implementation ที่มีเงื่อนไขเฉพาะเจาะจงได้โดยไม่ทำให้ signature ของ struct หรือ impl หลักซับซ้อนเกินไป + +Trait bounds ยังสามารถใช้กับการ implement trait อื่น ๆ ได้อีกด้วย เช่นเมื่อเราต้องการให้ประเภทหนึ่งนำ trait `Copy` มาใช้ก็ต่อเมื่อองค์ประกอบภายในของมันก็นำ `Copy` มาใช้เช่นกัน นี่เป็นพื้นฐานของการอนุมาน trait ในไลบรารีมาตรฐาน เช่นการที่ `Option` จะนำ `Clone` มาใช้เมื่อ `T` นำ `Clone` มาใช้ + +## Trait objects + +บางครั้งเราต้องการเก็บประเภทที่แตกต่างกันซึ่งนำ trait เดียวกันมาใช้ในคอลเลกชันเดียวกัน เช่นเวกเตอร์ที่สามารถเก็บทั้ง `NewsArticle` และ `Tweet` ได้พร้อมกัน เพราะทั้งสองนำ trait `Summary` มาใช้ ในกรณีนี้เราไม่สามารถใช้ generics แบบปกติได้เพราะเวกเตอร์ต้องการประเภทที่แน่นอนในเวลาคอมไพล์ ดังนั้นเราจึงใช้ trait object ซึ่งทำโดยใช้คีย์เวิร์ด `dyn` ตามด้วยชื่อ trait ทำให้เราสามารถเก็บอินสแตนซ์ของประเภทต่าง ๆ ที่นำ trait นั้นมาใช้ได้ในชนิดเดียวกัน + +ตัวอย่างการใช้ trait object กับ trait `Summary`: + +```rust +use crate::{NewsArticle, Tweet, Summary}; + +fn main() { + let article = NewsArticle { + headline: String::from("Penguins win the Stanley Cup Championship!"), + location: String::from("Pittsburgh, PA, USA"), + author: String::from("Iceburgh"), + content: String::from("The Pittsburgh Penguins once again are the best \ + hockey team in the NHL."), + }; + + let tweet = Tweet { + username: String::from("horse_ebooks"), + content: String::from( + "of course, as you probably already know, people", + ), + reply: false, + retweet: false, + }; + + // สร้างเวกเตอร์ที่เก็บ trait object ของ Summary + let items: Vec> = vec![ + Box::new(article), + Box::new(tweet), + ]; + + for item in items { + println!("{}", item.summarize()); + } +} +``` + +ในตัวอย่างนี้เราได้สร้างเวกเตอร์ของ `Box` ซึ่งแต่ละองค์ประกอบคือกล่องที่ชี้ไปยังอินสแตนซ์ที่นำ trait `Summary` มาใช้ เมื่อเราเรียกเมธอด `summarize` ผ่าน trait object จะเกิดการส่งข้อความแบบไดนามิก (dynamic dispatch) หมายความว่าคอมไพเลอร์จะไม่ทราบว่าฟังก์ชันที่แท้จริงที่จะถูกเรียกคืออะไรจนถึงเวลารันไทม์ และจะใช้ตาราง vtable เพื่อค้นหาฟังก์ชันที่เหมาะสม + +เรามาเปรียบเทียบระหว่าง static dispatch ที่เกิดจากการใช้ generics กับ dynamic dispatch ที่เกิดจากการใช้ trait object + +เมื่อเราใช้ generics เช่นฟังก์ชัน `notify(item: &T)` คอมไพเลอร์จะสร้างเวอร์ชันเฉพาะของฟังก์ชันสำหรับแต่ละประเภทที่ใช้เรียก เช่น `notify_NewsArticle` และ `notify_Tweet` ซึ่งทำให้เกิดการผูกมัดแบบคงที่ (static dispatch) และไม่มีค่าใช้จ่ายในการค้นหาฟังก์ชันเวลารันไทม์ แต่จะทำให้เกิดการคัดลอกโค้ด (code duplication) หากมีหลายประเภทที่ใช้ฟังก์ชันเดียวกันมากเกินไป + +ในทางกลับกันเมื่อเราใช้ trait object เช่น `&dyn Summary` หรือ `Box` การเรียกเมธอดจะเกิดขึ้นผ่านไดนามิกดิสแพตช์ ซึ่งมีค่าใช้จ่ายเล็กน้อยจากการค้นหาฟังก์ชันใน vtable แต่ช่วยให้เราสามารถเก็บประเภทต่าง ๆ ที่นำ trait เดียวกันมาใช้ในคอลเลกชันเดียวกันได้ ซึ่งเป็นสิ่งที่ generics ทำไม่ได้โดยตรง + +การเลือกระหว่าง static dispatch และ dynamic dispatch ขึ้นอยู่กับกรณีการใช้งาน หากเราทราบประเภททั้งหมดในเวลาคอมไพล์และต้องการประสิทธิภาพสูงสุด เราควรใช้ generics หากเราต้องการความยืดหยุ่นในการเก็บประเภทต่าง ๆ ร่วมกัน เราควรใช้ trait object + +## Standard library traits + +ไลบรารีมาตรฐานของ Rust มี trait หลายตัวที่ถูกออกแบบมาเพื่อให้พฤติกรรมทั่วไปที่เราต้องการบ่อย ๆ ทำให้เราไม่ต้องเขียนซ้ำเอง ทrait ที่สำคัญได้แก่ `Display`, `Debug`, `Clone`, `Copy`, `From`, `Into`, และ `Default` ซึ่งแต่ละตัวมีวัตถุประสงค์เฉพาะเจาะจงและมักถูกใช้ร่วมกับ derive macro เพื่อให้การ implement เป็นไปโดยอัตโนมัติ + +เริ่มต้นด้วย `Display` และ `Debug` ซึ่งทั้งสองใช้สำหรับการแปลงค่าเป็นสตริง แต่มีวัตถุประสงค์แตกต่างกัน `Display` มีไว้สำหรับการแสดงผลต่อผู้ใช้ปลายทาง โดยใช้แมโคร `println!` หรือแมโคร `format!` กับเครื่องหมาย `{}` ในขณะที่ `Debug` มีไว้สำหรับนักพัฒนาในการดีบัก โดยใช้แมโคร `println!` กับเครื่องหมาย `{:?}` หรือ `{:#?}` เพื่อแสดงข้อมูลโครงสร้างอย่างละเอียด เราสามารถ implement ทั้งสอง trait ได้ด้วยตนเองหรือใช้ derive macro เมื่อเป็นไปได้ + +ตัวอย่างการ implement `Display` และ `Debug` ด้วยตนเองสำหรับโครงสร้าง `Point`: + +```rust +use std::fmt; + +struct Point { + x: i32, + y: i32, +} + +impl fmt::Display for Point { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +impl fmt::Debug for Point { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Point") + .field("x", &self.x) + .field("y", &self.y) + .finish() + } +} + +fn main() { + let point = Point { x: 3, y: 4 }; + + println!("จุด (Display): {}", point); + println!("จุด (Debug): {:?}", point); +} +``` + +เมื่อรันโปรแกรมนี้เราจะเห็นว่า `Display` ให้ผลลัพธ์ในรูปแบบ `(3, 4)` ในขณะที่ `Debug` ให้ผลลัพธ์ในรูปแบบ `Point { x: 3, y: 4 }` ซึ่งแสดงฟิลด์ทั้งหมดอย่างชัดเจน + +ต่อมาคือ `Clone` และ `Copy` ซึ่งทั้งสองใช้สำหรับการคัดลอกค่า แต่มีความแตกต่างกันอย่างสำคัญ `Clone` เป็น trait ที่ให้ความสามารถในการคัดลอกลึก (deep copy) ผ่านเมธอด `clone()` ซึ่งอาจมีการทำงานที่มีค่าใช้จ่ายสูงขึ้นอยู่กับประเภท ในขณะที่ `Copy` เป็น trait ที่บ่งบอกว่าประเภทสามารถคัดลอกได้โดยการคัดลอกบิตอย่างง่าย (shallow copy) โดยไม่มีค่าใช้จ่ายเพิ่มเติม และไม่ต้องใช้เมธอดใด ๆ ทั้งสิ้น ประเภทที่เป็น `Copy` จะต้องมีขนาดที่ทราบในเวลาคอมไพล์และไม่มีการจัดสรรทรัพยากรแบบไดนามิก เช่นตัวเลขพื้นฐาน boolean หรือ tuple ที่ประกอบด้วยประเภทเหล่านี้เท่านั้น + +เราสามารถ derive ทั้งสอง trait ได้เมื่อเป็นไปได้ดังต่อไปนี้: + +```rust +#[derive(Debug, Clone, Copy)] +struct Point { + x: i32, + y: i32, +} + +fn main() { + let point1 = Point { x: 5, y: 10 }; + let point2 = point1.clone(); // ใช้ Clone + let point3 = point1; // ใช้ Copy เพราะ Point เป็น Copy + + println!("จุดที่ 1: {:?}", point1); + println!("จุดที่ 2 (clone): {:?}", point2); + println!("จุดที่ 3 (copy): {:?}", point3); +} +``` + +อย่างไรก็ตามหากโครงสร้างของเราประกอบด้วยประเภทที่ไม่เป็น `Copy` เช่น `String` หรือ `Vec` เราจะไม่สามารถ derive `Copy` ได้ แต่เรายังสามารถ derive `Clone` ได้เพื่อให้สามารถทำการคัดลอกลึกได้ + +ต่อมาคือ `From` และ `Into` ซึ่งเป็น trait ที่ใช้สำหรับการแปลงประเภทหนึ่งไปยังอีกประเภทหนึ่ง `From` ถูกใช้เมื่อเราต้องการกำหนดวิธีการแปลงจากประเภท `T` เป็นประเภทปัจจุบันของเรา ในขณะที่ `Into` ถูกใช้เมื่อเราต้องการแปลงจากประเภทปัจจุบันของเราไปเป็นประเภท `T` โดยทั่วไปแล้วหากเรา implement `From` สำหรับประเภทหนึ่ง เราจะทำให้เราได้ `Into<ที่นั้น>` โดยอัตโนมัติผ่านการ implement ทั่วไปในไลบรารีมาตรฐาน ดังนั้นเรามักจะ implement เพียงฝั่งเดียวเท่านั้น + +ตัวอย่างการใช้ `From` และ `Into` กับโครงสร้าง `Point` ที่แปลงจาก tuple: + +```rust +struct Point { + x: i32, + y: i32, +} + +// Implement From<(i32, i32)> สำหรับ Point +impl From<(i32, i32)> for Point { + fn from((x, y): (i32, i32)) -> Self { + Self { x, y } + } +} + +fn main() { + let point_from_tuple: Point = (5, 10).into(); // ใช้ Into ที่ได้จาก From + println!("จุดจากทูเปิล: ({}, {})", point_from_tuple.x, point_from_tuple.y); + + // หรือใช้ From โดยตรง + let point_direct = Point::from((3, 7)); + println!("จุดจาก from โดยตรง: ({}, {})", point_direct.x, point_direct.y); +} +``` + +ในตัวอย่างนี้เราได้ implement `From<(i32, i32)>` สำหรับ `Point` ซึ่งทำให้เราสามารถใช้ `.into()` บนทูเปิลเพื่อแปลงเป็น `Point` ได้อย่างสะดวก และเนื่องจากมีการ implement `From` อยู่แล้ว เราจึงสามารถใช้ `Point::from` ได้โดยตรงเช่นกัน + +สุดท้ายคือ `Default` ซึ่งให้ค่าเริ่มต้นสำหรับประเภทผ่านฟังก์ชัน `default()` นี่เป็นประโยชน์เมื่อเราต้องการสร้างอินสแตนซ์โดยไม่ต้องระบุค่าเริ่มต้นทุกฟิลด์ โดยเฉพาะอย่างยิ่งเมื่อทำงานกับสตรักต์ที่มีหลายฟิลด์ เราสามารถ implement `Default` ด้วยตนเองหรือใช้ derive เมื่อเป็นไปได้ + +ตัวอย่างการใช้ `Default` กับสตรักต์ที่มีค่าเริ่มต้น: + +```rust +#[derive(Default, Debug)] +struct Settings { + width: u32, + height: u32, + enabled: bool, +} + +fn main() { + // ใช้ Default เพื่อสร้างอินสแตนซ์ด้วยค่าเริ่มต้น + let settings = Settings::default(); + println!("การตั้งค่าเริ่มต้น: {:?}", settings); + + // กำหนดค่าเฉพาะบางฟิลด์ + let custom_settings = Settings { + width: 800, + height: 600, + ..Default::default() // ใช้ค่าเริ่มต้นสำหรับฟิลด์ที่เหลือ + }; + println!("การตั้งค่ากำหนดเอง: {:?}", custom_settings); +} +``` + +จากตัวอย่างข้างต้นเราเห็นว่า `default()` จะคืนค่าที่เป็นศูนย์หรือค่าเริ่มต้นตามประเภทของฟิลด์แต่ละฟิลด์ และเราสามารถใช้โครงสร้างอัปเดตกับ `..Default::default()` เพื่อกำหนดค่าเฉพาะบางฟิลด์และให้ฟิลด์ที่เหลือใช้ค่าเริ่มต้นได้ + + trait เหล่านี้ไม่เพียงแต่ช่วยให้เราเขียนโค้ดได้สั้นลงเท่านั้น แต่ยังช่วยให้เกิดความสอดคล้องและความสามารถในการทำงานร่วมกันกับไลบรารีมาตรฐานและไลบรารีของบุคคลที่สามอีกด้วย ตัวอย่างเช่นฟังก์ชันทั่วไปในไลบรารีมาตรฐานมักจะกำหนดขอบเขตของพารามิเตอร์ด้วย trait เหล่านี้ เช่น `FromStr` สำหรับการแปลงจากสตริง หรือ `Hash` สำหรับการใช้งานในแฮชแมป + +## เชื่อมโยงกับ rs-wsProxy + +ในโปรเจกต์จริงอย่าง rs-wsProxy ซึ่งเป็นพร็อกซี่ WebSocket ที่สร้างด้วย Rust เราจะเห็นการใช้ trait และ generics อย่างแพร่หลายในหลายไลบรารีที่เราพึ่งพา เช่น Axum ซึ่งเป็นเฟรมเวิร์กเว็บที่ใช้สำหรับสร้างเซิร์ฟเวอร์ HTTP และ WebSocket ใน Axum แนวคิดของ trait มีความสำคัญอย่างมากผ่าน trait เช่น `IntoResponse` ซึ่งใช้แปลงค่าต่าง ๆ เป็นการตอบสนอง HTTP ได้อย่างยืดหยุ่น ตัวอย่างเช่นเราสามารถคืนค่าเป็น `String`, `&str`, หรือแม้แต่ประเภทที่เราสร้างเองที่ได้ implement `IntoResponse` เพื่อให้ Axum รู้ว่าจะแปลงค่านั้นเป็นการตอบสนอง HTTP อย่างไรได้อย่างถูกต้อง + +อีกตัวอย่างหนึ่งคือ `FromRequest` ซึ่งใช้สกัดข้อมูลจากคำขอ HTTP เช่นหัวข้อ คิวรีสตริง หรือร่างกายของคำขอ เพื่อส่งต่อให้กับแฮนด์เลอร์ของเรา ด้วยการ implement `FromRequest` สำหรับประเภทของเราเอง เราสามารถดึงข้อมูลที่ต้องการจากคำขอได้อย่างปลอดภัยและเป็นธรรมชาติ เช่นการดึงโทเค็นการตรวจสอบสิทธิ์จากหัวข้อ Authorization หรือการอ่านข้อมูล JSON จากร่างกายของคำขอ + +นอกจากนี้ `FromRef` และ `FromRefMut` ยังใช้ในการแชร์สถานะหรือการเชื่อมต่อระหว่างชั้นต่าง ๆ ของแอปพลิเคชัน เช่นการแชร์การเชื่อมต่อฐานข้อมูลหรือการกำหนดค่าผ่านสถานะของแอปพลิเคชัน ซึ่งช่วยให้เราหลีกเลี่ยงการใช้ตัวแปรทั่วไปและทำให้โค้ดมีความปลอดภัยสูงขึ้นในบริบทของการทำงานแบบอะซิงโครนัส + +ในส่วนของไลบรารี clap ซึ่งใช้สำหรับสร้างอินเตอร์เฟซบรรทัดคำสั่ง เราจะเห็นการใช้ derive macro เพื่อสร้างการ implement trait `Parser` จากโครงสร้างที่เรากำหนดเอง ทำให้เราสามารถกำหนดอาร์กิวเมนต์และออปชันของคำสั่งได้โดยการกำหนดฟิลด์ในสตรักต์และใส่แอตทริบิวต์ derive ที่เหมาะสม เช่น `#[arg(long)]` หรือ `#[arg(short, long)]` จากนั้น clap จะสร้างโค้ดที่จำเป็นในการวิเคราะห์อาร์กิวเมนต์บรรทัดคำสั่งและเติมค่าให้กับฟิลด์ของสตรักต์โดยอัตโนมัติ ซึ่งช่วยลดการเขียนโค้ดซ้ำซ้อนและทำให้การบำรุงรักษาง่ายขึ้น + +สุดท้ายในด้านการเขียนโปรแกรมแบบอะซิงโครนัส Rust ใช้ trait `Future` เป็นแกนหลักของการทำงานแบบ non-blocking ฟิวเจอร์แสดงถึงการคำนวณที่จะเสร็จสิ้นในอนาคตและสามารถรอคอยได้โดยใช้ `.await` คีย์เวิร์ด ไลบรารีอย่าง Tokio ให้รันไทม์ที่สามารถรันฟิวเจอร์เหล่านี้ได้อย่างมีประสิทธิภาพ โดยใช้ระบบจัดการงานและการแจ้งเตือนเหตุการณ์ (event loop) ที่ทำให้เราสามารถเขียนโค้ดแบบอะซิงโครนัสที่ดูเหมือนซิงโครนัสได้โดยไม่บล็อกเธรดหลัก + +การทำความเข้าใจว่า trait และ generics ทำงานอย่างไรในระดับพื้นฐานจะช่วยให้เราสามารถอ่านและเขียนโค้ดที่ใช้ไลบรารีเหล่านี้ได้อย่างลึกซึ้งมากขึ้น เราจะเห็นว่าการออกแบบไลบรารีเหล่านี้ไม่ใช่เพียงแค่การให้ฟังก์ชันสำเร็จรูปเท่านั้น แต่ยังเป็นการให้กรอบการทำงานที่เราสามารถขยายและปรับแต่งได้ผ่านทาง trait bounds และ generics ซึ่งเป็นหัวใจสำคัญของการเขียนโค้ดที่เป็นนามธรรมและสามารถนำกลับมาใช้ใหม่ได้ใน Rust + +## สรุป + +ในบทนี้เราได้สำรวจแนวคิดหลักสองประการของ Rust ที่ทำให้ภาษานี้มีพลังและความปลอดภัยสูง ได้แก่ traits และ generics เราได้เห็นว่า trait ทำหน้าที่เหมือนกับ interface ในภาษาอื่น ๆ แต่มีความยืดหยุ่นมากกว่าด้วยความสามารถในการให้ default implementation และอนุญาตให้เรา implement trait สำหรับประเภทใด ๆ ได้ไม่ว่าจะเป็นสตรักต์ที่เราสร้างเองหรือประเภทจากไลบรารีมาตรฐาน เราได้เรียนรู้วิธีการกำหนด trait การ implement trait ให้กับประเภทต่าง ๆ และการใช้ trait เป็นพารามิเตอร์ในฟังก์ชันทั้งในรูปแบบ `impl Trait` และรูปแบบ trait bound ปกติ พร้อมทั้งการใช้หลาย trait bounds และ `where` clause เพื่อจัดการเงื่อนไขที่ซับซ้อน + +ในส่วนของ generics เราได้เห็นว่ามันช่วยให้เราเขียนฟังก์ชัน สตรักต์ และเอ็นัมที่ทำงานกับหลายประเภทได้โดยไม่ต้องทำซ้ำโค้ด ผ่านกลไกการสร้างโค้ดเฉพาะเจาะจงในเวลาคอมไพล์ (monomorphization) ซึ่งทำให้เราได้ประสิทธิภาพเทียบเท่ากับการเขียนโค้ดเฉพาะเจาะจงแต่ไม่ต้องเขียนซ้ำ เราได้ดูตัวอย่างของฟังก์ชัน generic ที่หาค่ามากที่สุดในสไลซ์ สตรักต์ generic เช่น `Point` และเอ็นัม generic เช่น `Option` และ `Result` ที่อยู่ในไลบรารีมาตรฐาน + +เราได้พูดถึง trait bounds ซึ่งเป็นเงื่อนไขที่เรากำหนดให้กับพารามิเตอร์ generic เพื่อให้แน่ใจว่าประเภทนั้นมีความสามารถบางอย่างที่เราต้องการ เช่นการสามารถเปรียบเทียบได้ (`PartialOrd`) หรือการสามารถแสดงผลได้ (`Display`) เราได้เห็นวิธีการรวมหลาย trait bounds ด้วยเครื่องหมาย `+` และการใช้ `where` clause เพื่อให้เงื่อนไขอ่านง่ายขึ้นเมื่อมีหลายเงื่อนไขหรือมีความยาวเหยียด + +ในส่วนของ trait objects เราได้เรียนรู้วิธีการใช้ `dyn Trait` เพื่อเก็บอินสแตนซ์ของประเภทต่าง ๆ ที่นำ trait เดียวกันมาใช้ในคอลเลกชันเดียวกัน ผ่านกลไกไดนามิกดิสแพตช์ ซึ่งมีค่าใช้จ่ายเล็กน้อยจากการค้นหาฟังก์ชันใน vtable แต่ช่วยให้เราสามารถเขียนโค้ดที่ยืดหยุ่นได้เมื่อเราไม่ทราบประเภทที่แน่นอนในเวลาคอมไพล์ เราได้เปรียบเทียบระหว่าง static dispatch ที่เกิดจากการใช้ generics กับ dynamic dispatch ที่เกิดจากการใช้ trait objects และได้อภิปรายถึงสถานการณ์ที่ควรใช้แต่ละแบบ + +สุดท้ายเราได้สำรวจ trait ที่สำคัญในไลบรารีมาตรฐาน เช่น `Display`, `Debug`, `Clone`, `Copy`, `From`, `Into`, และ `Default` พร้อมทั้งตัวอย่างการ implement และการใช้ derive macro เพื่อลดภาระในการเขียนโค้ดซ้ำซ้อน เราได้เห็นว่าทrait เหล่านี้ไม่เพียงแต่ให้พฤติกรรมพื้นฐานเท่านั้น แต่ยังเป็นพื้นฐานสำหรับการทำงานร่วมกับไลบรารีอื่น ๆ เช่น Axum, clap และ Tokio ในโปรเจกต์จริงอย่าง rs-wsProxy + +โดยสรุปแล้ว traits และ generics คือรากฐานที่ทำให้ Rust สามารถให้ความปลอดภัยของประเภทและการทำงานที่มีประสิทธิภาพสูงได้ในขณะที่ยังคงความสามารถในการแสดงออกและการนำกลับมาใช้ใหม่ การทำความเข้าใจแนวคิดเหล่านี้อย่างลึกซึ้งจะช่วยให้เราเขียนโค้ด Rust ที่แข็งแกร่ง ยืดหยุ่น และสามารถบำรุงรักษาได้ไม่ว่าจะเป็นโครงการขนาดเล็กหรือระบบขนาดใหญ่ที่ซับซ้อน + +สำหรับผู้ที่ต้องการศึกษาต่อไป บทต่อไปในซีรีส์นี้จะพูดถึงการเขียนโปรแกรมแบบอะซิงโครนัสใน Rust โดยใช้ไลบรารี Tokio ซึ่งเราจะได้เห็นว่า trait `Future` และระบบ async/await ทำงานร่วมกันอย่างไรเพื่อให้เราสามารถเขียนโค้ดที่ไม่บล็อกและมีประสิทธิภาพสูงได้ + +ลิงก์ไปยังบทก่อนหน้า: [Collections, Iterators และ Error Handling](/posts/rust/rust-collections-errors/) +ลิงก์ไปยังบทถัดไป: [Async Rust กับ Tokio](/posts/rust/rust-async-tokio/) \ No newline at end of file diff --git a/content/posts/rust/rust-wsproxy-proxy-deploy/featured-image.jpeg b/content/posts/rust/rust-wsproxy-proxy-deploy/featured-image.jpeg new file mode 100644 index 0000000..05b3e4e Binary files /dev/null and b/content/posts/rust/rust-wsproxy-proxy-deploy/featured-image.jpeg differ diff --git a/content/posts/rust/rust-wsproxy-proxy-deploy/index.en.md b/content/posts/rust/rust-wsproxy-proxy-deploy/index.en.md new file mode 100644 index 0000000..7306384 --- /dev/null +++ b/content/posts/rust/rust-wsproxy-proxy-deploy/index.en.md @@ -0,0 +1,765 @@ +--- +title: "Building wsProxy — Proxy Core & Deployment" +subtitle: "" +date: 2026-07-14T09:00:00+07:00 +lastmod: 2026-07-14T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "The final pieces of wsProxy: TCP connection, bidirectional pump, TLS, testing, Docker, and Kubernetes deployment" +license: "" +images: [] +tags: ["Rust", "Tutorial", "WebSocket", "Tokio", "Docker"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +# Building wsProxy — Proxy Core & Deployment + +The final chapter! From that Facebook post by [rayrag.com](https://rayrag.com/) showing RO playable in a browser, to revisiting Rust over 7 parts, to building the CLI, config, and server — we now complete the proxy's heart: the TCP connection logic and bidirectional data pump that bridges WebSocket and TCP streams. We'll also cover TLS encryption, testing, and production deployment with Docker and Kubernetes. + + + +In [Part 7](/posts/rust/rust-wsproxy-server/), we built the HTTP server, WebSocket upgrade handler, and configuration system. Source: [https://github.com/bouroo/rs-wsProxy](https://github.com/bouroo/rs-wsProxy) + +## TCP Connection (proxy.rs) + +The proxy's heart is the TCP connection establishment and data pumping mechanism. Let's start with the `connect_tcp` function that resolves hostnames and establishes TCP connections: + +```rust +use tokio::net::{TcpStream, lookup_host}; +use std::net::IpAddr; + +/// Establishes a TCP connection to the specified address. +/// +/// Performs DNS lookup, filters for IPv4 addresses, and sets TCP_NODELAY. +pub async fn connect_tcp(addr: &str) -> Result { + // Resolve the address to IP endpoints + let addrs: Vec<_> = lookup_host(addr) + .await + .map_err(|e| format!("DNS lookup failed for '{}': {}", addr, e))? + .filter(|a| { + // Only keep IPv4 addresses for compatibility + matches!(a, IpAddr::V4(_)) + }) + .collect(); + + // Ensure we found at least one IPv4 address + if addrs.is_empty() { + return Err(format!("no IPv4 address found for '{}'", addr)); + } + + // Try each address until we establish a connection + let mut last_err = None; + for addr in addrs { + match TcpStream::connect(&addr).await { + Ok(stream) => { + // Disable Nagle's algorithm for low-latency proxying + if let Err(e) = stream.set_nodelay(true) { + return Err(format!("failed to set TCP_NODELAY: {}", e)); + } + return Ok(stream); + } + Err(e) => last_err = Some(e), + } + } + + Err(format!( + "failed to connect to {} after trying all resolved addresses: {}", + addr, + last_err.map(|e| e.to_string()).unwrap_or_else(|| "unknown error".to_string()) + )) +} +``` + +### Key Details Explained + +**DNS Resolution and IPv4 Filtering**: We use `tokio::net::lookup_host` to perform asynchronous DNS resolution. This returns all IP addresses (both IPv4 and IPv6) associated with the hostname. Since our target environments primarily use IPv4 and to avoid potential compatibility issues, we filter for IPv4 addresses only using `matches!(a, IpAddr::V4(_))`. If no IPv4 addresses are found, we return a clear error. + +**Connection Attempt Loop**: We iterate through each resolved IP address, attempting to establish a TCP connection. This provides resilience against partial DNS failures - if one IP address is unreachable, we try the next. The loop continues until either a connection succeeds or we exhaust all addresses. + +**TCP_NODELAY Optimization**: After establishing a connection, we set the TCP_NODELAY socket option to disable Nagle's algorithm. This is crucial for proxy applications where low latency is important - we want small packets to be sent immediately rather than being buffered for potential aggregation. Without this setting, interactive protocols (like the roBrowser protocol wsProxy serves) would experience noticeable delays. + +**Error Propagation**: Throughout the function, we convert low-level IO errors into descriptive String errors that include context about what operation failed and the target address. This makes debugging connection issues much simpler in production environments. + +## Bidirectional Pump — The Concept + +With a TCP connection established, we need to move data bidirectionally between the WebSocket and TCP streams. Unlike unidirectional data flow, a proxy must handle simultaneous data movement in both directions: + +1. **WebSocket → TCP**: Messages received from the WebSocket client must be forwarded to the TCP backend +2. **TCP → WebSocket**: Data received from the TCP backend must be sent to the WebSocket client + +These operations are independent and can occur concurrently - the WebSocket might receive a message while we're still sending previous TCP data, and vice versa. Using sequential blocking operations would create head-of-line blocking where one direction stalls the other. + +Tokio's `select!` macro provides the perfect solution: it allows us to run multiple asynchronous operations concurrently and proceed as soon as any one completes. We'll create two asynchronous tasks - one for each direction - and use `select!` to wait for either to complete (indicating the connection should close). + +## WS → TCP Direction + +Let's examine the WebSocket-to-TCP data flow. The roBrowser protocol uses binary WebSocket frames exclusively, so we filter for `Message::Binary` variants: + +```rust +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::protocol::Message; +use tokio::io::{self, AsyncWriteExt}; + +/// Forwards data from WebSocket to TCP stream. +async fn ws_to_tcp( + mut ws_rx: tokio_tungstenite::WebSocketStream, + mut tcp_write: impl AsyncWriteExt + Unpin, +) -> Result<(), String> { + while let Some(msg) = ws_rx.next().await { + match msg { + Ok(Message::Binary(data)) => { + // Forward binary WebSocket frames to TCP + if tcp_write.write_all(&data).await.is_err() { + break; + } + // Important: flush to ensure data is sent immediately + if tcp_write.flush().await.is_err() { + break; + } + } + Ok(Message::Close(_)) => { + // WebSocket initiated close - forward to TCP then break + let _ = tcp_write.shutdown().await; + break; + } + Err(e) => return Err(format!("WebSocket receive error: {}", e)), + _ => {} // Ignore other message types (text, ping, pong) + } + } + Ok(()) +} +``` + +### WS → TCP Mechanics + +**Binary Frame Filtering**: We explicitly handle only `Message::Binary` variants because the roBrowser protocol encodes all data as binary frames. Text frames, ping/pong, and other WebSocket control frames are ignored for data forwarding (though we handle Close frames specially). + +**Immediate Flushing**: After writing data to the TCP stream, we call `flush()` to ensure bytes are sent immediately rather than buffered. This minimizes latency for interactive applications. + +**Close Frame Handling**: When we receive a WebSocket Close frame, we initiate a TCP shutdown (sending FIN packet) before breaking the loop. This allows the TCP backend to gracefully detect connection termination. + +**Error Propagation**: Any error in receiving WebSocket messages or writing to TCP immediately terminates the forwarding loop and returns an error. + +## TCP → WS Direction + +The TCP-to-WebSocket direction requires more sophisticated buffer management to efficiently handle streaming data: + +```rust +use tokio::io::{self, AsyncReadExt}; +use bytes::{BytesMut, Buf}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::protocol::Message; + +/// Forwards data from TCP to WebSocket stream. +async fn tcp_to_ws( + mut tcp_read: impl AsyncReadExt + Unpin, + mut ws_tx: tokio::sync::mpsc::UnboundedSender, +) -> Result<(), String> { + // Allocate buffer with reasonable initial capacity + let mut buf = BytesMut::with_capacity(65536); + + loop { + // Ensure buffer has sufficient capacity for read operations + if buf.capacity() < 4096 { + buf.reserve(65536 - buf.capacity()); + } + + // Read data from TCP into our buffer + match tcp_read.read_buf(&mut buf).await { + // Zero bytes indicates TCP stream closed + Ok(0) => { + // Send close frame to WebSocket client + let _ = ws_tx.send(Message::Close(None)).await; + break; + } + Ok(n) => { + // Extract all available data as a contiguous byte slice + let data = buf.split().freeze(); + + // Attempt to send data to WebSocket + if ws_tx.send(Message::Binary(data)).await.is_err() { + // Send failed - likely WebSocket closed + break; + } + // Buffer automatically reused for next read + } + Err(e) => return Err(format!("TCP read error: {}", e)), + } + } + Ok(()) +} +``` + +### TCP → WS Buffer Management + +**BytesMut for Efficient Buffering**: We use `bytes::BytesMut` which provides mutable byte buffer with efficient resizing and zero-copy operations. Starting with 64KB capacity balances memory usage with reducing reallocation frequency. + +**Dynamic Capacity Management**: Before each read operation, we ensure the buffer has at least 4KB free space. If not, we reserve additional capacity, we reserve another 64KB. This prevents frequent small reallocations while bounding memory growth. + +**Zero-Copy Data Extraction**: The `split()` method extracts all buffered data as a new `BytesMut` instance without copying, and `freeze()` converts it to an immutable `Bytes` that's cheap to clone. This avoids data duplication when sending to the WebSocket sender. + +**Read Loop Semantics**: +- `read_buf` returns `Ok(0)` when the TCP stream is closed (EOF) +- For positive byte counts, we extract and forward all buffered data +- Any I/O error breaks the loop and propagates upward + +This design ensures efficient memory usage while maintaining low-latency data forwarding - critical for proxy performance. + +## Stream Splitting + +When we establish a TCP connection, we immediately split it into separate read and write halves: + +```rust +let (tcp_read, tcp_write) = tcp_stream.into_split(); +``` + +### Why Split Streams? + +Tokio's `TcpStream` internally uses a mutex-like BiLock to synchronize access between read and write operations. When we perform concurrent read and write operations on the same stream, they contend for this lock, creating unnecessary synchronization overhead. + +By splitting the stream into `owned ReadHalf` and `owned WriteHalf`, we eliminate this contention: +- Each half can be used independently without locking +- Multiple tasks can read and write concurrently with zero synchronization cost +- Each half owns its portion of the resource and will close the connection when dropped + +This optimization is particularly important in our proxy where we have two concurrent data pumps (WS→TCP and TCP→WS) operating simultaneously on the same TCP connection. + +## TLS with rustls + +For secure connections, wsProxy supports TLS termination using the rustls library. Here's how we configure the TLS acceptor: + +```rust +use rustls::{ServerConfig, RootCertStore}; +use rustls_pemfile::{certs, pkcs8_private_keys}; +use std::fs::File; +use std::io::BufReader; +use axum::extract::State; +use axum::response::Response; +use axum::http::StatusCode; + +/// Loads TLS configuration from PEM-encoded certificate and key files. +pub fn load_tls_config(cert_path: &str, key_path: &str) -> Result { + // Load certificate chain + let cert_file = File::open(cert_path) + .map_err(|e| format!("Failed to open certificate file '{}': {}", cert_path, e))?; + let cert_reader = BufReader::new(cert_file); + let cert_chain = certs(cert_reader) + .map_err(|e| format!("Failed to parse certificates: {}", e))?; + + // Load private key + let key_file = File::open(key_path) + .map_err(|e| format!("Failed to open key file '{}': {}", key_path, e))?; + let key_reader = BufReader::new(key_file); + let keys = pkcs8_private_keys(key_reader) + .map_err(|e| format!("Failed to parse private key: {}", e))?; + + if keys.is_empty() { + return Err("No private keys found in key file".to_string()); + } + + // Configure TLS server + let mut config = ServerConfig::builder_with_root_certificates(RootCertStore::empty()) + .with_no_client_auth() + .with_single_cert(cert_chain, keys[0].clone()) + .map_err(|e| format!("Failed to build TLS config: {}", e))?; + + // Optional: configure ALPN for HTTP/2 and HTTP/1.1 + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + + Ok(config) +} +``` + +### TLS Integration Points + +**Certificate Loading**: We use `rustls_pemfile` to parse PEM-encoded certificates and private keys. This approach avoids blocking the async runtime during TLS setup by performing file I/O during initialization. + +**rustls Configuration**: We build a `ServerConfig` with no client authentication (typical for server-side TLS) and provide our certificate chain and private key. The ALPN protocols enable efficient protocol negotiation for HTTP/2 fallback. + +**axum-server Integration**: In our HTTP server setup, we conditionally create a TLS acceptor when TLS configuration is provided: + +```rust +let listener = if let Some(tls_config) = &tls_config { + let tls_acceptor = tokio_rustls::TcpAcceptor::from(tls_config.clone()); + tokio::net::TcpListener::bind(&tls_bind_addr) + .await + .map_err(|e| format!("Failed to bind TLS listener: {}", e))? + .into_tls(tls_acceptor) +} else { + tokio::net::TcpListener::bind(&http_bind_addr) + .await + .map_err(|e| format!("Failed to bind HTTP listener: {}", e))? +}; +``` + +This allows the same handler code to work for both plain HTTP and HTTPS connections, with axum-server handling the TLS transparently. + +## Graceful Shutdown + +Production services must handle shutdown signals gracefully to prevent data loss and allow active connections to complete. Here's our signal handling implementation: + +```rust +use tokio::signal; +use tokio::time::{timeout, Duration}; +use futures::future::join_all; + +/// Handles graceful shutdown on SIGINT and SIGTERM. +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); + }; + + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + info!("Shutdown signal received, starting graceful shutdown..."); +} + +/// Performs graceful shutdown with timeout for active connections. +async fn graceful_shutdown( + server: Server, + shutdown_timeout: Duration, +) { + // Stop accepting new connections + server.close(); + + // Wait for active connections to complete, with timeout + match timeout(shutdown_timeout, server.wait_for_rest()).await { + Ok(_) => info!("All connections closed gracefully"), + Err(_) => { + warn!("Timeout waiting for connections to close, forcing shutdown"); + } + } +} +``` + +### Shutdown Workflow + +**Signal Handling**: We listen for both SIGINT (Ctrl+C) and SIGTERM (termination signal from orchestrators like Kubernetes) using Tokio's signal module. The `select!` waits for either signal. + +**Two-Phase Shutdown**: +1. **Stop accepting new connections**: Call `server.close()` on the underlying TCP listener +2. **Drain existing connections**: Wait for active connections to complete using `server.wait_for_rest()` with a configurable timeout + +This approach ensures we don't abruptly terminate active proxy sessions while still enforcing a maximum shutdown duration to prevent hanging processes. + +## Testing + +Comprehensive testing validates both individual components and end-to-end behavior. Let's look at our test strategy: + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::TcpListener; + use std::net::SocketAddr; + + #[tokio::test] + async fn test_connect_tcp_success() { + // Start a simple TCP echo server for testing + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0; 1024]; + while let Ok(n) = socket.read(&mut buf).await { + if n == 0 { break; } + let _ = socket.write_all(&buf[..n]).await; + } + } + }); + + // Test connection to our test server + let result = connect_tcp(&addr.to_string()).await; + assert!(result.is_ok(), "Failed to connect to test server: {}", result.unwrap_err()); + + // Clean up + let _ = listener.shutdown().await; + } + + #[tokio::test] + async fn test_connect_tcp_ipv4_only() { + // Test that we only get IPv4 addresses even if IPv6 is available + let result = connect_tcp("localhost").await; + // Should succeed with IPv4 localhost (127.0.0.1) + assert!(result.is_ok()); + } +} +``` + +### Integration Tests + +Our integration tests verify the complete WebSocket-to-TCP proxy pipeline: + +```rust +#[tokio::test] +async fn test_ws_to_tcp_proxy() { + // 1. Start TCP echo server + let tcp_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let tcp_addr = tcp_listener.local_addr().unwrap(); + let tcp_echo_task = tokio::spawn(async move { + if let Ok((mut socket, _)) = tcp_listener.accept().await { + let mut buf = [0; 1024]; + while let Ok(n) = socket.read(&mut buf).await { + if n == 0 { break; } + let _ = socket.write_all(&buf[..n]).await; + } + } + }); + + // 2. Start WebSocket server (our proxy frontend) + // ... (setup code omitted for brevity) + + // 3. Connect WebSocket client and exchange data + // ... (test WebSocket connection and data transfer) + + // 4. Cleanup + tcp_echo_task.abort(); + let _ = tcp_listener.shutdown().await; +} +``` + +These tests verify: +- Individual components (`connect_tcp` function) +- End-to-end data flow (WebSocket client → proxy → TCP server → proxy → WebSocket client) +- Error handling and connection termination sequences +- TLS handshake when enabled + +## Docker Deployment + +Containerizing wsProxy enables consistent deployment across environments. Here's our multi-stage Dockerfile: + +```dockerfile +# syntax = docker/dockerfile:1.4 + +# ------ Build Stage ------ +FROM rust:1.75-slim-bullseye AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create app directory +WORKDIR /app + +# Cache dependencies by copying Cargo files first +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release +RUN rm -rf src + +# Copy actual source code +COPY . . + +# Build application in release mode +RUN touch src/main.rs +RUN cargo build --release + +# ------ Runtime Stage ------ +FROM debian:bullseye-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 appuser +WORKDIR /app + +# Copy built binary from builder stage +COPY --from=builder /app/target/release/wsproxy . + +# Use non-root user +USER appuser + +# Expose ports +EXPOSE 8080 +EXPOSE 8443 + +# Entry point +ENTRYPOINT ["./wsproxy"] +``` + +### Docker Compose for Local Development + +```yaml +version: '3.8' + +services: + wsproxy: + build: . + ports: + - "8080:8080" + - "8443:8443" + environment: + - WS_PROXY_TLS_CERT=/certs/fullchain.pem + - WS_PROXY_TLS_KEY=/certs/privkey.pem + - WS_PROXY_LOG_LEVEL=info + volumes: + - ./certs:/certs:ro + - ./config.toml:/app/config.toml:ro + restart: unless-stopped +``` + +The multi-stage build separates compilation dependencies from the runtime image, resulting in a minimal ~50MB final container. We run as a non-root user for security and expose both HTTP (8080) and HTTPS (8443) ports. + +## Kubernetes Deployment + +For production orchestration, we provide Kubernetes manifests: + +### Deployment Manifest (`deploy/k8s/deployment.yaml`) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wsproxy + labels: + app: wsproxy +spec: + replicas: 3 + selector: + matchLabels: + app: wsproxy + template: + metadata: + labels: + app: wsproxy + spec: + containers: + - name: wsproxy + image: bouroo/wsproxy:latest + ports: + - containerPort: 8080 + name: http + - containerPort: 8443 + name: https + env: + - name: WS_PROXY_LOG_LEVEL + value: "info" + - name: WS_PROXY_TARGET_HOST + value: "backend-service" + - name: WS_PROXY_TARGET_PORT + value: "80" + volumeMounts: + - name: tls-certs + mountPath: /certs + readOnly: true + - name: config + mountPath: /app/config.toml + subPath: config.toml + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" + volumes: + - name: tls-certs + secret: + secretName: wsproxy-tls + - name: config + configMap: + name: wsproxy-config +``` + +### Service Manifest (`deploy/k8s/service.yaml`) + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: wsproxy + labels: + app: wsproxy +spec: + type: LoadBalancer + ports: + - name: http + port: 80 + targetPort: 8080 + - name: https + port: 443 + targetPort: 8443 + selector: + app: wsproxy +``` + +This deployment includes: +- Horizontal pod autoscaling readiness (3 replicas) +- TLS termination via Kubernetes secrets +- Liveness and readiness probes for health checking +- Resource requests and limits for QoS +- ConfigMap for configuration separation +- LoadBalancer service for external access + +## CI/CD Pipeline + +Our GitHub Actions workflow automates testing, building, and deployment: + +### CI Workflow (`.github/workflows/ci.yml`) + +```yaml +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Cargo dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run cargo fmt --check + run: cargo fmt --check + + - name: Run cargo clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test -- --test-threads=1 + + - name: Build release binary + run: cargo build --release +``` + +### CD Workflow (`.github/workflows/cd.yml`) + +```yaml +name: CD + +on: + push: + branches: [ main ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: bouroo/wsproxy:latest, bouroo/wsproxy:${{ github.sha }} + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'v1.28.0' + + - name: Configure AWS EKS + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Update kubeconfig + run: aws eks update-kubeconfig --name wsproxy-cluster + + - name: Deploy to Kubernetes + run: | + kubectl set image deployment/wsproxy wsproxy=bouroo/wsproxy:${{ github.sha }} + kubectl rollout status deployment/wsproxy +``` + +The CI pipeline ensures code quality through formatting checks, linting, and testing. The CD pipeline builds Docker images, pushes to a registry, and performs rolling updates to Kubernetes clusters. + +## Series Conclusion + +Over eight articles, we've journeyed from Rust fundamentals to a production-ready WebSocket proxy: + +1. **[Part 1: Getting Started with Rust](/posts/rust/rust-getting-started/)** — Installation, Cargo, variables, data types, functions, control flow +2. **[Part 2: Ownership, Borrowing & Lifetimes](/posts/rust/rust-ownership-borrowing/)** — The ownership model, references, slices, lifetimes +3. **[Part 3: Structs, Enums & Pattern Matching](/posts/rust/rust-structs-enums/)** — Custom types, Option, match, Result intro +4. **[Part 4: Collections, Iterators & Error Handling](/posts/rust/rust-collections-errors/)** — Vec, HashMap, iterator adaptors, the `?` operator +5. **[Part 5: Traits & Generics](/posts/rust/rust-traits-generics/)** — Trait definitions, trait bounds, trait objects, standard traits +6. **[Part 6: Async Rust with Tokio](/posts/rust/rust-async-tokio/)** — async/await, runtime, tasks, channels, async I/O, `select!` +7. **[Part 7: Building wsProxy — CLI, Config & Server](/posts/rust/rust-wsproxy-server/)** — clap, axum router, WebSocket upgrade, verify pipeline +8. **Part 8: Proxy Core & Deployment (This Article)** — TCP connection, bidirectional pump, TLS, testing, Docker/K8s + +We've covered the complete stack: +- **Networking**: TCP/UDP, DNS resolution, TLS encryption +- **Asynchronous Rust**: Tokio runtime, futures, streams, and buffers +- **WebSocket Protocol**: Frame handling, binary data transfer, connection lifecycle +- **HTTP Server**: Routing, middleware, and integration with WebSockets +- **Production Concerns**: Testing strategies, containerization, orchestration, and observability + +The wsProxy project demonstrates how Rust's zero-cost abstractions, memory safety, and excellent async ecosystem enable building high-performance network services that rival C/C++ implementations while providing superior developer productivity and safety. + +Looking back, it all started with a simple Facebook post from [rayrag.com](https://rayrag.com/) showing RO running in a browser. That single moment of curiosity led to revisiting Rust and building a production-ready proxy. That's the beauty of programming — curiosity leads to learning, and learning leads to creation. + +The complete source code is available at [https://github.com/bouroo/rs-wsProxy](https://github.com/bouroo/rs-wsProxy). Thank you for following along this journey — happy coding! + +[← Previous: Building wsProxy — CLI, Config & Server](/posts/rust/rust-wsproxy-server/) +Source: https://github.com/bouroo/rs-wsProxy \ No newline at end of file diff --git a/content/posts/rust/rust-wsproxy-proxy-deploy/index.md b/content/posts/rust/rust-wsproxy-proxy-deploy/index.md new file mode 100644 index 0000000..2e0e47f --- /dev/null +++ b/content/posts/rust/rust-wsproxy-proxy-deploy/index.md @@ -0,0 +1,780 @@ +--- +title: "สร้าง wsProxy ตอนที่ 2 — Proxy Core และ Deployment" +subtitle: "" +date: 2026-07-14T09:00:00+07:00 +lastmod: 2026-07-14T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: " ส่วนประกอบสุดท้ายของ wsProxy: TCP connection, bidirectional pump, TLS, testing, Docker และ Kubernetes deployment" +license: "" +images: [] +tags: ["Rust", "Tutorial", "WebSocket", "Tokio", "Docker"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +# สร้าง wsProxy ตอนที่ 2 — Proxy Core และ Deployment + +บทสุดท้าย! จากโพสต์ [rayrag.com](https://rayrag.com/) ที่เห็นบน Facebook จนอยากสร้าง WebSocket-to-TCP proxy ของตัวเอง เราได้เรียนรู้ Rust มาทั้ง 7 ตอน และสร้าง CLI, config, server ไปแล้ว ตอนนี้เราจะสร้างหัวใจของ proxy — TCP connection และ bidirectional pump ที่ส่งข้อมูลระหว่าง WebSocket และ TCP แล้วปิดด้วย testing และ deployment ให้พร้อมใช้งานจริง + + + +ซอร์สโค้ด: https://github.com/bouroo/rs-wsProxy + +## TCP Connection (proxy.rs) + +ในส่วนนี้เราจะดูการเชื่อมต่อ TCP ไปยังเซิร์ฟเวอร์เป้าหมาย ฟังก์ชัน `connect_tcp` มีหน้าที่ทำการ resolve ชื่อโดเมนเป็นที่อยู่ IP และสร้างการเชื่อมต่อ TCP โดยเปิดใช้งาน TCP_NODELAY เพื่อลดความหน่วงในการส่งข้อมูลเล็กน้อย + +```rust +pub async fn connect_tcp(addr: &str) -> Result { + // ทำการ resolve ชื่อโดเมนเป็นรายการที่อยู่ IP + let addrs: Vec<_> = tokio::net::lookup_host(addr) + .await + .map_err(|e| format!("DNS lookup failed for '{}': {}", addr, e))? + // กรองเฉพที่อยู่ IPv4 เท่านั้น (เราไม่รองรับ IPv6 ในตัวอย่างนี้) + .filter(|a| a.is_ipv4()) + .collect(); + + // ตรวจสอบว่ามีที่อยู่ที่เหลือหลังจากกรองหรือไม่ + if addrs.is_empty() { + return Err(format!("No IPv4 addresses found for {}", addr)); + } + + // พยายามเชื่อมต่อไปยังแต่ละที่อยู่จนกว่าจะสำเร็จ + for addr in addrs { + match TcpStream::connect(&addr).await { + Ok(stream) => { + // เปิดใช้งาน TCP_NODELAY เพื่อปิด Nagle's algorithm + // ทำให้ข้อมูลเล็กๆ ถูกส่งทันทีโดยไม่รอให้เต็ม buffer + if let Err(e) = stream.set_nodelay(true) { + return Err(format!("Failed to set TCP_NODELAY: {}", e)); + } + return Ok(stream); + } + Err(e) => { + // ลองที่อยู่ต่อไปถ้าการเชื่อมต่อล้มเหลว + continue; + } + } + } + + Err(format!("Failed to connect to any address for {}", addr)) +} +``` + +คำอธิบายโค้ด: +1. `tokio::net::lookup_host` ทำการ DNS lookup แบบ asynchronous เพื่อได้รายการที่อยู่ IP ที่เกี่ยวข้องกับโดเมน +2. ผลลัพธ์ถูกแปลงเป็น `Vec` และกรองเฉพาะที่อยู่ IPv4 ด้วย `filter(|a| a.is_ipv4())` +3. หากไม่มีที่อยู่ IPv4 เหลืออยู่ จะคืนค่าข้อผิดพลาดทันที +4. ลูปผ่านแต่ละที่อยู่และพยายามสร้างการเชื่อมต่อ TCP ด้วย `TcpStream::connect` +5. เมื่อการเชื่อมต่อสำเร็จ เราตั้งค่า `TCP_NODELAY` เป็น `true` เพื่อปิด Nagle's algorithm ซึ่งทำให้ข้อมูลเล็กถูกส่งทันทีโดยไม่รอให้ buffer เต็ม เหมาะสำหรับแอปพลิเคชันที่ต้องการความหน่วงต่ำเช่น WebSocket proxy +6. หากการตั้งค่า `TCP_NODELAY` ล้มเหลว จะคืนค่าข้อผิดพลาด +7. หากการเชื่อมต่อล้มเหลวกับที่อยู่หนึ่ง จะลองที่อยู่ถัดไปในรายการ +8. หากไม่สามารถเชื่อมต่อได้กับที่อยู่ใดเลย จะคืนค่าข้อผิดพลาดที่บ่งบอกว่าล้มเหลวทั้งหมด + +การออกแบบเช่นนี้ทำให้ proxy มีความทนทานต่อการเปลี่ยนแปลงของ DNS และสามารถทำงานได้ในสภาพแวดล้อมที่มีหลายที่อยู่ IP (เช่น load balancing) + +## Bidirectional Pump — แนวคิด + +ใน WebSocket proxy เราต้องส่งข้อมูลสองทิศทางพร้อมกัน: +- จาก WebSocket ไปยัง TCP (ws → tcp): ข้อความที่ได้รับจากไคลเอนต์ WebSocket จะถูกส่งไปยังเซิร์ฟเวอร์ TCP เป้าหมาย +- จาก TCP ไปยัง WebSocket (tcp → ws): ข้อมูลที่ได้รับจากเซิร์ฟเวอร์ TCP จะถูกส่งกลับไปยังไคลเอนต์ WebSocket + +เหตุผลที่ต้องทำสองทิศทางพร้อมกันก็คือการสื่อสารผ่าน proxy มักเป็นแบบ duplex หมายความว่าทั้งไคลเอนต์และเซิร์ฟเวอร์สามารถส่งข้อมูลได้ตลอดเวลาโดยไม่ต้องรอให้อีกฝ่ายส่งเสร็จก่อน หากเราทำแบบ sequential (ทำทิศทางหนึ่งให้เสร็จก่อนค่อยทำอีกทิศทาง) จะทำให้เกิดความหน่วงและอาจทำให้ข้อมูลค้างอยู่ใน buffer ไม่ถูกส่งออกไปทันที + +ใน Rust กับ Tokio เราใช้ `tokio::select!` macro เพื่อรอเหตุการณ์จากหลายแหล่งพร้อมกัน มันจะทำงานเมื่อใดก็ตามที่หนึ่งในหลายๆ ฟิวเจอร์ที่เรากำหนดพร้อมที่จะทำงาน จากนั้นเราจะรันฟิวเจอร์นั้นและทำซ้ำไปเรื่อยๆ จนกว่าจะมีเงื่อนไขให้หยุด + +ตัวอย่างการใช้งานคร่าวๆ: +```rust +tokio::select! { + result = ws_to_tcp => { + // จัดการเมื่อทิศทาง ws→tcp เสร็จสิ้น + } + result = tcp_to_ws => { + // จัดการเมื่อทิศทาง tcp→ws เสร็จสิ้น + } +} +``` + +ด้วยวิธีนี้เราสามารถจัดการการไหลของข้อมูลสองทิศทางได้อย่างมีประสิทธิภาพโดยไม่บล็อกซึ่งกันและกัน + +## WS → TCP Direction + +ทิศทางจาก WebSocket ไปยัง TCP มีหน้าที่รับข้อความจาก WebSocket receiver (`ws_rx`) และเขียนไปยัง TCP writer (`tcp_write`) เราสนใจเฉพาะข้อความประเภท binary เท่านั้นเพราะโปรโตคอลของเรา (roBrowser) ใช้ binary frame ในการส่งข้อมูล + +```rust +let ws_to_tcp = async { + while let Some(msg) = ws_rx.next().await { + match msg { + Ok(Message::Binary(data)) => { + // เขียนข้อมูล binary ลงใน TCP stream + if let Err(e) = tcp_write.write_all(&data).await { + // หากเกิดข้อผิดพลาดในการเขียน ให้ออกจากลูป + break; + } + } + Ok(Message::Close(_)) => { + // ได้รับสัญญาณปิดการเชื่อมต่อจาก WebSocket + break; + } + _ => { + // ละเลยข้อความประเภทอื่นๆ (เช่น text, ping, pong) + } + } + } +}; +``` + +คำอธิบาย: +- เราใช้ `ws_rx.next().await` เพื่อรับข้อความถัดจาก WebSocket stream แบบ non-blocking +- เมื่อได้รับ `Message::Binary(data)` เราจะเขียนข้อมูลทั้งหมดลงใน `tcp_write` ด้วย `write_all` ซึ่งจะพยายามเขียนข้อมูลจนหมดก่อนจะคืนค่า +- หากการเขียนล้มเหลว (เช่น เซิร์ฟเวอร์ TCP ปิดการเชื่อมต่อ) เราจะออกจากลูปทันที +- หากได้รับ `Message::Close` เราจะออกจากลูปเพื่อจบการทำงานของทิศทางนี้ +- ข้อความประเภทอื่นๆ (เช่น text, ping, pong) จะถูกละเลยเพราะไม่เกี่ยวข้องกับการส่งข้อมูลต้นทางในโปรโตคอลของเรา + +การออกแบบให้รับเฉพาะ binary frame ช่วยให้เรามั่นใจว่าข้อมูลที่ส่งไปยัง TCP เซิร์ฟเวอร์นั้นเป็นข้อมูลต้นทางที่ไม่ถูกดัดแปลง ซึ่งสำคัญต่อการทำงานของโปรโตคอลเช่น roBrowser ที่คาดหวังข้อมูลไบนารีที่สมบูรณ์ + +## TCP → WS Direction + +ทิศทางจาก TCP ไปยัง WebSocket มีความซับซ้อนมากขึ้นเล็กน้อยเพราะเราต้องจัดการกับการอ่านข้อมูลจาก TCP stream ที่อาจมาเป็นชิ้นส่วน fragmented เราใช้ `BytesMut` เป็น buffer แบบไดนามิกเพื่อสะสมข้อมูลที่อ่านมาได้จนกว่าจะมีข้อมูลพร้อมส่งเป็น WebSocket binary frame + +```rust +let tcp_to_ws = async { + let mut buf = BytesMut::with_capacity(65536); + loop { + // ตรวจสอบและขยาย buffer หากความจุเหลือน้อยเกินไป + if buf.capacity() < 4096 { + buf.reserve(65536); + } + match tcp_read.read_buf(&mut buf).await { + Ok(0) => { + // อ่านได้ 0 ไบต์ หมายถึงการเชื่อมต่อ TCP ถูกปิดอย่างปกติ + let _ = ws_tx.send(Message::Close(None)).await; + break; + } + Ok(n) => { + // อ่านได้ n ไบต์ (> 0) + // แบ่งข้อมูลที่อ่านได้ออกจาก buffer เป็น independent slice + let data = buf.split().freeze(); + // พยายามส่งข้อมูลเป็น WebSocket binary frame + if ws_tx.send(Message::Binary(data)).await.is_err() { + // หากส่งไม่สำเร็จ (เช่น WebSocket ถูกปิด) ให้ออกจากลูป + break; + } + // วนลูปต่อเพื่ออ่านข้อมูลเพิ่มเติม + } + Err(e) => { + // เกิดข้อผิดพลาดในการอ่านจาก TCP + break; + } + } + } +}; +``` + +คำอธิบายอย่างละเอียด: +1. เราเริ่มต้นด้วยการสร้าง `BytesMut` ที่มีความจุเริ่มต้น 64 KiB (65536 ไบต์) เพื่อลดการจัดสรรหน่วยความจำบ่อยครั้ง +2. ในแต่ละรอบของลูป เราตรวจสอบว่าความจุที่เหลือใน buffer น้อยกว่า 4 KiB หรือไม่ หากน้อยกว่าเราจะสำรองพื้นที่เพิ่มอีก 64 KiB ด้วย `reserve` เพื่อให้มีพื้นที่เพียงพอสำหรับการอ่านครั้งต่อไป +3. เราเรียก `tcp_read.read_buf(&mut buf).await` เพื่ออ่านข้อมูลจาก TCP stream เข้าไปใน buffer โดยไม่คัดลอกข้อมูล (zero-copy) เนื่องจาก `read_buf` จะเขียนข้อมูลโดยตรงเข้าไปใน buffer ที่เราให้มา +4. หากการอ่านได้ 0 ไบต์ (`Ok(0)`) หมายถึงการเชื่อมต่อ TCP ถูกปิดโดยเซิร์ฟเวอร์ปลายทางอย่างปกติ เราจึงส่งสัญญาณ `Message::Close` ไปยัง WebSocket เพื่อแจ้งให้ไคลเอนต์ทราบว่าการเชื่อมต่อกำลังจะปิด จากนั้นออกจากลูป +5. หากการอ่านได้ข้อมูล (`Ok(n)` โดยที่ n > 0): + - เราใช้ `buf.split()` เพื่อแบ่งข้อมูลทั้งหมดที่อยู่ใน buffer ออกเป็นสองส่วน: ส่วนที่ถูกแบ่งออก (ซึ่งมีข้อมูลที่เราอ่านได้ในรอบนี้) และส่วนที่เหลืออยู่ใน buffer เดิม + - จากนั้นเราเรียก `.freeze()` บนส่วนที่ถูกแบ่งออกเพื่อแปลงมันเป็น `Bytes` ที่ไม่สามารถเปลี่ยนแปลงได้ ซึ่งเหมาะสำหรับการส่งผ่านไปยังส่วนอื่นของโปรแกรมโดยไม่ต้องกังวลว่าจะถูกแก้ไข + - สุดท้ายเราพยายามส่ง `Bytes` นี้เป็น WebSocket binary frame ด้วย `ws_tx.send(Message::Binary(data)).await` + - หากการส่งล้มเหลว (คืนค่า `Err`) เราจะออกจากลูปทันทีเพราะอาจหมายถึงการเชื่อมต่อ WebSocket มีปัญหา +6. หากเกิดข้อผิดพลาดในการอ่านจาก TCP (`Err(e)`) เราจะออกจากลูปทันที + +การใช้ `BytesMut` ร่วมกับ `split()` และ `freeze()` ช่วยให้เราสามารถจัดการ buffer ได้อย่างมีประสิทธิภาพโดยไม่ต้องคัดลอกข้อมูลหลายครั้ง เราอ่านข้อมูลเข้าไปใน buffer เดียว เมื่อมีข้อมูลพร้อมส่งเราจะแบ่งออกมาเป็นชิ้นอิสระแล้วส่งออกไป ทำให้เหลือพื้นที่ใน buffer เดิมสำหรับอ่านข้อมูลครั้งต่อไปได้ทันที โดยไม่ต้องเคลียร์หรือย้ายข้อมูล + +## Stream Splitting + +ทำไมเราต้องแยก TCP stream ออกเป็นสองส่วน (`tcp.into_split()`) แทนที่จะใช้ stream เดียวกันสำหรับการอ่านและเขียน? คำตอบอยู่ที่ลักษณะของ `TcpStream` ใน Tokio ที่เมื่อเราใช้วิธีการอ่านและเขียนบน stream เดียวกันจากงานหลายงานพร้อมกัน จะเกิดการแย่งชิงทรัพยากร (contention) บน BiLock ภายใน ซึ่งทำให้ประสิทธิภาพลดลง + +เมื่อเราเรียก `tcp.into_split()` เราจะได้คู่ `(ReadHalf, WriteHalf)` ซึ่งแต่ละส่วนสามารถถูกใช้งานโดยงานต่างๆ ได้อย่างอิสระโดยไม่ต้องแย่งชิงล็อคเดียวกัน นี่เป็นสิ่งสำคัญอย่างยิ่งในสถานการณ์เช่น proxy ที่เรามีงานอ่านจาก TCP (เพื่อส่งไปยัง WebSocket) และงานเขียนไปยัง TCP (จาก WebSocket) ทำงานพร้อมกันตลอดเวลา + +โดยการแยก stream เราจึงหลีกเลี่ยงปัญหาประสิทธิภาพที่อาจเกิดขึ้นจากการแย่งชิงล็อค และทำให้การอ่านและเขียนสามารถทำงานได้อย่างเต็มความเร็วของการเชื่อมต่อ TCP โดยไม่มีการหน่วงเหนี่ยวที่ไม่จำเป็น + +## TLS ด้วย rustls + +การรองรับ TLS ใน wsProxy ทำได้โดยการใช้ไลบรารี `rustls` ซึ่งเป็นไลบรารี TLS ที่ทันสมัยและปลอดภัยเขียนด้วย Rust อย่างบริสุทธิ์ เราไม่ใช้ OpenSSL เพื่อหลีกเลี่ยงปัญหาการจัดการหน่วยความจำและความซับซ้อนในการสร้าง + +ฟังก์ชัน `start_tls` มีหน้าที่เพิ่มการเข้ารหัส TLS ให้กับการเชื่อมต่อ TCP ที่มีอยู่แล้ว มันทำงานโดยการโหลดใบรับรองและคีย์ส่วนตัวจากไฟล์ PEM สร้างคอนฟิกูเรชัน `rustls` จากนั้นจึงทำการ handshake TLS กับเซิร์ฟเวอร์ปลายทาง + +```rust +pub async fn start_tls(stream: TcpStream, domain: &str) -> Result, String> { + // โหลดใบรับรองจากไฟล์ PEM + let certs = rustls_pemfile::certs(&mut std::fs::File::open("certs/server.crt") + .map_err(|e| format!("Failed to open certificate file: {}", e))?) + .collect::, _>>() + .map_err(|e| format!("Failed to parse certificate: {}", e))?; + + // โหลดคีย์ส่วนตัวจากไฟล์ PEM + let key = rustls_pemfile::pkcs8_private_keys(&mut std::fs::File::open("certs/server.key") + .map_err(|e| format!("Failed to open key file: {}", e))?) + .remove(0) + .map_err(|e| format!("Failed to parse private key: {}", e))?; + + // สร้างคอนฟิกูเรชันสำหรับไคลเอนต์ TLS + let config = rustls::ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(certs) + .with_single_cert(vec![certs[0].clone()], key) + .map_err(|e| format!("Failed to build TLS config: {}", e))?; + + // ทำการ handshake TLS กับเซิร์ฟเวอร์ปลายทางโดยใช้ชื่อโดเมนสำหรับ SNI + let connector = rustls::TokioTlsConnector::from(Arc::new(config)); + let tls_stream = connector + .connect(domain, stream) + .await + .map_err(|e| format!("TLS handshake failed: {}", e))?; + + Ok(tls_stream) +} +``` + +คำอธิบาย: +1. เราโหลดใบรับรองจากไฟล์ `certs/server.crt` โดยใช้ `rustls_pemfile::certs` ซึ่งคืนค่าอิตอเรเตอร์ของใบรับรอง เราเก็บรวบรวมเข้าสู่เวกเตอร์และจัดการกับข้อผิดพลาดที่อาจเกิดขึ้นจากการเปิดไฟล์หรือการแปลงรูปแบบ +2. เช่นเดียวกันเราโหลดคีย์ส่วนตัวจากไฟล์ `certs/server.key` โดยใช้ `rustls_pemfile::pkcs8_private_keys` เราสมมติว่ามีคีย์ส่วนตัวเพียงหนึ่งเดียวในไฟล์จึงใช้ `.remove(0)` เพื่อดึงออกมา +3. เราสร้าง `rustls::ClientConfig` โดย: + - ใช้ค่าตั้งค่าที่ปลอดภัยเป็นค่าเริ่มต้นด้วย `with_safe_defaults()` + - เพิ่มใบรับรองรูทที่เชื่อถือได้ด้วย `with_root_certificates` (ในกรณีนี้เราใช้ใบรับรองเดียวกันกับที่เราโหลดมาเป็นรูท ซึ่งเหมาะสำหรับการทดสอบหรือการใช้ใบรับรองที่ลงชื่อเอง) + - กำหนดใบรับรองและคีย์ส่วนตัวของไคลเอนต์ด้วย `with_single_cert` +4. สร้าง `TokioTlsConnector` จากคอนฟิกูเรชันที่ได้มา +5. ทำการเชื่อมต่อ TLS โดยเรียก `connector.connect(domain, stream)` โดยพารามิเตอร์แรกคือชื่อโดเมนที่ใช้สำหรับ Server Name Indication (SNI) และพารามิเตอร์ที่สองคือ TCP stream ที่มีอยู่แล้ว +6. หาก handshake สำเร็จ เราจะได้ `TlsStream` ซึ่งห่อหุ้ม TCP stream เดิมและให้อินเทอร์เฟซเดียวกันสำหรับการอ่านและเขียน แต่ข้อมูลจะถูกเข้ารหัสและถอดรหัสโดยอัตโนมัติผ่าน TLS + +ในส่วนของเซิร์ฟเวอร์ (เมื่อ wsProxy ทำหน้าที่เป็นเซิร์ฟเวอร์ WebSocket ที่มี TLS) เราใช้ความสามารถที่ติดมากับ `axum-server` ซึ่งรองรับ TLS โดยตรงผ่านการระบุ `tls_config` ในการสร้างเซิร์ฟเวอร์ ทำให้เราไม่ต้องจัดการ handshake TLS ด้วยตัวเองในระดับแอปพลิเคชัน + +## Graceful Shutdown + +การปิดการทำงานอย่างสง่างาม (graceful shutdown) เป็นสิ่งสำคัญสำหรับเซิร์ฟเวอร์ผลิตเพื่อให้แน่ใจว่าการเชื่อมต่อที่มีอยู่ทั้งหมดได้รับการจัดการอย่างเหมาะสมก่อนที่กระบวนการจะสิ้นสุดลง เราจัดการกับสัญญาณสองประเภท: +- `SIGINT` (จากการกด Ctrl+C ในเทอร์มินัล) +- `SIGTERM` (สัญญาณปิดการทำงานมาตรฐานจากระบบปฏิบัติการหรือผู้จัดการกระบวนการเช่น Kubernetes) + +เราใช้ `tokio::select!` เพื่อรอทั้งการทำงานของเซิร์ฟเวอร์และสัญญาณเหล่านี้พร้อมกัน เมื่อรับสัญญาณใดสัญญาณหนึ่ง เราจะเริ่มกระบวนการปิดการทำงานโดย: +1. หยุดการรับการเชื่อมต่อใหม่ +2. รอให้การเชื่อมต่อที่มีอยู่ทั้งหมดเสร็จสิ้นภายในระยะเวลาที่กำหนด (เช่น 30 วินาที) +3. หากเกินเวลาที่กำหนด เราจะบังคับปิดการเชื่อมต่อที่เหลืออยู่และออกจากโปรแกรม + +```rust +#[tokio::main] +async fn main() { + // ... การตั้งค่าเซิร์ฟเวอร์และตัวแปรต่างๆ + + // สร้างสัญญาณสำหรับการปิดการทำงาน + let shutdown_signal = async { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!("รับสัญญาณ Ctrl+C เริ่มการปิดการทำงาน"); + } + _ = tokio::signal::sigterm() => { + println!("รับสัญญาณ SIGTERM เริ่มการปิดการทำงาน"); + } + } + }; + + // รอทั้งการทำงานของเซิร์ฟเวอร์และสัญญาณปิดการทำงาน + tokio::select! { + _ = server_future => { + println!("เซิร์ฟเวอร์หยุดทำงานโดยไม่มีสัญญาณ"); + } + _ = shutdown_signal => { + // เริ่มกระบวนการปิดการทำงานอย่างสง่างาม + println!("เริ่มการปิดการทำงานอย่างสง่างาม..."); + // ที่นี่เราจะหยุดการรับการเชื่อมต่อใหม่และรอให้การเชื่อมต่อที่มีอยู่เสร็จสิ้น + // ตัวอย่างการใช้ shutdown_timeout หรือวิธีการที่คล้ายกันขึ้นกับเฟรมเวิร์กที่ใช้ + // สำหรับตัวอย่างนี้เราสมมติว่ามีฟังก์ชัน graceful_shutdown ที่จัดการขั้นตอนเหล่านี้ + if let Err(e) = graceful_shutdown().await { + eprintln!("เกิดข้อผิดพลาดระหว่างการปิดการทำงาน: {}", e); + } + } + } +} +``` + +ในการใช้งานจริงกับ `axum` เราอาจใช้วิธีการเช่น: +```rust +let server = Server::bind(&addr).serve(app.into_make_service()); +let graceful = server.with_graceful_shutdown(shutdown_signal); +if let Err(e) = graceful.await { + eprintln!("เซิร์ฟเวอร์ทำงานผิดพลาด: {}", e); +} +``` + +โดยที่ `shutdown_signal` คือฟิวเจอร์ที่จะเสร็จสมบูรณ์เมื่อได้รับสัญญาณปิดการทำงาน วิธีนี้ทำให้ `axum` หยุดรับการเชื่อมต่อใหม่และรอให้การเชื่อมต่อที่มีอยู่เสร็จสิ้นก่อนที่จะปิดตัวลง + +## Testing + +การทดสอบเป็นส่วนสำคัญที่ช่วยให้มั่นใจว่าโค้ดของเราทำงานได้อย่างถูกต้องและสามารถปรับปรุงหรือรีฟักเตอร์ได้โดยไม่กลัวว่าจะทำลายฟังก์ชันการทำงานที่มีอยู่ ใน wsProxy เรามีการทดสอบสองระดับ: +1. **Unit Tests**: ทดสอบฟังก์ชันและเมธอดแต่ละตัวอย่างแยกจากกัน +2. **Integration Tests**: ทดสอบการทำงานร่วมกันของหลายส่วนในสภาพแวดล้อมที่ใกล้เคียงกับการใช้งานจริง + +### Unit Tests ตัวอย่าง + +```rust +#[cfg(test)] +mod tests { + use super::*; + use tokio::net::TcpListener; + + #[tokio::test] + async fn test_connect_tcp_to_echo() { + // เริ่มเซิร์ฟเวอร์ echo แบบง่ายๆ บนที่อยู่สุ่ม + let (handle, echo_addr) = echo_server("127.0.0.1:0").await; + let addr = echo_addr.to_string(); + + // ทดสอบการเชื่อมต่อ TCP ไปยังเซิร์ฟเวอร์ echo + let mut stream = connect_tcp(&addr).await.expect("ควรเชื่อมต่อ TCP สำเร็จ"); + + // ส่งข้อมูลทดสอบ + let test_data = b"hello wsProxy"; + stream.write_all(test_data).await.expect("ควรเขียนข้อมูลสำเร็จ"); + + // อ่านข้อมูลที่สะท้อนกลับมา + let mut buffer = Vec::new(); + stream.read_to_end(&mut buffer).await.expect("ควรอ่านข้อมูลสำเร็จ"); + + // ตรวจสอบว่าข้อมูลที่ได้รับตรงกับที่ส่งไป + assert_eq!(buffer, test_data); + + // ทำความสะอาดโดยปิดเซิร์ฟเวอร์ทดสอบ + handle.abort(); + } + + #[tokio::test] + async fn test_validate_target() { + // ทดสอบการตรวจสอบที่อยู่เป้าหมายว่าถูกต้องหรือไม่ + assert!(validate_target("example.com:80").is_ok()); + assert!(validate_target("192.168.1.1:443").is_ok()); + assert!(validate_target("[2001:db8::1]:8080").is_ok()); // IPv6 + + // ที่อยู่ที่ไม่ถูกต้อง + assert!(validate_target("invalid_hostname!!!:80").is_err()); + assert!(validate_target("example.com:99999").is_err()); // พอร์ตเกินขอบเขต + } +} + +// ฟังก์ชันช่วยสำหรับสร้างเซิร์ฟเวอร์ echo แบบง่ายๆ สำหรับการทดสอบ +async fn echo_server(addr: &str) -> (tokio::task::JoinHandle<()>, SocketAddr) { + let listener = TcpListener::bind(addr).await.expect("ควรผูกที่อยู่สำเร็จ"); + let addr = listener.local_addr().expect("ควรได้ที่อยู่ท้องถิ่น"); + let handle = tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((mut socket, _)) => { + tokio::spawn(async move { + let mut buf = vec![0; 1024]; + loop { + let n = match socket.read(&mut buf).await { + Ok(0) => return, // การเชื่อมต่อถูกปิด + Ok(n) => n, + Err(e) => { + eprintln!("เกิดข้อผิดพลาดในการอ่าน: {}", e); + return; + } + }; + if let Err(e) = socket.write_all(&buf[..n]).await { + eprintln!("เกิดข้อผิดพลาดในการเขียน: {}", e); + return; + } + } + }); + } + Err(e) => { + eprintln!("เกิดข้อผิดพลาดในการยอมรับการเชื่อมต่อ: {}", e); + break; + } + } + } + }); + (handle, addr) +} +``` + +คำอธิบายการทดสอบ: +- `test_connect_tcp_to_echo`: เริ่มต้นโดยการสร้างเซิร์ฟเวอร์ echo แบบง่ายๆ ที่ฟังบนที่อยู่สุ่ม (`127.0.0.1:0` หมายถึงให้ระบบเลือกพอร์ตว่าง) จากนั้นทดสอบการเชื่อมต่อ TCP ไปยังที่อยู่นั้นโดยใช้ฟังก์ชัน `connect_tcp` ที่เราต้องการทดสอบ หลังจากเชื่อมต่อสำเร็จเราส่งข้อความทดสอบและตรวจสอบว่าข้อมูลที่ได้รับกลับมาตรงกับที่ส่งไป +- `test_validate_target`: ทดสอบฟังก์ชัน `validate_target` (ซึ่งเราสมมติว่ามีอยู่ในโค้ดจริง) ด้วยกรณีต่างๆ ทั้งที่อยู่ที่ถูกต้องและไม่ถูกต้อง เพื่อให้มั่นใจว่าฟังก์ชันสามารถตรวจจับที่อยู่ที่ผิดรูปแบบได้อย่างถูกต้อง + +### Integration Tests ตัวอย่าง + +สำหรับการทดสอบแบบ integration เราอาจต้องการทดสอบการทำงานทั้งหมดของ proxy ตั้งแต่การรับการเชื่อมต่อ WebSocket ไปจนถึงการสื่อสารกับเซิร์ฟเวอร์ TCP เป้าหมายและกลับมา ซึ่งอาจต้องใช้เฟรมเวิร์กทดสอบที่สามารถจำลองไคลเอนต์ WebSocket และเซิร์ฟเวอร์ TCP ได้ + +ตัวอย่างการตั้งค่า integration test อาจมีลักษณะดังนี้ (โค้ดจริงอาจซับซ้อนกว่านี้ขึ้นกับความต้องการ): + +```rust +#[tokio::test] +async fn test_websocket_to_tcp_echo() { + // เริ่มเซิร์ฟเวอร์ TCP echo ที่อยู่เบื้องหลัง proxy + let tcp_handle = start_tcp_echo_server().await; + + // เริ่ม wsProxy เซิร์ฟเวอร์ที่ชี้ไปยังเซิร์ฟเวอร์ TCP echo ที่เราเริ่มต้นไว้ + let proxy_handle = start_wsproxy_server(tcp_echo_addr).await; + + // เชื่อมต่อเป็นไคลเอนต์ WebSocket ไปยัง wsProxy + let mut ws_client = connect_wsclient(proxy_addr).await?; + + // ส่งข้อความ binary ผ่าน WebSocket + let test_msg = b"integration test data"; + ws_client.send(Message::Binary(test_msg.to_vec())).await?; + + // รับข้อความตอบกลับจาก WebSocket ควรเป็นข้อมูลเดียวกันที่ส่งไป + if let Some(Ok(Message::Binary(received))) = ws_client.recv().await { + assert_eq!(received, test_msg); + } else { + panic!("ไม่ได้รับข้อความ binary ที่คาดหวัง"); + } + + // ทำความสะอาด + ws_client.close().await?; + proxy_handle.abort(); + tcp_handle.abort(); +} +``` + +การทดสอบเหล่านี้ช่วยให้เราตรวจสอบได้ว่า: +1. ฟังก์ชัน `connect_tcp` ทำงานได้อย่างถูกต้องในการสร้างการเชื่อมต่อ TCP +2. กลไกการแบ่งปันทิศทางข้อมูล (bidirectional pump) สามารถส่งข้อมูลทั้งสองทิศทางได้อย่างถูกต้อง +3. การจัดการข้อผิดพลาดและการปิดการเชื่อมต่อทำงานได้ตามที่คาดหวัง +4. การรองรับ TLS (ถ้ามีการตั้งค่า) ทำงานได้อย่างถูกต้อง +5. การตั้งค่าเซิร์ฟเวอร์และการทำงานโดยรวมทำงานได้อย่างถูกต้องในสภาพแวดล้อมที่ใกล้เคียงกับการใช้งานจริง + +การมีการทดสอบที่ครอบคลุมช่วยให้เราสามารถรีฟักเตอร์โค้ดได้อย่างมั่นใจและลดโอกาสที่จะนำบั๊กเข้ามาโดยไม่ได้ตั้งใจ + +## Docker Deployment + +การจัดส่ง wsProxy ด้วย Docker ทำให้ง่ายต่อการนำไปใช้งานในสภาพแวดล้อมต่างๆ โดยไม่ต้องกังวลเกี่ยวกับการติดตั้งขึ้นต่อกันหรือความแตกต่างของระบบปฏิบัติการ เราใช้เทคนิค multi-stage build เพื่อสร้างภาพที่มีขนาดเล็กและปลอดภัย โดยแยกขั้นตอนการสร้าง (build) ออกจากขั้นตอนการรันไทม์ (runtime) + +### Containerfile + +```dockerfile +# ขั้นตอนการสร้าง (builder stage) +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release + +# ขั้นตอนการรันไทม์ (runtime stage) +FROM debian:bookworm-slim +COPY --from=builder /app/target/release/rs-wsProxy /usr/local/bin/ +EXPOSE 5999 +CMD ["rs-wsProxy"] +``` + +คำอธิบายขั้นตอนโดยละเอียด: +1. **ขั้นตอนการสร้าง (builder stage)**: + - เริ่มต้นจากภาพ `rust:1-bookworm` ซึ่งมี Rust toolchain ที่ติดตั้งมาแล้วพร้อมใช้งาน + - ตั้งไดเรกทอรีการทำงานเป็น `/app` + - คัดลอกไฟล์โครงการทั้งหมดเข้าไปในภาพด้วย `COPY . .` + - รัน `cargo build --release` เพื่อสร้างไบนารีตัวปล่อยในโหมดปล่อยที่ปรับให้เหมาะสมที่สุด + +2. **ขั้นตอนการรันไทม์ (runtime stage)**: + - เริ่มต้นจากภาพ `debian:bookworm-slim` ซึ่งเป็นภาพ Debian ที่มีขนาดเล็กและมีเฉพาะสิ่งจำเป็นพื้นฐาน + - คัดลอกไบนารีที่สร้างได้จากขั้นตอน builder ไปยังตำแหน่ง `/usr/local/bin/` ในภาพรันไทม์ด้วย `COPY --from=builder /app/target/release/rs-wsProxy /usr/local/bin/` + - เปิดเผยพอร์ต 5999 ซึ่งเป็นพอร์ตเริ่มต้นที่ wsProxy ใช้ฟังการเชื่อมต่อเข้ามา + - กำหนดคำสั่งเริ่มต้นเมื่อรันคอนเทนเนอร์เป็น `rs-wsProxy` ซึ่งจะเรียกใช้ไบนารีที่เราคัดลอกมา + +ข้อดีของวิธีการนี้คือ ภาพสุดท้ายจะไม่มีเครื่องมือสร้าง Rust หรือไฟล์ต้นทางใดๆ เลย ทำให้มีขนาดเล็กและลดพื้นที่ผิวที่อาจถูกโจมตี (attack surface) เนื่องจากมีเพียงไบนารีและไลบรารีระบบที่จำเป็นเท่านั้น + +### docker-compose.yaml สำหรับการพัฒนาและทดสอบ + +สำหรับการพัฒนาและทดสอบในเครื่อง local เราสามารถใช้ docker-compose เพื่อกำหนดบริการต่างๆ ได้อย่างง่ายดาย ตัวอย่างต่อไปนี้แสดงให้เห็นถึงการรัน wsProxy พร้อมกับเซิร์ฟเวอร์ทดสอบแบบ echo: + +```yaml +version: '3.8' + +services: + wsproxy: + build: . + ports: + - "5999:5999" + environment: + - LOG_LEVEL=info + - TARGET_HOST=echo-server + - TARGET_PORT=7 + depends_on: + - echo-server + + echo-server: + image: nginx:alpine + command: ["/bin/sh", "-c", "while true; do echo -e 'HTTP/1.1 200 OK\r\n\r\nHello from echo server' | nc -l -p 7; done"] + ports: + - "7:7" +``` + +คำอธิบาย: +- บริการ `wsproxy` สร้างภาพจาก Dockerfile ในไดเรกทอรีปัจจุบันและแมปพอร์ต 5999 ของคอนเทนเนอร์ไปยังพอร์ต 5999 ของโฮสต์ +- ตัวแปรสภาพแวดล้อมเช่น `LOG_LEVEL`, `TARGET_HOST`, และ `TARGET_PORT` ถูกส่งไปยังคอนเทนเนอร์เพื่อตั้งค่าการทำงานของ wsProxy (สมมติว่า wsProxy อ่านการตั้งค่าจากตัวแปรสภาพแวดล้อม) +- บริการ `echo-server` ใช้ภาพ `nginx:alpine` และรันคำสั่ง shell ที่สร้างเซิร์ฟเวอร์ echo แบบง่ายๆ บนพอร์ต 7 โดยใช้ `netcat` (nc) +- `depends_on` ทำให้แน่ใจว่าเซิร์ฟเวอร์ echo จะเริ่มต้นก่อนที่ wsProxy จะพยายามเชื่อมต่อไปยังมัน + +ด้วยวิธีนี้ นักพัฒนาสามารถรัน `docker-compose up` เพื่อเริ่มต้นทั้งสองบริการและทดสอบการทำงานของ wsProxy ได้ทันทีโดยไม่ต้องติดตั้งอะไรเพิ่มเติมบนเครื่องโฮสต์ + +## Kubernetes Deployment + +สำหรับการจัดส่งในสภาพแวดล้อมผลิตที่ต้องการความสามารถในการขยายขนาด การจัดการ และความทนทานต่อความล้มเหลว เราสามารถใช้ Kubernetes ได้ แม้ว่าการตั้งค่าเต็มรูปแบบอาจซับซ้อน แต่เราจะแสดงองค์ประกอบพื้นฐานที่จำเป็นสำหรับการรัน wsProxy ในคลัสเตอร์ Kubernetes + +### Deployment + +ไฟล์ `deploy/k8s/deployment.yaml` กำหนดว่าจะรันกี่รีพอร์ติกของ wsProxy และตั้งค่าต่างๆ เช่น ภาพคอนเทนเนอร์ ตัวแปรสภาพแวดล้อม และทรัพยากรที่ต้องการ + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wsproxy-deployment + labels: + app: wsproxy +spec: + replicas: 3 + selector: + matchLabels: + app: wsproxy + template: + metadata: + labels: + app: wsproxy + spec: + containers: + - name: wsproxy + image: bouroo/rs-wsproxy:latest + ports: + - containerPort: 5999 + env: + - name: LOG_LEVEL + value: "info" + - name: TARGET_HOST + value: "backend-service" + - name: TARGET_PORT + value: "8080" + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" +``` + +คำอธิบายสำคัญ: +- `replicas: 3` ระบุว่าต้องการให้มีรีพอร์ติกของ wsProxy ทำงานอยู่ 3 ตัวพร้อมกันเพื่อความทนทานต่อความล้มเหลวและความสามารถในการรับโหลด +- `selector` และ `template.metadata.labels` ใช้เพื่อเชื่อมโยง Deployment กับ Pod ที่มันจัดการ +- ภาพคอนเทนเนอร์ที่ใช้คือ `bouroo/rs-wsproxy:latest` (สมมติว่ามีการส่งภาพไปยัง registry แล้ว) +- เปิดเผยพอร์ตคอนเทนเนอร์ 5999 ซึ่งเป็นพอร์ตที่ wsProxy ฟังการเชื่อมต่อเข้ามา +- ตัวแปรสภาพแวดล้อมตั้งค่าระดับการบันทึก โฮสต์และพอร์ตของเซิร์ฟเวอร์เป้าหมายที่ proxy ควรเชื่อมต่อไปยัง +- ส่วน `resources` กำหนดขั้นต่ำและสูงสุดของทรัพยากรที่คอนเทนเนอร์สามารถใช้ได้ ช่วยให้ Kubernetes จัดสรรทรัพยากรได้อย่างเหมาะสมและป้องกันไม่ให้คอนเทนเนอร์ใช้ทรัพยากรมากเกินไป + +### Service + +ไฟล์ `deploy/k8s/service.yaml` กำหนดวิธีการเข้าถึง wsProxy จากภายในหรือภายนอกคลัสเตอร์ Kubernetes ในตัวอย่างนี้เราใช้ `LoadBalancer` เพื่อให้ได้ที่อยู่ IP สาธารณะ (หากรันบนคลาวด์ที่สนับสนุน) หรือสามารถเปลี่ยนเป็น `NodePort` หรือ `ClusterIP` ได้ตามความต้องการ + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: wsproxy-service + labels: + app: wsproxy +spec: + selector: + app: wsproxy + ports: + - protocol: TCP + port: 80 + targetPort: 5999 + type: LoadBalancer +``` + +คำอธิบาย: +- `selector` ตรงกับ label ของ Pod ที่สร้างจาก Deployment ทำให้บริการนี้ส่งต่อการเชื่อมต่อไปยัง Pod ของ wsProxy +- บริการฟังที่พอร์ต 80 และส่งต่อการเชื่อมต่อไปยังพอร์ต 5999 ของ Pod (`targetPort`) +- ประเภท `LoadBalancer` ทำให้ผู้ให้บริการคลาวด์สร้างโหลดบาลานเซอร์ภายนอกเพื่อกระจายการจราจรไปยังรีพอร์ติกทั้งหมด + +### ConfigMap (ตัวเลือก) + +หากเราต้องการแยกการตั้งค่าออกจากภาพคอนเทนเนอร์ เราสามารถใช้ ConfigMap ได้ ตัวอย่างเช่นเราอาจต้องการเปลี่ยนระดับการบันทึกโดยไม่ต้องสร้างภาพใหม่ + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: wsproxy-config + labels: + app: wsproxy +data: + LOG_LEVEL: "debug" + TARGET_HOST: "backend-service" + TARGET_PORT: "8080" +``` + +จากนั้นใน Deployment เราจะอ้างอิง ConfigMap ผ่าน `envFrom`: +```yaml +envFrom: + - configMapRef: + name: wsproxy-config +``` + +วิธีนี้ทำให้สามารถปรับเปลี่ยนการตั้งค่าได้อย่างรวดเร็วโดยการอัปเดต ConfigMap และทำการ rolling update รีพอร์ติกโดยไม่ต้องสร้างภาพคอนเทนเนอร์ใหม่ + +## CI/CD + +เพื่อให้มั่นใจว่าการเปลี่ยนแปลงใดๆ ที่ส่งไปยังคลังโค้ดจะผ่านการทดสอบและสามารถจัดส่งได้อย่างน่าเชื่อถือ เราตั้งค่าประบบ CI/CD โดยใช้ GitHub Actions ซึ่งช่วยให้เราอัตโนมัติกระบวนการสร้าง ทดสอบ ตรวจสอบ และจัดส่งภาพ Docker + +### ci.yml - Continuous Integration + +ไฟล์ `.github/workflows/ci.yml` กำหนดขั้นตอนการทำงานที่จะรันเมื่อมีการ push หรือ pull request ไปยังสาขาหลักหรือสาขาที่กำหนด งานนี้มุ่งเน้นไปที่การทดสอบ การตรวจสอบโค้ด และการตรวจสอบความปลอดภัยพื้นฐาน + +```yaml +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: ติดตั้ง Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: ตรวจสอบรูปแบบโค้ด + run: cargo fmt -- --check + + - name: รัน Clippy + run: cargo clippy -- -D warnings + + - name: รันการทดสอบ + run: cargo test --release + + - name: สร้างไบนารี + run: cargo build --release +``` + +คำอธิบายขั้นตอน: +1. `actions/checkout@v3` ดึงโค้ดจากคลังมาทำงาน +2. ติดตั้ง Rust toolchain เวอร์ชันเสถียรล่าสุดโดยใช้ `dtolnay/rust-toolchain` +3. ตรวจสอบรูปแบบโค้ดด้วย `cargo fmt -- --check` เพื่อให้มั่นใจว่าโค้ดทั้งหมดปฏิบัติตามรูปแบบมาตรฐาน +4. รัน Clippy ซึ่งเป็นเครื่องมือตรวจสอบโค้ดของ Rust เพื่อหาข้อผิดพลาดทั่วไปและปรับปรุงโค้ด โดยเราให้มันทำการตรวจสอบแบบเข้มงวด (`-D warnings`) เพื่อให้การทำงานล้มเหลวหากมีการเตือนใดๆ +5. รันการทดสอบทั้งหมดในโหมดปล่อยด้วย `cargo test --release` +6. สร้างไบนารีในโหมดปล่อยด้วย `cargo build --release` เพื่อตรวจสอบว่าโค้ดสามารถสร้างได้สำเร็จ + +หากขั้นตอนใดขั้นตอนหนึ่งล้มเหลว งานทั้งหมดจะถูกทำเครื่องหมายว่าล้มเหลวและการ merge pull request จะถูกบล็อกจนกว่าปัญหาจะได้รับการแก้ไข + +### cd.yml - Continuous Deployment + +ไฟล์ `.github/workflows/cd.yml` กำหนดขั้นตอนการทำงานที่จะรันเมื่อมีการ release ใหม่ถูกเผยแพร่ (หรือตามเงื่อนไขอื่นๆ เช่นการ push ไปยังสาขา release) งานนี้มุ่งเน้นไปที่การสร้างภาพ Docker การทดสอบภาพ และการส่งภาพไปยัง registry + +```yaml +name: CD + +on: + release: + types: [published] + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: ตั้งค่า QEMU + uses: docker/setup-qemu-action@v2 + + - name: ตั้งค่า Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: เข้าสู่ระบบ Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: สร้างและส่งภาพ Docker + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: bouroo/rs-wsproxy:latest +``` + +คำอธิบายขั้นตอน: +1. เช่นเคย เริ่มต้นด้วยการดึงโค้ดจากคลัง +2. ตั้งค่า QEMU ซึ่งช่วยให้เราสร้างภาพสำหรับหลายแพลตฟอร์มได้ (เช่น linux/amd64, linux/arm64) แม้จะรันบนผู้รันที่เป็น x86_64 ก็ตาม +3. ตั้งค่า Docker Buildx ซึ่งเป็นปลั๊กอิน CLI ของ Docker ที่ให้ความสามารถในการสร้างภาพขั้นสูงรวมถึงการสร้างหลายแพลตฟอร์ม +4. เข้าสู่ระบบ Docker Hub โดยใช้ความลับที่เก็บไว้ในคลัง (`secrets.DOCKERHUB_USERNAME` และ `secrets.DOCKERHUB_TOKEN`) +5. ใช้ `docker/build-push-action` เพื่อสร้างภาพจาก Dockerfile ในไดเรกทอรีปัจจุบันและส่งไปยัง Docker Hub ด้วยแท็ก `bouroo/rs-wsproxy:latest` + +ด้วยการตั้งค่า CI/CD นี้ ทุกครั้งที่มีการเผยแพร่ release ใหม่ ภาพ Docker ล่าสุดจะถูกสร้างทดสอบและส่งไปยัง registry อัตโนมัติ ทำให้สามารถนำไปใช้งานในสภาพแวดล้อมต่างๆ ได้อย่างรวดเร็วและเชื่อถือได้ + +## สรุปทั้งซีรีส์ + +เราได้เดินทางมาถึงบทสุดท้ายของซีรีส์การสร้าง wsProxy ด้วย Rust แล้ว ในแปดบทที่ผ่านมาเราได้ครอบคลุมตั้งแต่พื้นฐานของ Rust จนถึงการสร้างผลิตภัณฑ์ที่พร้อมสำหรับการใช้งานในสภาพแวดล้อมผลิตจริง มาทบทวนสิ่งที่เราได้เรียนรู้กัน: + +**บทที่ 1: พื้นฐาน Rust และการตั้งค่าโปรเจกต์** +- เรียนรู้ไวยากรณ์พื้นฐานของ Rust เช่นตัวแปร ประเภทข้อมูล และการควบคุมการไหล +- ตั้งค่าโปรเจกต์ Rust ใหม่ด้วย Cargo และเข้าใจโครงสร้างไดเรกทอรีมาตรฐาน +- เขียนโปรแกรม "Hello, World!" แรกใน Rust และรันมัน + +**บทที่ 2: การทำงานกับไฟล์และการจัดการข้อผิดพลาด** +- สำรวจไลบรารีมาตรฐานของ Rust สำหรับการอ่านและเขียนไฟล์ +- เข้าใจโมเดลการจัดการข้อผิดพลาดของ Rust ด้วย `Result` และ `Option` +- ฝึกเขียนฟังก์ชันที่คืนค่า `Result` และใช้ตัวดำเนินการ `?` เพื่อเผยแพร่ข้อผิดพลาด + +**บทที่ 3: การเขียนโปรแกรมแบบไม่ซิงโครนัสด้วย Tokio** +- ทำความเข้าใจแนวคิดของการเขียนโปรแกรมแบบไม่ซิงโครนัสและเหตุผลที่มันสำคัญสำหรับแอปพลิเคชันเครือข่าย +- เรียนรู้วิธีการใช้งาน Tokio รันไทม์เพื่อจัดการงานแบบไม่ซิงโครนัส +- สำรวจแนวคิดของฟิวเจอร์และงาน (task) ใน Tokio + +**บทที่ 4: WebSocket กับ Tokio-Tungstenite** +- ศึกษาโปรโตคอล WebSocket และการทำงานของมัน +- เรียนรู้การใช้ไลบรารี `tokio-tungstenite` เพื่อจัดการการเชื่อมต่อ WebSocket +- สร้างเซิร์ฟเวอร์ WebSocket พื้นฐานที่สามารถรับและส่งข้อความได้ + +**บทที่ 5: การออกแบบ CLI และการตั้งค่า** +- สำรวจไลบรารี `clap` สำหรับการสร้างอินเทอร์เฟซบรรทัดคำสั่งที่ทรงพลัง +- ออกแบบโครงสร้างการตั้งค่าแอปพลิเคชันโดยใช้ไลบรารี `config` +- นำไปใช้งานจริงด้วยการสร้าง CLI ที่สามารถรับอาร์กิวเมนต์และไฟล์การตั้งค่าได้ + +**บทที่ 6: การสร้างเซิร์ฟเวอร์ HTTP ด้วย Axum** +- ทำความรู้จักกับเฟรมเวิร์ก Axum ซึ่งสร้างอยู่บนพื้นฐานของ Tokio และ Tower +- สร้างเส้นทาง (route) และจัดการตัวประมวลผล (handler) สำหรับคำขอ HTTP ต่างๆ +- ผสานรวมการจัดการ WebSocket เข้ากับเซิร์ฟเวอร์ HTTP ของ Axum + +**บทที่ 7: การเชื่อมต่อ TCP พื้นฐานและการจัดการข้อมูล** +- ศึกษาการสร้างการเชื่อมต่อ TCP ด้วย `tokio::net::TcpStream` +- เรียนรู้เทคนิคการอ่านและเขียนข้อมูลจากสตรีม TCP อย่างมีประสิทธิภาพ +- สำรวจการใช้ `BytesMut` เพื่อจัดการ buffer แบบไดนามิกเพื่อลดการจัดสรรหน่วยความจำ + +**บทที่ 8: หัวใจของ Proxy, TLS, การทดสอบ และการจัดส่ง (บทปัจจุบัน)** +- สร้างฟังก์ชัน `connect_tcp` ที่ทนทานสำหรับการทำ DNS lookup และเชื่อมต่อ TCP พร้อมการตั้งค่า TCP_NODELAY +- ออกแบบและนำไปใช้งาน bidirectional pump ด้วย `tokio::select!` เพื่อจัดการการไหลของข้อมูลสองทิศทางพร้อมกัน +- อธิบายเหตุผลที่ต้องแยก TCP stream เป็น ReadHalf และ WriteHalf เพื่อหลีกเลี่ยง BiLock overhead +- นำไปใช้งาน TLS ด้วยไลบรารี `rustls` เพื่อความปลอดภัยของการเชื่อมต่อ +- อธิบายกลไกการปิดการทำงานอย่างสง่างาม (graceful shutdown) เพื่อจัดการสัญญาณระบบอย่างเหมาะสม +- สร้างการทดสอบหน่วยและการทดสอบแบบ integration เพื่อให้มั่นใจในความถูกต้องของโค้ด +- แสดงวิธีการจัดส่งด้วย Docker ผ่าน multi-stage build และ docker-compose สำหรับการพัฒนา +- อธิบายการจัดส่งใน Kubernetes ด้วย Deployment, Service และ ConfigMap +- ตั้งค่าระบบ CI/CD ด้วย GitHub Actions เพื่ออัตโนมัติกระบวนการทดสอบ สร้าง และจัดส่ง + +ตลอดซีรีส์นี้เราได้เห็นว่า Rust ไม่ใช่แค่ภาษาที่ปลอดภัยและเร็วเท่านั้น แต่ยังให้เครื่องมือและไลบรารีที่ทรงพลังสำหรับการสร้างระบบที่ซับซ้อนเช่น WebSocket proxy ได้อย่างสะดวกและมั่นใจได้ ความปลอดภัยของหน่วยความภาพโดยไม่ต้องพึ่ง garbage collector ระบบประเภทที่เข้มงวด และคุณสมบัติเช่น pattern matching ทำให้การเขียนโปรโตคอลเครือข่ายเป็นไปอย่างถูกต้องและมีประสิทธิภาพ + +ย้อนกลับไปตอนที่เห็นโพสต์จาก [rayrag.com](https://rayrag.com/) บน Facebook ที่เล่น RO บนเว็บเบราว์เซอร์ได้ ผมไม่นึกเลยว่าจะตื่นเต้นจนอยากสร้างของตัวเอง และได้กลับมาทบทวน Rust ทั้งหมดตั้งแต่ต้นจนจบซีรีส์นี้ จากความสงสัยง่ายๆ กลายเป็นโปรเจกต์จริงที่ใช้งานได้ นี่คือเสน่ห์ของการเขียนโปรแกรม — ความสงสัยนำไปสู่การเรียนรู้ และการเรียนรู้นำไปสู่การสร้างสรรค์ + +เราหวังว่าซีรีส์นี้จะเป็นประโยชน์ต่อผู้อ่านไม่ว่าจะเป็นผู้ที่เพิ่งเริ่มเรียนรู้ Rust หรือผู้ที่มีประสบการณ์แล้วแต่ต้องการดูตัวอย่างการสร้างแอปพลิเคชันจริงที่ใช้เทคนิคสมัยใหม่ของ Rust อย่างเต็มที่ + +ซอร์สโค้ดทั้งหมดของโครงการนี้สามารถดูได้ที่: https://github.com/bouroo/rs-wsProxy + +← ก่อนหน้า: [สร้าง wsProxy — CLI, Config และ Server](/posts/rust/rust-wsproxy-server/) + +ขอขอบคุณที่ติดตามอ่านจนจบซีรีส์นี้ หากคุณมีคำถาม ข้อเสนอแนะ หรือต้องการแบ่งปันประสบการณ์ในการสร้างแอปพลิเคชันด้วย Rust อย่าลังเลที่จะแสดงความคิดเห็นหรือติดต่อผู้เขียนผ่านช่องทางต่างๆ ที่ให้ไว้ + +Happy coding! 🚀 \ No newline at end of file diff --git a/content/posts/rust/rust-wsproxy-server/featured-image.jpeg b/content/posts/rust/rust-wsproxy-server/featured-image.jpeg new file mode 100644 index 0000000..59438f3 Binary files /dev/null and b/content/posts/rust/rust-wsproxy-server/featured-image.jpeg differ diff --git a/content/posts/rust/rust-wsproxy-server/index.en.md b/content/posts/rust/rust-wsproxy-server/index.en.md new file mode 100644 index 0000000..c8accc5 --- /dev/null +++ b/content/posts/rust/rust-wsproxy-server/index.en.md @@ -0,0 +1,564 @@ +--- +title: "Building wsProxy — CLI, Config & Server" +subtitle: "" +date: 2026-07-13T09:00:00+07:00 +lastmod: 2026-07-13T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "Build a WebSocket-to-TCP proxy with clap, axum, and tokio — CLI args, config, logging, and the HTTP/WS server" +license: "" +images: [] +tags: ["Rust", "Tutorial", "WebSocket", "Axum", "Tokio"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + + + +Time to build the real project! After seeing that Facebook post from [rayrag.com](https://rayrag.com/) showing RO playable in a browser via WebSocket, I wanted to build my own proxy. Now, after 6 parts of learning Rust fundamentals, we finally put it all together. wsProxy is a WebSocket-to-TCP proxy for [roBrowser](https://github.com/vthibault/roBrowser) — it bridges browser WebSocket clients to plain TCP game servers. Source code is available at [https://github.com/bouroo/rs-wsProxy](https://github.com/bouroo/rs-wsProxy). + +## Project Structure + +Let's first look at the project structure we'll build: + +``` +rs-wsProxy/ +├── Cargo.toml +├── src/ +│ ├── main.rs +│ ├── lib.rs +│ ├── config.rs +│ ├── logging.rs +│ ├── modules.rs +│ ├── proxy.rs +│ └── server.rs +└── tests/ + └── integration_test.rs +``` + +Each module has a clear responsibility: +- `main.rs`: Application entry point, runtime setup, and graceful shutdown +- `lib.rs`: Library re-exports and shared types +- `config.rs`: CLI argument parsing with Clap +- `logging.rs`: Tracing subscriber setup +- `modules.rs`: Request validation logic (redirects and allow-lists) +- `proxy.rs`: Core WebSocket-to-TCP proxy logic (covered in Part 8) +- `server.rs`: Axum HTTP server and WebSocket handlers +- `tests/`: Integration tests + +## Cargo.toml — Dependencies + +Here's our `Cargo.toml` with explanations for each dependency: + +```toml +[package] +name = "rs-wsproxy" +version = "0.1.0" +edition = "2021" +edition = "2021" + +[dependencies] +# Axum web framework built on Tokio and Tower +axum = "0.7" +# TLS support for Axum (HTTPS/WSS) +axum-server = "0.4" +# Async runtime +tokio = { version = "1", features = ["full"] } +# Rustls TLS implementation +rustls = "0.21" +# Command-line argument parser +clap = { version = "4.4", features = ["derive"] } +# Structured logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +# Byte manipulation for proxying data +bytes = "1.5" +# Async utilities +futures-util = "0.3" +# Development dependencies +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +**Why these dependencies?** +- **Axum**: Provides ergonomic routing and extraction built on Tower and Tokio +- **Axum-server**: Enables HTTPS/WSS support via rustls +- **Tokio**: The async runtime that powers everything +- **Rustls**: Modern TLS implementation (safer than OpenSSL) +- **Clap**: Derive-based CLI argument parser with excellent ergonomics +- **Tracing**: Structured, contextual logging with EnvFilter support +- **Bytes**: Efficient byte buffer handling for proxying data +- **Futures-util**: Essential async utilities like `StreamExt` and `SinkExt` + +## CLI with clap (config.rs) + +We define our CLI arguments in `src/config.rs` using Clap's derive macros: + +```rust +use clap::Parser; +use std::net::SocketAddr; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use std::net::SocketAddrV4; +use std::net::SocketAddrV6; +use std::net::ToSocketAddrs; +use std::net::ToSocketAddrs; +use std::net::ToSocketAddrs; +use std::net::ToSocketAddrs; + +/// WebSocket to TCP proxy server +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct Args { + /// Listen address (default: 0.0.0.0:8080) + #[arg(long, default_value_t = SocketAddr::from(([0, 0, 0, 0], 8080)))] + pub addr: SocketAddr, + + /// Number of Tokio worker threads (default: num_cpus) + #[arg(long, default_value_t = num_cpus::get())] + pub threads: usize, + + /// Enable SSL/TLS (wss:// and https://) + #[arg(long)] + pub ssl: bool, + + /// Path to SSL certificate file (required if ssl=true) + #[arg(long)] + pub cert: Option, + + /// Path to SSL private key file (required if ssl=true) + #[arg(long)] + pub key: Option, + + /// Comma-separated list of allowed target hosts (empty = deny all, unset = allow all) + #[arg(long, value_delimiter = ',')] + pub allow: Option>, + + /// Comma-separated list of redirect rules (from=to,from2=to2) + #[arg(long, value_delimiter = ','), value_parser = parse_redirect_pair)] + pub redirect: Option>, + + /// Default target host:port when none specified in WebSocket path + #[arg(long)] + pub default_target: Option, +} + +/// Parse a single "from=to" redirect pair +fn parse_redirect_pair(src: &str) -> Result<(String, String), String> { + let mut parts = src.splitn(2, '='); + let from = parts + .next() + .ok_or_else(|| "missing '=' in redirect pair")? + .trim() + .to_string(); + let to = parts + .next() + .ok_or_else(|| "missing '=' in redirect pair")? + .trim() + .to_string(); + Ok((from, to)) +} +``` + +**Key Clap features explained:** +- `#[arg(long, default_value_t = ...)]`: Sets default values using Rust expressions +- `value_delimiter = ','`: Splits comma-separated values into a vector +- `value_parser = parse_redirect_pair`: Custom validator for redirect format +- `Option>` for `allow`: `None` = allow all, `Some(vec![])` = deny all, `Some(vec![...])` = allow list +- Custom parser for redirect pairs ensures `from=to` format + +## AppState — Shared State + +Our application state is shared across all routes via Axum's extractor pattern: + +```rust +use std::collections::HashMap; +use std::sync::Arc; + +/// Application state shared across all request handlers +#[derive(Clone)] +pub struct AppState { + /// None = allow all targets, Some(vec[]) = deny all, Some(vec[hosts]) = allow list + pub allowed_servers: Option>, + /// Redirect mappings: incoming host -> target host + pub redirects: HashMap, + /// Default target when WebSocket path doesn't specify host:port + pub default_target: Option, +} + +impl AppState { + /// Create new state from CLI arguments + pub fn from_args(args: &crate::config::Args) -> Self { + Self { + allowed_servers: args.allow.as_ref().map(|v| v.clone()), + redirects: args + .redirect + .as_ref() + .map(|v| v.iter().cloned().collect()) + .unwrap_or_default(), + default_target: args.default_target.clone(), + } + } +} +``` + +**Why `Option>` for `allowed_servers`?** +- `None` (flag not provided): Open proxy - allow any target +- `Some(vec![])` (empty explicit list): Deny all targets +- `Some(vec![allowed hosts])`: Allow only specified hosts + +This three-state design gives us fine-grained control: +1. Open proxy (default, convenient for dev) +2. Strict deny-all (secure by default) +3. Explicit allow-list (production use) + +## Parsing Config + +Helper functions in `config.rs` convert CLI arguments into usable data structures: + +```rust +/// Build allowed server list from CLI arg +pub fn build_allowed_list(allow: &Option>) -> Option> { + allow.as_ref().map(|v| { + v.iter() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() + }) +} + +/// Build redirect map from CLI arg +pub fn build_redirects(redirect: &Option>) -> std::collections::HashMap { + redirect + .as_ref() + .map(|v| { + v.iter() + .map(|(k, v)| { + ( + k.trim().to_lowercase(), + v.trim().to_string(), + ) + }) + .collect() + }) + .unwrap_or_default() +} +``` + +**Iterator chain explanation:** +1. `as_ref()`: Convert `&Option` to `Option<&T>` to avoid moving +2. `.map(|v| ...)`: Transform the inner vector if present +3. `.iter()`: Iterate over each string +4. `.map(|s| ...)`: Transform each string (trim + lowercase) +5. `.filter(...)`: Remove empty strings +6. `.collect()`: Build new Vector + +For redirects, we do similar processing but collect into a `HashMap` for O(1) lookups. + +## Logging (logging.rs) + +We configure tracing with environment variable support: + +```rust +use tracing_subscriber::{fmt, EnvFilter, layer::SubscriberExt}; + +/// Initialize tracing subscriber with environment filter +pub fn init_logging() { + // Format logs with target, level, and message + fmt() + .with_target(true) + .with_level(true) + .with_thread_ids(true) + .with_target(true) + // Enable filtering via RUST_LOG env var + .with_env_filter(EnvFilter::from_default_env()) + .init(); +} +``` + +**Usage:** +- Set `RUST_LOG=info` for general info logs +- Set `RUST_LOG=wsproxy=debug,axum::rejection=trace` for detailed debugging +- Automatic JSON output in JSON-logging environments (like Kubernetes) +- Thread IDs help debug concurrency issues + +## Axum Server (server.rs) + +Our Axum server setup with WebSocket upgrade handlers: + +```rust +use axum::{ + extract::{State, WebSocketUpgrade}, + response::Response, + routing::get, + Router, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tracing::{info, info_span}; + +use crate::{AppState, modules::verify, proxy::handle_proxy}; + +/// Create Axum router with shared state +pub fn app(state: Arc) -> Router { + Router::new() + .route("/", get(|| async { "WebSocket to TCP Proxy" })) + .route("/ws/:target", get(ws_handler)) + .route("/:target", get(ws_handler)) + .with_state(state) +} + +/// WebSocket upgrade handler +async fn ws_handler( + State(state): State>, + ws: WebSocketUpgrade, + // Extract target from path: /ws/example.com:8080 or /example.com:8080 + axum::extract::Path(target): axum::extract::Path, +) -> Response { + // Validate and normalize target + let target = match verify(&state, &target) { + Ok(t) => t, + Err(e) => { + return Response::builder() + .status(400) + .body(e.into()) + .unwrap(); + } + }; + + ws.on_upgrade(move |socket| handle_proxy(socket, target)) +} + +/// Start the server +pub async fn run(addr: SocketAddr, state: Arc) { + let listener = TcpListener::bind(addr).await.expect("Failed to bind"); + info!(%addr, "server listening"); + + axum::serve(listener, app(state)) + .await + .expect("server error"); +} +``` + +**Key points:** +- Two routes: `/` (health check) and `/:target` or `/ws/:target` (WebSocket) +- `State>` shares application state efficiently +- `WebSocketUpgrade` extractor handles WebSocket handshake +- Path extraction gives us the target host:port +- Validation happens before WebSocket upgrade +- `on_upgrade` converts established WebSocket to our proxy handler + +## Verify Pipeline (modules.rs) + +Request validation happens in two phases: + +```rust +use std::collections::HashMap; + +/// Validate and normalize target host:port +pub fn validate_target(target: &str) -> Result { + // Must contain colon for host:port + if !target.contains(':') { + return Err(format!("target must be in host:port format, got '{target}'")); + } + + let parts: Vec<&str> = target.splitn(2, ':').collect(); + let host = parts[0].to_lowercase(); + let port = parts[1]; + + // Validate port is numeric and in valid range + let port: u16 = port + .parse() + .map_err(|_| format!("port must be a number, got '{port}'"))?; + if port == 0 || port > 65535 { + return Err(format!("port must be between 1-65535, got {port}")); + } + + Ok(format!("{host}:{port}")) +} + +/// Apply redirect rules then check allow-list +pub fn verify(state: &super::AppState, target: &str) -> Result { + // Step 1: Apply redirect rules (if any) + let redirected_target = state + .redirects + .get(target) + .map(|t| t.as_str()) + .unwrap_or(target); + + // Step 2: Validate format (host:port) + let validated_target = validate_target(redirected_target)?; + + // Step 3: Extract host portion for allow-list check + let host = validated_target + .split(':') + .next() + .ok_or_else(|| "invalid target format".to_string())? + .to_lowercase(); + + // Step 4: Check allow-list (None = allow all, Some([]) = deny all) + match &state.allowed_servers { + None => Ok(validated_target), // Allow all + Some(list) if list.is_empty() => Err(format!("target '{host}' not allowed (empty allow-list)")), + Some(list) => { + if list.contains(&host) { + Ok(validated_target) + } else { + Err(format!("target '{host}' not in allow-list")) + } + } + } +} +``` + +**Pipeline explanation:** +1. **Redirect check**: If target matches a redirect rule, use the destination +2. **Format validation**: Ensure `host:port` format with valid port number +3. **Host extraction**: Split to get hostname for allow-list checking +4. **Allow-list check**: + - `None` (flag not set): Allow any target + - `Some([])` (empty explicit list): Deny all targets + - `Some([hosts])`: Only allow listed hosts + +## main.rs — Entry Point + +Application entry point with graceful shutdown: + +```rust +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::signal; +use tracing::info; + +mod config; +mod logging; +mod modules; +mod proxy; +mod server; + +use crate::{config::Args, server::AppState}; + +#[tokio::main] +async fn main() { + // Initialize logging first + logging::init_logging(); + + // Parse command line arguments + let args = Args::parse(); + info!(?args, "starting wsproxy"); + + // Build application state + let state = Arc::new(AppState::from_args(&args)); + + // Configure server address + let addr = args.addr; + + // Start server + let server_task = tokio::spawn(async move { + server::run(addr, state).await; + }); + + // Wait for shutdown signal + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => info!("received Ctrl+C, shutting down"), + _ = terminate => info!("received terminate signal, shutting down"), + } + + // Graceful shutdown: wait for server task to finish + let _ = server_task.await; + info!("server stopped"); +} +``` + +**Key features:** +- `#[tokio::main]` macro sets up multi-threaded runtime +- Logging initialized before anything else +- Arguments parsed into strongly-typed `Args` struct +- Application state wrapped in `Arc` for cheap cloning +- Server runs in separate task so we can wait for shutdown signals +- Handles both Ctrl+C (SIGINT) and SIGTERM (Unix) +- Graceful shutdown waits for server task to complete + +## Testing + +Run and test the proxy: + +```bash +# Run with default settings (HTTP on port 8080, open proxy) +cargo run --release + +# Test with HTTP client (returns plain text) +curl http://localhost:8080/ +# Output: WebSocket to TCP Proxy + +# Test WebSocket endpoint (requires WebSocket client) +# Using websocat: +# echo "hello" | websocat ws://localhost:8080/echo.websocket.org:80 + +# With SSL (requires cert/key files): +cargo run -- --ssl --cert ./cert.pem --key ./key.pem + +# With allow-list (only allow specific hosts): +cargo run -- --allow=echo.websocket.org,example.com:80 + +# With redirects (redirect traffic from legacy.example.com to new.example.com): +cargo run -- --redirect=legacy.example.com:80=new.example.com:80 + +# With default target (when no target specified in path): +cargo run -- --default-target=echo.websocket.org:80 +``` + +**Testing with websocat:** +```bash +# Send message through proxy to echo.websocket.org:80 +echo "Hello WebSocket" | websocat ws://localhost:8080/echo.websocket.org:80 + +# Should receive echo response from server +``` + +**Expected behavior:** +- Root endpoint (`/`) returns plain text description +- WebSocket endpoints (`/ws/` or `/`) establish proxy connection +- Invalid targets return HTTP 400 with error message +- Redirects rewrite target before validation +- Allow-list blocks non-matching hosts (when enabled) + +## Summary + +We've built a functional WebSocket-to-TCP proxy with: +- Production-ready CLI using Clap with smart defaults +- Flexible configuration via allow-lists, redirects, and default targets +- Structured logging with Tracy and EnvFilter support +- Axum-based HTTP/WebSocket server with graceful shutdown +- Two-stage validation pipeline (redirects → validation → allow-listing) +- Proper state sharing via Arc pattern + +**Next steps:** In Part 8 we'll implement the core proxy logic in `proxy.rs` that actually bridges WebSocket and TCP connections, handle binary/data transfer, and add connection pooling. + +**Links:** +← Previous: [Async Rust with Tokio](/posts/rust/rust-async-tokio/) +Next: [Building wsProxy — Proxy Core & Deployment](/posts/rust/rust-wsproxy-proxy-deploy/) +Source: https://github.com/bouroo/rs-wsProxy \ No newline at end of file diff --git a/content/posts/rust/rust-wsproxy-server/index.md b/content/posts/rust/rust-wsproxy-server/index.md new file mode 100644 index 0000000..419b03f --- /dev/null +++ b/content/posts/rust/rust-wsproxy-server/index.md @@ -0,0 +1,937 @@ +--- +title: "สร้าง wsProxy ตอนที่ 1 — CLI, Config และ Server" +subtitle: "" +date: 2026-07-13T09:00:00+07:00 +lastmod: 2026-07-13T09:00:00+07:00 +draft: false +author: "Kawin Viriyaprasopsook" +authorLink: "https://kawin.dev" +description: "เริ่มสร้าง WebSocket-to-TCP Proxy ด้วย clap, axum และ tokio — CLI args, config, logging และ HTTP/WS server" +license: "" +images: [] +tags: ["Rust", "Tutorial", "WebSocket", "Axum", "Tokio"] +categories: ["Rust"] +featuredImage: "featured-image.jpeg" +featuredImagePreview: "featured-image.jpeg" +lightgallery: true +--- + +# สร้าง wsProxy ตอนที่ 1 — CLI, Config และ Server + +สวัสดีครับ! ในบทความนี้เราจะเริ่มสร้างโปรเจกต์จริง ๆ ชื่อว่า **rs-wsProxy** ซึ่งเป็น WebSocket-to-TCP Proxy ที่ออกแบบมาสำหรับใช้งานร่วมกับ [roBrowser](https://github.com/vthibault/roBrowser) (โปรเจกต์จำลอง Ragnarok Online บนเว็บ) เพื่อให้สามารถเชื่อมต่อจากเว็บเบราว์เซอร์ไปยังเซิร์ฟเวอร์เกมได้โดยตรงผ่าน WebSocket + +ย้อนกลับไปตอนที่เห็นโพสต์จาก [rayrag.com](https://rayrag.com/) บน Facebook ที่เล่น RO บนเว็บเบราว์เซอร์ได้ ผมก็เลยอยากสร้าง proxy ตัวนี้ขึ้นมาเองด้วย Rust หลังจากเรียนรู้พื้นฐานมาทั้ง 6 ตอน ถึงเวลาเอาทุกอย่างมาประกอบกันแล้ว + +บทความนี้เป็น Part 1 ของซีรีส์สองส่วน โดยเราจะเน้นไปที่การตั้งค่าโครงสร้างโปรเจกต์ การตั้งค่า CLI ด้วย `clap` การจัดการการตั้งค่า (configuration) ระบบ logging ด้วย `tracing` และการตั้งค่าเซิร์ฟเวอร์ HTTP/WebSocket ด้วย `axum` และ `tokio` + +ใน Part 2 (ซึ่งจะตามมาในบทความถัดไป) เราจะเจาะลึกเข้าไปในส่วนของ proxy core — กลไกการเชื่อมต่อ TCP และการส่งต่อข้อมูลระหว่าง WebSocket และ TCP socket รวมถึงการจัดการการเชื่อมต่อแบบพร้อมกันหลาย ๆ รายการ (concurrent connections) และการจัดการข้อผิดพลาด + +คุณสามารถดูโค้ดต้นฉบับทั้งหมดได้ที่: https://github.com/bouroo/rs-wsProxy + + + +## โครงสร้างโปรเจกต์ + +มาเริ่มด้วยการสร้างโครงสร้างไฟล์ของโปรเจกต์กันก่อน โครงสร้างของ rs-wsProxy มีดังนี้: + +``` +rs-wsProxy/ +├── src/ +│ ├── main.rs # Entry point ของแอปพลิเคชัน +│ ├── lib.rs # ไลบรารีราก (ถ้ามี) – ในกรณีนี้เราจะใช้เป็นการประกาศโมดูล +│ ├── config.rs # จัดการการประมวลผลอาร์กิวเมนต์บรรทัดคำสั่งและสร้างสถานะแอปพลิเคชัน (AppState) +│ ├── logging.rs # ตั้งค่าระบบ logging ด้วย tracing +│ ├── modules.rs # ฟังก์ชันช่วยเหลือสำหรับการตรวจสอบและแปลงเป้าหมายการเชื่อมต่อ (target validation) +│ ├── proxy.rs # ตรรกะหลักของ proxy: การเชื่อมต่อ TCP และการส่งต่อข้อมูลระหว่าง WebSocket และ TCP +│ └── server.rs # การตั้งค่าเซิร์ฟเวอร์ HTTP/WebSocket ด้วย axum และการกำหนดเส้นทาง (routes) +├── Cargo.toml # Manifest ของ Cargo ที่ระบุ dependencies และ metadata +└── tests/ # โฟลเดอร์สำหรับทดสอบ (ยังว่างใน Part 1) +``` + +เราจะสร้างไฟล์เหล่านี้ทีละไฟล์พร้อมคำอธิบายอย่างละเอียดในภาษาไทย + +## Cargo.toml — Dependencies + +มาเริ่มกันที่ไฟล์ `Cargo.toml` ซึ่งเป็นไฟล์กำหนดค่าของ Cargo ที่ระบุชื่อโครงการ เวอร์ชัน เอดิชัน และที่สำคัญที่สุดคือ dependencies ที่เราจะใช้ในโปรเจกต์นี้ + +```toml +[package] +name = "rs-wsProxy" +version = "1.0.0" +edition = "2021" + +[lib] +name = "rs_ws_proxy" +path = "src/lib.rs" + +[dependencies] +axum = { version = "0.8", features = ["ws"] } +axum-server = { version = "0.8", features = ["tls-rustls"] } +tokio-rustls = "0.26" +rustls = "0.23" +tokio = { version = "1", features = ["full"] } +futures-util = "0.3" +clap = { version = "4", features = ["derive", "env"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +bytes = "1" +``` + +มาไล่ดูทีละตัวว่าทำไมเราถึงเลือกใช้แต่ละไลบรารี: + +- **axum** (`= 0.8`, features = ["ws"]): เฟรมเวิร์กเว็บที่สร้างบนบน `tokio` และ `tower` ใช้สำหรับสร้าง HTTP server และ WebSocket endpoints ฟีเจอร์ `ws` เปิดใช้งานการสนับสนุน WebSocket +- **axum-server** (`= 0.8`, features = ["tls-rustls"]): ให้การสนับสนุน TLS ผ่าน rustls สำหรับ axum (แม้ว่าในโปรเจกต์นี้เราอาจจะไม่เปิดใช้ TLS โดยตรง แต่เราเตรียมไว้เผื่ออนาคต) +- **tokio-rustls** (`0.26`) และ **rustls** (`0.23`): ไลบรารีสำหรับการทำ TLS ทันสมัยที่ปลอดภัยและรวดเร็ว เข้ากันได้กับ tokio +- **tokio +- **tokio** (`= 1`, features = [`full`]): รันไทม์แบบอะซิงโครนัสที่ทรงพลังสำหรับการเขียนแอปพลิเคชันแบบไม่บล็อกใน Rust ฟีเจอร์ `full` จะเปิดใช้ฟีเจอร์หลักทั้งหมด (เช่น เวลา, ทาสก์, ซิงค์, ฯลฯ) ที่เราต้องการ +- **futures-util** (`0.3`): ยูทิลิตี้เสริมสำหรับทำงานกับฟิวเจอร์และสตรีม เช่น `StreamExt`, `SinkExt` ฯลฯ ซึ่งเราจะใช้ในส่วนของ proxy เพื่อทำการปั๊มข้อมูลระหว่าง WebSocket และ TCP +- **clap** (`= 4`, features = [`derive`, `env`]): ไลบรารีสำหรับสร้าง command-line interface แบบ declarative ด้วย derive macro ฟีเจอร์ `env` ทำให้เราสามารถดึงค่าพารามิเตอร์จากตัวแปรสภาพแวดล้อมได้โดยอัตโนมัติ +- **tracing** (`0.1`) และ **tracing-subscriber** (`= 0.3`, features = [`env-filter`]): ระบบ logging ที่มีโครงสร้าง (structured logging) และความสามารถในการกรองข้อความผ่าน environment variable (`RUST_LOG`) ทำให้เราสามารถควบคุมระดับการบันทึกได้อย่างละเอียดโดยไม่ต้องแก้โค้ด +- **bytes** (`1`): ไลบรารีสำหรับจัดการกับบัฟเฟอร์ไบต์ที่มีประสิทธิภาพและสามารถแชร์กันได้ (`Bytes`, `BytesMut`) ซึ่งจะมีประโยชน์มากเมื่อเราต้องทำการอ่านและเขียนข้อมูลระหว่างซ็อกเก็ต + +## CLI ด้วย clap (config.rs) + +ต่อไปเราจะสร้างไฟล์ `src/config.rs` ซึ่งจะประกอบด้วย: + +1. โครงสร้าง `Args` ที่อนุมานจาก `clap::Parser` เพื่อรับค่าอาร์กิวเมนต์จากบรรทัดคำสั่งและตัวแปรสภาพแวดล้อม +2. โครงสร้าง `AppState` ที่จะถูกแชร์ไปทั่วทั้งแอปพลิเคชันผ่าน `std::sync::Arc` +3. ฟังก์ชันช่วยเหลือสำหรับการแปลงสตริงที่รับมาจากอาร์กิวเมนต์ (เช่น รายการเซิร์ฟเวอร์ที่อนุญาต หรือแมปของการเปลี่ยนเส้นทาง) ไปเป็นรูปแบบที่ใช้งานได้ภายในแอปพลิเคชัน + +มาเริ่มด้วยการกำหนดโครงสร้าง `Args`: + +```rust +use clap::Parser; +use std::collections::HashMap; +use std::net::SocketAddr; + +/// WebSocket-to-TCP proxy for roBrowser +#[derive(Parser, Debug)] +#[command(name = "wsproxy", version, about = "WebSocket-to-TCP proxy")] +pub struct Args { + /// พอร์ตที่เซิร์ฟเวอร์จะฟังอยู่ (ค่าเริ่มต้น: 5999) + #[arg(short = 'p', long = "port", env = "WSPROXY_PORT", default_value_t = 5999)] + pub port: u16, + + /// จำนวนเวิร์กเกอร์ทาสก์สำหรับ tokio runtime (ค่าเริ่มต้น: 1) + #[arg(short = 't', long = "threads", env = "WSPROXY_THREADS", default_value_t = 1)] + pub threads: usize, + + /// เปิดใช้งาน SSL/TLS (ต้องมีใบรับรองและคีย์) + #[arg(short = 's', long = "ssl", env = "WSPROXY_SSL")] + pub ssl: bool, + + /// รายการเซิร์ฟเวอร์ที่อนุญาตให้เชื่อมต่อได้ (คั่นด้วยเครื่องหมาย comma) ตัวอย่าง: "game1.example.com:80,game2.example.com:443" + /// หากไม่ระบุ (None) จะอนุญาตให้เชื่อมต่อได้ทุกเซิร์ฟเวอร์ (open proxy) + /// หากระบุเป็นสตริงว่าง ("") หรือเวกเตอร์ว่าง จะไม่อนุญาตให้เชื่อมต่อใด ๆ เลย + #[arg(short = 'a', long = "allow", env = "WSPROXY_ALLOW")] + pub allow: Option, + + /// กฎการเปลี่ยนเส้นทาง (redirect rules) ในรูปแบบ "from=to;from2=to2" + /// ตัวอย่าง: "game.example.com:80=game-secure.example.com:443" + #[arg(short = 'r', long = "redirect", env = "WSPROXY_REDIRECT")] + pub redirect: Option, + + /// เซิร์ฟเวอร์เป้าหมายเริ่มต้นเมื่อไม่มีการระบุเป้าหมายใน URL (เช่น เมื่อเชื่อมต่อที่ /ws แทนที่จะเป็น /ws/game.example.com:80) + #[arg(short = 'd', long = "default-target", env = "WSPROXY_DEFAULT_TARGET")] + pub default_target: Option, + + /// ที่อยู่ที่จะบินด์เซิร์ฟเวอร์ (ค่าเริ่มต้น: 0.0.0.0 หมายถึงฟังทุกอินเทอร์เฟซ) + #[arg(long = "host", env = "WSPROXY_HOST", default_value = "0.0.0.0")] + pub host: String, +} +``` + +### คำอธิบายโครงสร้าง `Args` + +- เราใช้ `derive(Parser)` จากไลบรารี `clap` เพื่อให้มันสร้างโค้ดสำหรับการวิเคราะห์อาร์กิวเมนต์บรรทัดคำสั่งให้เราโดยอัตโนมัติ จากฟิลด์และแอททริบิวต์ที่เรากำหนด +- แต่ละฟิลด์มีแอททริบิวต์ `arg` ที่กำหนดชื่อสั้น (`short`), ชื่อยาว (`long`), ตัวแปรสภาพแวดล้อม (`env`), และค่าเริ่มต้น (`default_value_t` หรือ `default_value`) +- ฟิลด์ `port`, `threads` มีค่าเริ่มต้นเป็นตัวเลข (`default_value_t`) +- ฟิลด์ `ssl` เป็นบูลีนแบบสวิตช์ (flag) – หากมีการระบุ flag จะเป็น `true` ไม่เช่นนั้นจะเป็น `false` +- ฟิลด์ `allow`, `redirect`, `default_target` เป็น `Option` เพื่อให้สามารถไม่ระบุค่าได้ (คือ `None`) +- ฟิลด์ `host` มีค่าเริ่มต้นเป็น `"0.0.0.0"` ซึ่งหมายถึงการฟังบนทุกอินเทอร์เฟซเครือข่ายที่มีอยู่ + +ต่อไปเราจะกำหนดโครงสร้าง `AppState` ซึ่งจะถูกแชร์ไปทั่วทั้งแอปพลิเคชันโดยใช้ `std::sync::Arc` เพื่อให้สามารถเข้าถึงได้อย่างปลอดภัยจากหลายทาสก์ (เนื่องจากเราใช้ `tokio` ซึ่งทำงานแบบอะซิงโครนัสและอาจมีหลายงานที่ทำงานพร้อมกัน) + +```rust +use std::collections::HashMap; + +/// สถานะที่แชร์กันทั่วทั้งแอปพลิเคชัน +#[derive(Debug, Clone)] +pub struct AppState { + /// รายการเซิร์ฟเวอร์ที่อนุญาตให้เชื่อมต่อได้ (ถ้าเป็น None หมายถึงอนุญาตทุกเซิร์ฟเวอร์) + /// ถ้าเป็น Some(Vec) แต่เวกเตอร์ว่าง จะหมายถึงไม่อนุญาตเซิร์ฟเวอร์ใด ๆ เลย + pub allowed_servers: Option>, + + /// แมปของกฎการเปลี่ยนเส้นทาง: จากรูปแบบต้นทางไปยังรูปแบบปลายทาง + /// ตัวอย่าง: "game.example.com:80" -> "game-secure.example.com:443" + pub redirects: HashMap, + + /// เซิร์ฟเวอร์เป้าหมายเริ่มต้นเมื่อไม่มีการระบุเป้าหมายใน URL + pub default_target: Option, +} +``` + +#### คำอธิบายฟิลด์ใน `AppState` + +- `allowed_servers`: เป็น `Option>` เพื่อให้เราสามารถแสดงสามสถานะได้อย่างชัดเจน: + - `None` (ไม่ได้ตั้งค่า) → เปิด proxy (อนุญาตให้เชื่อมต่อเซิร์ฟเวอร์ใด ๆ ก็ได้) + - `Some(vec![])` (เวกเตอร์ว่าง) → ปฏิเสธการเชื่อมต่อทั้งหมด (ไม่มีเซิร์ฟเวอร์ใดได้รับอนุญาต) + - `Some(vec![s1, s2, ...])` → อนุญาตเฉพาะเซิร์ฟเวอร์ที่อยู่ในรายการนี้เท่านั้น +- `redirects`: ใช้ `HashMap` เพื่อจับคู่ระหว่างเป้าหมายต้นทางกับเป้าหมายปลายทาง ตัวอย่างเช่น อาจใช้เพื่อบังคับให้การเชื่อมต่อไปยังเซิร์ฟเวอร์เก่าเป็นการเชื่อมต่อไปยังเซิร์ฟเวอร์ใหม่ที่มีการเข้ารหัส +- `default_target`: ใช้เมื่อผู้ใช้เชื่อมต่อมาที่เส้นทางเช่น `/ws` (โดยไม่ระบุเป้าหมาย) แทนที่จะเป็น `/ws/game.example.com:80` ในกรณีนี้เราจะใช้ค่าที่ตั้งค่านี้เป็นเซิร์ฟเวอร์เป้าหมายเริ่มต้น + +ต่อไปเราจะเพิ่มฟังก์ชันช่วยเหลือสำหรับการแปลงสตริงที่รับมาจากอาร์กิวเมนต์บรรทัดคำสั่งให้อยู่ในรูปแบบที่เหมาะสมกับ `AppState` + +```rust +use std::net::SocketAddr; + +impl Args { + /// แปลงสตริงที่คั่นด้วยเครื่องหมาย comma ที่ได้จาก `--allow` ให้เป็น `Option>` + /// หากสตริงเป็น None หรือว่างเปล่า จะคืนค่า None + /// หากสตริงไม่ว่าง จะแยกด้วย comma, trim แต่ละรายการ และกรองรายการว่างออก + pub fn build_allowed_list(&self) -> Option> { + self.allow.as_ref().filter(|s| !s.is_empty()).map(|s| { + s.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + } + + /// แปลงสตริงที่ได้จาก `--redirect` ให้เป็น `HashMap` + /// รูปแบบที่คาดหวัง: "from1=to1;from2=to2" + /// หากสตริงเป็น None หรือว่างเปล่า จะคืนค่า HashMap ว่าง + pub fn build_redirects(&self) -> HashMap { + self.redirect + .as_ref() + .map(|s| { + s.split(';') + .filter(|pair| !pair.is_empty()) + .filter_map(|pair| { + let mut parts = pair.splitn(2, '='); + let from = parts.next().map(|s| s.trim()); + let to = parts.next().map(|s| s.trim()); + match (from, to) { + (Some(from), Some(to)) if !from.is_empty() && !to.is_empty() => { + Some((from.to_string(), to.to_string())) + } + _ => None, + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// แปลงสตริงโฮสต์และพอร์ต (เช่น "game.example.com:80") ให้เป็น `SocketAddr` + /// คืนค่า `Ok(SocketAddr)` หากสำเร็จ หรือ `Err(String)` หากล้มเหลว + pub fn parse_socket_addr(&self, addr: &str) -> Result { + addr.parse::() + .map_err(|e| format!("ไม่สามารถแยกวิเคราะห์ที่อยู่ '{}' เป็น SocketAddr ได้: {}", addr, e)) + } + + /// สร้างอินสแตนซ์ของ `AppState` จากอาร์กิวเมนต์ที่ได้รับ + pub fn into_app_state(self) -> AppState { + AppState { + allowed_servers: self.build_allowed_list(), + redirects: self.build_redirects(), + default_target: self.default_target.filter(|s| !s.is_empty()), + } + } +} +``` + +### คำอธิบายฟังก์ชันช่วยเหลือใน `impl Args` + +- `build_allowed_list`: + - ตรวจสอบว่าฟิลด์ `allow` เป็น `Some` และไม่ใช่สตริงว่าง หากไม่เงื่อนไขนี้ให้คืนค่า `None` + - หากมีค่า เราจะแยกสตริงด้วยเครื่องหมาย comma (`,`), ตัดช่องว่างด้านหน้าและหลังแต่ละส่วน (`trim`), กรองออกรายการที่ว่างเปล่าหลังการตัดช่องว่าง, จากนั้นแปลงแต่ละรายการเป็น `String` และเก็บลงในเวกเตอร์ + - ผลลัพธ์คือ `Option>` ที่สอดคล้องกับนิยามของฟิลด์ `allowed_servers` ใน `AppState` + +- `build_redirects`: + - หากฟิลด์ `redirect` เป็น `None` หรือว่างเปล่า ให้คืนค่า `HashMap` ว่าง + - หากมีค่า เราจะแยกสตริงด้วยเครื่องหมายจุดไข่ปลา (`;`) เพื่อได้คู่ `from=to` หลาย ๆ คู่ + - สำหรับแต่ละคู่ เราจะแยกอีกครั้งด้วยเครื่องหมายเท่ากับ (`=`) โดยใช้ `splitn(2, '=')` เพื่อให้ได้สูงสุดสองส่วน (ป้องกันกรณีที่มี `=` มากกว่าหนึ่งตัวในค่า `to`) + - ตรวจสอบว่าทั้ง `from` และ `to` มีอยู่และไม่ว่างหลังจากการตัดช่องว่าง แล้วจึงแปลงเป็น `String` และใส่ลงใน `HashMap` + - ส่งคืน `HashMap` ที่สร้างขึ้น + +- `parse_socket_addr`: + - ฟังก์ชันยูทิลิตี้ง่าย ๆ ที่พยายามแยกวิเคราะห์สตริงที่อยู่ (เช่น `"example.com:80"`) ให้เป็น `std::net::SocketAddr` + - หากล้มเหลวจะคืนค่า `Err` พร้อมข้อความแสดงข้อผิดพลาดที่อธิบายได้ชัดเจน + - ฟังก์ชันนี้จะถูกใช้ในภายหลังเมื่อเราต้องตรวจสอบว่าผู้ใช้ป้อนที่อยู่ในรูปแบบที่ถูกต้องหรือไม่ (เช่น ในเส้นทาง WebSocket) + +- `into_app_state`: + - แปลงอินสแตนซ์ของ `Args` ที่ได้จากการประมวลผลอาร์กิวเมนต์บรรทัดคำสั่งให้กลายเป็น `AppState` ที่พร้อมใช้งานทั่วทั้งแอปพลิเคชัน + - เรียกใช้ฟังก์ชันช่วยเหลือสองฟังก์ชันด้านบนเพื่อสร้าง `allowed_servers` และ `redirects` + - สำหรับ `default_target` เราจะใช้ `filter` เพื่อแปลงสตริงว่างให้เป็น `None` (เนื่องจากใน `AppState` เราต้องการให้เป็น `Option` และต้องการปฏิบัติต่อสตริงว่างเหมือนไม่ได้ตั้งค่า) + +ตอนนี้เรามีส่วนจัดการการตั้งค่าและอาร์กิวเมนต์บรรทัดคำสั่งแล้ว ต่อไปเราจะสร้างไฟล์ `src/logging.rs` เพื่อตั้งค่าระบบ logging ด้วย `tracing` และ `tracing-subscriber` + +## Logging (logging.rs) + +การทำ logging ที่ดีเป็นสิ่งสำคัญสำหรับการดีบักและการตรวจสอบแอปพลิเคชันเซิร์ฟเวอร์ ในไฟล์นี้เราจะตั้งค่า `tracing` subscriber ที่สามารถอ่านระดับการบันทึกจากตัวแปรสภาพแวดล้อม `RUST_LOG` ได้ (ตามมาตรฐานของ `tracing`) + +```rust +use tracing_subscriber::{fmt, EnvFilter}; + +/// ตั้งค่าระบบ logging ด้วย tracing +/// +/// ฟังก์ชันนี้ควรถูกเรียกใช้ครั้งเดียวที่จุดเริ่มต้นของแอปพลิเคชัน (ใน main) +/// มันจะตั้งค่า subscriber ของ tracing เพื่อส่งเหตุการณ์ไปยังมาตรฐานเอาต์พุต (stdout) +/// โดยใช้รูปแบบที่อ่านง่าย และอนุญาตให้กรองระดับการบันทึกผ่านตัวแปรสภาพแวดล้อม RUST_LOG +/// +/// ตัวอย่างการใช้งาน: +/// ```bash +/// RUST_LOG=info,wsproxy=debug ./wsproxy +/// ``` +/// จะแสดงข้อความในระดับ info ขึ้นไปทั่วไป และระดับ debug สำหรับโมดูลที่ชื่อขึ้นต้นด้วย `wsproxy` +pub fn init() { + // สร้างตัวกรองจากตัวแปรสภาพแวดล้อม RUST_LOG (หากไม่ได้ตั้งค่า จะใช้ "info" เป็นค่าเริ่มต้น) + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")); + + // ตั้งค่า subscriber ของ tracing + tracing_subscriber::fmt() + .with_env_filter(filter) + .init(); +} +``` + +### คำอธิบายฟังก์ชัน `init` + +- เราใช้ `EnvFilter::try_from_default_env()` เพื่อพยายามอ่านตัวแปรสภาพแวดล้อม `RUST_LOG` หากไม่พบหรือไม่สามารถแยกวิเคราะห์ได้ เราจะใช้ตัวกรองเริ่มต้นที่ระดับ `"info"` +- จากนั้นเราสร้าง `tracing_subscriber::fmt()` ซึ่งเป็นผู้ส่งออกเหตุการณ์ไปยัง stdout ในรูปแบบที่อ่านง่าย (มีสี หากเทอร์มินัลรองรับ) +- เราใช้ `.with_env_filter(filter)` เพื่อนำตัวกรองที่เราสร้างมาใช้ +- สุดท้ายเราเรียก `.init()` เพื่อตั้งค่า subscriber นี้ให้เป็นตัวจัดการเหตุการณ์เริ่มต้นของ `tracing` + +เมื่อมีการตั้งค่าแล้ว เราสามารถใช้มาโคร `tracing::info!`, `tracing::warn!`, `tracing::error!`, `tracing::debug!` และอื่น ๆ ในโค้ดของเราเพื่อบันทึกเหตุการณ์ต่าง ๆ ได้อย่างง่ายดาย + +ต่อไปเราจะสร้างไฟล์ `src/modules.rs` ซึ่งจะมีฟังก์ชันช่วยเหลือสำหรับการตรวจสอบและแปลงเป้าหมายการเชื่อมต่อ (target validation) ซึ่งเป็นส่วนสำคัญของการทำหน้าที่เป็น proxy อย่างปลอดภัย + +## Verify Pipeline (modules.rs) + +ในส่วนนี้เราจะกำหนดฟังก์ชันสองฟังก์ชันหลัก: + +1. `validate_target` – ตรวจสอบว่าสตริงที่อยู่ (เช่น `"example.com:80"`) มีรูปแบบที่ถูกต้องเป็น `SocketAddr` หรือไม่ +2. `verify` – ตรรกะหลักสำหรับการตรวจสอบว่าไคลเอนต์ได้รับอนุญาตให้เชื่อมต่อไปยังเป้าหมายที่ร้องขอหรือไม่ โดยพิจารณาจาก: + - กฎการเปลี่ยนเส้นทาง (redirect rules) – หากตรงกับกฎใดกฎหนึ่ง จะใช้เป้าหมายที่ถูกแทนที่ตามกฎนั้น + - รายการเซิร์ฟเวอร์ที่อนุญาต (allow list) – หากมีการตั้งค่ารายการนี้ จะต้องตรวจสอบว่าเป้าหมาย (หลังจากการเปลี่ยนเส้นทางแล้ว) อยู่ในรายการนี้ + - หากไม่มีการตั้งค่ารายการอนุญาต (คือ `None`) จะถือว่าเป็น open proxy (อนุญาตให้เชื่อมต่อได้ทุกที่) + +```rust +use std::net::SocketAddr; +use crate::config::AppState; + +/// ตรวจสอบว่าสตริงที่อยู่มีรูปแบบที่ถูกต้องเป็น SocketAddr หรือไม่ +/// +/// ฟังก์ชันนี้จะพยายามแยกวิเคราะห์สตริงที่ให้มาเป็น SocketAddr +/// หากสำเร็จจะคืนค่า Ok(SocketAddr) หากล้มเหลวจะคืนค่า Err พร้อมข้อความอธิบาย +pub fn validate_target(target: &str) -> Result { + target + .parse::() + .map_err(|e| format!("ที่อยู่ '{}' ไม่ถูกต้อง: {}", target, e)) +} + +/// ตรวจสอบและอาจปรับเปลี่ยนเป้าหมายการเชื่อมต่อตามกฎที่ตั้งค่าไว้ใน AppState +/// +/// ขั้นตอนการทำงาน: +/// 1. ตรวจสอบก่อนว่าสตริงเป้าหมายเดิมมีรูปแบบที่ถูกต้อง (โดยใช้ validate_target) +/// 2. ตรวจสอบกฎการเปลี่ยนเส้นทาง (redirects) ใน AppState: +/// - หากพบกฎที่ตรงกับเป้าหมายต้นทาง (จาก) ให้แทนที่เป้าหมายด้วยปลายทาง (ไป) ตามกฎนั้น +/// - หากพบหลายกฎที่ตรงกัน จะใช้กฎแรกที่พบ (เนื่องจากเราใช้ HashMap ซึ่งไม่รับประกันลำดับ แต่ในทางปฏิบัติเราควรออกแบบกฎให้ไม่ซ้อนทับกัน) +/// 3. ตรวจสอบรายการเซิร์ฟเวอร์ที่อนุญาต (allowed_servers) ใน AppState: +/// - หากเป็น None → อนุญาตให้เชื่อมต่อได้ทุกที่ (open proxy) +/// - หากเป็น Some(vec) แต่เวกเตอร์ว่าง → ไม่อนุญาตให้เชื่อมต่อที่ไหนเลย +/// - หากเป็น Some(vec) ที่มีรายการ → ตรวจสอบว่าเป้าหมาย (หลังจากการเปลี่ยนเส้นทางแล้ว) อยู่ในรายการนี้หรือไม่ +/// +/// พารามิเตอร์: +/// - `state`: สถานะแอปพลิเคชันที่มีกฎการตั้งค่าต่าง ๆ +/// - `target`: สตริงที่อยู่ต้นทางที่ไคลเอนต์ต้องการเชื่อมต่อไป (เช่น "game.example.com:80") +/// +/// ส่งคืน: +/// - Ok(SocketAddr) หากการเชื่อมต่อได้รับอนุญาต (อาจมีการเปลี่ยนเส้นทางแล้ว) +/// - Err(String) หากการเชื่อมต่อถูกปฏิเสธ พร้อมข้อความอธิบายเหตุผล +pub fn verify(state: &AppState, target: &str) -> Result { + // ขั้นตอนที่ 1: ตรวจสอบรูปแบบของที่อยู่ต้นทาง + let mut addr = validate_target(target)?; + + // ขั้นตอนที่ 2: ตรวจสอบกฎการเปลี่ยนเส้นทาง (ถ้ามี) + if let Some(from) = state.redirects.get(target) { + // พบกฎการเปลี่ยนเส้นทางที่ตรงกันทั้งสตริง + addr = validate_target(from)?; + } else { + // หากไม่พบการตรงกันแบบเต็มสตริง เราอาจต้องการตรวจสอบแบบขึ้นต้นด้วยหรือไม่? + // ในการออกแบบปัจจุบันเราใช้การตรงกันแบบเต็มสตริงเท่านั้น + // หากต้องการสนับสนุนการจับคู่แบบขึ้นต้นด้วย เราจำเป็นต้องเปลี่ยนการตรวจสอบนี้ + // แต่สำหรับตอนนี้เราจะใช้การจับคู่แบบเต็มสตริงเท่านั้น + } + + // ขั้นตอนที่ 3: ตรวจสอบรายการเซิร์ฟเวอร์ที่อนุญาต (ถ้ามีการตั้งค่า) + match &state.allowed_servers { + None => { + // ไม่มีการตั้งค่ารายการอนุญาต → อนุญาตให้เชื่อมต่อได้ทุกที่ (open proxy) + Ok(addr) + } + Some(list) => { + if list.is_empty() { + // รายการอนุญาตว่างเปล่า → ไม่อนุญาตให้เชื่อมต่อที่ไหนเลย + Err(format!( + "การเชื่อมต่อไปยัง {} ถูกปฏิเสธ: ไม่มีเซิร์ฟเวอร์ใดได้รับอนุญาต (allow list ว่างเปล่า)", + target + )) + } else { + // ตรวจสอบว่าที่อยู่ (หลังจากการเปลี่ยนเส้นทางแล้ว) อยู่ในรายการอนุญาตหรือไม่ + // เนื่องจากเราเก็บที่อยู่ในรูปแบบ String ใน allowed_servers เราจึงต้องแปลง addr กลับเป็น String เพื่อเปรียบเทียบ + let addr_str = addr.to_string(); + if list.contains(&addr_str) { + Ok(addr) + } else { + Err(format!( + "การเชื่อมต่อไปยัง {} ถูกปฏิเสธ: ไม่อยู่ในรายการเซิร์ฟเวอร์ที่อนุญาต", + target + )) + } + } + } + } +} +``` + +### คำอธิบายฟังก์ชันใน `modules.rs` + +- `validate_target`: + - เป็นฟังก์ชันยูทิลิตี้ที่เรียกใช้ `SocketAddr::parse` จากไลบรารีมาตรฐานของ Rust + - หากการแยกวิเคราะห์สำเร็จ จะคืนค่า `Ok(SocketAddr)` + - หากล้มเหลว จะคืนค่า `Err` พร้อมข้อความอธิบายที่ช่วยให้ผู้ใช้เข้าใจว่าทำไมที่อยู่ที่ให้มาจึงไม่ถูกต้อง (เช่น ขาดพอร์ต, มีอักขระที่ไม่ได้รับอนุญาต ฯลฯ) + +- `verify`: + - นี่คือฟังก์ชันหลักที่ใช้ในการตัดสินใจว่าจะอนุญาตให้เชื่อมต่อไปยังเป้าหมายที่ร้องขอหรือไม่ + - ขั้นตอนการทำงานถูกแบ่งออกเป็นสามขั้นตอนหลักตามที่อธิบายไว้ในคอมเมนต์ + - ขั้นตอนที่ 1: ตรวจสอบรูปแบบของที่อยู่ต้นทางโดยใช้ `validate_target` + - ขั้นตอนที่ 2: ตรวจสอบกฎการเปลี่ยนเส้นทาง (redirects) + - เราใช้ `HashMap::get` เพื่อค้นหาเป้าหมายต้นทางที่แน่นอนในแมปของกฎการเปลี่ยนเส้นทาง + - หากพบ เราจะแทนที่ที่อยู่ต้นทางด้วยที่อยู่ปลายทางจากกฎนั้น และทำการตรวจสอบรูปแบบใหม่อีกครั้ง (เผื่อว่ากฎการเปลี่ยนเส้นทางอาจให้ค่าที่ไม่ถูกต้อง) + - หมายเหตุ: ในการออกแบบปัจจุบันเราใช้การจับคู่แบบเต็มสตริงเท่านั้น หากต้องการสนับสนุนการจับคู่แบบขึ้นต้นด้วยหรือแบบนิพจน์ปกติ เราจำเป็นต้องปรับเปลี่ยนตรงนี้ แต่สำหรับการใช้งานทั่วไปของ roBrowser การจับคู่แบบเต็มสตริงก็เพียงพอแล้ว + - ขั้นตอนที่ 3: ตรวจสอบรายการเซิร์ฟเวอร์ที่อนุญาต (allowed_servers) + - หาก `allowed_servers` เป็น `None` หมายถึงไม่มีการตั้งค่ารายการอนุญาต → ถือเป็น open proxy (อนุญาตให้เชื่อมต่อได้ทุกที่) + - หากเป็น `Some(vec)` แต่เวกเตอร์ว่างเปล่า → หมายถึงผู้ใช้ต้องการปฏิเสธการเชื่อมต่อทั้งหมด (อาจใช้สำหรับการปิดชั่วคราวหรือการตั้งค่าที่ผิดโดยไม่ตั้งใจ) + - หากเป็น `Some(vec)` ที่มีองค์ประกอบหนึ่งขึ้นไป → เราจะตรวจสอบว่าที่อยู่ปลายทาง (หลังจากการเปลี่ยนเส้นทางแล้ว) อยู่ในรายการนี้หรือไม่ + - เพื่อทำการเปรียบเทียบเราจำเป็นต้องแปลง `SocketAddr` กลับเป็นสตริงโดยใช้ `to_string()` เนื่องจากเราเก็บรายการอนุญาตเป็นเวกเตอร์ของสตริง + - หากพบในรายการ → คืนค่า `Ok(addr)` (อนุญาตให้เชื่อมต่อ) + - หากไม่พบในรายการ → คืนค่า `Err` พร้อมข้อความอธิบายว่าการเชื่อมต่อถูกปฏิเสธเนื่องจากไม่อยู่ในรายการที่อนุญาต + +ต่อไปเราจะสร้างไฟล์ `src/server.rs` ซึ่งจะประกอบด้วยการตั้งค่าเซิร์ฟเวอร์ HTTP/WebSocket ด้วยเฟรมเวิร์ก `axum` รวมถึงการกำหนดเส้นทาง (routes) และผู้จัดการเหตุการณ์ (handlers) + +## Axum Server (server.rs) + +ในไฟล์นี้เราจะกำหนด: + +1. เส้นทาง (routes) สำหรับเซิร์ฟเวอร์ HTTP: + - `GET /` – เส้นทางหลักที่คืนค่าข้อความต้อนรับสั้น ๆ + - `GET /ws` – เส้นทาง WebSocket สำหรับการเชื่อมต่อไปยังเซิร์ฟเวอร์เป้าหมายเริ่มต้น (หากมีการตั้งค่า) + - `GET /{*target}` – เส้นทาง WebSocket ที่รับพาธแบบไวด์การ์ดเพื่อระบุเซิร์ฟเวอร์เป้าหมายแบบไดนามิก (เช่น `/ws/game.example.com:80`) + +2. ผู้จัดการเหตุการณ์ (handlers) สำหรับแต่ละเส้นทาง: + - `get_root` – ส่งข้อความต้อนรับกลับเป็นข้อความธรรมดา + - `ws_upgrade_default` – จัดการการอัปเกรดเป็น WebSocket สำหรับเส้นทาง `/ws` โดยใช้เซิร์ฟเวอร์เป้าหมายเริ่มต้นจากสถานะแอปพลิเคชัน + - `ws_upgrade` – จัดการการอัปเกรดเป็น WebSocket สำหรับเส้นทางแบบไวด์การ์ด โดยดึงเป้าหมายจากพาธและตรวจสอบผ่านฟังก์ชัน `verify` จาก `modules.rs` + +3. ฟังก์ชัน `create_app` ที่ประกอบทุกอย่างเข้าด้วยกันโดยสร้าง `axum::Router` และเพิ่มสถานะแอปพลิเคชันเข้าไปด้วย `with_state` + +```rust +use std::net::SocketAddr; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use futures_util::{SinkExt, StreamExt}; +use tracing::{info, warn}; + +use crate::config::AppState; +use crate::modules::verify; + +/// เส้นทางหลักของเซิร์ฟเวอร์ – คืนค่าข้อความต้อนรับสั้น ๆ +async fn get_root() -> &'static str { + "Welcome to rs-wsProxy! WebSocket-to-TCP proxy for roBrowser." +} + +/// จัดการการอัปเกรดเป็น WebSocket สำหรับเส้นทาง `/ws` (ใช้เซิร์ฟเวอร์เป้าหมายเริ่มต้น) +async fn ws_upgrade_default( + State(state): State, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + // หากมีการตั้งค่าเซิร์ฟเวอร์เป้าหมายเริ่มต้น ให้ใช้มัน ไม่เช่นนั้นคืนค่าข้อผิดพลาด + let target = match &state.default_target { + Some(t) if !t.is_empty() => t.as_str(), + _ => { + return Err("ไม่มีการตั้งค่าเซิร์ฟเวอร์เป้าหมายเริ่มต้น".to_string()); + } + }; + + // ตรวจสอบว่าการเชื่อมต่อไปยังเป้าหมายเริ่มต้นนี้ได้รับอนุญาตหรือไม่ + match verify(state, target) { + Ok(addr) => { + // หากได้รับอนุญาต ให้ดำเนินการอัปเกรดเป็น WebSocket และส่งที่อยู่ที่ตรวจสอบแล้วไปยังตัวจัดการ WebSocket + Ok(ws.on_upgrade(move |socket| handle_socket(socket, addr))) + } + Err(e) => Err(format!("การเชื่อมต่อถูกปฏิเสธ: {}", e)), + } +} + +/// จัดการการอัปเกรดเป็น WebSocket สำหรับเส้นทางแบบไวด์การ์ด `/ {*target}` +async fn ws_upgrade( + State(state): State, + Path(target): Path, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + // ตรวจสอบว่าการเชื่อมต่อไปยังเป้าหมายที่ระบุในพาธนี้ได้รับอนุญาตหรือไม่ + match verify(state, &target) { + Ok(addr) => { + // หากได้รับอนุญาต ให้ดำเนินการอัปเกรดเป็น WebSocket และส่งที่อยู่ที่ตรวจสอบแล้วไปยังตัวจัดการ WebSocket + Ok(ws.on_upgrade(move |socket| handle_socket(socket, addr))) + } + Err(e) => Err(format!("การเชื่อมต่อถูกปฏิเสธ: {}", e)), + } +} + +/// จัดการการสื่อสารผ่าน WebSocket หลังจากการอัปเกรดสำเร็จ +/// +/// ฟังก์ชันนี้จะรับ `WebSocket` stream และ `SocketAddr` ของเซิร์ฟเวอร์เป้าหมาย +/// จากนั้นจะสร้างการเชื่อมต่อ TCP ไปยังที่อยู่นั้น และทำการปั๊มข้อมูลสองทางระหว่าง WebSocket และ TCP socket +async fn handle_socket(mut ws: WebSocket, addr: SocketAddr) { + // บันทึกข้อมูลการเชื่อมต่อใหม่ + info!("รับการเชื่อมต่อ WebSocket ใหม่ ไปยัง {}", addr); + + // สร้างการเชื่อมต่อ TCP ไปยังเซิร์ฟเวอร์เป้าหมาย + match tokio::net::TcpStream::connect(addr).await { + Ok(mut tcp_stream) => { + info!("เชื่อมต่อ TCP ไปยัง {} สำเร็จ", addr); + + // แยก WebSocket ออกเป็นสตรีมผู้ส่ง (sink) และผู้รับ (stream) + let (mut ws_sender, mut ws_receiver) = ws.split(); + + // แยก TcpStream ออกเป็นผู้อ่านและผู้เขียน + let (mut tcp_reader, mut tcp_writer) = tokio::io::split(tcp_stream); + + // งานที่ 1: ส่งข้อมูลจาก WebSocket ไปยัง TCP socket + let ws_to_tcp = async { + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Binary(data)) => { + // หากได้รับข้อมูลไบนารีจาก WebSocket ให้เขียนลง TCP socket + if let Err(e) = tcp_writer.write_all(&data).await { + warn!("เกิดข้อผิดพลาดในการเขียนข้อมูลไปยัง TCP socket: {}", e); + break; + } + } + Ok(Message::Close(_)) => { + // หากได้รับสัญญาณปิดการเชื่อมจาก WebSocket ให้หยุดลูป + break; + } + Err(e) => { + // หากเกิดข้อผิดพลาดในการรับข้อมูลจาก WebSocket + warn!("เกิดข้อผิดพลาดในการรับข้อมูลจาก WebSocket: {}", e); + break; + } + _ => { + // ละเลยข้อความประเภทอื่น ๆ (เช่น Text, Ping, Pong) ในตัวอย่างนี้ + // ในการใช้งานจริงอาจต้องจัดการกับข้อความประเภทอื่น ๆ ตามความเหมาะสม + } + } + } + // เมื่อลูปสิ้นสุด ให้พยายามปิดการเขียนไปยัง TCP socket + let _ = tcp_writer.shutdown().await; + }; + + // งานที่ 2: ส่งข้อมูลจาก TCP socket ไปยัง WebSocket + let tcp_to_ws = async { + let mut buffer = vec![0u8; 4096]; // บัฟเฟอร์ชั่วคราวสำหรับอ่านข้อมูลจาก TCP socket + loop { + match tcp_reader.read(&mut buffer).await { + Ok(0) => { + // อ่านได้ 0 ไบต์ หมายถึงการเชื่อมต่อ TCP ถูกปิดโดยเซิร์ฟเวอร์ปลายทาง + break; + } + Ok(n) => { + // ได้รับข้อมูลจาก TCP socket ให้ส่งเป็นข้อมูลไบนารีไปยัง WebSocket + if ws_sender + .send(Message::Binary(binary::Bytes::copy_from_slice(&buffer[..n]))).await + .is_err() + { + // หากการส่งล้มเหลว (อาจเป็นเพราะ WebSocket ถูกปิดแล้ว) ให้หยุดลูป + break; + } + } + Err(e) => { + // เกิดข้อผิดพลาดในการอ่านจาก TCP socket + warn!("เกิดข้อผิดพลาดในการอ่านข้อมูลจาก TCP socket: {}", e); + break; + } + } + } + // เมื่อลูปสิ้นสุด ให้พยายามปิด WebSocket connection + let _ = ws_sender.send(Message::Close(None)).await; + }; + + // รันงานทั้งสองพร้อมกัน และรอให้ทั้งสองงานเสร็จสิ้น (หรือใดงานหนึ่งล้มเหลว) + tokio::select! { + _ = ws_to_tcp => {}, + _ = tcp_to_ws => {}, + } + + info!("การเชื่อมต่อกับ {} ถูกปิดแล้ว", addr); + } + Err(e) => { + // หากไม่สามารถเชื่อมต่อ TCP ไปยังเซิร์ฟเวอร์เป้าหมายได้ + warn!("ไม่สามารถเชื่อมต่อไปยัง {}: {}", addr, e); + // แจ้งให้ไคลเอนต์ทราบว่าการเชื่อมต่อล้มเหลวโดยการปิด WebSocket ด้วยรหัสสถานะภายใน + let _ = ws.send(Message::Close(None)).await; + } + } +} + +/// สร้างแอปพลิเคชัน Axum พร้อมเส้นทางและสถานะที่แชร์กัน +pub fn create_app(state: AppState) -> Router { + Router::new() + // เส้นทางหลักสำหรับตรวจสอบว่าเซิร์ฟเวอร์ทำงานอยู่ + .route("/", get(get_root)) + // เส้นทาง WebSocket สำหรับเชื่อมต่อไปยังเซิร์ฟเวอร์เป้าหมายเริ่มต้น (ถ้ามีการตั้งค่า) + .route("/ws", get(ws_upgrade_default)) + // เส้นทาง WebSocket แบบไวด์การ์ดเพื่อระบุเซิร์ฟเวอร์เป้าหมายแบบไดนามิกจากพาธ + // ตัวอย่าง: /ws/game.example.com:80 + .route("/ws/*target", get(ws_upgrade)) + // เพิ่มสถานะแอปพลิเคชันเข้าไปในเราเตอร์ เพื่อให้ผู้จัดการเหตุการณ์สามารถเข้าถึงได้ผ่านการสกัด State + .with_state(state) +} +``` + +### คำอธิบายโค้ดใน `server.rs` + +- การนำเข้า (imports): + - เรานำเข้าฟังก์ชันและโครงสร้างที่จำเป็นจาก `axum` สำหรับการสร้างเราเตอร์ การกำหนดเส้นทาง และการสกัดข้อมูลจากคำขอ (เช่น `State`, `Path`) + - เรานำเข้า `WebSocket`, `WebSocketUpgrade` และประเภทข้อความ WebSocket จาก `axum::extract::ws` + - เรานำเข้า `SinkExt` และ `StreamExt` จาก `futures_util` เพื่อให้สามารถใช้เมธอดเช่น `.next()` บนสตรีมและ `.send()` บนซิงค์ได้อย่างสะดวก + - เรานำเข้า `tracing` มาใช้สำหรับการบันทึกเหตุการณ์ต่าง ๆ ระหว่างการทำงานของเซิร์ฟเวอร์ + - เรานำเข้าโครงสร้าง `AppState` จากโมดูล `config` และฟังก์ชัน `verify` จากโมดูล `modules` + +- ผู้จัดการเหตุการณ์ (handlers): + - `get_root`: เพียงแค่คืนค่าสตริงต้อนรับเป็นการตอบสนอง HTTP ธรรมดา + - `ws_upgrade_default`: จัดการการร้องขอ WebSocket ไปยังเส้นทาง `/ws` + - ดึงสถานะแอปพลิเคชันจากตัวสกัด `State` + - ตรวจสอบว่ามีการตั้งค่า `default_target` ในสถานะหรือไม่ หากมีและไม่ว่างเปล่า ให้ใช้ค่านั้นเป็นเป้าหมาย + - หากไม่มีการตั้งค่า `default_target` ให้คืนค่าข้อผิดพลาด + - ตรวจสอบว่าได้รับอนุญาตให้เชื่อมต่อไปยังเป้าหมายนั้นหรือไม่โดยใช้ฟังก์ชัน `verify` จากโมดูล `modules` + - หากได้รับอนุญาต ให้เรียกใช้ `ws.on_upgrade` และส่งการเชื่อมต่อ WebSocket ที่อัปเกรดแล้วและที่อยู่ที่ตรวจสอบแล้วไปยังฟังก์ชัน `handle_socket` + - หากไม่ได้รับอนุญาต ให้คืนค่าข้อผิดพลาดพร้อมข้อความอธิบาย + - `ws_upgrade`: คล้ายกับ `ws_upgrade_default` แต่รับเป้าหมายจากพาธของ URL โดยใช้ตัวสกัด `Path` + - ตัวอย่าง: หากผู้ใช้เชื่อมต่อมาที่ `/ws/game.example.com:80` ตัวสกัด `Path` จะดึงสตริง `"game.example.com:80"` ไปยังตัวแปร `target` + - จากนั้นทำการตรวจสอบเหมือนกับใน `ws_upgrade_default` + +- `handle_socket`: ฟังก์ชันที่จัดการการสื่อสารจริง ๆ หลังจากที่ WebSocket ถูกอัปเกรดสำเร็จแล้ว + - รับพารามิเตอร์สองอย่าง: `ws` ซึ่ง是 `WebSocket` stream ที่เชื่อมต่อกับไคลเอนต์ และ `addr` ซึ่ง是 `SocketAddr` ของเซิร์ฟเวอร์เป้าหมายที่ได้รับการตรวจสอบแล้ว + - บันทึกข้อมูลเมื่อมีการเชื่อมต่อใหม่เข้ามาโดยใช้ `tracing::info!` + - พยายามสร้างการเชื่อมต่อ TCP ไปยังเซิร์ฟเวอร์เป้าหมายโดยใช้ `tokio::net::TcpStream::connect` + - หากสำเร็จ ให้ดำเนินการต่อไปยังการตั้งค่าการส่งผ่านข้อมูลสองทาง + - หากล้มเหลว ให้บันทึกคำเตือนและพยายามปิดการเชื่อมต่อ WebSocket โดยส่งข้อความ Close + - เมื่อเชื่อมต่อ TCP สำเร็จ เราจะแบ่งการทำงานออกเป็นสองงานหลักที่ทำงานพร้อมกันโดยใช้ `tokio::select!`: + 1. **WebSocket → TCP (`ws_to_tcp`)**: + - แยก WebSocket ออกเป็นผู้ส่ง (`ws_sender`) และผู้รับ (`ws_receiver`) โดยใช้เมธอด `split()` + - วนลูปเพื่อรับข้อความจาก WebSocket ผ่าน `ws_receiver.next()` + - หากได้รับข้อความไบนารี (`Message::Binary`) ให้เขียนข้อมูลนั้นลงใน TCP socket ผ่าน `tcp_writer.write_all()` + - หากได้รับข้อความปิดการเชื่อม (`Message::Close`) ให้ออกจากลูป + - หากเกิดข้อผิดพลาดในการรับข้อความจาก WebSocket ให้บันทึกคำเตือนและออกจากลูป + - หลังออกจากลูป ให้พยายามปิดการเขียนไปยัง TCP socket โดยเรียก `tcp_writer.shutdown()` + 2. **TCP → WebSocket (`tcp_to_ws`)**: + - แยก TcpStream ออกเป็นผู้อ่าน (`tcp_reader`) และผู้เขียน (`tcp_writer`) โดยใช้ `tokio::io::split` + - สร้างบัฟเฟอร์ชั่วคราวขนาด 4096 ไบต์สำหรับอ่านข้อมูลจาก TCP socket + - วนลูปเพื่ออ่านข้อมูลจาก TCP socket เข้าสู่บัฟเฟอร์โดยใช้ `tcp_reader.read()` + - หากอ่านได้ 0 ไบต์ หมายถึงการเชื่อมต่อ TCP ถูกปิดโดยเซิร์ฟเวอร์ปลายทาง ให้ออกจากลูป + - หากอ่านได้ข้อมูลจำนวน `n` ไบต์ ให้พยายามส่งเป็นข้อความไบนารี (`Message::Binary`) ไปยัง WebSocket ผ่าน `ws_sender.send()` + - หากการส่งล้มเหลว (อาจเป็นเพราะ WebSocket ถูกปิดแล้ว) ให้ออกจากลูป + - หากเกิดข้อผิดพลาดในการอ่านจาก TCP socket ให้บันทึกคำเตือนและออกจากลูป + - หลังออกจากลูป ให้พยายามส่งข้อความปิดการเชื่อมไปยัง WebSocket โดยเรียก `ws_sender.send(Message::Close(None))` + + - งานทั้งสองนี้จะทำงานพร้อมกันโดยใช้ `tokio::select!` ซึ่งจะรอจนกว่างานใดงานหนึ่งจะเสร็จสิ้น (ไม่ว่าจะเสร็จสมบูรณ์หรือเนื่องจากข้อผิดพลาด) จากนั้นจึงออกจากฟังก์ชัน + - เมื่อทั้งสองงานเสร็จสิ้น ให้บันทึกข้อมูลว่าการเชื่อมต่อกับเซิร์ฟเวอร์เป้าหมายถูกปิดแล้ว + +- `create_app`: ฟังก์ชันที่สร้างและคืนค่า `axum::Router` ที่พร้อมใช้งาน + - สร้างเราเตอร์ใหม่โดยใช้ `Router::new()` + - เพิ่มเส้นทาง GET สำหรับเส้นทางหลัก (`"/"`) ที่เชื่อมโยงกับผู้จัดการเหตุการณ์ `get_root` + - เพิ่มเส้นทาง GET สำหรับเส้นทาง WebSocket เริ่มต้น (`"/ws"`) ที่เชื่อมโยงกับผู้จัดการเหตุการณ์ `ws_upgrade_default` + - เพิ่มเส้นทาง GET สำหรับเส้นทาง WebSocket แบบไวด์การ์ด (`"/ws/*target"`) ที่เชื่อมโยงกับผู้จัดการเหตุการณ์ `ws_upgrade` + - เครื่องหมาย `*` ในพาธหมายถึง "จับคู่ทุกอย่างที่เหลืออยู่ในพาธและส่งเป็นพารามิเตอร์" + - ในกรณีนี้ พาธ `/ws/game.example.com:80` จะทำให้ตัวแปร `target` ในผู้จัดการเหตุการณ์ได้รับค่า `"game.example.com:80"` + - สุดท้าย เราใช้เมธอด `with_state` เพื่อแนบสถานะแอปพลิเคชัน (`state`) เข้ากับเราเตอร์ เพื่อให้ผู้จัดการเหตุการณ์ทุกตัวสามารถเข้าถึงสถานะนี้ได้ผ่านการสกัด `State` + +ต่อไปเราจะสร้างไฟล์หลักของแอปพลิเคชัน `src/main.rs` ซึ่งจะเป็นจุดเริ่มต้นของโปรแกรม ที่นี่เราจะ: + +1. เริ่มต้นระบบ logging +2. ประมวลผลอาร์กิวเมนต์บรรทัดคำสั่งโดยใช้ `clap` +3. ตั้งค่า runtime ของ `tokio` ด้วยจำนวนเวิร์กเกอร์ที่กำหนด +4. สร้างสถานะแอปพลิเคชันจากอาร์กิวเมนต์ +5. สร้างแอปพลิเคชัน Axum โดยใช้สถานะนั้น +6. ผูกเซิร์ฟเวอร์กับที่อยู่และพอร์ตที่ระบุ +7. จัดการสัญญาณเพื่อปิดเซิร์ฟเวอร์อย่างนุ่มนวลเมื่อได้รับ SIGINT หรือ SIGTERM + +## main.rs — Entry Point + +```rust +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::signal; +use tokio::sync::Notify; +use tracing::info; + +use clap::Parser; +use tokio::net::TcpListener; + +use rs_ws_proxy::config::Args; +use rs_ws_proxy::logging; +use rs_ws_proxy::server::create_app; + +/// ฟังก์ชันหลักของแอปพลิเคชัน +#[tokio::main] +async fn main() { + // 1. เริ่มต้นระบบ logging + logging::init(); + info!("เริ่มต้น rs-wsProxy..."); + + // 2. ประมวลผลอาร์กิวเมนต์บรรทัดคำสั่ง + let args = Args::parse(); + info!( + "กำลังเริ่มเซิร์ฟเวอร์บน {}:{}, threads = {}, ssl = {}", + args.host, args.port, args.threads, args.ssl + ); + + // บันทึกการตั้งค่าต่าง ๆ หากมีการระบุ + if let Some(ref allow) = args.allow { + info!("อนุญาตให้เชื่อมต่อเฉพาะ: {}", allow); + } else { + info!("โหมดเปิด proxy (อนุญาตให้เชื่อมต่อได้ทุกที่)"); + } + + if let Some(ref redirect) = args.redirect { + if !redirect.is_empty() { + info!("กฎการเปลี่ยนเส้นทาง: {}", redirect); + } + } + + if let Some(ref default_target) = args.default_target { + if !default_target.is_empty() { + info!("เซิร์ฟเวอร์เป้าหมายเริ่มต้น: {}", default_target); + } + } + + // 3. สร้างสถานะแอปพลิเคชันจากอาร์กิวเมนต์ + let state = Arc::new(args.into_app_state()); + + // 4. สร้างแอปพลิเคชัน Axum + let app = create_app(Arc::clone(&state)); + + // 5. สร้างที่อยู่ที่จะบินด์เซิร์ฟเวอร์ + let addr = SocketAddr::new( + args.host + .parse() + .expect("ไม่สามารถแยกวิเคราะห์ที่อยู่อินเทอร์เฟซได้"), + args.port, + ); + + // 6. สร้าง TCP listener + let listener = TcpListener::bind(&addr) + .await + .expect(&format!("ไม่สามารถผูกเซิร์ฟเวอร์กับที่อยู่ {}", addr)); + + info!("เซิร์ฟเวอร์กำลังฟังที่ {}", addr); + + // 7. สร้างสัญญาณแจ้งเตือนสำหรับการปิดเซิร์ฟเวอร์อย่างนุ่มนวล + let shutdown_signal = Arc::new(Notify::new()); + let shutdown_signal_clone = shutdown_signal.clone(); + + // เริ่มต้นงานที่รอสัญญาณการปิดเซิร์ฟเวอร์ (SIGINT หรือ SIGTERM) + let shutdown_task = tokio::spawn(async move { + // รอสัญญาณจากระบบปฏิบัติการ + tokio::select! { + _ = signal::ctrl_c() => { + info!("ได้รับสัญญาณ SIGINT (Ctrl+C), กำลังปิดเซิร์ฟเวอร์..."); + } + _ = signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) => { + info!("ได้รับสัญญาณ SIGTERM, กำลังปิดเซิร์ฟเวอร์..."); + } + } + // แจ้งให้งานหลักทราบว่าถึงเวลาปิดเซิร์ฟเวอร์แล้ว + shutdown_signal_clone.notify_one(); + }); + + // 8. เริ่มต้นเซิร์ฟเวอร์โดยใช้ hyper ผ่าน axum + // เราใช้ `axum::serve` ซึ่งจะรับการเชื่อมต่อ TCP จาก listener และจัดการด้วยแอปพลิเคชัน Axum ของเรา + let server_task = tokio::spawn(async move { + // เรียกใช้ axum::serve เพื่อเริ่มรับการเชื่อมต่อ + if let Err(e) = axum::serve(listener, app.into_make_service()).await { + eprintln!("เซิร์ฟเวอร์ทำงานผิดพลาด: {}", e); + } + }); + + // 9. รอให้ทั้งงานเซิร์ฟเวอร์และงานรอสัญญาณปิดเซิร์ฟเวอร์เสร็จสิ้น + tokio::select! { + _ = server_task => { + info!("งานเซิร์ฟเวอร์สิ้นสุดลง"); + } + _ = async { + shutdown_signal.notified().await; + } => { + // เมื่อได้รับสัญญาณให้ปิดเซิร์ฟเวอร์ เราจะทำการปิด listener อย่างชัดเจน + // ซึ่งจะทำให้ axum หยุดรับการเชื่อมต่อใหม่และรอให้การเชื่อมต่อที่มีอยู่เสร็จสิ้น + drop(listener); + } + } + + // รองานที่เหลือให้เสร็จสิ้นก่อนออกจากโปรแกรม + let _ = shutdown_task.await; + let _ = server_task.await; + + info!("เซิร์ฟเวอร์ถูกปิดลงอย่างปลอดภัยแล้ว"); +} +``` + +### คำอธิบายโค้ดใน `main.rs` + +- การนำเข้า (imports): + - เรานำเข้าฟังก์ชันและโครงสร้างที่จำเป็นจากไลบรารีมาตรฐานของ Rust เช่น `std::net::SocketAddr` สำหรับที่อยู่เครือข่าย และ `std::sync::Arc` สำหรับการนับจำนวนการอ้างอิงแบบอะตอมิกเพื่อให้สามารถแชร์สถานะระหว่างทาสก์ได้อย่างปลอดภัย + - เรานำเข้า `tokio::signal` สำหรับการรอสัญญาณจากระบบปฏิบัติการ (เช่น SIGINT จากการกด Ctrl+C) และ `tokio::sync::Notify` สำหรับการส่งสัญญาณระหว่างงานภายในโปรแกรมของเราเอง + - เรานำเข้า `tracing::info` เพื่อใช้ในการบันทึกเหตุการณ์สำคัญต่าง ๆ ระหว่างการทำงานของเซิร์ฟเวอร์ + - เรานำเข้าฟังก์ชันและโครงสร้างจากไลบรารีภายนอกที่เราได้เพิ่มเข้าไปใน `Cargo.toml`: + - `clap::Parser` สำหรับการประมวลผลอาร์กิวเมนต์บรรทัดคำสั่ง + - `tokio::net::TcpListener` สำหรับการสร้าง TCP listener ที่จะรอรับการเชื่อมต่อเข้ามา + - โมดูลของเราเอง: `rs_ws_proxy::config::Args` (โครงสร้างอาร์กิวเมนต์), `rs_ws_proxy::logging` (ฟังก์ชันเริ่มต้น logging), และ `rs_ws_proxy::server::create_app` (ฟังก์ชันสร้างแอปพลิเคชัน Axum) + +- ฟังก์ชันหลัก `main`: + - เราทำเครื่องหมายฟังก์ชันนี้ด้วยแอททริบิวต์ `#[tokio::main]` เพื่อให้โทคิโอตั้งค่ารันไทม์แบบอัตโนมัติเมื่อโปรแกรมเริ่มทำงาน + - ขั้นตอนที่ 1: เริ่มต้นระบบ logging โดยเรียกใช้ `logging::init()` ที่เราได้กำหนดไว้ใน `src/logging.rs` จากนั้นบันทึกข้อความว่าเริ่มต้นโปรแกรมแล้ว + - ขั้นตอนที่ 2: ประมวลผลอาร์กิวเมนต์บรรทัดคำสั่งโดยเรียกใช้ `Args::parse()` จากไลบรารี `clap` ซึ่งจะทำการวิเคราะห์อาร์กิวเมนต์จากบรรทัดคำสั่งและตัวแปรสภาพแวดล้อมตามที่เราได้กำหนดไว้ในโครงสร้าง `Args` จากนั้นบันทึกข้อมูลการตั้งค่าต่าง ๆ เช่น ที่อยู่และพอร์ตที่จะบินด์ จำนวนเวิร์กเกอร์ สถานะ SSL และการตั้งค่าต่าง ๆ ที่เกี่ยวกับการอนุญาต การเปลี่ยนเส้นทาง และเซิร์ฟเวอร์เป้าหมายเริ่มต้น (หากมีการระบุ) + - ขั้นตอนที่ 3: สร้างสถานะแอปพลิเคชันโดยเรียกใช้เมธอด `into_app_state()` บนออบเจกต์ `args` ที่ได้จากการ parse แล้ว แปลงผลลัพธ์ให้เป็น `Arc` เพื่อให้สามารถแชร์สถานะนี้ไปยังทาสก์ต่าง ๆ ได้อย่างปลอดภัยโดยใช้การอ้างอิงแบบนับจำนวน + - ขั้นตอนที่ 4: สร้างแอปพลิเคชัน Axum โดยเรียกใช้ฟังก์ชัน `create_app` ที่เราได้กำหนดไว้ใน `src/server.rs` และส่งสถานะแอปพลิเคชันที่เราได้สร้างไว้ให้กับมัน ผลลัพธ์ที่ได้คือ `axum::Router` ที่พร้อมใช้งาน + - ขั้นตอนที่ 5: สร้างที่อยู่ที่จะบินด์เซิร์ฟเวอร์โดยแยกวิเคราะห์สตริงที่อยู่จากอาร์กิวเมนต์ `args.host` เป็น `std::net::IpAddr` และรวมกับพอร์ต `args.port` เพื่อสร้าง `SocketAddr` หากการแยกวิเคราะห์ที่อยู่ล้มเหลว เราจะใช้ `expect()` เพื่อทำให้โปรแกรมหยุดทำงานและแสดงข้อความข้อผิดพลาด + - ขั้นตอนที่ 6: สร้าง TCP listener โดยใช้ `tokio::net::TcpListener::bind` กับที่อยู่ที่เราได้สร้างไว้ จากนั้นรอให้การผูกที่อยู่สำเร็จโดยใช้ `.await` หากล้มเหลวเราจะใช้ `expect()` เพื่อหยุดโปรแกรมและแสดงข้อความที่ระบุว่าทำไม่สามารถผูกเซิร์ฟเวอร์กับที่อยู่นั้นได้ + - ขั้นตอนที่ 7: เตรียมการจัดการสัญญาณเพื่อปิดเซิร์ฟเวอร์อย่างนุ่มนวล + - เราสร้าง `Arc` ซึ่งเป็นสัญญาณแจ้งเตือนที่สามารถแชร์ระหว่างงานได้อย่างปลอดภัย + - เราโคลนสัญญาณนี้เพื่อให้งานหลักและงานรอสัญญาณสามารถใช้ร่วมกันได้ + - เราสร้างงานใหม่โดยใช้ `tokio::spawn` ที่จะรอให้เกิดสัญญาณจากระบบปฏิบัติการไม่ว่าจะเป็น SIGINT (จากการกด Ctrl+C) หรือ SIGTERM (สัญญาณสิ้นสุดการทำงาน) เมื่อได้รับสัญญาณใดสัญญาณหนึ่ง เราจะบันทึกข้อความและส่งสัญญาณแจ้งเตือนไปยัง `shutdown_signal` เพื่อบอกให้งานหลักทราบว่าถึงเวลาปิดเซิร์ฟเวอร์แล้ว + - ขั้นตอนที่ 8: เริ่มต้นเซิร์ฟเวอร์โดยใช้ `axum::serve` + - เราสร้างงานใหม่โดยใช้ `tokio::spawn` อีกครั้งเพื่อรันเซิร์ฟเวอร์ + - ภายในงานนี้ เราเรียกใช้ `axum::serve(listener, app.into_make_service())` ซึ่งจะทำให้เซิร์ฟเวอร์เริ่มรับการเชื่อมต่อ TCP จาก `listener` ที่เราได้สร้างไว้ และจัดการแต่ละการเชื่อมต่อโดยใช้แอปพลิเคชัน Axum ที่เราได้สร้างไว้ (แปลงเป็น `MakeService` โดยใช้ `.into_make_service()`) + - หากเซิร์ฟเวอร์พบข้อผิดพลาดขณะทำงาน เราจะพิมพ์ข้อความข้อผิดพลาดออกไปยังมาตรฐานข้อผิดพลาด (stderr) + - ขั้นตอนที่ 9: รอให้ทั้งงานเซิร์ฟเวอร์และงานรอสัญญาณปิดเซิร์ฟเวอร์เสร็จสิ้น + - เราใช้ `tokio::select!` เพื่อรอให้งานใดงานหนึ่งจากสองงานนี้เสร็จสิ้นก่อน: + - หากงานเซิร์ฟเวอร์เสร็จสิ้นก่อน (ซึ่งอาจเกิดขึ้นได้หากมีข้อผิดพลาดร้ายแรง) เราจะบันทึกข้อความว่างานเซิร์ฟเวอร์สิ้นสุดลง + - หากงานรอสัญญาณเสร็จสิ้นก่อน (ซึ่งหมายความว่าเราได้รับสัญญาณให้ปิดเซิร์ฟเวอร์) เราจะทำการปิด `listener` อย่างชัดเจนโดยใช้ `drop(listener)` ซึ่งจะทำให้ `axum` หยุดรับการเชื่อมต่อใหม่และรอให้การเชื่อมต่อที่มีอยู่ทั้งหมดเสร็จสิ้นก่อนที่จะปิดตัวเอง + - หลังจากที่ `tokio::select! เสร็จสิ้น เราจะรองานที่เหลือให้เสร็จสิ้นโดยใช้ `let _ = shutdown_task.await;` และ `let _ = server_task.await;` เพื่อให้แน่ใจว่าทุกอย่างถูกทำความสะอาดอย่างเหมาะสมก่อนที่โปรแกรมจะออก + - สุดท้ายเราบันทึกข้อความว่าเซิร์ฟเวอร์ถูกปิดลงอย่างปลอดภัยแล้ว + +## ทดสอบการทำงาน + +ตอนนี้เรามีโครงสร้างพื้นฐานของโปรเจกต์ rs-wsProxy แล้ว ลองมาทดสอบรันโปรแกรมกันดู + +ก่อนอื่น ให้แน่ใจว่าคุณอยู่ในไดเรกทอรีรากของโปรเจกต์ แล้วรันคำสั่งต่อไปนี้เพื่อคอมไพล์และรันโปรแกรมด้วยการตั้งค่าเริ่มต้น: + +```bash +cargo run +``` + +คุณควรเห็นผลลัพธ์ประมาณนี้: + +``` +[INFO wsproxy] เริ่มต้น rs-wsProxy... +[INFO wsproxy] กำลังเริ่มเซิร์ฟเวอร์บน 0.0.0.0:5999, threads = 1, ssl = false +[INFO wsproxy] โหมดเปิด proxy (อนุญาตให้เชื่อมต่อได้ทุกที่) +[INFO wsproxy] เซิร์ฟเวอร์กำลังฟังที่ 0.0.0.0:5999 +``` + +จากนั้นในเทอร์มินัลอีกหน้าต่างหนึ่ง คุณสามารถทดสอบโดยใช้ `curl` เพื่อเรียกใช้เส้นทาง HTTP ปกติ: + +```bash +curl http://localhost:5999/ +``` + +คุณควรได้รับคำตอบว่า: + +``` +Welcome to rs-wsProxy! WebSocket-to-TCP proxy for roBrowser. +``` + +ต่อมาคุณสามารถทดสอบการเชื่อมต่อ WebSocket โดยใช้เครื่องมืออย่าง `websocat` (ถ้ายังไม่ได้ติดตั้ง ให้ติดตั้งโดยใช้ `cargo install websocat` หรือใช้แพ็กเกจจ์ของระบบปฏิบัติการของคุณ): + +```bash +websocat ws://localhost:5999/ws +``` + +เนื่องจากเรายังไม่ได้ตั้งค่าเซิร์ฟเวอร์เป้าหมายเริ่มต้น (`default_target`) คุณจะเห็นข้อผิดพลาดในการเชื่อมต่อ เนื่องจากเซิร์ฟเวอร์จะพยายามตรวจสอบว่าการเชื่อมต่อไปยังเซิร์ฟเวอร์เป้าหมายเริ่มต้นได้รับอนุญาตหรือไม่ แต่เนื่องจากไม่มีการตั้งค่าเอาไว้ มันจึงคืนค่าข้อผิดพลาด + +ลองตั้งค่าเซิร์ฟเวอร์เป้าหมายเริ่มต้นโดยใช้ตัวแปรสภาพแวดล้อมหรืออาร์กิวเมนต์บรรทัดคำสั่งดู: + +```bash +WSPROXY_DEFAULT_TARGET="echo.websocket.org:443" cargo run +``` + +จากนั้นทดสอบการเชื่อมต่อ WebSocket อีกครั้ง: + +```bash +websocat ws://localhost:5999/ws +``` + +คราวนี้คุณควรเห็นการเชื่อมต่อสำเร็จไปยัง `echo.websocket.org:443` (ซึ่งเป็นเซิร์ฟเวอร์ WebSocket สาธิตที่ส่งกลับข้อความที่คุณส่งไป) และคุณสามารถทดสอบโดยพิมพ์ข้อความใด ๆ ลงไปในเทอร์มินัลของ `websocat` แล้วมันจะส่งกลับมาทันที + +คุณยังสามารถทดสอบการระบุเซิร์ฟเวอร์เป้าหมายแบบไดนามิกผ่านพาธได้ด้วย เช่น หากคุณต้องการเชื่อมต่อไปยัง `echo.websocket.org:443` โดยตรงโดยไม่ต้องพึ่งพาการตั้งค่าเริ่มต้น: + +```bash +websocat ws://localhost:5999/ws/echo.websocket.org:443 +``` + +หากคุณต้องการทดสอบฟีเจอร์การอนุญาตเฉพาะเซิร์ฟเวอร์บางตัว คุณสามารถรันเซิร์ฟเวอร์ด้วยการตั้งค่า `allow` ดังนี้: + +```bash +WSPROXY_ALLOW="echo.websocket.org:443" WSPROXY_DEFAULT_TOKEN="echo.websocket.org:443" cargo run +``` + +จากนั้นทดสอบการเชื่อมต่อไปยังเซิร์ฟเวอร์ที่ได้รับอนุญาต: + +```bash +websocat ws://localhost:5999/ws/echo.websocket.org:443 +``` + +ควรเชื่อมต่อสำเร็จ + +และทดสอบการเชื่อมต่อไปยังเซิร์ฟเวอร์ที่ไม่อนุญาต: + +```bash +ws://localhost:5999/ws/google.com:443 +``` + +ควรถูกปฏิเสธโดยเซิร์ฟเวอร์ และคุณจะเห็นข้อความแสดงข้อผิดพลาดในไคลเอนต์ websocat + +## สรุป + +ในบทความนี้เราได้สร้างพื้นฐานของโปรเจกต์ rs-wsProxy ขึ้นมาแล้ว โดยเราได้: + +1. สร้างโครงสร้างไฟล์ของโปรเจกต์อย่างเป็นระบบ +2. กำหนด dependencies ที่จำเป็นใน `Cargo.toml` และอธิบายเหตุผลว่าเราเลือกใช้ไลบรารีแต่ละตัวเพราะเหตุผลใด +3. สร้างระบบจัดการอาร์กิวเมนต์บรรทัดคำสั่งและสภาพแวดล้อมด้วย `clap` ในไฟล์ `src/config.rs` รวมถึงการกำหนดโครงสร้าง `Args` และ `AppState` พร้อมฟังก์ชันช่วยเหลือสำหรับการแปลงและตรวจสอบค่าต่าง ๆ +4. ตั้งค่าระบบ logging ด้วย `tracing` และ `tracing-subscriber` ในไฟล์ `src/logging.rs` +5. สร้างฟังก์ชันช่วยเหลือสำหรับการตรวจสอบและยืนยันความถูกต้องของเป้าหมายการเชื่อมต่อในไฟล์ `src/modules.rs` รวมถึงตรรกะสำหรับการเปลี่ยนเส้นทางและการตรวจสอบรายการอนุญาต +6. สร้างเซิร์ฟเวอร์ HTTP/WebSocket ด้วย `axum` และ `tokio` ในไฟล์ `src/server.rs` โดยกำหนดเส้นทางสำหรับหน้าหลัก เว็บซ็อกเก็ตเริ่มต้น และเว็บซ็อกเก็ตแบบไดนามิกผ่านพาธ +7. เขียนฟังก์ชันหลักใน `src/main.rs` ที่ทำหน้าที่เริ่มต้นระบบ logging ประมวลผลอาร์กิวเมนต์ สร้างสถานะแอปพลิเคชัน สร้างแอปพลิเคชัน Axum ผูกเซิร์ฟเวอร์กับที่อยู่และพอร์ต และจัดการการปิดเซิร์ฟเวอร์อย่างนุ่มนวลเมื่อได้รับสัญญาณจากระบบปฏิบัติการ + +เราได้ทดสอบการทำงานเบื้องต้นโดยการรันเซิร์ฟเวอร์และทดสอบการเชื่อมต่อด้วย `curl` และ `websocat` ซึ่งแสดงให้เห็นว่าระบบพื้นฐานของเราทำงานได้ตามที่คาดหวัง + +ในบทความถัดไป (Part 2) เราจะเจาะลึกเข้าไปในส่วนของ proxy core — กลไกการเชื่อมต่อ TCP และการส่งต่อข้อมูลระหว่าง WebSocket และ TCP socket ซึ่งเราได้วางโครงสร้างคร่ ๆ ไว้ในฟังก์ชัน `handle_socket` ใน `src/server.rs` แต่เราจะปรับปรุงให้มีความแข็งแรงมากขึ้น จัดการกับข้อผิดพลาดได้ดียิ่งขึ้น และเพิ่มฟีเจอร์ต่าง ๆ เช่น การจัดการความดันย้อนกลับ (backpressure) และการหมดเวลาเชื่อมต่อ (timeouts) + +อย่าลืมติดตามตอนต่อไปนะครับ! + +### ลิงก์ที่เกี่ยวข้อง + +- บทความก่อนหน้า: [Async Rust กับ Tokio](/posts/rust/rust-async-tokio/) +- บทความถัดไป: [สร้าง wsProxy — Proxy Core และ Deployment](/posts/rust/rust-wsproxy-proxy-deploy/) +- ซอร์สโค้ด: https://github.com/bouroo/rs-wsProxy \ No newline at end of file diff --git a/static/.DS_Store b/static/.DS_Store new file mode 100644 index 0000000..e6d023a Binary files /dev/null and b/static/.DS_Store differ diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000..c3fb25e Binary files /dev/null and b/static/android-chrome-192x192.png differ diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000..3294cc8 Binary files /dev/null and b/static/android-chrome-512x512.png differ diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..15d7b28 Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/browserconfig.xml b/static/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/static/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000..e2fb73a Binary files /dev/null and b/static/favicon-16x16.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..f793281 Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..2583edf Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/img/featured-image.webp b/static/img/featured-image.webp new file mode 100644 index 0000000..c1cb43f Binary files /dev/null and b/static/img/featured-image.webp differ diff --git a/static/img/go-featured-image.png b/static/img/go-featured-image.png new file mode 100644 index 0000000..6f2cb5b Binary files /dev/null and b/static/img/go-featured-image.png differ diff --git a/static/img/go-featured-image.webp b/static/img/go-featured-image.webp new file mode 100644 index 0000000..229f431 Binary files /dev/null and b/static/img/go-featured-image.webp differ diff --git a/static/mstile-150x150.png b/static/mstile-150x150.png new file mode 100644 index 0000000..c83d78d Binary files /dev/null and b/static/mstile-150x150.png differ diff --git a/static/safari-pinned-tab.svg b/static/safari-pinned-tab.svg new file mode 100644 index 0000000..bfbd6d5 --- /dev/null +++ b/static/safari-pinned-tab.svg @@ -0,0 +1,667 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/site.webmanifest b/static/site.webmanifest new file mode 100644 index 0000000..b20abb7 --- /dev/null +++ b/static/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/themes/LoveIt b/themes/LoveIt new file mode 160000 index 0000000..0349869 --- /dev/null +++ b/themes/LoveIt @@ -0,0 +1 @@ +Subproject commit 0349869aa8aa8a09ca44c648da7a31618d45cc1d