diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 74a6311..0000000 --- a/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -checklist -node_modules/ -yarn.lock diff --git a/404.html b/404.html new file mode 100644 index 0000000..a6917e2 --- /dev/null +++ b/404.html @@ -0,0 +1,17 @@ + + + + + + Egg + + + + + + + +

404

That's a Four-Oh-Four.
Take me home.
+ + + diff --git a/README.md b/README.md deleted file mode 100644 index 9ee19e2..0000000 --- a/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# eggjs.github.io - -```bash -$ npm install -$ npm run dev -``` diff --git a/assets/css/0.styles.4659e948.css b/assets/css/0.styles.4659e948.css new file mode 100644 index 0000000..b34b4a2 --- /dev/null +++ b/assets/css/0.styles.4659e948.css @@ -0,0 +1 @@ +#nprogress{pointer-events:none}#nprogress .bar{background:#3eaf7c;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}#nprogress .peg{display:block;position:absolute;right:0;width:100px;height:100%;box-shadow:0 0 10px #3eaf7c,0 0 5px #3eaf7c;opacity:1;transform:rotate(3deg) translateY(-4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border-color:#3eaf7c transparent transparent #3eaf7c;border-style:solid;border-width:2px;border-radius:50%;-webkit-animation:nprogress-spinner .4s linear infinite;animation:nprogress-spinner .4s linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .bar,.nprogress-custom-parent #nprogress .spinner{position:absolute}@-webkit-keyframes nprogress-spinner{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.icon.outbound{color:#aaa;display:inline-block;vertical-align:middle;position:relative;top:-1px}.sticker{position:fixed}.sticker.stick-float{top:auto;position:absolute}.table-of-contents{display:none!important}.egg-toc{position:fixed;display:none;width:11.5rem;max-height:100vh;overflow-y:auto;padding:7rem 0;top:0;right:0;box-sizing:border-box;background:#fff;z-index:0}.egg-toc .egg-toc-item{position:relative;padding:.25rem .6rem .25rem 1.5rem;line-height:1.5rem;border-left:2px solid rgba(0,0,0,.08);overflow:hidden}.egg-toc .egg-toc-item a{display:block;color:#2c3e50;width:100%;box-sizing:border-box;font-size:.88rem;font-weight:400;text-decoration:none;transition:color .3s;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.egg-toc .egg-toc-item.active{border-left-color:#3eaf7c}.egg-toc .egg-toc-item.active a,.egg-toc .egg-toc-item:hover a{color:#3eaf7c}.egg-toc .egg-toc-h3 a{padding-left:1rem}.egg-toc .egg-toc-h4 a{padding-left:2rem}.egg-toc .egg-toc-h5 a{padding-left:3rem}.egg-toc .egg-toc-h6 a{padding-left:4rem}@media (min-width:1000px){.egg-toc{display:block}}.home{padding:4rem 0 0;margin:0 auto;display:block;font-size:.88rem}.home .hero{text-align:center;background:#fff no-repeat 50%;background-size:cover;color:#fff;overflow:hidden}.home .hero h1{letter-spacing:.13rem;line-height:2rem;margin:3rem 0 1rem}.home .hero h1,.home .hero h2{font-size:2rem;text-align:center}.home .hero h2{margin:1rem 0 2rem;line-height:2.25rem;border:none;font-weight:200;padding:0 1rem}.home .hero .description{max-width:35rem;font-size:1.6rem;line-height:1.3;color:#6a8bad}.home .hero .action-button{display:flex;width:12.13rem;height:2.38rem;font-size:1rem;align-items:center;justify-content:center;color:#fff;background-color:#04ae62;border-radius:.25rem;margin:2.94rem auto 3.06rem;transition:background-color .3s;border:1px solid transparent;border-bottom-color:#0aea85}.home .hero .action-button:hover{background-color:#05ce74}.home .features{padding:4.5rem 0 9.88rem;margin:auto;text-align:center;color:#62726b}.home .feature{width:16.01rem;padding:0 4.13rem/2;display:inline-block;vertical-align:top;margin-bottom:1rem;box-sizing:border-box}.home .feature .feature-icon{height:8.5rem;display:flex;align-items:center;justify-content:center}.home .feature .feature-icon img{transform:scale(.5)}.home .feature h2{font-size:1.25rem;line-height:2rem;color:#0d261b;margin:1.5rem 0 1rem;border-bottom:none;padding-bottom:0}.home .feature p{color:#315947;text-align:justify;line-height:1.5rem;width:11.88rem;margin:auto}.home .whosusing{position:relative;background:#f8f8f8;border:1px solid transparent}.home .whosusing .icon{display:block;margin:3.38rem auto 2.75rem;width:19.81rem}.home .whosusing .using-list{position:relative;max-width:58.25rem;margin:0 auto 7.75rem;padding:0;z-index:2}.home .whosusing .using-list li{list-style-type:none;background:#fff;margin:0 1rem 1rem;overflow:hidden;padding:1.5rem 1rem;border-radius:.5rem;box-shadow:0 0 2rem rgba(0,0,0,.1)}.home .whosusing .using-list .avatar{position:absolute;width:4rem;height:4rem;border-radius:100%;overflow:hidden;border:1px solid #16d98e;box-sizing:border-box;left:0}.home .whosusing .using-list .avatar img{width:100%;height:100%}.home .whosusing .using-list .info-wrapper{position:relative;max-width:43.13rem;min-height:4rem;padding-left:6rem;overflow:hidden;margin:auto}.home .whosusing .using-list .comment{margin:.69rem 0 .5rem;line-height:1.13rem;color:#0d261b}.home .whosusing .using-list .user-name{margin:0;line-height:1rem;font-size:.75rem;color:#698c7c}.home .background-icon{position:absolute;right:0;bottom:-3.13rem;z-index:1}@media (max-width:1150px){.home .features{max-width:700px}}.sidebar-button{cursor:pointer;display:none;width:1.25rem;height:1.25rem;position:absolute;padding:.6rem;top:.6rem;left:1rem}.sidebar-button .icon{display:block;width:1.25rem;height:1.25rem}@media (max-width:719px){.sidebar-button{display:block}}.egg-search-box{display:inline-block;position:relative;margin-right:1rem}.egg-search-box input{cursor:text;width:12.5rem;height:1.5rem;color:#4e6e8e;display:inline-block;border:1px solid #cfd4db;border-radius:2rem;font-size:.9rem;line-height:2rem;padding:0 .5rem 0 2rem;outline:none;transition:all .2s ease;background:#fff url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAAAXNSR0IArs4c6QAAAhhJREFUOBG1VTtLXEEUPmd29wekSYpUKwipk8JuRWJpCMTClDYWRlfB1VId2dIsJK7RwkJBEC0MgaSMiIqFjXVAWKsVTJMfsI/xfKNzuTs77t5FMs2d8/i+OXMec5kCa6q08taQGTOGcsz0Ei6yr8r+lIkPvhWWjwKwFhXHpZmvxf56vbFljBm0euaKOFxjb4iywt6HPTOfpNOpibXZxSvIoRURT33RuWadfgjFM0H+ShlaWJ/Xf+Kg6c/6VYNpVQ4YEfp/aaXel+eWzuI+bm+JEWmt1riQq2bEkN8o6B3nEPp+Kulx0ZclPbVMJjUQilwBiOvbSBOQwv/h4Dww91hoWxejUE3T/I3rbxb0u1ZzZ2mypH8iLYrVsF9QheoDjpx2pmm3OozjiHsotJREW/ELFXd6bG8xgrUcnpNCn7qW8myJRGDB4Tvb4vnKnmVDUds6LFJRtc3vNL1+DfUJ/saHIRWnmCg0v2/sJgMjhcsKR9uQKMw+COxEdWPy7A4jHHueiRT6D7OPfnyYKN8nKFtfjDbzud/DANji4UHB7ItcTkLuRlpIDStg21dUzZ4fISGVW0oW+PDFa/qoh3Q9Th8RQ5n02cT1EalpNopCPhoibyF2J7Y99NKnaClUH4VyOdXHOn17Sfsh8iCxOyDJN04uh35//obGkJYnTx5IkGOkQ5L+4e8lbSOgJxODJCIn3pV/WD90/23dAScr+l05jhNpAAAAAElFTkSuQmCC) .6rem no-repeat;background-size:.75rem;font-size:.75rem;box-sizing:border-box}.egg-search-box input:focus{cursor:auto;border-color:#3eaf7c}.egg-search-box .suggestions{background:#fff;width:20rem;position:absolute;top:1.5rem;padding:.4rem 0;list-style-type:none;box-shadow:0 14px 11px -6px rgba(0,0,0,.15)}.egg-search-box .suggestions.align-right{right:0}.egg-search-box .suggestion{line-height:1.4rem;padding:.5rem 1rem;border-radius:4px;cursor:pointer}.egg-search-box .suggestion a{font-size:.88rem;white-space:normal;color:#0d261d;font-weight:400}.egg-search-box .suggestion.focused a{color:#698c7c}@media (max-width:959px){.egg-search-box input{cursor:pointer;width:0;border-color:transparent;position:relative;background-size:1rem}.egg-search-box input:focus{cursor:text;left:0;width:10rem;background-size:.75rem}}@media (-ms-high-contrast:none){.egg-search-box input{height:2rem}}@media (max-width:959px) and (min-width:719px){.egg-search-box .suggestions{left:0}}@media (max-width:719px){.egg-search-box{margin-right:0}.egg-search-box input{left:1rem}.egg-search-box .suggestions{right:0}}@media (max-width:419px){.egg-search-box .suggestions{width:calc(100vw - 4rem)}.egg-search-box input:focus{width:8rem}}.dropdown-enter,.dropdown-leave-to{height:0!important}.dropdown-wrapper{position:relative;cursor:pointer;height:100%}.dropdown-wrapper .dropdown-title{display:block}.dropdown-wrapper .dropdown-title:hover{border-color:transparent}.dropdown-wrapper .dropdown-title .arrow{vertical-align:middle;margin-top:-1px;margin-left:.4rem}.dropdown-wrapper .dropdown-item{color:inherit;line-height:1.7rem}.dropdown-wrapper .dropdown-item h4{position:relative;margin:.1rem 0 0;padding:.2rem 1.5rem .2rem 1.25rem}.dropdown-wrapper .dropdown-item h4>a{position:static}.dropdown-wrapper .dropdown-item .dropdown-subitem-wrapper{padding:0;list-style:none}.dropdown-wrapper .dropdown-item .dropdown-subitem-wrapper .dropdown-subitem{font-size:.9em}.dropdown-wrapper .dropdown-item .dropdown-subitem-wrapper a{line-height:1.7rem;border-bottom:none;font-weight:400;margin-bottom:0;padding:0 1.5rem 0 1.25rem;color:#444}.dropdown-wrapper .dropdown-item a{position:relative;display:block}.dropdown-wrapper .dropdown-item a.router-link-active,.dropdown-wrapper .dropdown-item a:hover{color:#3eaf7c}.dropdown-wrapper .dropdown-item:first-child h4{margin-top:0}@media (max-width:719px){.dropdown-wrapper.open .dropdown-title{margin-bottom:.5rem}.dropdown-wrapper .nav-dropdown{transition:height .1s ease-out;overflow:hidden}.dropdown-wrapper .nav-dropdown .dropdown-item h4{border-top:0;margin-top:0;padding-top:0}.dropdown-wrapper .nav-dropdown .dropdown-item>a,.dropdown-wrapper .nav-dropdown .dropdown-item h4{line-height:2rem}.dropdown-wrapper .nav-dropdown .dropdown-item .dropdown-subitem{font-size:14px;padding-left:1rem}.dropdown-wrapper .dropdown-title{position:relative}.dropdown-wrapper .dropdown-title .arrow{position:absolute;right:100%}.dropdown-wrapper .dropdown-subitem-wrapper a{font-size:.88rem!important}}@media (min-width:719px){.dropdown-wrapper.dropdown-horizontal .nav-dropdown{right:0;transform:translateX(20%);left:auto;width:42rem}.dropdown-wrapper.dropdown-horizontal .nav-dropdown .dropdown-item{float:left;list-style-type:none;height:14rem;width:25%}.dropdown-wrapper.dropdown-horizontal .nav-dropdown .dropdown-item h4{margin-bottom:.5rem}.dropdown-wrapper:hover .nav-dropdown,.nav-item:hover .nav-dropdown{display:block!important}.dropdown-wrapper .dropdown-title .arrow{border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #ccc;border-bottom:0}.dropdown-wrapper .nav-dropdown{display:none;height:auto!important;box-sizing:border-box;max-height:calc(100vh - 2.7rem);overflow-y:auto;position:absolute;top:100%;left:0;margin:0;background-color:#fff;padding:.6rem 0;box-shadow:0 .68rem .69rem -.2rem rgba(0,0,0,.15);text-align:left;white-space:nowrap}}.nav-links{display:inline-block;height:100%}.nav-links a{line-height:1.4rem;color:inherit}.nav-links a.router-link-active,.nav-links a:hover{color:#3eaf7c}.nav-links .nav-item{position:relative;display:inline-block;height:100%;padding-left:1.5rem;line-height:2rem;vertical-align:top}.nav-links .nav-item .dropdown-title,.nav-links .nav-item>a{position:relative;display:flex;padding:0 .38rem;height:100%;align-items:center;line-height:2rem}.nav-links .nav-item:first-child{padding-left:0}.nav-links .repo-link{padding-left:1.5rem}@media (max-width:719px){.nav-links .nav-item,.nav-links .repo-link{padding-left:0}}@media (min-width:719px){.nav-links a.router-link-active,.nav-links a:hover{color:#2c3e50}.dropdown-wrapper.active:after,.nav-item>a:not(.external).router-link-active:after,.nav-item>a:not(.external):hover:after{position:absolute;display:block;content:"";width:100%;height:2px;bottom:0;left:0;background-color:#46bd87}}.navbar{padding:0 1.5rem;line-height:2.6rem;position:fixed;z-index:20;top:0;left:0;right:0;height:4rem;background-color:#fff;box-sizing:border-box;box-shadow:0 .13rem 1.5rem 0 rgba(0,0,0,.08)}.navbar .left-logo-part{position:absolute;width:17.5rem;left:0;top:0;bottom:0;margin:auto;text-align:right;display:flex;align-items:center;justify-content:right;color:#0d261d;z-index:1}.navbar .left-logo-part img{vertical-align:middle;margin-top:-.3rem}.navbar .left-logo-part>a{width:100%;height:1.88rem;line-height:1.88rem;box-sizing:border-box;padding:0 2.5rem;border-right:1px solid #ebebeb}.navbar .left-logo-part .egg-search-box{position:absolute;vertical-align:top;top:0;left:100%;bottom:0;margin:auto auto auto 2.5rem}.navbar .left-logo-part .egg-search-box .suggestions{top:4rem;padding-top:0;margin:0;text-align:left}.navbar .left-logo-part .egg-search-box input{position:absolute;top:0;bottom:0;margin:auto}.navbar a,.navbar img,.navbar span{display:inline-block}.navbar .logo{height:2.6rem;min-width:2.6rem;margin-right:.8rem;vertical-align:top}.navbar .site-name{font-size:1.3rem;font-weight:600;color:#2c3e50;position:relative}.navbar .links{padding-left:1.5rem;box-sizing:border-box;background-color:#fff;white-space:nowrap;font-size:.9rem;position:absolute;right:11.5rem;top:0;bottom:0;margin:auto;display:flex}@media (max-width:1200px){.navbar .left-logo-part{position:relative;width:auto;text-align:left;display:inline-block;height:4rem;line-height:4rem}.navbar .left-logo-part img{display:none}.navbar .left-logo-part>a{padding:0 2.5rem 0 0}.navbar .links{right:1.5rem}}@media (max-width:1000px){.navbar .left-logo-part>a{padding-right:1rem}.navbar .left-logo-part .egg-search-box{margin-left:.5rem}}@media (max-width:719px){.navbar{padding-left:4rem}.navbar .left-logo-part{width:100%}.navbar .left-logo-part>a{width:auto;display:inline-block;border-right:none}.navbar .left-logo-part .egg-search-box,.navbar .left-logo-part .egg-search-box input{right:0;left:auto}.navbar .left-logo-part .egg-search-box input:focus{right:0}.navbar .can-hide{display:none}.navbar .links{padding-left:1.5rem}}.page-edit,.page-nav{margin:0 auto;padding:3rem 4rem}@media (max-width:1000px){.page-edit,.page-nav{padding:2rem}}@media (max-width:419px){.page-edit,.page-nav{padding:1.5rem}}.page{padding-bottom:2rem;display:block;min-height:100vh}.page-edit{padding-top:1rem;padding-bottom:1rem;overflow:auto;font-size:.9em}.page-edit .edit-link{float:right}.page-edit .edit-link .outbound{display:inline-block!important}.page-edit .edit-link a{color:#4e6e8e;margin-right:.25rem}.page-edit .last-updated{display:inline-block}.page-edit .last-updated .prefix{font-weight:500;color:#4e6e8e}.page-edit .last-updated .time{font-weight:400;color:#aaa}.page-nav{padding-top:1rem;padding-bottom:0;font-size:1rem;line-height:1.5rem}.page-nav .inner{min-height:2rem;margin-top:0;border-top:1px solid #eaecef;padding-top:3rem;overflow:auto}.page-nav .inner a{color:#698c7f}.page-nav .inner a:hover span{color:#00b362}.page-nav .inner span{font-size:1rem;color:#0d261d}.page-nav .prev{float:left}.page-nav .next{float:right}@media (max-width:719px){.page-edit .edit-link{margin-bottom:.5rem}.page-edit .last-updated{font-size:.8em;float:none;text-align:left}}.global-foot{position:relative}.global-foot .friend-links{background:#06080a;min-height:28.38rem;color:hsla(0,0%,100%,.65);border-bottom:1px solid #2b2b2b;box-sizing:border-box}.global-foot .friend-links *{color:hsla(0,0%,100%,.65)}.global-foot .friend-links .friend-list-wrapper{max-width:80rem;display:flex;margin:auto;padding-top:5.38rem}.global-foot .friend-links .friend-list{flex:1;text-align:center}.global-foot .friend-links .friend-list-align{display:inline-block;text-align:left}.global-foot .friend-links .friend-list-item{display:block;font-size:.88rem;line-height:1.38rem;margin:.75rem 0}.global-foot .friend-links .friend-list-item:hover{color:#fff}.global-foot .friend-links .friend-list-title{font-size:1rem;color:#fff;line-height:1.5rem;padding-bottom:.75rem;font-weight:700}.global-foot .friend-links .friend-qrcode{width:11.63rem;height:11.31rem;background:#fff;margin:.75rem auto;border-radius:.25rem;overflow:hidden}.global-foot .friend-links .friend-qrcode img{width:100%;height:100%}.global-foot .copyright{display:flex;min-height:4rem;align-items:center;justify-content:center;background:#06080a;font-size:1rem;color:hsla(0,0%,100%,.65);padding:1rem 0;box-sizing:border-box}.global-foot .copyright span{margin:0 .5rem}.global-foot .copyright *{color:hsla(0,0%,100%,.65)}@media (max-width:719px){.global-foot .friend-links .friend-list-wrapper{display:block;padding-top:2rem}.global-foot .friend-links .friend-list-wrapper .friend-list{display:block;margin-bottom:2rem}.global-foot .friend-links .friend-list-wrapper .friend-list-align{width:21.88rem;text-align:center}}.sidebar-item{margin-right:-1px}.sidebar-item .sidebar-item-heading{position:relative;font-size:1rem;color:#0d261d;line-height:2.5rem;box-sizing:border-box;padding-left:3rem;padding-right:1rem;display:block;cursor:pointer}.sidebar-item .sidebar-group-items a.active,.sidebar-item .sidebar-item-heading.active{border-right:2px solid #00b362}.sidebar-item .sidebar-group-items a.active,.sidebar-item .sidebar-group-items a:hover,.sidebar-item .sidebar-item-heading.active,.sidebar-item .sidebar-item-heading:hover{background:rgba(60,241,173,.15)}.sidebar-item .arrow{position:absolute;left:2rem;top:0;bottom:0;margin:auto}.sidebar-item .sidebar-group-items{transition:height .1s ease-out;overflow:hidden;line-height:2rem;font-size:.88rem}.sidebar-item .sidebar-group-items a{color:#315947;padding-left:4rem;padding-right:1rem}@media (max-width:719px){.sidebar-item{margin-left:-1.5rem}}.sidebar{font-size:15px;background-color:#fff;width:17.5rem;position:fixed;z-index:10;margin:0;height:100vh;left:0;top:0;box-sizing:border-box;overflow-y:auto;padding-top:7rem}.sidebar .sidebar-wrap{min-height:100%;border-right:1px solid #eaecef;padding-bottom:3rem;box-sizing:border-box}.sidebar ul{padding:0;margin:0;list-style-type:none}.sidebar a{display:block}.sidebar .nav-links{display:none;border-bottom:1px solid #eaecef;padding:.5rem 0 .75rem}.sidebar .nav-links a{font-weight:600;font-size:1rem}.sidebar .nav-links .nav-item,.sidebar .nav-links .repo-link{display:block;line-height:1.25rem;font-size:1.1em;padding:.2rem 0 .2rem 1.18rem}@media (max-width:719px){.sidebar .nav-links{display:block}}code[class*=language-],pre[class*=language-]{color:#ccc;background:none;font-family:Consolas,Monaco,Andale Mono,Ubuntu Mono,monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}.content code{color:#476582;padding:.25rem .5rem;margin:0;font-size:.85em;background-color:rgba(27,31,35,.05);border-radius:3px}.content code .token.deleted{color:#ec5975}.content code .token.inserted{color:#3eaf7c}.content pre,.content pre[class*=language-]{line-height:1.4;padding:1.25rem 1.5rem;margin:.85rem 0;background-color:#282c34;border-radius:6px;overflow:auto}.content pre[class*=language-] code,.content pre code{color:#fff;padding:0;background-color:transparent;border-radius:0}div[class*=language-]{position:relative;background-color:#282c34;border-radius:6px}div[class*=language-] .highlight-lines{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;padding-top:1.3rem;position:absolute;top:0;left:0;width:100%;line-height:1.4}div[class*=language-] .highlight-lines .highlighted{background-color:rgba(0,0,0,.66)}div[class*=language-] pre,div[class*=language-] pre[class*=language-]{background:transparent;position:relative;z-index:1}div[class*=language-]:before{position:absolute;z-index:3;top:.8em;right:1em;font-size:.75rem;color:hsla(0,0%,100%,.4)}div[class*=language-]:not(.line-numbers-mode) .line-numbers-wrapper{display:none}div[class*=language-].line-numbers-mode .highlight-lines .highlighted{position:relative}div[class*=language-].line-numbers-mode .highlight-lines .highlighted:before{content:" ";position:absolute;z-index:3;left:0;top:0;display:block;width:3.5rem;height:100%;background-color:rgba(0,0,0,.66)}div[class*=language-].line-numbers-mode pre{padding-left:4.5rem;vertical-align:middle}div[class*=language-].line-numbers-mode .line-numbers-wrapper{position:absolute;top:0;width:3.5rem;text-align:center;color:hsla(0,0%,100%,.3);padding:1.25rem 0;line-height:1.4}div[class*=language-].line-numbers-mode .line-numbers-wrapper .line-number,div[class*=language-].line-numbers-mode .line-numbers-wrapper br{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}div[class*=language-].line-numbers-mode .line-numbers-wrapper .line-number{position:relative;z-index:4;font-size:.85em}div[class*=language-].line-numbers-mode:after{content:"";position:absolute;z-index:2;top:0;left:0;width:3.5rem;height:100%;border-radius:6px 0 0 6px;border-right:1px solid rgba(0,0,0,.66);background-color:#282c34}.language-log pre code{word-break:break-all;white-space:normal}div[class~=language-js]:before{content:"js"}div[class~=language-ts]:before{content:"ts"}div[class~=language-html]:before{content:"html"}div[class~=language-md]:before{content:"md"}div[class~=language-vue]:before{content:"vue"}div[class~=language-css]:before{content:"css"}div[class~=language-sass]:before{content:"sass"}div[class~=language-scss]:before{content:"scss"}div[class~=language-less]:before{content:"less"}div[class~=language-stylus]:before{content:"stylus"}div[class~=language-go]:before{content:"go"}div[class~=language-java]:before{content:"java"}div[class~=language-c]:before{content:"c"}div[class~=language-sh]:before{content:"sh"}div[class~=language-yaml]:before{content:"yaml"}div[class~=language-py]:before{content:"py"}div[class~=language-docker]:before{content:"docker"}div[class~=language-dockerfile]:before{content:"dockerfile"}div[class~=language-makefile]:before{content:"makefile"}div[class~=language-javascript]:before{content:"js"}div[class~=language-typescript]:before{content:"ts"}div[class~=language-markup]:before{content:"html"}div[class~=language-markdown]:before{content:"md"}div[class~=language-json]:before{content:"json"}div[class~=language-ruby]:before{content:"rb"}div[class~=language-python]:before{content:"py"}div[class~=language-bash]:before{content:"sh"}div[class~=language-php]:before{content:"php"}.custom-block .custom-block-title{font-weight:600;margin-bottom:-.4rem}.custom-block.danger,.custom-block.tip,.custom-block.warning{padding:.1rem 1.5rem;border-left-width:.5rem;border-left-style:solid;margin:1rem 0}.custom-block.tip{background-color:#f3f5f7;border-color:#42b983}.custom-block.warning{background-color:rgba(255,229,100,.3);border-color:#e7c000;color:#6b5900}.custom-block.warning .custom-block-title{color:#b29400}.custom-block.warning a{color:#2c3e50}.custom-block.danger{background-color:#ffe6e6;border-color:#c00;color:#4d0000}.custom-block.danger .custom-block-title{color:#900}.custom-block.danger a{color:#2c3e50}pre.vue-container{border-left:.5rem solid;border-color:#42b983;border-radius:0}pre.vue-container>code{font-size:14px!important}pre.vue-container>code>p{margin:-5px 0 -20px}pre.vue-container>code code{background-color:#42b983!important;padding:3px 5px;border-radius:3px;color:#000}pre.vue-container>code em{color:grey;font-weight:light}.arrow{display:inline-block;width:0;height:0}.arrow.up{border-bottom:6px solid #698c7f}.arrow.down,.arrow.up{border-left:4px solid transparent;border-right:4px solid transparent}.arrow.down{border-top:6px solid #698c7f}.arrow.right{border-left:6px solid #698c7f}.arrow.left,.arrow.right{border-top:4px solid transparent;border-bottom:4px solid transparent}.arrow.left{border-right:6px solid #698c7f}.content:not(.custom){margin:0 auto;padding:3rem 4rem}@media (max-width:1000px){.content:not(.custom){padding:2rem}}@media (max-width:419px){.content:not(.custom){padding:1.5rem}}body,html{padding:0;margin:0;background-color:#fff;width:100%;overflow-x:hidden}*{-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Helvetica,PingFang SC,Hiragino Sans GB,Microsoft YaHei,SimSun,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-size:1rem;color:#2c3e50}.outbound{display:none!important}.theme-container{position:relative}.page{padding-left:17.5rem}.sidebar-mask{position:fixed;z-index:9;top:0;left:0;width:100vw;height:100vh;display:none}.content img{max-width:100%;display:block;box-shadow:0 0 1px rgba(0,0,0,.5);border-radius:2px}.content .md-img-wrapper:after{content:"";display:block;clear:both}.content .align-left{margin-left:0}.content .align-right{float:right}.content .align-center{margin-left:auto;margin-right:auto}.content:not(.custom)>:first-child{margin-top:4rem}.content:not(.custom) a:hover{text-decoration:underline}.content:not(.custom) p.demo{padding:1rem 1.5rem;border:1px solid #ddd;border-radius:4px}.content.custom{padding:0;margin:0}a{font-weight:500;text-decoration:none}a,p a code{color:#04ae62}p a code{font-weight:400}kbd{background:#eee;border:.15rem solid #ddd;border-bottom:.25rem solid #ddd;border-radius:.15rem;padding:0 .15em}blockquote{font-size:.9rem;color:#999;border-left:.5rem solid #dfe2e5;margin:.5rem 0;padding:.25rem 0 .25rem 1rem}blockquote>p{margin:0}ol,ul{padding-left:1.2em}strong{font-weight:600}h1,h2,h3,h4,h5,h6{font-weight:600;line-height:1.25}.content:not(.custom)>h1,.content:not(.custom)>h2,.content:not(.custom)>h3,.content:not(.custom)>h4,.content:not(.custom)>h5,.content:not(.custom)>h6{margin-top:-3.5rem;padding-top:5rem;margin-bottom:0}.content:not(.custom)>h1:first-child,.content:not(.custom)>h2:first-child,.content:not(.custom)>h3:first-child,.content:not(.custom)>h4:first-child,.content:not(.custom)>h5:first-child,.content:not(.custom)>h6:first-child{margin-top:-1.5rem;margin-bottom:1rem}.content:not(.custom)>h1:first-child+.custom-block,.content:not(.custom)>h1:first-child+p,.content:not(.custom)>h1:first-child+pre,.content:not(.custom)>h2:first-child+.custom-block,.content:not(.custom)>h2:first-child+p,.content:not(.custom)>h2:first-child+pre,.content:not(.custom)>h3:first-child+.custom-block,.content:not(.custom)>h3:first-child+p,.content:not(.custom)>h3:first-child+pre,.content:not(.custom)>h4:first-child+.custom-block,.content:not(.custom)>h4:first-child+p,.content:not(.custom)>h4:first-child+pre,.content:not(.custom)>h5:first-child+.custom-block,.content:not(.custom)>h5:first-child+p,.content:not(.custom)>h5:first-child+pre,.content:not(.custom)>h6:first-child+.custom-block,.content:not(.custom)>h6:first-child+p,.content:not(.custom)>h6:first-child+pre{margin-top:2rem}h1:hover .header-anchor,h2:hover .header-anchor,h3:hover .header-anchor,h4:hover .header-anchor,h5:hover .header-anchor,h6:hover .header-anchor{opacity:1}h1{font-size:2.2rem}h2{font-size:1.65rem;padding-bottom:.3rem;border-bottom:1px solid #eaecef}h3{font-size:1.35rem}a.header-anchor{font-size:.85em;float:left;margin-left:-.87em;padding-right:.23em;margin-top:.125em;opacity:0}a.header-anchor:hover{text-decoration:none}.line-number,code,kbd{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}ol,p,ul{line-height:1.7}hr{border:0;border-top:1px solid #eaecef}table{border-collapse:collapse;margin:1rem 0;display:block;overflow-x:auto}tr{border-top:1px solid #dfe2e5}tr:nth-child(2n){background-color:#f6f8fa}td,th{border:1px solid #dfe2e5;padding:.6em 1em}.theme-container.sidebar-open .sidebar-mask{display:block}.theme-container.no-navbar .content:not(.custom)>h1,.theme-container.no-navbar h2,.theme-container.no-navbar h3,.theme-container.no-navbar h4,.theme-container.no-navbar h5,.theme-container.no-navbar h6{margin-top:1.5rem;padding-top:0}.theme-container.no-navbar .sidebar{top:0}@media (min-width:720px){.theme-container.no-sidebar .sidebar{display:none}.theme-container.no-sidebar .content{padding-left:0}}@media (min-width:1000px){.page{padding-right:11.5rem}}@media (max-width:1000px){.sidebar{font-size:15px;width:14.35rem}.page{padding-left:14.35rem}.no-sidebar .page{padding-left:2rem}}@media (max-width:719px){.sidebar{top:0;padding-top:4rem;transform:translateX(-100%);transition:transform .2s ease}.page{padding-left:0!important}.theme-container.sidebar-open .sidebar{transform:translateX(0)}.theme-container.no-navbar .sidebar{padding-top:0}}@media (max-width:419px){h1{font-size:1.9rem}.content div[class*=language-]{margin:.85rem -1.5rem;border-radius:0}}.module-layout .content{text-align:center;max-width:1000px}.module-layout h1{position:relative;font-size:1.5rem;height:1.75rem;line-height:1.75rem}.module-layout h1>a{display:none}.module-layout h1:after{content:"";position:absolute;width:3.5rem;height:2px;background-color:#16d98e;bottom:-1rem;left:0;right:0;margin:auto}.module-layout .ecosystem-list{padding-top:.38rem}.module-layout .ecosystem-item{position:relative;width:16.44rem;height:11rem;padding:1rem 1.5rem;box-sizing:border-box;box-shadow:0 1.06rem 2.38rem -1.25rem #ced9d5;display:inline-block;overflow:hidden;text-align:left;margin:1.5rem;transition:box-shadow .3s}.module-layout .ecosystem-item:hover{box-shadow:0 1.06rem 2.38rem -.5rem #ced9d5}.module-layout .ecosystem-item>h2{font-size:1rem;border-bottom:none;height:1.5rem;line-height:1.5rem;margin:0}.module-layout .ecosystem-item>ul{height:6rem;overflow:hidden}.module-layout .ecosystem-item ul{padding-left:1.25rem;color:#698c7f;margin:0}.module-layout .ecosystem-item li{font-size:.75rem;text-align:justify;height:1.25rem;line-height:1.25rem;margin:.25rem 0}.module-layout .ecosystem-item li a{color:#698c7f}.module-layout .ecosystem-item li a:hover{color:#16d98e}.badge[data-v-e8bb0bea]{display:inline-block;font-size:14px;height:18px;line-height:18px;border-radius:3px;padding:0 6px;color:#fff;margin-right:5px;background-color:#42b983}.badge.middle[data-v-e8bb0bea]{vertical-align:middle}.badge.top[data-v-e8bb0bea]{vertical-align:top}.badge.green[data-v-e8bb0bea],.badge.tip[data-v-e8bb0bea]{background-color:#42b983}.badge.error[data-v-e8bb0bea]{background-color:#da5961}.badge.warn[data-v-e8bb0bea],.badge.warning[data-v-e8bb0bea],.badge.yellow[data-v-e8bb0bea]{background-color:#e7c000} \ No newline at end of file diff --git a/assets/img/middleware.5fabc0c7.gif b/assets/img/middleware.5fabc0c7.gif new file mode 100644 index 0000000..784fc73 Binary files /dev/null and b/assets/img/middleware.5fabc0c7.gif differ diff --git a/assets/img/onion.2972bdca.png b/assets/img/onion.2972bdca.png new file mode 100644 index 0000000..a9fc265 Binary files /dev/null and b/assets/img/onion.2972bdca.png differ diff --git a/assets/img/todomvc.c40dbbd3.png b/assets/img/todomvc.c40dbbd3.png new file mode 100644 index 0000000..cbb6fe5 Binary files /dev/null and b/assets/img/todomvc.c40dbbd3.png differ diff --git a/assets/js/1.afc15f94.js b/assets/js/1.afc15f94.js new file mode 100644 index 0000000..d3afdf2 --- /dev/null +++ b/assets/js/1.afc15f94.js @@ -0,0 +1 @@ +(window.webpackJsonp=window.webpackJsonp||[]).push([[1],[,,,,,,,,,,function(t,n,e){var r=e(13),i=e(34),o=e(17),u=e(27),s=e(47),c=function(t,n,e){var a,f,l,p,h=t&c.F,v=t&c.G,d=t&c.S,g=t&c.P,y=t&c.B,x=v?r:d?r[n]||(r[n]={}):(r[n]||{}).prototype,m=v?i:i[n]||(i[n]={}),b=m.prototype||(m.prototype={});for(a in v&&(e=n),e)l=((f=!h&&x&&void 0!==x[a])?x:e)[a],p=y&&f?s(l,r):g&&"function"==typeof l?s(Function.call,l):l,x&&u(x,a,l,t&c.U),m[a]!=l&&o(m,a,p),g&&b[a]!=l&&(b[a]=l)};r.core=i,c.F=1,c.G=2,c.S=4,c.P=8,c.B=16,c.W=32,c.U=64,c.R=128,t.exports=c},function(t,n,e){var r=e(46)("wks"),i=e(45),o=e(13).Symbol,u="function"==typeof o;(t.exports=function(t){return r[t]||(r[t]=u&&o[t]||(u?o:i)("Symbol."+t))}).store=r},function(t,n){t.exports=function(t){try{return!!t()}catch(t){return!0}}},function(t,n){var e=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=e)},function(t,n,e){var r=e(18);t.exports=function(t){if(!r(t))throw TypeError(t+" is not an object!");return t}},function(t,n,e){var r=e(104)("wks"),i=e(105),o=e(24).Symbol,u="function"==typeof o;(t.exports=function(t){return r[t]||(r[t]=u&&o[t]||(u?o:i)("Symbol."+t))}).store=r},function(t,n,e){"use strict";e.d(n,"a",function(){return o}),e.d(n,"j",function(){return u}),e.d(n,"c",function(){return s}),e.d(n,"i",function(){return c}),e.d(n,"f",function(){return f}),e.d(n,"g",function(){return l}),e.d(n,"h",function(){return p}),e.d(n,"d",function(){return h}),e.d(n,"b",function(){return v}),e.d(n,"e",function(){return d}),e.d(n,"l",function(){return g}),e.d(n,"m",function(){return x}),e.d(n,"k",function(){return m});const r=/#.*$/,i=/\.(md|html)$/,o=/\/$/,u=/^(https?:|mailto:|tel:)/;function s(t,n,e){if(!t)return e;let r,i=n;for(;(i=i.$parent)&&!r;)r=i.$refs[t];return r&&r.$el&&(r=r.$el),r||e}function c(t){return decodeURI(t).replace(r,"").replace(i,"")}function a(t){const n=t.match(r);if(n)return n[0]}function f(t){return u.test(t)}function l(t){return/^mailto:/.test(t)}function p(t){return/^tel:/.test(t)}function h(t){return t&&t.getBoundingClientRect?t.getBoundingClientRect().top+document.body.scrollTop+document.documentElement.scrollTop:0}function v(t){if(f(t))return t;const n=t.match(r),e=n?n[0]:"",i=c(t);return o.test(i)?t:i+".html"+e}function d(t,n){const e=t.hash,r=a(n);return(!r||e===r)&&c(t.path)===c(n)}function g(t,n,e){e&&(n=y(n,e));const r=c(n);for(let n=0;nObject.assign({},t))).forEach(t=>{2===t.level?n=t:n&&(n.children||(n.children=[])).push(t)}),t.filter(t=>2===t.level)}(t.headers||[]);return[{type:"group",collapsable:!1,title:t.title,children:n.map(n=>({type:"auto",title:n.title,basePath:t.path,path:t.path+"#"+n.slug,children:n.children||[]}))}]}(t);const s=u.sidebar||o.sidebar;if(!s)return[];const{base:c,config:a}=function(t,n){if(Array.isArray(n))return{base:"/",config:n};for(const r in n)if(0===(e=t,/(\.html|\/)$/.test(e)?e:e+"/").indexOf(r))return{base:r,config:n[r]};var e;return{}}(n,s);return a?a.map(t=>(function t(n,e,r,i){if("string"==typeof n)return g(e,n,r);if(Array.isArray(n))return Object.assign(g(e,n[0],r),{title:n[1]});i&&console.error("[vuepress] Nested sidebar groups are not supported. Consider using navbar + categories instead.");const o=n.children||[];return{type:"group",title:n.title,children:o.map(n=>t(n,e,r,!0)),collapsable:!1!==n.collapsable}})(t,i,c)):[]}function m(t,n,e="/",r=1){const{pages:i}=n;return t.map(t=>{if("string"==typeof t){const n=g(i,t,t.startsWith("/")?"/":e);return{text:n.title,link:n.regularPath,items:[],type:"link"}}let o=e;if(t.link){t.link=v(y(t.link,e));const n=t.link.split(/#|\?/)[0];o=(o=n.endsWith("/")?n:n.endsWith(".html")?n.substring(0,n.lastIndexOf("/")+1)||"/":`${n}/`).startsWith("/")?o:`${e}${o}`}return t.items&&r<3&&(t.items=m(t.items,n,o,r+1)),t.text=t.text||(a(t.link)||"").substring(1),t.type=t.items&&t.items.length?"links":"link",t})}},function(t,n,e){var r=e(26),i=e(44);t.exports=e(19)?function(t,n,e){return r.f(t,n,i(1,e))}:function(t,n,e){return t[n]=e,t}},function(t,n){t.exports=function(t){return"object"==typeof t?null!==t:"function"==typeof t}},function(t,n,e){t.exports=!e(12)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(t,n){t.exports=function(t){if(null==t)throw TypeError("Can't call method on "+t);return t}},function(t,n){var e=t.exports={version:"2.6.9"};"number"==typeof __e&&(__e=e)},function(t,n,e){var r=e(35),i=Math.min;t.exports=function(t){return t>0?i(r(t),9007199254740991):0}},function(t,n,e){"use strict";var r=e(12);t.exports=function(t,n){return!!t&&r(function(){n?t.call(null,function(){},1):t.call(null)})}},function(t,n){var e=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=e)},function(t,n){t.exports={}},function(t,n,e){var r=e(14),i=e(85),o=e(87),u=Object.defineProperty;n.f=e(19)?Object.defineProperty:function(t,n,e){if(r(t),n=o(n,!0),r(e),i)try{return u(t,n,e)}catch(t){}if("get"in e||"set"in e)throw TypeError("Accessors not supported!");return"value"in e&&(t[n]=e.value),t}},function(t,n,e){var r=e(13),i=e(17),o=e(28),u=e(45)("src"),s=e(124),c=(""+s).split("toString");e(34).inspectSource=function(t){return s.call(t)},(t.exports=function(t,n,e,s){var a="function"==typeof e;a&&(o(e,"name")||i(e,"name",n)),t[n]!==e&&(a&&(o(e,u)||i(e,u,t[n]?""+t[n]:c.join(String(n)))),t===r?t[n]=e:s?t[n]?t[n]=e:i(t,n,e):(delete t[n],i(t,n,e)))})(Function.prototype,"toString",function(){return"function"==typeof this&&this[u]||s.call(this)})},function(t,n){var e={}.hasOwnProperty;t.exports=function(t,n){return e.call(t,n)}},function(t,n,e){var r=e(47),i=e(91),o=e(30),u=e(22),s=e(125);t.exports=function(t,n){var e=1==t,c=2==t,a=3==t,f=4==t,l=6==t,p=5==t||l,h=n||s;return function(n,s,v){for(var d,g,y=o(n),x=i(y),m=r(s,v,3),b=u(x.length),k=0,_=e?h(n,b):c?h(n,0):void 0;b>k;k++)if((p||k in x)&&(g=m(d=x[k],k,y),t))if(e)_[k]=g;else if(g)switch(t){case 3:return!0;case 5:return d;case 6:return k;case 2:_.push(d)}else if(f)return!1;return l?-1:a||f?f:_}}},function(t,n,e){var r=e(20);t.exports=function(t){return Object(r(t))}},function(t,n,e){var r=e(32),i=e(61);t.exports=e(33)?function(t,n,e){return r.f(t,n,i(1,e))}:function(t,n,e){return t[n]=e,t}},function(t,n,e){var r=e(39),i=e(154),o=e(155),u=Object.defineProperty;n.f=e(33)?Object.defineProperty:function(t,n,e){if(r(t),n=o(n,!0),r(e),i)try{return u(t,n,e)}catch(t){}if("get"in e||"set"in e)throw TypeError("Accessors not supported!");return"value"in e&&(t[n]=e.value),t}},function(t,n,e){t.exports=!e(98)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(t,n){var e=t.exports={version:"2.6.9"};"number"==typeof __e&&(__e=e)},function(t,n){var e=Math.ceil,r=Math.floor;t.exports=function(t){return isNaN(t=+t)?0:(t>0?r:e)(t)}},function(t,n){var e={}.toString;t.exports=function(t){return e.call(t).slice(8,-1)}},function(t,n,e){var r=e(91),i=e(20);t.exports=function(t){return r(i(t))}},function(t,n,e){var r=e(24),i=e(21),o=e(97),u=e(31),s=e(40),c=function(t,n,e){var a,f,l,p=t&c.F,h=t&c.G,v=t&c.S,d=t&c.P,g=t&c.B,y=t&c.W,x=h?i:i[n]||(i[n]={}),m=x.prototype,b=h?r:v?r[n]:(r[n]||{}).prototype;for(a in h&&(e=n),e)(f=!p&&b&&void 0!==b[a])&&s(x,a)||(l=f?b[a]:e[a],x[a]=h&&"function"!=typeof b[a]?e[a]:g&&f?o(l,r):y&&b[a]==l?function(t){var n=function(n,e,r){if(this instanceof t){switch(arguments.length){case 0:return new t;case 1:return new t(n);case 2:return new t(n,e)}return new t(n,e,r)}return t.apply(this,arguments)};return n.prototype=t.prototype,n}(l):d&&"function"==typeof l?o(Function.call,l):l,d&&((x.virtual||(x.virtual={}))[a]=l,t&c.R&&m&&!m[a]&&u(m,a,l)))};c.F=1,c.G=2,c.S=4,c.P=8,c.B=16,c.W=32,c.U=64,c.R=128,t.exports=c},function(t,n,e){var r=e(60);t.exports=function(t){if(!r(t))throw TypeError(t+" is not an object!");return t}},function(t,n){var e={}.hasOwnProperty;t.exports=function(t,n){return e.call(t,n)}},function(t,n,e){for(var r=e(184),i=e(68),o=e(27),u=e(13),s=e(17),c=e(67),a=e(11),f=a("iterator"),l=a("toStringTag"),p=c.Array,h={CSSRuleList:!0,CSSStyleDeclaration:!1,CSSValueList:!1,ClientRectList:!1,DOMRectList:!1,DOMStringList:!1,DOMTokenList:!0,DataTransferItemList:!1,FileList:!1,HTMLAllCollection:!1,HTMLCollection:!1,HTMLFormElement:!1,HTMLSelectElement:!1,MediaList:!0,MimeTypeArray:!1,NamedNodeMap:!1,NodeList:!0,PaintRequestList:!1,Plugin:!1,PluginArray:!1,SVGLengthList:!1,SVGNumberList:!1,SVGPathSegList:!1,SVGPointList:!1,SVGStringList:!1,SVGTransformList:!1,SourceBufferList:!1,StyleSheetList:!0,TextTrackCueList:!1,TextTrackList:!1,TouchList:!1},v=i(h),d=0;df;)if((s=c[f++])!=s)return!0}else for(;a>f;f++)if((t||f in c)&&c[f]===e)return t||f||0;return!t&&-1}}},function(t,n,e){var r=e(11)("unscopables"),i=Array.prototype;null==i[r]&&e(17)(i,r,{}),t.exports=function(t){i[r][t]=!0}},function(t,n,e){"use strict";var r=e(134)(!0);t.exports=function(t,n,e){return n+(e?r(t,n).length:1)}},function(t,n,e){"use strict";var r=e(135),i=RegExp.prototype.exec;t.exports=function(t,n){var e=t.exec;if("function"==typeof e){var o=e.call(t,n);if("object"!=typeof o)throw new TypeError("RegExp exec method returned something other than an Object or null");return o}if("RegExp"!==r(t))throw new TypeError("RegExp#exec called on incompatible receiver");return i.call(t,n)}},function(t,n,e){"use strict";var r,i,o=e(94),u=RegExp.prototype.exec,s=String.prototype.replace,c=u,a=(r=/a/,i=/b*/g,u.call(r,"a"),u.call(i,"a"),0!==r.lastIndex||0!==i.lastIndex),f=void 0!==/()??/.exec("")[1];(a||f)&&(c=function(t){var n,e,r,i,c=this;return f&&(e=new RegExp("^"+c.source+"$(?!\\s)",o.call(c))),a&&(n=c.lastIndex),r=u.call(c,t),a&&r&&(c.lastIndex=c.global?r.index+r[0].length:n),f&&r&&r.length>1&&s.call(r[0],e,function(){for(i=1;i")}),l=function(){var t=/(?:)/,n=t.exec;t.exec=function(){return n.apply(this,arguments)};var e="ab".split(t);return 2===e.length&&"a"===e[0]&&"b"===e[1]}();t.exports=function(t,n,e){var p=s(t),h=!o(function(){var n={};return n[p]=function(){return 7},7!=""[t](n)}),v=h?!o(function(){var n=!1,e=/a/;return e.exec=function(){return n=!0,null},"split"===t&&(e.constructor={},e.constructor[a]=function(){return e}),e[p](""),!n}):void 0;if(!h||!v||"replace"===t&&!f||"split"===t&&!l){var d=/./[p],g=e(u,p,""[t],function(t,n,e,r,i){return n.exec===c?h&&!i?{done:!0,value:d.call(n,e,r)}:{done:!0,value:t.call(e,n,r)}:{done:!1}}),y=g[0],x=g[1];r(String.prototype,t,y),i(RegExp.prototype,p,2==n?function(t,n){return x.call(t,this,n)}:function(t){return x.call(t,this)})}}},function(t,n,e){},function(t,n,e){var r=e(46)("keys"),i=e(45);t.exports=function(t){return r[t]||(r[t]=i(t))}},function(t,n){t.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(t,n){t.exports=function(t){return"object"==typeof t?null!==t:"function"==typeof t}},function(t,n){t.exports=function(t,n){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:n}}},function(t,n){var e={}.toString;t.exports=function(t){return e.call(t).slice(8,-1)}},function(t,n){var e=Math.ceil,r=Math.floor;t.exports=function(t){return isNaN(t=+t)?0:(t>0?r:e)(t)}},function(t,n){t.exports=function(t){if(null==t)throw TypeError("Can't call method on "+t);return t}},function(t,n,e){var r=e(166),i=e(64);t.exports=function(t){return r(i(t))}},function(t,n,e){var r=e(104)("keys"),i=e(105);t.exports=function(t){return r[t]||(r[t]=i(t))}},function(t,n){t.exports={}},function(t,n,e){var r=e(96),i=e(59);t.exports=Object.keys||function(t){return r(t,i)}},function(t,n,e){},function(t,n,e){},function(t,n,e){},function(t,n,e){},function(t,n,e){},function(t,n,e){},function(t,n,e){},,,,,,,,,function(t,n,e){"use strict";var r=e(10),i=e(22),o=e(89),u="".startsWith;r(r.P+r.F*e(90)("startsWith"),"String",{startsWith:function(t){var n=o(this,t,"startsWith"),e=i(Math.min(arguments.length>1?arguments[1]:void 0,n.length)),r=String(t);return u?u.call(n,r,e):n.slice(e,e+r.length)===r}})},function(t,n,e){t.exports=!e(19)&&!e(12)(function(){return 7!=Object.defineProperty(e(86)("div"),"a",{get:function(){return 7}}).a})},function(t,n,e){var r=e(18),i=e(13).document,o=r(i)&&r(i.createElement);t.exports=function(t){return o?i.createElement(t):{}}},function(t,n,e){var r=e(18);t.exports=function(t,n){if(!r(t))return t;var e,i;if(n&&"function"==typeof(e=t.toString)&&!r(i=e.call(t)))return i;if("function"==typeof(e=t.valueOf)&&!r(i=e.call(t)))return i;if(!n&&"function"==typeof(e=t.toString)&&!r(i=e.call(t)))return i;throw TypeError("Can't convert object to primitive value")}},function(t,n){t.exports=!1},function(t,n,e){var r=e(49),i=e(20);t.exports=function(t,n,e){if(r(n))throw TypeError("String#"+e+" doesn't accept regex!");return String(i(t))}},function(t,n,e){var r=e(11)("match");t.exports=function(t){var n=/./;try{"/./"[t](n)}catch(e){try{return n[r]=!1,!"/./"[t](n)}catch(t){}}return!0}},function(t,n,e){var r=e(36);t.exports=Object("z").propertyIsEnumerable(0)?Object:function(t){return"String"==r(t)?t.split(""):Object(t)}},function(t,n,e){var r=e(36);t.exports=Array.isArray||function(t){return"Array"==r(t)}},function(t,n,e){"use strict";var r=e(49),i=e(14),o=e(133),u=e(53),s=e(22),c=e(54),a=e(55),f=e(12),l=Math.min,p=[].push,h=!f(function(){RegExp(4294967295,"y")});e(56)("split",2,function(t,n,e,f){var v;return v="c"=="abbc".split(/(b)*/)[1]||4!="test".split(/(?:)/,-1).length||2!="ab".split(/(?:ab)*/).length||4!=".".split(/(.?)(.?)/).length||".".split(/()()/).length>1||"".split(/.?/).length?function(t,n){var i=String(this);if(void 0===t&&0===n)return[];if(!r(t))return e.call(i,t,n);for(var o,u,s,c=[],f=(t.ignoreCase?"i":"")+(t.multiline?"m":"")+(t.unicode?"u":"")+(t.sticky?"y":""),l=0,h=void 0===n?4294967295:n>>>0,v=new RegExp(t.source,f+"g");(o=a.call(v,i))&&!((u=v.lastIndex)>l&&(c.push(i.slice(l,o.index)),o.length>1&&o.index=h));)v.lastIndex===o.index&&v.lastIndex++;return l===i.length?!s&&v.test("")||c.push(""):c.push(i.slice(l)),c.length>h?c.slice(0,h):c}:"0".split(void 0,0).length?function(t,n){return void 0===t&&0===n?[]:e.call(this,t,n)}:e,[function(e,r){var i=t(this),o=null==e?void 0:e[n];return void 0!==o?o.call(e,i,r):v.call(String(i),e,r)},function(t,n){var r=f(v,t,this,n,v!==e);if(r.done)return r.value;var a=i(t),p=String(this),d=o(a,RegExp),g=a.unicode,y=(a.ignoreCase?"i":"")+(a.multiline?"m":"")+(a.unicode?"u":"")+(h?"y":"g"),x=new d(h?a:"^(?:"+a.source+")",y),m=void 0===n?4294967295:n>>>0;if(0===m)return[];if(0===p.length)return null===c(x,p)?[p]:[];for(var b=0,k=0,_=[];kc;)r(s,e=n[c++])&&(~o(a,e)||a.push(e));return a}},function(t,n,e){var r=e(153);t.exports=function(t,n,e){if(r(t),void 0===n)return t;switch(e){case 1:return function(e){return t.call(n,e)};case 2:return function(e,r){return t.call(n,e,r)};case 3:return function(e,r,i){return t.call(n,e,r,i)}}return function(){return t.apply(n,arguments)}}},function(t,n){t.exports=function(t){try{return!!t()}catch(t){return!0}}},function(t,n,e){var r=e(60),i=e(24).document,o=r(i)&&r(i.createElement);t.exports=function(t){return o?i.createElement(t):{}}},function(t,n,e){"use strict";var r=e(159)(!0);e(101)(String,"String",function(t){this._t=String(t),this._i=0},function(){var t,n=this._t,e=this._i;return e>=n.length?{value:void 0,done:!0}:(t=r(n,e),this._i+=t.length,{value:t,done:!1})})},function(t,n,e){"use strict";var r=e(102),i=e(38),o=e(160),u=e(31),s=e(25),c=e(161),a=e(107),f=e(170),l=e(15)("iterator"),p=!([].keys&&"next"in[].keys()),h=function(){return this};t.exports=function(t,n,e,v,d,g,y){c(e,n,v);var x,m,b,k=function(t){if(!p&&t in O)return O[t];switch(t){case"keys":case"values":return function(){return new e(this,t)}}return function(){return new e(this,t)}},_=n+" Iterator",w="values"==d,S=!1,O=t.prototype,L=O[l]||O["@@iterator"]||d&&O[d],C=L||k(d),$=d?w?k("entries"):C:void 0,j="Array"==n&&O.entries||L;if(j&&(b=f(j.call(new t)))!==Object.prototype&&b.next&&(a(b,_,!0),r||"function"==typeof b[l]||u(b,l,h)),w&&L&&"values"!==L.name&&(S=!0,C=function(){return L.call(this)}),r&&!y||!p&&!S&&O[l]||u(O,l,C),s[n]=C,s[_]=h,d)if(x={values:w?C:k("values"),keys:g?C:k("keys"),entries:$},y)for(m in x)m in O||o(O,m,x[m]);else i(i.P+i.F*(p||S),n,x);return x}},function(t,n){t.exports=!0},function(t,n,e){var r=e(63),i=Math.min;t.exports=function(t){return t>0?i(r(t),9007199254740991):0}},function(t,n,e){var r=e(21),i=e(24),o=i["__core-js_shared__"]||(i["__core-js_shared__"]={});(t.exports=function(t,n){return o[t]||(o[t]=void 0!==n?n:{})})("versions",[]).push({version:r.version,mode:e(102)?"pure":"global",copyright:"© 2019 Denis Pushkarev (zloirock.ru)"})},function(t,n){var e=0,r=Math.random();t.exports=function(t){return"Symbol(".concat(void 0===t?"":t,")_",(++e+r).toString(36))}},function(t,n){t.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(t,n,e){var r=e(32).f,i=e(40),o=e(15)("toStringTag");t.exports=function(t,n,e){t&&!i(t=e?t:t.prototype,o)&&r(t,o,{configurable:!0,value:n})}},function(t,n,e){var r=e(64);t.exports=function(t){return Object(r(t))}},function(t,n,e){var r=e(62),i=e(15)("toStringTag"),o="Arguments"==r(function(){return arguments}());t.exports=function(t){var n,e,u;return void 0===t?"Undefined":null===t?"Null":"string"==typeof(e=function(t,n){try{return t[n]}catch(t){}}(n=Object(t),i))?e:o?r(n):"Object"==(u=r(n))&&"function"==typeof n.callee?"Arguments":u}},function(t,n,e){"use strict";var r=e(14),i=e(30),o=e(22),u=e(35),s=e(53),c=e(54),a=Math.max,f=Math.min,l=Math.floor,p=/\$([$&`']|\d\d?|<[^>]*>)/g,h=/\$([$&`']|\d\d?)/g;e(56)("replace",2,function(t,n,e,v){return[function(r,i){var o=t(this),u=null==r?void 0:r[n];return void 0!==u?u.call(r,o,i):e.call(String(o),r,i)},function(t,n){var i=v(e,t,this,n);if(i.done)return i.value;var l=r(t),p=String(this),h="function"==typeof n;h||(n=String(n));var g=l.global;if(g){var y=l.unicode;l.lastIndex=0}for(var x=[];;){var m=c(l,p);if(null===m)break;if(x.push(m),!g)break;""===String(m[0])&&(l.lastIndex=s(p,o(l.lastIndex),y))}for(var b,k="",_=0,w=0;w=_&&(k+=p.slice(_,O)+A,_=O+S.length)}return k+p.slice(_)}];function d(t,n,r,o,u,s){var c=r+t.length,a=o.length,f=h;return void 0!==u&&(u=i(u),f=p),e.call(s,f,function(e,i){var s;switch(i.charAt(0)){case"$":return"$";case"&":return t;case"`":return n.slice(0,r);case"'":return n.slice(c);case"<":s=u[i.slice(1,-1)];break;default:var f=+i;if(0===f)return e;if(f>a){var p=l(f/10);return 0===p?e:p<=a?void 0===o[p-1]?i.charAt(1):o[p-1]+i.charAt(1):e}s=o[f-1]}return void 0===s?"":s})}})},function(t,n,e){var r=e(26).f,i=e(28),o=e(11)("toStringTag");t.exports=function(t,n,e){t&&!i(t=e?t:t.prototype,o)&&r(t,o,{configurable:!0,value:n})}},function(t,n,e){var r=e(30),i=e(68);e(192)("keys",function(){return function(t){return i(r(t))}})},function(t,n,e){"use strict";e(193)("link",function(t){return function(n){return t(this,"a","href",n)}})},function(t,n,e){"use strict";var r=e(10),i=e(29)(0),o=e(23)([].forEach,!0);r(r.P+r.F*!o,"Array",{forEach:function(t){return i(this,t,arguments[1])}})},function(t,n,e){"use strict";var r=e(74);e.n(r).a},function(t,n,e){"use strict";var r=e(75);e.n(r).a},function(t,n,e){"use strict";e(142),e(149);var r=e(121),i=(e(50),e(110),e(95),e(41),e(112),e(84),e(93),e(113),e(114),e(16)),o={props:{item:{required:!0}},computed:{link:function(){return Object(i.b)(this.item.link)},exact:function(){var t=this;return this.$site.locales?Object.keys(this.$site.locales).some(function(n){return n===t.link}):"/"===this.link}},methods:{isExternal:i.f,isMailto:i.g,isTel:i.h}},u=e(0),s=Object(u.a)(o,function(){var t=this,n=t.$createElement,e=t._self._c||n;return t.isExternal(t.link)?e("a",t._b({staticClass:"nav-link external",attrs:{href:t.link,target:t.isMailto(t.link)||t.isTel(t.link)?null:"_blank",rel:t.isMailto(t.link)||t.isTel(t.link)?null:"noopener noreferrer"}},"a",t.$attrs,!1),[t._v("\n "+t._s(t.item.text)+"\n "),e("OutboundLink")],1):e("router-link",t._b({staticClass:"nav-link",attrs:{to:t.link,exact:t.exact}},"router-link",t.$attrs,!1),[t._v(t._s(t.item.text))])},[],!1,null,null,null).exports,c={components:{NavLink:s,DropdownTransition:e(119).a},data:function(){return{open:!1,activeLink:"",exactLink:!1}},props:{item:{required:!0}},watch:{$route:function(){this.exactLink=!1,this.activeLink="",this.checkActive([this.item])}},mounted:function(){this.checkActive([this.item])},methods:{toggle:function(){this.open=!this.open},checkActive:function(t){var n=this;t.forEach(function(t){if(!n.exactLink&&"/"!==n.$route.path&&n.$route.path!==n.$localePath&&!t.addr&&(t.items&&n.checkActive(t.items),t.link)){var e=t.link.split(/#|\?/)[0];if(e===n.$route.path)n.activeLink=t.link,n.exactLink=!0;else if(e.startsWith(n.$route.path)||n.$route.path.startsWith(e)){if(n.activeLink&&e.length1){var e=this.$page.path,i=this.$router.options.routes,o=this.$site.themeConfig.locales||{},u={text:this.$themeLocaleConfig.selectText||"Languages",type:"links",addr:"langs",items:Object.keys(n).map(function(r){var u,s=n[r],c=o[r]&&o[r].label||s.lang;return s.lang===t.$lang?u=e:(u=e.replace(t.$localeConfig.path,r),i.some(function(t){return t.path===u})||(u=r)),{text:c,link:u}})};return[].concat(Object(r.a)(this.userNav),[u])}return this.userNav},repoLink:function(){var t=this.$site.themeConfig,n=t.repo,e=t.repoLink;if(e=!1===e?void 0:e||n,e)return/^https?:/.test(e)?e:"https://github.com/".concat(e)},repoLabel:function(){if(this.repoLink){if(this.$site.themeConfig.repoLabel)return this.$site.themeConfig.repoLabel;for(var t=this.repoLink.match(/^https?:\/\/[^\/]+/)[0],n=["GitHub","GitLab","Bitbucket"],e=0;e2&&void 0!==arguments[2]?arguments[2]:0;if(t){if((t=t.toLowerCase()).includes(n))r+=t===n?1e4:1e3;else{if(!(i.length>1))return;var s=i.filter(function(n){if(t.includes(n))return r+=n===t?500:300,!0});if(!s.length)return}var c=e();u[c.path]||(u[c.path]=!0,o.push({weight:r,page:e()}))}},c=function(n){var i=e[n];if(t.getPageLocalePath(i)!==r)return"continue";if(!t.isSearchable(i))return"continue";if(i.frontmatter&&i.frontmatter.keyword){Array.isArray(i.frontmatter.keyword)||(i.frontmatter.keyword=i.frontmatter.keyword.split(",").map(function(t){return t.trim()}));for(var o=0;o0?this.focusIndex--:this.focusIndex=this.suggestions.length-1)},onDown:function(){this.showSuggestions&&(this.focusIndex "+t._s(n.header.title))]:t._e()],2)])}),0):t._e()])},[],!1,null,null,null).exports);function s(t,n){return t.ownerDocument.defaultView.getComputedStyle(t,null)[n]}var c={components:{SidebarButton:i,NavLinks:e(117).a,SearchBox:u},data:function(){return{linksWrapMaxWidth:null}},mounted:function(){var t=this,n=parseInt(s(this.$el,"paddingLeft"))+parseInt(s(this.$el,"paddingRight")),e=function(){document.documentElement.clientWidth<719?t.linksWrapMaxWidth=null:t.linksWrapMaxWidth=t.$el.offsetWidth-n-(t.$refs.siteName&&t.$refs.siteName.offsetWidth||0)};e(),window.addEventListener("resize",e,!1)},computed:{algolia:function(){return this.$themeLocaleConfig.algolia||this.$site.themeConfig.algolia||{}},isAlgoliaSearch:function(){return this.algolia&&this.algolia.apiKey&&this.algolia.indexName},searchTitle:function(){return this.$siteTitle?"在 ".concat(this.$siteTitle," 中搜索"):"搜索"}}},a=(e(197),Object(r.a)(c,function(){var t=this,n=t.$createElement,e=t._self._c||n;return e("header",{staticClass:"navbar"},[e("SidebarButton",{on:{"toggle-sidebar":function(n){return t.$emit("toggle-sidebar")}}}),t._v(" "),e("div",{staticClass:"left-logo-part"},[e("router-link",{staticClass:"home-link",attrs:{to:t.$localePath}},[t.$site.themeConfig.logo?e("img",{staticClass:"logo",attrs:{src:t.$withBase(t.$site.themeConfig.logo),alt:t.$siteTitle}}):t._e(),t._v(" "),t.$siteTitle?e("span",{ref:"siteName",staticClass:"site-name",class:{"can-hide":t.$site.themeConfig.logo}},[t._v(t._s(t.$siteTitle))]):t._e()]),t._v(" "),!1!==t.$site.themeConfig.search?e("SearchBox",{attrs:{placeholder:t.searchTitle}}):t._e()],1),t._v(" "),e("div",{staticClass:"links",style:t.linksWrapMaxWidth?{"max-width":t.linksWrapMaxWidth+"px"}:{}},[e("NavLinks",{staticClass:"can-hide"})],1)],1)},[],!1,null,null,null));n.a=a.exports},function(t,n,e){"use strict";var r={name:"DropdownTransition",methods:{setHeight:function(t){t.style.height=t.scrollHeight+"px"},unsetHeight:function(t){t.style.height=""}}},i=(e(194),e(0)),o=Object(i.a)(r,function(){var t=this.$createElement;return(this._self._c||t)("transition",{attrs:{name:"dropdown"},on:{enter:this.setHeight,"after-enter":this.unsetHeight,"before-leave":this.setHeight}},[this._t("default")],2)},[],!1,null,null,null);n.a=o.exports},function(t,n,e){"use strict";var r={computed:{config:function(){return this.$themeLocaleConfig.foot}}},i=(e(198),e(0)),o=Object(i.a)(r,function(){var t=this,n=t.$createElement,e=t._self._c||n;return e("div",{staticClass:"global-foot"},[e("div",{staticClass:"friend-links"},[e("div",{staticClass:"friend-list-wrapper"},t._l(t.config.friendList,function(n,r){return e("div",{key:r,staticClass:"friend-list"},[e("div",{staticClass:"friend-list-align"},[e("div",{staticClass:"friend-list-title"},[t._v(t._s(n.title))]),t._v(" "),t._l(n.list,function(n,r){return e("a",{key:r,staticClass:"friend-list-item",attrs:{href:n.url,target:"_blank"}},[t._v(t._s(n.name))])}),t._v(" "),n.qrcode?e("div",{staticClass:"friend-qrcode"},[e("img",{attrs:{src:t.$withBase(n.qrcode),alt:n.name}})]):t._e()],2)])}),0)]),t._v(" "),e("div",{staticClass:"copyright"},[e("span",{staticClass:"span"},[t._l(t.config.copyright,function(n,r){return[n.url?e("a",{key:r,attrs:{href:n.url}},[t._v(t._s(n.text))]):e("span",{key:r},[t._v(t._s(n.text))])]})],2)])])},[],!1,null,null,null);n.a=o.exports},function(t,n,e){"use strict";var r=e(150),i=e.n(r);var o=e(157),u=e.n(o),s=e(177),c=e.n(s);function a(t){return function(t){if(i()(t)){for(var n=0,e=new Array(t.length);n1?arguments[1]:void 0)}}),e(52)("includes")},function(t,n,e){"use strict";var r=e(10),i=e(89);r(r.P+r.F*e(90)("includes"),"String",{includes:function(t){return!!~i(this,t,"includes").indexOf(t,arguments.length>1?arguments[1]:void 0)}})},function(t,n,e){var r=e(14),i=e(48),o=e(11)("species");t.exports=function(t,n){var e,u=r(t).constructor;return void 0===u||null==(e=r(u)[o])?n:i(e)}},function(t,n,e){var r=e(35),i=e(20);t.exports=function(t){return function(n,e){var o,u,s=String(i(n)),c=r(e),a=s.length;return c<0||c>=a?t?"":void 0:(o=s.charCodeAt(c))<55296||o>56319||c+1===a||(u=s.charCodeAt(c+1))<56320||u>57343?t?s.charAt(c):o:t?s.slice(c,c+2):u-56320+(o-55296<<10)+65536}}},function(t,n,e){var r=e(36),i=e(11)("toStringTag"),o="Arguments"==r(function(){return arguments}());t.exports=function(t){var n,e,u;return void 0===t?"Undefined":null===t?"Null":"string"==typeof(e=function(t,n){try{return t[n]}catch(t){}}(n=Object(t),i))?e:o?r(n):"Object"==(u=r(n))&&"function"==typeof n.callee?"Arguments":u}},function(t,n,e){"use strict";var r=e(55);e(10)({target:"RegExp",proto:!0,forced:r!==/./.exec},{exec:r})},function(t,n,e){"use strict";var r=e(10),i=e(29)(2);r(r.P+r.F*!e(23)([].filter,!0),"Array",{filter:function(t){return i(this,t,arguments[1])}})},function(t,n,e){"use strict";e(139)("trim",function(t){return function(){return t(this,3)}})},function(t,n,e){var r=e(10),i=e(20),o=e(12),u=e(140),s="["+u+"]",c=RegExp("^"+s+s+"*"),a=RegExp(s+s+"*$"),f=function(t,n,e){var i={},s=o(function(){return!!u[t]()||"​…"!="​…"[t]()}),c=i[t]=s?n(l):u[t];e&&(i[e]=c),r(r.P+r.F*s,"String",i)},l=f.trim=function(t,n){return t=String(i(t)),1&n&&(t=t.replace(c,"")),2&n&&(t=t.replace(a,"")),t};t.exports=f},function(t,n){t.exports="\t\n\v\f\r   ᠎              \u2028\u2029\ufeff"},function(t,n,e){"use strict";var r=e(57);e.n(r).a},function(t,n,e){var r=e(13),i=e(143),o=e(26).f,u=e(147).f,s=e(49),c=e(94),a=r.RegExp,f=a,l=a.prototype,p=/a/g,h=/a/g,v=new a(p)!==p;if(e(19)&&(!v||e(12)(function(){return h[e(11)("match")]=!1,a(p)!=p||a(h)==h||"/a/i"!=a(p,"i")}))){a=function(t,n){var e=this instanceof a,r=s(t),o=void 0===n;return!e&&r&&t.constructor===a&&o?t:i(v?new f(r&&!o?t.source:t,n):f((r=t instanceof a)?t.source:t,r&&o?c.call(t):n),e?this:l,a)};for(var d=function(t){t in a||o(a,t,{configurable:!0,get:function(){return f[t]},set:function(n){f[t]=n}})},g=u(f),y=0;g.length>y;)d(g[y++]);l.constructor=a,a.prototype=l,e(27)(r,"RegExp",a)}e(148)("RegExp")},function(t,n,e){var r=e(18),i=e(144).set;t.exports=function(t,n,e){var o,u=n.constructor;return u!==e&&"function"==typeof u&&(o=u.prototype)!==e.prototype&&r(o)&&i&&i(t,o),t}},function(t,n,e){var r=e(18),i=e(14),o=function(t,n){if(i(t),!r(n)&&null!==n)throw TypeError(n+": can't set as prototype!")};t.exports={set:Object.setPrototypeOf||("__proto__"in{}?function(t,n,r){try{(r=e(47)(Function.call,e(145).f(Object.prototype,"__proto__").set,2))(t,[]),n=!(t instanceof Array)}catch(t){n=!0}return function(t,e){return o(t,e),n?t.__proto__=e:r(t,e),t}}({},!1):void 0),check:o}},function(t,n,e){var r=e(146),i=e(44),o=e(37),u=e(87),s=e(28),c=e(85),a=Object.getOwnPropertyDescriptor;n.f=e(19)?a:function(t,n){if(t=o(t),n=u(n,!0),c)try{return a(t,n)}catch(t){}if(s(t,n))return i(!r.f.call(t,n),t[n])}},function(t,n){n.f={}.propertyIsEnumerable},function(t,n,e){var r=e(96),i=e(59).concat("length","prototype");n.f=Object.getOwnPropertyNames||function(t){return r(t,i)}},function(t,n,e){"use strict";var r=e(13),i=e(26),o=e(19),u=e(11)("species");t.exports=function(t){var n=r[t];o&&n&&!n[u]&&i.f(n,u,{configurable:!0,get:function(){return this}})}},function(t,n,e){"use strict";var r=e(14),i=e(22),o=e(53),u=e(54);e(56)("match",1,function(t,n,e,s){return[function(e){var r=t(this),i=null==e?void 0:e[n];return void 0!==i?i.call(e,r):new RegExp(e)[n](String(r))},function(t){var n=s(e,t,this);if(n.done)return n.value;var c=r(t),a=String(this);if(!c.global)return u(c,a);var f=c.unicode;c.lastIndex=0;for(var l,p=[],h=0;null!==(l=u(c,a));){var v=String(l[0]);p[h]=v,""===v&&(c.lastIndex=o(a,i(c.lastIndex),f)),h++}return 0===h?null:p}]})},function(t,n,e){t.exports=e(151)},function(t,n,e){e(152),t.exports=e(21).Array.isArray},function(t,n,e){var r=e(38);r(r.S,"Array",{isArray:e(156)})},function(t,n){t.exports=function(t){if("function"!=typeof t)throw TypeError(t+" is not a function!");return t}},function(t,n,e){t.exports=!e(33)&&!e(98)(function(){return 7!=Object.defineProperty(e(99)("div"),"a",{get:function(){return 7}}).a})},function(t,n,e){var r=e(60);t.exports=function(t,n){if(!r(t))return t;var e,i;if(n&&"function"==typeof(e=t.toString)&&!r(i=e.call(t)))return i;if("function"==typeof(e=t.valueOf)&&!r(i=e.call(t)))return i;if(!n&&"function"==typeof(e=t.toString)&&!r(i=e.call(t)))return i;throw TypeError("Can't convert object to primitive value")}},function(t,n,e){var r=e(62);t.exports=Array.isArray||function(t){return"Array"==r(t)}},function(t,n,e){t.exports=e(158)},function(t,n,e){e(100),e(171),t.exports=e(21).Array.from},function(t,n,e){var r=e(63),i=e(64);t.exports=function(t){return function(n,e){var o,u,s=String(i(n)),c=r(e),a=s.length;return c<0||c>=a?t?"":void 0:(o=s.charCodeAt(c))<55296||o>56319||c+1===a||(u=s.charCodeAt(c+1))<56320||u>57343?t?s.charAt(c):o:t?s.slice(c,c+2):u-56320+(o-55296<<10)+65536}}},function(t,n,e){t.exports=e(31)},function(t,n,e){"use strict";var r=e(162),i=e(61),o=e(107),u={};e(31)(u,e(15)("iterator"),function(){return this}),t.exports=function(t,n,e){t.prototype=r(u,{next:i(1,e)}),o(t,n+" Iterator")}},function(t,n,e){var r=e(39),i=e(163),o=e(106),u=e(66)("IE_PROTO"),s=function(){},c=function(){var t,n=e(99)("iframe"),r=o.length;for(n.style.display="none",e(169).appendChild(n),n.src="/service/javascript:",(t=n.contentWindow.document).open(),t.write(" + + diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..505ad78 --- /dev/null +++ b/icon.svg @@ -0,0 +1,94 @@ + + + + Group 8 + Created with Sketch Beta. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/img_egg/background.png b/img_egg/background.png new file mode 100644 index 0000000..5166313 Binary files /dev/null and b/img_egg/background.png differ diff --git a/img_egg/banner.png b/img_egg/banner.png new file mode 100644 index 0000000..f97ad4a Binary files /dev/null and b/img_egg/banner.png differ diff --git a/img_egg/icon-1.png b/img_egg/icon-1.png new file mode 100644 index 0000000..0cd4e4a Binary files /dev/null and b/img_egg/icon-1.png differ diff --git a/img_egg/icon-2.png b/img_egg/icon-2.png new file mode 100644 index 0000000..e313b3b Binary files /dev/null and b/img_egg/icon-2.png differ diff --git a/img_egg/icon-3.png b/img_egg/icon-3.png new file mode 100644 index 0000000..9c53f45 Binary files /dev/null and b/img_egg/icon-3.png differ diff --git a/img_egg/icon-4.png b/img_egg/icon-4.png new file mode 100644 index 0000000..1ca6ab5 Binary files /dev/null and b/img_egg/icon-4.png differ diff --git a/img_egg/qrcode.png b/img_egg/qrcode.png new file mode 100644 index 0000000..d4ad3fd Binary files /dev/null and b/img_egg/qrcode.png differ diff --git a/img_egg/qrcode_dingtalk.png b/img_egg/qrcode_dingtalk.png new file mode 100644 index 0000000..f15d2a8 Binary files /dev/null and b/img_egg/qrcode_dingtalk.png differ diff --git a/img_egg/whosusing.png b/img_egg/whosusing.png new file mode 100644 index 0000000..fbd9d04 Binary files /dev/null and b/img_egg/whosusing.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..8bade04 --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + Egg + + + + + + + +

Egg.js

Born to build better enterprise frameworks and apps with Node.js & Koa

+ QuickStart → +

完善的生态

基于开源生态,专为泛蚂蚁生态定制,一分钟接入后端服务中间件,支持多种部署环境。

高效自然的研发体验

渐进式开发,学习曲线平滑,提供一站式开发套件,为研发全流程保驾护航。

高质量、可信赖

高质量,完备的测试,内置集团安全策略,双十一等线上大规模顶级流量压力考验。

灵活、高扩展性

约定优于配置,高度灵活的定制性,业界领先的插件机制和上层业务框架机制。

+ + + diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..612e30c --- /dev/null +++ b/logo.svg @@ -0,0 +1,60 @@ + + + + icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/quickstart/egg.html b/quickstart/egg.html new file mode 100644 index 0000000..8c7e621 --- /dev/null +++ b/quickstart/egg.html @@ -0,0 +1,26 @@ + + + + + + Simple Egg Application | Egg + + + + + + + + + + + diff --git a/quickstart/index.html b/quickstart/index.html new file mode 100644 index 0000000..7c1cf85 --- /dev/null +++ b/quickstart/index.html @@ -0,0 +1,26 @@ + + + + + + QuickStart | Egg + + + + + + + + + + + diff --git a/zh/guide/application.html b/zh/guide/application.html new file mode 100644 index 0000000..5422d57 --- /dev/null +++ b/zh/guide/application.html @@ -0,0 +1,117 @@ + + + + + + Application | Egg + + + + + + + +

Application

使用场景

Application 是全局应用对象,继承于 Koa.Application,可以用于扩展全局的方法和对象。

在一个应用中,一个进程只会实例化一个 Application 实例。

注意事项

Node.js 进程间是无法共享对象的,因此每个进程都会有一个 Application 实例。

获取方式

Application 对象几乎可以在编写应用时的任何一个地方获取到:

ControllerService 等可以通过 this.app,或者所有 Context 对象上的 ctx.app

// app/controller/home.js
+class HomeController extends Controller {
+  async index() {
+    // 从 `Controller/Service` 基类继承的属性: `this.app`
+    console.log(this.app.config.name);
+    // 从 ctx 对象上获取
+    console.log(this.ctx.app.config.name);
+  }
+}
+

几乎所有被框架 Loader 加载的文件,都可以 export 一个函数,并接收 app 作为参数:

Router

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/', controller.home.index);
+};
+

Middleware

// app/middleware/response_time.js
+module.exports = (options, app) => {
+  // 加载期传递 app 实例
+  console.log(app);
+
+  return async function responseTime(ctx, next) {};
+};
+

常用属性和方法

app.config

应用的配置

app.router

对应的路由对象。

app.controller

对应的 Controller 对象。

app.logger

用于应用级别的日志记录,如记录启动阶段的一些数据信息,记录一些业务上与请求无关的信息。

更多参见 日志 文档。

app.middleware

挂载后的所有 Middleware 对象。

app.server

对应的 HTTP ServerHTTPS Server 实例。

可以在 生命周期serverDidReady 事件之后获取到。

app.curl()

通过 HttpClient 发起请求。

app.createAnonymousContext()

在某些非用户请求的场景下,我们也需要访问到 Context,此时该方法获取:

const ctx = app.createAnonymousContext();
+await ctx.service.user.list();
+

如何扩展

我们支持开发者通过 app/extend/application.js 来扩展 Application

方法扩展

// app/extend/application.js
+module.exports = {
+  foo(param) {
+    // this 就是 app 对象,在其中可以调用 app 上的其他方法,或访问属性
+  },
+};
+

属性扩展

一般来说属性的计算只需要进行一次,否则在多次访问属性时会计算多次,降低应用性能。

推荐的方式是使用 Symbol + Getter 的模式来实现缓存。

例如,增加一个 app.nunjucks 属性:

// app/extend/application.js
+const NUNJUCKS = Symbol('Application#nunjucks');
+const nunjuck = require('nunjuck');
+
+module.exports = {
+  get nunjucks() {
+    if (!this[NUNJUCKS]) {
+      // this 就是 app 对象,可以获取到 app 上的其他属性
+      this[NUNJUCKS] = new nunjucks.Environment(this.config.nunjucks);
+    }
+    return this[NUNJUCKS];
+  },
+};
+

编写测试

对于扩展的逻辑,我们一般需要通过单元测试来保证代码质量。

// test/app/extend/application.js
+const { app, assert } = require('egg-mock');
+
+describe('test/app/extend/application.js', () => {
+  it('should export nunjucks', () => {
+    assert(app.nunjucks);
+    assert(app.nunjucks.renderString('{{ name }}', { name: 'TZ' }) === 'TZ');
+  });
+});
+

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

按照环境进行扩展

另外,还可以根据运行环境进行有选择的扩展。

app/extend/application.unittest.js 定义的扩展,只在 unittest 环境生效。

// app/extend/application.unittest.js
+module.exports = {
+  mockXX(k, v) {
+  },
+};
+

这个文件只会在 unittest 环境加载。

同理,对于下文中的 ApplicationContextRequestResponseHelper 都可以使用这种方式针对某个环境进行扩展。

+ + + diff --git a/zh/guide/config.html b/zh/guide/config.html new file mode 100644 index 0000000..0377e2f --- /dev/null +++ b/zh/guide/config.html @@ -0,0 +1,115 @@ + + + + + + 配置 | Egg + + + + + + + +

配置

方案选型

配置的管理有多种方案,以下列一些常见的方案:

  • 使用平台管理配置,应用构建时将当前环境的配置放入包内,启动时指定该配置。但应用就无法一次构建多次部署,而且本地开发环境想使用配置会变的很麻烦。
  • 使用平台管理配置,在启动时将当前环境的配置通过环境变量传入,这是比较优雅的方式,但框架对运维的要求会比较高,需要部署平台支持,同时开发环境也有相同痛点。
  • 使用代码管理配置,在代码中添加多个环境的配置,在启动时传入当前环境的参数即可。但无法全局配置,必须修改代码。

我们选择了最后一种配置方案,配置即代码,配置的变更也应该经过 Review 后才能发布。应用包本身是可以部署在多个环境的,只需要指定运行环境即可。

运行环境

Egg 应用是一次构建多地部署,所以 Egg 会根据外部传入的一些配置来决定如何运行。

env

应用开发者可以通过 app.config.env 获取当前运行环境。

以下为框架支持的运行环境:

serverEnv NODE_ENV 说明
local - 本地开发环境
unittest test 单元测试环境
prod production 生产环境

运行环境会决定插件是否开启,选择默认的配置项,对开发者非常友好。

配置文件

框架会根据不同的运行环境来加载不同的配置文件。

showcase
+├── app
+└── config
+    ├── config.default.js
+    ├── config.prod.js
+    ├── config.unittest.js
+    ├── config.default.js
+    └── config.local.js
+
  • config.default.js 为默认的配置文件,所有环境都会加载它,绝大部分配置应该写在这里
  • 然后会根据运行环境加载对应的配置,并覆盖默认配置的同名配置。 +
    • prod 环境会加载 config.prod.jsconfig.default.js 文件。
    • 然后 config.prod.js 会覆盖 config.default.js 的同名配置。

具体的运行环境与配置文件的加载规则,参见应用部署文档相关章节。

配置定义

配置文件返回的是一个 Object 对象,支持三种写法,请根据具体场合选择合适的写法。

// config/config.default.js
+module.exports = {
+  logger: {
+    dir: '/home/admin/logs/demoapp',
+  },
+};
+

配置文件也可以简化的写成 exports.key = value 形式。

// config/config.default.js
+exports.keys = 'my-cookie-secret-key';
+exports.logger = {
+  level: 'DEBUG',
+};
+

也可以是一个 function,入参为 appInfo

// config/config.default.js
+const path = require('path');
+
+module.exports = appInfo => {
+  const config = {};
+
+  config.logger = {
+    dir: path.join(appInfo.root, 'logs', appInfo.name),
+  };
+
+  return config;
+};
+

友情提示

一些插件文档里面,描述配置时,可能会使用 exports.pluginName = {} 的方式。

复制时,请根据你的具体配置写法进行修正。

AppInfo

内置的 appInfo 有:

appInfo 说明
pkg package.json
name 应用名,同 pkg.name
baseDir 应用的代码根目录。
HOME 用户目录,如 admin 账户为 /home/admin
root 应用根目录,localunittest 环境下为 baseDir,其他都为 HOME

注意事项

值得注意的是:appInfo.root 是一个优雅的适配。

比如在服务器环境我们会使用 /home/admin/logs 作为日志目录,而本地开发时又不想污染用户目录,这样的适配就很好解决这个问题。

加载规则

应用、插件、框架都可以定义这些配置,而且目录结构都是一致的。

但存在优先级(应用 > 框架 > 插件),相对于此运行环境的优先级会更高。

框架会按加载顺序使用 extend2 模块进行深度拷贝。

比如在 prod 环境加载一个配置的加载顺序如下,后加载的会覆盖前面的同名配置。

-> 插件 config.default.js
+-> 框架 config.default.js
+-> 应用 config.default.js
+-> 插件 config.prod.js
+-> 框架 config.prod.js
+-> 应用 config.prod.js
+

注意事项

合并配置时,对于数组的处理是直接覆盖而不是合并。

const a = {
+  arr: [ 1, 2 ],
+};
+const b = {
+  arr: [ 3 ],
+};
+extend(true, a, b);
+// => { arr: [ 3 ] }
+

根据上面的例子,框架直接覆盖数组而不是进行合并。

常见问题

为什么我的配置不生效?

首先,要确保不会犯以下的低级错误:

// config/config.default.js
+exports.someKeys = 'abc';
+
+module.exports = appInfo => {
+  const config = {};
+  config.keys = '123456';
+  return config;
+};
+

其次,参考下一条 FAQ 来排查问题。

如何查看最终的配置?

框架的配置功能比较强大,有不同环境变量,又有框架、插件、应用等很多地方配置。

如果你分析问题时,想知道当前运行时使用的最终配置,框架提供了:

  • run/application_config.json 文件:最终的配置合并结果,可以用来分析问题。
  • run/application_config_meta.json 文件:用来排查属性的来源。

另外,基于安全的考虑,dump 出的文件中会对一些字段进行脱敏处理,主要包括两类:

  • 如密码、密钥等安全字段,可以通过 config.dump.ignore 配置。
  • 如函数、Buffer 等类型,JSON.stringify 后的内容特别大。

友情提示

注意:run 目录是每次启动期都会 dump 的信息,用于问题排查。

开发者修改该目录的文件将不会有任何效果,应该把该目录加到 gitignore 中。

+ + + diff --git a/zh/guide/context.html b/zh/guide/context.html new file mode 100644 index 0000000..911e134 --- /dev/null +++ b/zh/guide/context.html @@ -0,0 +1,313 @@ + + + + + + Context | Egg + + + + + + + +

Context

使用场景

Context 是一个 请求级别 的对象,继承自 Koa.Context

在每一次收到用户请求时都会实例化一个 Context 对象,它封装了该次请求的相关信息,并提供了许多便捷的方法来获取请求参数或者设置响应信息。

框架会将所有的 Service 挂载到 Context 实例上,某些插件也会将挂载一些其他的方法和对象。

获取方式

最常见的 Context 实例获取方式是在 Middleware, Controller 以及 Service 中。

ControllerService 等可以通过 this.ctx 获取:

// app/controller/home.js
+class HomeController extends Controller {
+  async index() {
+    const { ctx } = this;
+    ctx.body = ctx.query('name');
+  }
+}
+

MiddlewareKoa 框架保持一致:

// app/middleware/response_time.js
+module.exports = () => {
+  return async function responseTime(ctx, next) {
+    const start = Date.now();
+    await next();
+    const cost = Date.now() - start;
+    ctx.set('X-Response-Time', `${cost}ms`);
+  }
+};
+

在某些非用户请求的场景下,我们也需要访问到 Context,此时可以通过 ApplicationcreateAnonymousContext() 方法获取:

const ctx = app.createAnonymousContext();
+await ctx.service.user.list();
+

定时任务 也接收 Context 实例作为参数,以便执行一些定时的业务逻辑:

// app/schedule/refresh.js
+exports.task = async ctx => {
+  await ctx.service.posts.refresh();
+};
+

常用属性和方法

ctx.app

对应的 Application 实例。

ctx.service

对应的 Service 实例。

ctx.logger

与请求相关的 ContextLogger 实例。

它打印的日志都会在前面带上一些当前请求相关的信息。

[$userId/$ip/$traceId/${cost}ms $method $url]

通过这些信息,我们可以从日志快速定位请求,并串联一次请求中的所有的日志。

更多参见 日志 文档。

ctx.curl()

通过 HttpClient 发起请求。

ctx.runInBackground()

有些时候,我们在处理完用户请求后,希望立即返回响应,但同时需要异步执行一些操作。

// app/controller/trade.js
+class TradeController extends Controller {
+  async buy () {
+    const goods = {};
+    const result = await ctx.service.trade.buy(goods);
+
+    // 下单后需要进行一次核对,且不阻塞当前请求
+    ctx.runInBackground(async () => {
+      // 这里面的异常都会统统被 Backgroud 捕获掉,并打印错误日志
+      await ctx.service.trade.check(result);
+    });
+
+    ctx.body = { msg: '已下单' };
+  }
+}
+

ctx.query

在 URL 中 ? 后面的部分是一个 Query String,这一部分经常用于 GET 请求中传递参数。

// GET /api/user/list?limit=10&sort=name
+class UserController extends Controller {
+  async list() {
+    console.log(this.ctx.query);
+    // { limit: '10', sort: 'name' }
+    ctx.body = 'hi, egg';
+  }
+}
+

对应的测试:

// test/controller/home.test.js
+const { app, mock, assert } = require('egg-mock');
+
+describe('test/controller/home.test.js', () => {
+  it('should GET /', () => {
+    return app.httpRequest()
+      .get('/')
+      .set('User-Agent', 'egg-unittest')
+      .query({ limit: '10', sort: 'name' })
+      .expect(200);
+  });
+});
+

友情提示

鉴于 HTTP 协议的约定,在请求中获取到的查询参数,均为字符串,如有需要需自行转型。

值得注意的是,ctx.query 对重复的 key 只取第一个值,后面将被忽略。

/api/user?sort=name&id=2&id=3query.id === '2'

这样处理的原因是为了保持统一性,由于通常情况下我们都不会设计让用户传递相同的 key,所以我们经常会写类似下面的代码:

const key = ctx.query.key || '';
+if (key.startsWith('egg')) {
+  // do something
+}
+

而如果有人故意发起请求带上重复的 key 就会引发系统异常。因此框架保证了从 ctx.query 上获取的参数一旦存在,一定是字符串类型。

ctx.queries

如果你的系统设计允许用户传递相同的 key(不推荐),可以使用 ctx.queries

// GET /api/user?sort=name&id=2&id=3
+class UserController extends Controller {
+  async list() {
+    console.log(this.ctx.queries);
+    // { sort: [ 'name' ], id: [ '2', '3' ] }
+  }
+}
+
  • queries.id === [ '2', '3']
  • ctx.queries 的属性一定是数组类型,如 queries.name === [ 'sort' ]
  • 如果你确定只会传递一个,则应该使用 query.sort 而不是 queries.sort

ctx.params

获取 Router 命名参数。

ctx.routerPath

获取当前命中的 Router 路径。

ctx.routerName

获取当前命中的 Router 别名。

ctx.request.body

框架内置了 bodyParser,用于获取 POST 等的 请求 body

class UserController extends Controller {
+  async create() {
+    // 获取请求信息 `{ name: 'TZ' }`
+    console.log(this.ctx.request.body);
+    // ...
+  }
+}
+

对应的测试:

// test/controller/home.test.js
+it('should POST form', () => {
+
+  // 跳过 `CSRF` 校验
+  app.mockCsrf();
+
+  return app.httpRequest()
+    .post('/user/create')
+    .type('form')
+    .send({ name: 'TZ' })
+    .expect(200);
+});
+
+it('should POST JSON', () => {
+  app.mockCsrf();
+  return app.httpRequest()
+    .post('/user/create')
+    .type('json')
+    .send({ name: 'TZ' })
+    .expect(200);
+});
+

ctx.request.files

获取 file 模式上传的文件对象,参见 文件上传 文档。

ctx.get(name)

获取请求 Header 信息。

由于 HTTP 协议中 Header 是忽略大小写的,因此 ctx.headers 中的 Key 一律转为小写。

一般我们推荐使用 ctx.get(name) 来获取对应的 Header,它会忽略大小写。

ctx.get('User-Agent');
+
+ctx.headers['user-agent'];
+
+// 取不到值
+ctx.headers['User-Agent'];
+

ctx.cookies

读取 Cookie 对象,参见 Cookie 文档。

ctx.status =

HTTP 设计了非常多的状态码

正确地设置状态码,可以让响应更符合语义,参考 List of HTTP status codes

框架提供了一个便捷的 Setter 来进行状态码的设置:

class UserController extends Controller {
+  async create() {
+    // 设置状态码为 201
+    this.ctx.status = 201;
+  }
+};
+

对应的测试:

it('should POST /user', () => {
+  return app.httpRequest()
+    .post('/user')
+    .expect(201);
+});
+

ctx.body =

HTTP 请求的绝大部分数据都是通过 body 发送给请求方的。

  • 作为 API 接口,通常直接赋值一个 Object 对象。
  • 作为 HTML 页面,通常返回 HTML 字符串。
  • 作为文件下载等场景,还可以直接赋值为 Stream
// app/controller/home.js
+class HomeController extends Controller {
+  // GET /
+  async index() {
+    this.ctx.type = 'html';
+    this.ctx.body = '<html><h1>Hello</h1></html>';
+  }
+
+  // GET /api/info
+  async info() {
+    this.ctx.body = {
+      name: 'egg',
+      category: 'framework',
+      language: 'Node.js',
+    };
+  }
+
+  // GET /api/proxy
+  async proxy() {
+    const { ctx } = this;
+    const result = await ctx.curl(url, {
+      streaming: true,
+    });
+    ctx.set(result.header);
+    // result.res 是一个 stream
+    ctx.body = result.res;
+  }
+}
+

对应的测试:

it('should response html', () => {
+  return app.httpRequest()
+    .get('/')
+    .expect('<html><h1>Hello</h1></html>')
+    .expect(/Hello/);
+});
+
+it('should response json', () => {
+  return app.httpRequest()
+    .get('/api/info')
+    .expect({
+      name: 'egg',
+      category: 'framework',
+      language: 'Node.js',
+    })
+    .expect(res => {
+      assert(res.body.name === 'egg');
+    });
+});
+

ctx.set(name, value)

除了 状态码响应体 外,还可以通过响应 Header 设置一些扩展信息。

  • ctx.set(key, value):可以设置一个 Header
  • ctx.set(headers):可以同时设置多个 Header
// app/controller/proxy.js
+class ProxyController extends Controller {
+  async show() {
+    const { ctx } = this;
+    const start = Date.now();
+    ctx.body = await ctx.service.post.get();
+    const cost = Date.now() - start;
+    // 设置一个响应头
+    ctx.set('x-response-time', `${cost}ms`);
+  }
+};
+

对应的测试:

it('should send response header', () => {
+  return app.httpRequest()
+    .post('/api/post')
+    .expect('X-Response-Time', /\d+ms/);
+});
+

ctx.type =

和请求中的 body 一样,在响应也需要对应的 Content-Type 告知客户端如何对数据进行解析。

框架提供了该语法糖,等价于 ctx.set('Content-Type', mime)

  • json:对应于 API 接口的 application/json
  • html:对应于 HTML 页面的 text/html
  • 更多参见 mime-types

一般可以省略,框架会自动根据取值,来赋值对应的 Content-Type

// app/controller/user.js
+class UserController extends Controller {
+  async list() {
+    // 一般可以省略,框架会自动根据取值
+    this.ctx.body = { name: 'egg' };
+  }
+};
+

对应的测试:

it('should response json', () => {
+  return app.httpRequest()
+    .get('/api/user')
+    .expect('Content-Type', /json/);
+});
+

ctx.render()

通常来说,我们不会手写 HTML 页面,而是会通过模板引擎进行生成。

我们可以通过使用模板插件,来提供渲染能力。

class HomeController extends Controller {
+  async index() {
+    const ctx = this.ctx;
+    await ctx.render('home.tpl', { name: 'egg' });
+    // ctx.body = await ctx.renderString('hi, {{ name }}', { name: 'egg' });
+  }
+};
+

具体示例可以查看模板引擎

ctx.redirect()

重定向请求,默认为 302,如果需要,可以设置 ctx.status = 301

class UserController extends Controller {
+  async logout() {
+    const { ctx } = this;
+
+    ctx.logout();
+    ctx.redirect(ctx.get('referer') || '/');
+  }
+}
+

对应的测试:

it('should logout', () => {
+  return app.httpRequest()
+    .get('/user/logout')
+    .expect('Location', '/')
+    .expect(302);
+});
+

安全提示

基于安全考虑,默认只允许重定向处于白名单的域名。

更多参见 安全链接 文档。

ctx.request

由于 Node.js 原生的 HTTP Request 对象比较底层。

因此 Koa 做了一层薄薄的 Koa.Request 封装,提供了一系列方法获取 HTTP 请求相关信息。

一般你不需要直接调用它,Context 已经代理了它们的大部分方法和属性,如上文所述。

唯一的例外是:获取 POST 的 body 应该使用 ctx.request.body,而不是 ctx.body

// app/controller/user.js
+class UserController extends Controller {
+  async update() {
+    const { app, ctx } = this;
+    // 等价于 ctx.query 这个 getter
+    const id = ctx.request.query.id;
+
+    // 唯一的不同,获取 post body
+    const postBody = ctx.request.body;
+
+    // 等价于 ctx.body 这个 setter
+    ctx.response.body = await app.service.update(id, postBody);
+  }
+}
+

ctx.response

由于 Node.js 原生的 HTTP Response 对象比较底层。

因此 Koa 做了一层薄薄的 Koa.Response 封装,提供了一系列方法设置 HTTP 响应。

一般你不需要直接调用它,Context 已经代理了它们的大部分方法和属性,如上文所述。

更多

更多语法糖,请参见 Koa Aliases 文档。

如何扩展

我们支持开发者通过:

  • 通过 app/extend/context.js 来扩展 Context
  • 通过 app/extend/request.js 来扩展 Request
  • 通过 app/extend/response.js 来扩展 Response
  • 同样也支持在 app/extend/context.unittest.js 来根据运行环境扩展。

属性扩展

一般来说属性的计算只需要进行一次,否则在多次访问属性时会计算多次,降低应用性能。

推荐的方式是使用 Symbol + Getter 的模式来实现缓存。

// app/extend/context.js
+const UA = Symbol('Context#ua');
+const useragent = require('useragent');
+
+module.exports = {
+  get ua() {
+    if (!this[UA]) {
+      // this 就是 ctx 对象,在其中可以调用 ctx 上的其他方法,或访问属性
+      const uaString = this.get('user-agent');
+      this[UA] = useragent.parse(uaString);
+    }
+    return this[UA];
+  },
+};
+

编写测试

// test/app/extend/context.js
+const { app, assert } = require('egg-mock');
+
+describe('test/app/extend/contex.js', () => {
+  it('should parse ua', () => {
+    // 创建 ctx
+    const ctx = app.mockContext({
+      headers: {
+        'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_1) Chrome/15.0.874.24',
+      },
+    });
+
+    assert(ctx.ua.chrome);
+  });
+});
+

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

+ + + diff --git a/zh/guide/controller.html b/zh/guide/controller.html new file mode 100644 index 0000000..4c4dfdb --- /dev/null +++ b/zh/guide/controller.html @@ -0,0 +1,265 @@ + + + + + + Controller | Egg + + + + + + + +

Controller

使用场景

Controller 负责解析用户的输入,处理后返回相应的结果

Controller 其实就是一个特殊的 Middleware,它在洋葱模型的最里面。

场景举例:

  • 提供 AJAX 接口,接收用户的参数,查找数据库返回给用户或将用户的请求更新到数据库中。
  • 根据用户访问的 URL,渲染对应的模板返回 HTML 给浏览器渲染。
  • 作为代理服务器时,将用户的请求转发到其他服务上,并将处理结果返回给用户。

最佳实践

Controller 仅负责 HTTP 层的相关处理逻辑,不要包含太多业务逻辑。

  1. 获取用户通过 HTTP 传递过来的请求参数。
  2. 校验、组装参数。
  3. 调用 Service 进行业务处理。
  4. 必要时处理转换 Service 的返回结果,如渲染模板。
  5. 通过 HTTP 将结果响应给用户。

编写 Controller

我们约定把 Controller 放置在 app/controller 目录下:

// app/controller/user.js
+const { Controller } = require('egg');
+
+class UserController extends Controller {
+  async create() {
+    const { ctx, service } = this;
+
+    // 获取请求信息
+    const userInfo = ctx.request.body;
+
+    // 校验参数
+    ctx.assert(userInfo && userInfo.name, 422, 'user name is required.');
+
+    // 调用 Service 进行业务处理
+    const result = await service.user.create(userInfo);
+
+    // 响应内容和响应码
+    ctx.body = result;
+    ctx.status = 201;
+  }
+}
+module.exports = UserController;
+

然后通过路由配置 URL 请求映射:

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.post('/api/user', controller.user.create);
+};
+

然后通过 POST /api/user 即可访问。

生命周期

Controller 类会被挂载到 app.Controller 上,用于在 路由 配置 URL 映射。

但处理用户请求时,每一个请求都会实例化一个 Controller 实例。

Controller 是延迟实例化的,仅在请求调用到该 Controller 的时候,才会实例化。

因此,无需担心实例化的性能损耗,经过我们大规模的实践证明,可以忽略不计。

挂载规则

约定放置在 app/controller 目录下,支持多级目录,对应的文件名会转换为驼峰格式

app/controller/biz/user.js => app.controller.biz.user
+app/controller/sync_user.js => app.controller.syncUser
+app/controller/HackerNews.js => app.controller.hackerNews
+

常用属性和方法

Controller 实例继承 egg.Controller,提供以下属性:

  • this.ctx: 当前请求的上下文 Context 的实例,可以拿到各种便捷属性和方法。
  • this.app: 当前应用 Application 的实例,可以拿到全局对象和方法。
  • this.service:应用定义的 Service,可以调用业务逻辑层。
  • this.config:应用运行时的配置项
  • this.logger:logger 对象,使用方法类似 Context Logger,不同之处是通过这个 Logger 对象记录的日志,会额外加上该日志的文件路径,以便快速定位日志打印位置。

Controller 实战

HTTP 基础

由于 Controller 基本上是业务开发中唯一和 HTTP 协议打交道的地方,在继续往下了解之前,我们首先简单的看一下 HTTP 协议是怎样的。

如果我们发起一个 HTTP 请求来访问前面写的的 Controller

$ curl -X POST http://localhost:7001/api/user -d '{"name":"TZ"}' -H 'Content-Type:application/json; charset=UTF-8'
+

通过 curl 发出的 HTTP 请求的内容就会是下面这样的:

POST /api/user HTTP/1.1
+Host: localhost:7001
+Content-Type:application/json; charset=UTF-8
+
+{"name":"TZ"}
+

请求的第一行包含了三个信息,我们比较常用的是前面两个:

  • method:HTTP 方法,此处为 POST
  • path:HTTP 路径,此处为 /api/user,如果用户的请求中包含 query,也会在这里出现。

从第二行开始直到空行位置,都是请求的 Headers 部分:

  • Host:我们在浏览器发起请求的时候,域名会用来通过 DNS 解析找到服务的 IP 地址,但是浏览器也会将域名和端口号放在 Host 头中一并发送给服务端。
  • Content-Type:当我们的请求有 body 的时候,都会有 Content-Type 来标明我们的请求体是什么格式的。

之后的内容全部都是请求的 body,当请求是 POST, PUT 等方法的时候,可以带上请求体,服务端会根据 Content-Type 来解析请求体。

在服务端处理完这个请求后,会发送一个 HTTP 响应给客户端:

HTTP/1.1 201 Created
+Content-Type: application/json; charset=utf-8
+Content-Length: 13
+Date: Mon, 09 Jan 2019 08:40:28 GMT
+Connection: keep-alive
+
+{"id":1,"name":"TZ"}
+

第一行中也包含了三段,其中我们常用的主要是响应状态码,这个例子中它的值是 201,它的含义是在服务端成功创建了一条资源。

和请求一样,从第二行开始到下一个空行之间都是响应头,这里的 Content-Type, Content-Length 表示这个响应的格式是 JSON,长度为 13 个字节。

最后剩下的部分就是这次响应真正的内容。

获取请求参数

在 URL 中 ? 后面的部分是一个 Query String,这一部分经常用于 GET 请求中传递参数。

  • ctx.query:解析查询参数,转换为 Object,属性为字符串。
  • ctx.queries:同上,但支持同名的多个参数解析,属性为数组。
  • ctx.params:获取 Router 命名参数。
// GET /api/user/list?limit=10&sort=name
+class UserController extends Controller {
+  async list() {
+    console.log(this.ctx.query);
+    // { limit: '10', sort: 'name' }
+  }
+}
+

友情提示

鉴于 HTTP 协议的约定,在请求中获取到的查询参数,均为字符串,如有需要需自行转型。

具体使用参见 Context 文档。

获取请求 body

虽然我们可以通过 URL 传递参数,但是还是有诸多限制:

  • 浏览器中会对 URL 的长度有所限制,如果需要传递的参数过多就会无法传递。
  • 访问的 URL 往往会被记录到日志或浏览器中,有一些敏感数据通过 URL 传递会不安全。
  • GET 请求可能会被缓存,导致非预期的意外。

框架内置了 bodyParser,开发者可以通过 ctx.request.body 获取到对应的数据。

class UserController extends Controller {
+  async create() {
+    // 获取请求信息 `{ name: 'TZ' }`
+    console.log(this.ctx.request.body);
+  }
+}
+

友情提示

一个常见的错误是把 ctx.request.bodyctx.body 混淆,后者其实是 ctx.response.body 的简写。

解析 JSON / Form 请求

一般通过 Content-Type 来声明请求 body 的格式,常见的格式有 JSONForm

  • application/json:按 JSON 格式进行解析。
  • application/x-www-form-urlencoded:按 Form 格式进行解析。

框架默认限制 body 的大小为 100kb,如果你需要上传更大的内容,需配置:

// config/config.default.js
+module.exports = {
+  bodyParser: {
+    jsonLimit: '1mb',
+    formLimit: '1mb',
+  },
+};
+
  • 如果 body 超过了最大长度配置,会抛出一个状态码为 413 的异常。
  • 如果 body 解析失败(错误的 JSON),会抛出一个状态码为 400 的异常。
  • 支持 10mb 这种人性化的方式,具体参见 humanize-bytes 模块。

友情提示

如果我们应用前面还有一层反向代理(Nginx),则也需要调整它的配置,以确保反向代理也支持同样长度的请求 body。

解析 XML 请求

有些时候,我们需要解析 XML 协议,可配置:

// config/config.default.js
+exports.bodyParser = {
+  enableTypes: [ 'json', 'form', 'text' ],
+  extendTypes: {
+    text: [ 'application/xml' ],
+  },
+};
+

然后可以自行使用 XML 解析库分析 ctx.request.body 的原始字符串。

const { xml2js } = require('xml-js');
+const xmlContent = xml2js(ctx.request.body);
+

解析自定义类型

如需自定义协议,如 application/custom-rpc,内容一样为 JSON,则可以配置:

// config/config.default.js
+exports.bodyParser = {
+  extendTypes: {
+    json: 'application/custom-rpc',
+  },
+};
+

文件上传

请求 body 还可以通过 multipart/form-data 格式来实现文件上传。

框架内置了 egg-multipart 来支持该特性。

支持 filestream 模式,本文仅介绍前者,更多用法请阅读文件上传文档。

先启用 file 模式:

// config/config.default.js
+exports.multipart = {
+  mode: 'file',
+};
+

然后接收文件:

// app/controller/upload.js
+class UploadController extends Controller {
+  async upload() {
+    const { ctx } = this;
+    const file = ctx.request.files[0];
+    const name = 'egg-multipart-test/' + path.basename(file.filename);
+    // 然后可以对文件进行处理,如上传 OSS 之类的
+    // ...
+  }
+};
+

获取 Header

框架提供了 ctx.get(name) 方法来获取请求头,具体参见 Context 文档。

class HomeController extends Controller {
+  async index() {
+    console.log(this.ctx.get('user-agent'));
+  }
+}
+

代理服务器

大部分情况下,我们的 Web 服务都是在代理服务器(如Nginx) 后面,此时需要配置 config.proxy = true,框架对应的 Getter 会对应的增加处理逻辑。

  • ctx.ips:获取请求经过所有的中间设备 IP 地址列表。
  • ctx.ip:获取请求发起方的 IP 地址,对应的代理 HeaderX-Forwarded-For
  • ctx.host:获取 HOST,对应的代理 HeaderX-Forwarded-Host

另外,代理服务器处理 HTTPS 请求时,我们的 Web 服务收到的是内部的 HTTP 请求。

开发者可以通过 ctx.protocol 来获取客户端访问的协议,框架会解析 X-Forwarded-Prot

详细参见源码实现

通过 ctx.cookies,我们可以在 Controller 中便捷、安全的设置和读取 Cookie

具体可参见 Cookie 文档。

参数校验

在获取到用户请求的参数后,不可避免的要对参数进行一些校验。

在上面的示例中,我们简单的使用 ctx.assert 进行了校验。

实际业务中,会需要更复杂的校验,可以查看 egg-validate 等插件的文档。

调用 Service

不建议 Controller 中实现太多业务逻辑,一般通过 Service 层进行业务逻辑的封装。

这不仅能提高代码的复用性,同时可以让我们的业务逻辑更好测试。

发送 HTTP 响应

当业务逻辑完成之后,Controller 的最后一个职责就是将处理结果通过 HTTP 响应给用户。

  • ctx.body=:设置响应 body。
  • ctx.type=:设置响应的 Content-Type
  • ctx.status=:设置响应的状态码。
  • ctx.set(name, header):设置响应 Header
// app/controller/home.js
+class HomeController extends Controller {
+  async index() {
+    const { ctx } = this;
+
+    ctx.set('powered-by', 'egg');
+    ctx.body = {
+      name: 'egg',
+      category: 'framework',
+      language: 'Node.js',
+    };
+  }
+}
+

具体可以参见 Context 文档。

模板渲染

通常来说,我们不会手写 HTML 页面,而是会通过模板引擎进行生成。

我们可以通过使用模板插件,来提供渲染能力。

class HomeController extends Controller {
+  async index() {
+    const ctx = this.ctx;
+    await ctx.render('home.tpl', { name: 'egg' });
+    // ctx.body = await ctx.renderString('hi, {{ name }}', { name: 'egg' });
+  }
+};
+

具体示例可以查看模板引擎

JSONP

有时我们需要给非本域的页面提供接口服务,又由于一些历史原因无法通过 CORS 实现,可以通过 JSONP 来进行响应。

框架内置了 egg-jsonp 插件,提供了 app.jsonp() 来支持响应 JSONP 格式的数据。

使用

先通过路由中间件的方式来局部开启:

// app/router.js
+module.exports = app => {
+  const jsonp = app.jsonp();
+  app.router.get('/api/posts/:id', jsonp, app.controller.posts.show);
+  app.router.get('/api/posts', jsonp, app.controller.posts.list);
+};
+

然后在 Controller 中,只需要正常编写即可:

// app/controller/posts.js
+class PostController extends Controller {
+  async show() {
+    this.ctx.body = {
+      name: 'egg',
+      category: 'framework',
+      language: 'Node.js',
+    };
+  }
+}
+

用户请求对应的 URL 时带上 _callback=fn 查询参数,将会返回 JSONP 格式的数据。

配置

框架默认支持方法名为 callback_callback,并限制长度小于 50 字符。

如有需要,可以自定义配置:

// config/config.default.js
+exports.jsonp = {
+  callback: 'cb', // 识别 query 中的 `cb` 参数
+  limit: 100, // 函数名最长为 100 个字符
+};
+

通过上面的方式配置之后,如果用户通过 /api/posts/1?cb=fn 请求 JSONP

也可以在 app.jsonp() 创建中间件时覆盖默认的配置,以达到不同路由使用不同配置的目的:

// app/router.js
+module.exports = app => {
+  const { router, controller, jsonp } = app;
+  router.get('/api/posts', jsonp({ callback: 'cb' }), controller.posts.list);
+};
+

安全

JSONP 如果使用不当会导致非常多的安全问题,可以将 JSONP 接口分为三种类型:

  1. 查询非敏感数据,例如获取一个论坛的公开文章列表。
  2. 查询敏感数据,例如获取一个用户的交易记录。
  3. 提交数据并修改数据库,例如给某一个用户创建一笔订单。

如果我们的 JSONP 接口提供下面两类服务,在不做任何跨站防御的情况下,可能泄露用户敏感数据甚至导致用户被钓鱼。

因此框架给 JSONP 默认提供了 CSRF 校验referrer 校验,具体参见 JSONP XSS 相关的安全防范 文档。

// config/config.default.js
+module.exports = {
+  jsonp: {
+    csrf: true,
+    whiteList: /^https?:\/\/test.com\//,
+    // whiteList: '.test.com',
+    // whiteList: 'sub.test.com',
+    // whiteList: [ 'sub.test.com', 'sub2.test.com' ],
+  },
+};
+

提示

当 CSRF 和 referrer 校验同时开启时,请求发起方只需要满足任意一个条件即可通过 JSONP 的安全校验。

重定向

使用

可以通过 ctx.redirect(url) 来重定向请求。

默认为 302,如果需要,可以设置 ctx.status = 301

class UserController extends Controller {
+  async logout() {
+    const ctx = this.ctx;
+
+    ctx.logout();
+    ctx.redirect(ctx.get('referer') || '/');
+  }
+}
+

安全域名

框架通过 egg-security 插件覆盖了 Koa 原生的 ctx.redirect 实现,以提供更加安全的重定向。

  • ctx.redirect(url) 如果不在配置的白名单域名内,则禁止跳转。
  • ctx.unsafeRedirect(url) 不判断域名,直接跳转,一般不建议使用,明确了解可能带来的风险后使用。

security.domainWhiteList数组内为空,则默认会对所有跳转请求放行,即等同于ctx.unsafeRedirect(url)

安全提示

基于安全管控的原因,我们不推荐在应用层直接覆盖该属性,而是应该提交 Merge Request,除非该域名非阿里所属。

更多参见 安全插件 文档。

编写测试

框架集成了 SuperTest 用于 HTTP 测试。

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

测试 GET 请求

// test/controller/home.test.js
+const { app, mock, assert } = require('egg-mock');
+
+describe('test/controller/home.test.js', () => {
+  it('should GET /', () => {
+    return app.httpRequest()
+      .get('/')
+      .set('User-Agent', 'unittest')
+      .query({ limit: '10' })
+      .expect('hi, egg')
+      .expect('X-Response-Time', /\d+ms/)
+      .expect(200);
+  });
+});
+

测试 POST 请求

可以通过 app.mockCsrf() 来跳过 CSRF 校验。

// test/controller/home.test.js
+it('should POST form', () => {
+  app.mockCsrf();
+  return app.httpRequest()
+    .post('/api/body')
+    .type('form')
+    .send({ name: 'TZ' })
+    .expect(200);
+});
+
+it('should POST JSON', () => {
+  app.mockCsrf();
+  return app.httpRequest()
+    .post('/api/body')
+    .type('json')
+    .send({ name: 'TZ' })
+    .expect(200);
+});
+

测试文件上传

// test/controller/home.test.js
+it('should upload file', () => {
+  app.mockCsrf();
+  return app.httpRequest()
+    .post('/api/upload')
+    .field('name', 'just a test')
+    .attach('file', path.join(__dirname, 'egg.png'))
+    .expect(200);
+});
+

常见问题

missing csrf token

框架默认开启了 CSRF 安全限制。

因此新手开发者在 Postman 测试前端发起 AJAX单元测试 时经常遇到的一个报错:

nodejs.ForbiddenError: missing csrf token
+

如何处理可以阅读上述文档。

redirection is prohibited

nodejs.InternalServerError: a security problem has been detected for url "http://www.baidu.com/", redirection is prohibited.
+

如上所述,不允许重定向到非白名单的域名,具体处理参见安全域名

+ + + diff --git a/zh/guide/cookie.html b/zh/guide/cookie.html new file mode 100644 index 0000000..3573c5b --- /dev/null +++ b/zh/guide/cookie.html @@ -0,0 +1,112 @@ + + + + + + Cookie | Egg + + + + + + + +

Cookie

使用场景

HTTP 请求都是无状态的,但是我们的 Web 应用通常都需要知道发起请求的人是谁。

为了解决这个问题,HTTP 协议设计了一个特殊的请求头:Cookie

服务端可以通过响应头将少量数据响应给客户端,浏览器会遵循协议将数据保存,并在下次请求同一个服务的时候带上对应的数据。

Cookie 主要用于:

  • 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
  • 个性化设置(如用户自定义设置、主题等)
  • 浏览器行为跟踪(如跟踪分析用户行为等)

服务器使用 Set-Cookie 响应头部向用户浏览器发送 Cookie 信息:

HTTP/1.0 200 OK
+Content-type: text/html
+Set-Cookie: uid=123456
+Set-Cookie: user=tz
+

后续对该服务发起的每一次新请求,浏览器都会将之前保存的信息通过 Cookie 请求头回传:

GET /user HTTP/1.1
+Host: www.example.org
+Cookie: uid=123456; user=tz
+

框架内置了 egg-cookies 插件,提供了 ctx.cookies,用于便捷、安全的读写 Cookie

// app/controller/home.js
+class HomeController extends Controller {
+  async add() {
+    const { ctx } = this;
+    let count = ctx.cookies.get('count');
+    count = count ? Number(count) : 0;
+    ctx.cookies.set('count', ++count);
+    ctx.body = count;
+  }
+  async remove() {
+    const { ctx } = this;
+    ctx.cookies.set('count', null);
+    ctx.status = 204;
+  }
+}
+

友情提示

在使用 Cookie 时我们需要思考清楚它的场景:

  • 需要被浏览器保存多久?
  • 是否可以被 js 获取到?
  • 是否可以被前端修改?

框架默认配置下, Cookie 是加签不加密的,浏览器可以看到明文,js 不能访问,不能被客户端(手工)篡改。

术语解释

过期时间

ExpiresMax-Age 用于定义 Cookie 对应的键值对的持久化时间。

Expires 优先级低于 Max-Age,如果两者都没设置,则将会在关闭浏览器时失效。

作用域

DomainPath 标识定义了 Cookie 的作用域:即 Cookie 应该发送给哪些 URL。

安全

  • SecureCookie 只有在 HTTPS 协议下才会发送给服务端。
  • HttpOnlyCookie 将无法被 JavaScript 访问,从而避免 XSS 攻击。

加签 && 加密

  • 加签:对 Cookie 进行签名,避免前端篡改。不会修改原键值,而是新增一个 ${key}.sig 的键值。
  • 加密:对 Cookie 进行加密,避免 Cookie 明文写入,泄露给恶意用户。

API 说明

set(key, value, options)

框架提供了 ctx.set(key, value, options) 来向用户发送 Cookie 信息。

其中,keyvalue 称之为一个 键值对。配置参数 options下文

get(key, options)

Cookie 是通过同一个 Header 中传输过来的,因此需要通过该方法解析并获取对应的值。

值得注意的是,获取时的 options.signedoptions.encrypt 要和 set() 的时候保持一致。

options

术语 一一对应,支持以下参数配置:

  • maxAge: {Number} 在浏览器的最长保存时间。
  • expires: {Date} 失效时间。优先级低于 maxAge。如果两者都没设置,则将会在关闭浏览器时失效。
  • path: {String} 生效的 URL 路径,默认为 /,即当前域名下均可访问这个 Cookie。
  • domain: {String} 对生效的域名,默认没有配置,可以配置成只在指定域名才能访问。
  • httpOnly: {Boolean} 是否可以被 js 访问,默认为 true,不允许被 js 访问
  • secure: {Boolean} 框架会自动判断当前请求是否为 HTTPS,从而自动赋值。
  • signed: {Boolean}:是否加签,默认为 true。
  • encrypt: {Boolean} 是否加密,默认为 false。

此外,还扩展了:

  • overwrite {Boolean}:相同的 Key 的处理逻辑,为 true,则后设置的值会覆盖前面设置的,否则将会发送两个 Set-Cookie 响应头。

配置秘钥

由于我们在 Cookie 中需要用到加解密验签,所以需要配置一个秘钥供加密使用。

// config/config.default.js
+module.exports = {
+  keys: 'key1,key2',
+};
+

如果你没配置该属性,则在访问时会报错:

ERROR 17996 [-/::1/-/7ms GET /] nodejs.Error: Please set config.keysfirst
+

keys 配置成一个字符串,可以按照逗号分隔配置多个 key。

Cookie 在使用这个配置进行加解密时:

  • 加密加签时只会使用第一个秘钥。
  • 解密验签时会遍历 keys 进行解密。

如果我们想要更新 Cookie 的秘钥,但是又不希望之前设置到用户浏览器上的 Cookie 失效,可以将新的秘钥配置到 keys 最前面,等过一段时间之后再删去不需要的秘钥即可。

如果要获取前端或者其他系统设置的 Cookie,需要指定参数 signedfalse,避免对它做验签导致获取不到 Cookie 的值。

ctx.cookies.get('frontend-cookie', {
+  signed: false,
+});
+

如果想要 Cookie 在浏览器端可以被 js 访问并修改:

ctx.cookies.set(key, value, {
+  httpOnly: false,
+  signed: false,
+});
+

不允许浏览器看到明文内容

如果想要 Cookie 在浏览器端不能被修改,不能看到明文:

ctx.cookies.set(key, value, {
+  httpOnly: true, // 默认就是 true
+  encrypt: true, // 加密传输
+});
+
ctx.cookies.set(key, null);
+

编写测试

类似 Controller 的测试。

需注意的是:模拟 Cookies 可能需要加上对应的 sig 加签信息。

// test/controller/cookies.test.js
+const { app, mock, assert } = require('egg-mock');
+
+describe('test/controller/cookies.test.js', () => {
+  it('should GET /', () => {
+    return app.httpRequest()
+      .get('/cookies')
+      .set('cookie', [ 'name=tz; path=/; httponly,name.sig=KdTywxAfCA4vHc1fmNipTZ9zPhBatn1br5tXWomvO14; path=/; httponly' ])
+      .expect('set-cookie', /uid=123;/)
+      .expect(200);
+  });
+});
+

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

注意事项

  1. 由于浏览器和其他客户端实现的不确定性,为了保证 Cookie 可以写入成功,建议 value 通过 base64 编码或者其他形式 encode 之后再写入。
  2. 由于浏览器对 Cookie 有长度限制限制,所以尽量不要设置太长的 Cookie。一般来说不要超过 4093 bytes。当设置的 Cookie value 大于这个值时,框架会打印一条警告日志。
  3. 尽可能少写入数据到 Cookie
+ + + diff --git a/zh/guide/directory.html b/zh/guide/directory.html new file mode 100644 index 0000000..c966e21 --- /dev/null +++ b/zh/guide/directory.html @@ -0,0 +1,85 @@ + + + + + + 目录规范 | Egg + + + + + + + +

目录规范

对于一个团队框架来说,『约定优于配置』,按照一套统一的约定进行应用开发,可以极大地减少开发人员的沟通成本。

框架通过 Loader 机制来自动挂载文件,应用开发者只需要添加文件到对应的目录即可。

showcase
+├── app
+|   ├── router.js
+│   ├── controller
+│   |   └── home.js
+│   ├── service
+│   |   └── user.js
+│   ├── middleware
+│   |   └── response_time.js
+│   └── view
+│       └── home.tpl
+├── config
+|   ├── plugin.js
+|   ├── config.default.js
+│   ├── config.prod.js
+|   ├── config.local.js
+|   └── config.unittest.js
+├── test
+|   ├── controller
+|   |   └── home.test.js
+|   └── service
+|       └── user.test.js
+└── package.json
+

如上,为一个常见的应用目录结构:

  • app: 为主要的逻辑代码目录。 +
    • 常规 MVC 如: app/controllerapp/serviceapp/router.js 等。
    • 某些插件也会自定义加载规范,如 app/rpc 等目录的自动挂载。
  • config: 为配置目录,包含不同环境的配置文件,以及插件挂载声明。
  • test: 为单元测试目录。
  • run:每次启动期都会 dump 的相关信息,用于问题排查,建议加入 gitignore

文件挂载如下:

  • app/controller/home.js 会被自动挂载到 app.controller.home
  • app/service/user.js 会被自动挂载到 ctx.service.user

注意事项

需要注意的是,加载文件时会进行驼峰转换,因此文件名和挂载的属性名可能会存在差异:

  • 默认情况下,连字符和下划线均会被转换为驼峰格式。
  • app/middleware/response_time.js 挂载为 app.middleware.responseTime
  • 部分插件,如 mongoose 插件有特殊约定,会挂载为类格式,如 app.model.User

在后面的章节中,我们会逐步介绍具体的目录约定。

如果需要自定义加载规则,可以参见 Loader 相关文档。

+ + + diff --git a/zh/guide/error_handler.html b/zh/guide/error_handler.html new file mode 100644 index 0000000..12f84bb --- /dev/null +++ b/zh/guide/error_handler.html @@ -0,0 +1,203 @@ + + + + + + 异常处理 | Egg + + + + + + + +

异常处理

使用场景

健壮性,是一个应用的基本要求。如何正确的处理错误是非常重要的一件事。

实际开发中,错误可以分为几类:

  • 非期望的入参,如函数要求传递的是数值,却传递了字符串。
  • 意料之中的错误,如 Http 网络断开文件不存在等。
  • 完全意料之外的异常,譬如业务进程被外部杀死。

错误的处理也有一些通用的实践:

  • 需要记录错误的信息,位置,堆栈和上下文。
  • 根据内容协商来返回不同的响应格式。
  • 正式环境下,不能把详细的错误信息和堆栈抛到用户侧。

Node.js 异常处理

Node.js 里,对异常的处理非常重要,如果有未捕获异常会直接导致进程退出。

在早期的 Node.js 里, Error-first callbacks 是用的比较广泛的一种错误处理的约定。

但嵌套层次一多起来,就需要一层层的往上抛出,非常容易遗漏和出现问题。

因此,在 Async Function 异步编程模型出来后,通过 try..catch 来捕获错误,就直观了很多。

async create(data) {
+  try {
+    return await this.service.user.create(data);
+  } catch (err) {
+    this.logger.error('create user fail', err);
+    return {};
+  }
+}
+

注意事项

避免使用 callback,它抛出的错误,无法被 try 直接捕获,详见 Node.js Error 文档。

框架内置支持

框架内置了 onerror 插件,提供了统一的错误处理机制。

对一个请求处理过程中的 MiddlewareControllerService 等抛出的任何异常都会被它捕获。

业务错误处理

如果你需要对业务错误进行统一处理,可以如下:

// app/middleware/error_handler.js
+module.exports = () => {
+  return async function errorHandler(ctx, next) {
+    try {
+      await next();
+    } catch (err) {
+      const { app } = ctx;
+      // 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
+      app.emit('error', err, ctx);
+
+      const status = err.status || 500;
+
+      // 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息
+      const error = status === 500 && app.config.env === 'prod' ? 'Internal Server Error' : err.message;
+
+      // 仅供参考,需按自己的业务逻辑处理。
+      ctx.body = { error };
+      ctx.status = status;
+    }
+};
+

挂载中间件:

// config/config.default.js
+module.exports = {
+  middleware: [ 'errorHandler' ],
+  errorHandler: {
+    // 仅对该路径下的接口处理
+    match: '/api',
+  },
+};
+

框架兜底处理

框架通过 onerror 插件提供了统一的错误处理机制。

对一个请求的所有处理方法(MiddlewareControllerService)中抛出的任何异常都会被它捕获。

并自动根据请求想要获取的类型返回不同类型的错误(基于 Content Negotiation)。

请求需求的格式 环境 errorPageUrl 是否配置 返回内容
HTML & TEXT local & unittest - onerror 自带的错误页面,展示详细的错误信息
HTML & TEXT 其他 重定向到 errorPageUrl
HTML & TEXT 其他 onerror 自带的没有错误信息的简单错误页(不推荐)
JSON & JSONP local & unittest - JSON 对象或对应的 JSONP 格式响应,带详细的错误信息
JSON & JSONP 其他 - JSON 对象或对应的 JSONP 格式响应,不带详细的错误信息

errorPageUrl

onerror 插件支持 errorPageUrl 配置,当配置了 errorPageUrl 时,一旦用户请求线上应用的 HTML 页面异常,就会重定向到这个地址。

config/config.default.js

// config/config.default.js
+module.exports = {
+  onerror: {
+    // 线上页面发生异常时,重定向到这个页面上
+    errorPageUrl: '/50x.html',
+  },
+};
+

自定义统一异常处理

尽管框架提供了默认的统一异常处理机制,但是应用开发中经常需要对异常时的响应做自定义,特别是在做一些接口开发的时候。框架自带的 onerror 插件支持自定义配置错误处理方法,可以覆盖默认的错误处理方法。

// config/config.default.js
+module.exports = {
+  onerror: {
+    all(err, ctx) {
+      // 在此处定义针对所有响应类型的错误处理方法
+      // 注意,定义了 config.all 之后,其他错误处理方法不会再生效
+      ctx.body = 'error';
+      ctx.status = 500;
+    },
+    html(err, ctx) {
+      // html hander
+      ctx.body = '<h3>error</h3>';
+      ctx.status = 500;
+    },
+    json(err, ctx) {
+      // json hander
+      ctx.body = { message: 'error' };
+      ctx.status = 500;
+    },
+    jsonp(err, ctx) {
+      // 一般来说,不需要特殊针对 jsonp 进行错误定义,jsonp 的错误处理会自动调用 json 错误处理,并包装成 jsonp 的响应格式
+    },
+  },
+};
+

404

404 - NOT FOUND 是我们比较熟悉的一种错误。

框架并不是把它视为是一种异常,并在上面的兜底流程做处理,而是另行提供了处理逻辑。

默认返回值

如果一次用户请求,经过了 MiddlewareController 处理后,对应的 ctx.bodyctx.status 都未被赋值时,框架会视为 404

此时框架会默认根据 Accepet 头来响应对应的值:

// Accpet: application/json
+{ "message": "Not Found" }
+
+// Accept: text/html
+<h1>404 Not Found</h1>
+

重定向

框架也支持通过配置,将默认的 HTML 请求的 404 响应重定向到指定的页面。

// config/config.default.js
+module.exports = {
+  notfound: {
+    // 也可以是一个统一的 404 外链
+    pageUrl: '/404.html',
+  },
+};
+

自定义 404 响应

在一些场景下,我们需要自定义服务器 404 时的响应,只需要加入一个中间件即可统一处理:

// app/middleware/notfound_handler.js
+module.exports = () => {
+  return async function notFoundHandler(ctx, next) {
+    await next();
+    if (ctx.status === 404 && !ctx.body) {
+      if (ctx.acceptJSON) {
+        ctx.body = { error: 'Not Found' };
+      } else {
+        ctx.body = '<h1>Page Not Found</h1>';
+      }
+    }
+  };
+};
+

挂载中间件:

// config/config.default.js
+module.exports = {
+  middleware: [ 'notfoundHandler' ],
+};
+

常见问题

该不该 Catch

具体情况具体分析,没有绝对的银弹。

如果错误是非主流程的,是可选的,那可以自行兜底处理。

// app/service/ad.js
+class AdService extends Service {
+  async list() {
+    // 查询推荐的广告位数据,失败则返回空。
+    try {
+      return await this.ctx.db.ad.list();
+    } catch (err) {
+      // 打印错误日志
+      this.logger.error('list ad fail', err);
+      // 返回空数据,不影响主流程
+      return [];
+    }
+  }
+}
+

如果对应的错误,是需要告知用户或通知前端代码的,那可以通过上述的 业务错误处理 来统一反馈给用户。

回调错误无法捕获

按照正常代码写法,所有的异常都可以用这个方式进行捕获并处理,但是一定要注意一些特殊的写法可能带来的问题。

打一个不太正式的比方,我们的代码全部都在一个异步调用链上,所有的异步操作都通过 await 串接起来了,但是只要有一个地方跳出了异步调用链,异常就捕获不到了。

// app/controller/home.js
+class HomeController extends Controller {
+  async error () {
+    // 在回调里面抛错
+    setTimeout(() => {
+      throw new Error('this is an error throw from callback');
+    });
+  }
+}
+

正确的做法

// app/controller/home.js
+class HomeController extends Controller {
+  async buy () {
+    const { ctx } = this;
+
+    const config = await ctx.service.trade.buy({ id: '12345' });
+    // 下单后需要进行一次核对,且不阻塞当前请求
+    setImmediate(() => {
+      ctx.service.trade.check(request).catch(err => ctx.logger.error(err));
+    });
+  }
+}
+

在这个场景中,如果 service.trade.check 方法中代码有问题,导致执行时抛出了异常,尽管框架会在最外层通过 try catch 统一捕获错误,但是由于 setImmediate 中的代码『跳出』了异步链,它里面的错误就无法被捕捉到了。因此在编写类似代码的时候一定要注意。

当然,框架也考虑到了这类场景,提供了 ctx.runInBackground(scope) 辅助方法,通过它又包装了一个异步链,所有在这个 scope 里面的错误都会统一捕获。

class HomeController extends Controller {
+  async buy () {
+    const request = {};
+    const config = await ctx.service.trade.buy(request);
+    // 下单后需要进行一次核对,且不阻塞当前请求
+    ctx.runInBackground(async () => {
+      // 这里面的异常都会统统被 Backgroud 捕获掉,并打印错误日志
+      await ctx.service.trade.check(request);
+    });
+  }
+}
+
+ + + diff --git a/zh/guide/faq.html b/zh/guide/faq.html new file mode 100644 index 0000000..448dfae --- /dev/null +++ b/zh/guide/faq.html @@ -0,0 +1,75 @@ + + + + + + FAQ | Egg + + + + + + + +

FAQ

如果下面的内容无法解决你的问题,请查看 Egg issues

如何高效的反馈问题?

感谢您向我们反馈问题。

  1. 我们推荐如果是小问题(错别字修改,小的 bug fix)直接提交 PR。
  2. 如果是一个新需求,请提供:详细需求描述,最好是有伪代码示意。
  3. 如果是一个 BUG,请提供:复现步骤,错误日志以及相关配置,并尽量填写下面的模板中的条目。
  4. 如果可以,尽可能使用 egg-init --type=simple bug 提供一个最小可复现的代码仓库,方便我们排查问题。
  5. 不要挤牙膏似的交流,扩展阅读:如何向开源项目提交无法解答的问题

最重要的是,请明白一件事:开源项目的用户和维护者之间并不是甲方和乙方的关系,issue 也不是客服工单。在开 issue 的时候,请抱着一种『一起合作来解决这个问题』的心态,不要期待我们单方面地为你服务。

为什么我的配置不生效?

框架的配置功能比较强大,有不同环境变量,又有框架、插件、应用等很多地方配置。

如果你分析问题时,想知道当前运行时使用的最终配置,可以查看下 ${root}/run/application_config.json(worker 进程配置) 和 ${root}/run/agent_config.json(agent 进程配置) 这两个文件。(root 为应用根目录,只有在 local 和 unittest 环境下为项目所在目录,其他环境下都为 HOME 目录)

也可参见配置文件

PS:请确保没有写出以下代码:

// config/config.default.js
+exports.someKeys = 'abc';
+module.exports = appInfo => {
+  const config = {};
+  config.keys = '123456';
+  return config;
+};
+

线上的日志打印去哪里了?

默认配置下,本地开发环境的日志都会打印在应用根目录的 logs 文件夹下(${baseDir}/logs) ,但是在非开发期的环境(非 local 和 unittest 环境),所有的日志都会打印到 $HOME/logs 文件夹下(例如 /home/admin/logs)。这样可以让本地开发时应用日志互不影响,服务器运行时又有统一的日志输出目录。

进程管理为什么没有选型 PM2 ?

  1. PM2 模块本身复杂度很高,出了问题很难排查。我们认为框架使用的工具复杂度不应该过高,而 PM2 自身的复杂度超越了大部分应用本身。
  2. 没法做非常深的优化。
  3. 切实的需求问题,一个进程里跑 leader,其他进程代理到 leader 这种模式(多进程模型),在企业级开发中对于减少远端连接,降低数据通信压力等都是切实的需求。特别当应用规模大到一定程度,这就会是刚需。egg 本身起源于蚂蚁金服和阿里,我们对标的起点就是大规模企业应用的构建,所以要非常全面。这些特性通过 PM2 很难做到。

进程模型非常重要,会影响到开发模式,运行期间的深度优化等,我们认为可能由框架来控制比较合适。

如何使用 PM2 启动应用?

尽管我们不推荐使用 PM2 启动,但仍然是可以做到的。

首先,在项目根目录定义启动文件:

// server.js
+const egg = require('egg');
+
+const workers = Number(process.argv[2] || require('os').cpus().length);
+egg.startCluster({
+  workers,
+  baseDir: __dirname,
+});
+

这样,我们就可以通过 PM2 进行启动了:

pm2 start server.js
+

为什么会有 csrf 报错?

通常有两种 csrf 报错:

  • missing csrf token
  • invalid csrf token

Egg 内置的 egg-security 插件默认对所有『非安全』的方法,例如 POSTPUTDELETE 都进行 CSRF 校验。

请求遇到 csrf 报错通常是因为没有加正确的 csrf token 导致,具体实现方式,请阅读安全威胁 CSRF 的防范

本地开发时,修改代码后为什么 worker 进程没有自动重启?

没有自动重启的情况一般是在使用 Jetbrains 旗下软件(IntelliJ IDEA, WebStorm..),并且开启了 Safe Write 选项。

Jetbrains Safe Write 文档中有提到:

If this check box is selected, a changed file is first saved in a temporary file. If the save operation succeeds, the file being saved is replaced with the saved file. (Technically, the original file is deleted and the temporary file is renamed.)

由于使用了重命名导致文件监听的失效。解决办法是关掉 Safe Write 选项。(Settings | Appearance & Behavior | System Settings | Use "safe write" 路径可能根据版本有所不同)

+ + + diff --git a/zh/guide/helper.html b/zh/guide/helper.html new file mode 100644 index 0000000..c7e5765 --- /dev/null +++ b/zh/guide/helper.html @@ -0,0 +1,104 @@ + + + + + + Helper | Egg + + + + + + + +

Helper

使用场景

Helper 提供了一些实用的 utility 函数,避免逻辑分散各处,更容易编写测试用例。

框架内置了一些常用的 Helper 方法,我们也可以编写自定义的 Helper 方法。

访问方式

它是一个 请求级别 的对象,可以通过 ctx.helper 访问到 helper 对象。

Controller 中使用:

// app/controller/user.js
+class UserController extends Controller {
+  async fetch() {
+    const { app, ctx } = this;
+    const id = ctx.query.id;
+    const user = app.cache.get(id);
+    ctx.body = ctx.helper.formatUser(user);
+  }
+}
+

模板引擎中使用:

<!-- app/view/home.tpl -->
+{{ helper.shtml(value) }}
+

常用的属性和方法

Helper 上有以下属性:

  • thisHelper 对象本身,可以用来调用其他 Helper 方法。
  • this.ctx:对应的 Context 对象。
  • this.app:对应的 Application 对象。

框架默认提供以下 Helper 方法:

  • pathFor(name, params): 生成对应[路由]的 path 路径。
  • urlFor(name, params): 生成对应[路由]的 URL
  • shtml() / sjs() / ...: 由安全组件提供的安全方法。
// app/router.js
+app.get('user', '/user', controller.user);
+
+// 使用 helper 计算指定 path
+ctx.helper.pathFor('user', { limit: 10, sort: 'name' });
+// => /user?limit=10&sort=name
+

如何扩展

我们支持开发者通过 app/extend/helper.js 来扩展 Helper

// app/extend/helper.js
+module.exports = {
+  foo(param) {
+    // this 是 helper 对象,在其中可以调用其他 helper 方法
+    // this.ctx => context 对象
+    // this.app => application 对象
+  },
+
+  formatUser(user) {
+    return only(user, [ 'name', 'phone' ]);
+  }
+};
+

对应的测试:

// test/app/extend/helper.js
+const { app, assert } = require('egg-mock');
+
+describe('test/app/extend/helper.js', () => {
+  it('formatUser()', () => {
+    // 创建 ctx
+    const ctx = app.mockContext();
+
+    const result = ctx.helper.formatUser({ name: 'TZ', phone: 123, token: 'abcd' });
+
+    assert(result.name === 'TZ');
+    assert(!result.token);
+  });
+});
+

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

+ + + diff --git a/zh/guide/httpclient.html b/zh/guide/httpclient.html new file mode 100644 index 0000000..a87c3de --- /dev/null +++ b/zh/guide/httpclient.html @@ -0,0 +1,501 @@ + + + + + + HttpClient | Egg + + + + + + + +

HttpClient

使用背景

互联网时代,无数服务是基于 HTTP 协议进行通信的。

在前面我们了解到的,都是 Node.js 作为 Web 服务端的相关知识。

其实应用本身作为发起者,来调用后端服务也是一种非常常见的应用场景。

譬如:

  • 调用后端微服务,查询或更新数据。
  • 把日志上报给第三方服务。
  • 上传文件给后端服务。

因此,框架内置实现了一个 HttpClient,应用可以使用它来非常便捷地完成任何 HTTP 请求。

获取方式

app.httpclient

框架在应用初始化的时候,会自动将 HttpClient 初始化到 app.httpclient

它是基于 urllib 模块的扩展。

app.curl(http://github.com/url,%20options)

框架提供的语法糖,它等价于 app.httpclient.request(url, options)

const url = 'https://registry.npm.taobao.org/egg/latest';
+const result = await app.curl(url, { dataType: 'json' });
+console.log(result.data);
+

ctx.curl(http://github.com/url,%20options)

框架在 Context 中同样提供了对应的语法糖,这将是我们最常用的方法。

它的区别在于,会默认注入 options.ctx,从而在错误处理或打印 Trace 日志时,可以方便的获取到上游请求的相关信息。

// app/controller/http.js
+class HttpController extends Controller {
+  async index() {
+    const { ctx } = this;
+
+    // 示例:请求一个 npm 模块信息
+    const url = 'https://registry.npm.taobao.org/egg/latest';
+    const result = await ctx.curl(url, {
+      // 自动解析 JSON response
+      dataType: 'json',
+      // 3 秒超时
+      timeout: 3000,
+    });
+
+    ctx.body = {
+      status: result.status,
+      headers: result.headers,
+      package: result.data,
+    };
+  }
+}
+

常用参数及响应

请求参数

最常用到的 Options 参数如下:

  • options.methodHTTP 请求方法,默认为 GET,全大写格式。
  • options.data:发送的请求体,会根据 contentType 进行不同的处理。
  • options.contentType:发送的数据格式,取值 jsonform
  • options.dataType:对响应的数据进行格式转换,取值 jsontext
  • options.headers:请求头。

完整的请求参数 options 说明,参见下文的 options 参数详解 章节。

响应数据

  • result.status: 响应状态码,如 200, 302, 404, 500 等等。
  • result.headers: 响应头,类似 { 'content-type': 'text/html', ... }
  • result.data: 响应 body 数据,会根据 options.dataType 进行相应的格式转换。
  • result.res.timing:请求各阶段的耗时统计,需传递 options.timing 才会采集。

HttpClient 实战

以下示例,我们都使用 https://httpbin.org 提供的服务来测试。

发起 GET 请求

读取数据几乎都是使用 GET 请求,它是 HTTP 世界最常见的场景,也是最广泛的场景。

// app/controller/http.js
+class HttpController extends Controller {
+  async get() {
+    const { ctx } = this;
+    const result = await ctx.curl('https://httpbin.org/get?foo=bar');
+    ctx.status = result.status;
+    ctx.set(result.headers);
+    ctx.body = result.data;
+  }
+}
+

通过 POST 发送 JSON

微服务间通讯,JSON 是最常见的协议。

譬如,创建数据的场景一般来说都会使用 POST 发送 JSON 数据。

关键配置为:

  • method: 必须配置为 POST
  • data:需要传递的数据对应,Object 类型。
  • contentType: 'json':声明以 JSON 格式发送,框架会自动对其 stringify 处理。
  • dataType: 'json':告知框架应该自动把响应数据解析为 JSON 对象。
// app/controller/http.js
+class HttpController extends Controller {
+  async post() {
+    const { ctx } = this;
+    const result = await ctx.curl('https://httpbin.org/post', {
+      // 必须指定 method
+      method: 'POST',
+      // 通过 contentType 声明以 JSON 格式发送
+      contentType: 'json',
+      data: {
+        hello: 'world',
+        now: Date.now(),
+      },
+      // 明确告诉 HttpClient 以 JSON 格式处理返回的响应 body
+      dataType: 'json',
+    });
+    ctx.body = result.data;
+  }
+}
+

提交 Form 表单

也有很多接口是面向浏览器设计的,需要通过 Form 表单方式提交接口。

只需把对应的 contentType 配置为 form 即可,框架会自动组装为对应的格式,并通过 application/x-www-form-urlencoded 提交。








 















// app/controller/http.js
+class HttpController extends Controller {
+  async submit() {
+    const { ctx } = this;
+    const result = await ctx.curl('https://httpbin.org/post', {
+      method: 'POST',
+      // 通过 `form` 格式提交,application/x-www-form-urlencoded
+      contentType: 'form',
+      data: {
+        now: Date.now(),
+        foo: 'bar',
+      },
+      dataType: 'json',
+    });
+    ctx.body = result.data.form;
+    // 响应最终会是类似以下的结果:
+    // {
+    //   "foo": "bar",
+    //   "now": "1483864184348"
+    // }
+  }
+}
+

文件上传(Multipart)

当一个表单提交包含文件的时候,请求数据格式就必须以 multipart/form-data 进行提交了。

urllib 内置了 formstream 模块来帮助我们生成可以被消费的 form 对象。

关键配置为:

  • files:需要上传的文件,支持多种形式: +
    • 单文件上传:支持直接传递:String 文件路径 / Stream 对象 / Buffer 对象。
    • 多文件上传:数组或 Object 格式,若为后者,则 key 为对应的 fieldName。
  • data:将被转换为对应的 form field
// app/controller/http.js
+class HttpController extends Controller {
+  async upload() {
+    const { ctx } = this;
+
+    const result = await ctx.curl('https://httpbin.org/post', {
+      method: 'POST',
+      dataType: 'json',
+      data: {
+        foo: 'bar',
+      },
+
+      // 单文件上传
+      files: __filename,
+
+      // 多文件上传
+      // files: {
+      //   file1: __filename,
+      //   file2: fs.createReadStream(__filename),
+      //   file3: Buffer.from('mock file content'),
+      // },
+    });
+
+    ctx.body = result.data.files;
+    // 响应最终会是类似以下的结果:
+    // {
+    //   "file": "'use strict';\n\nconst For...."
+    // }
+  }
+}
+

文件上传(Stream)

Node.js 的世界里面,Stream 才是主流。

如果服务端支持流式上传,最友好的方式还是直接发送 Stream

Stream 实际会以 Transfer-Encoding: chunked 传输编码格式发送,这个转换是 HTTP 模块自动实现的。

关键配置为:

  • stream:通过 Stream 模式发送数据。
  • dataAsQueryString:可选,需要传递额外的请求参数的场景。
  • data:可选,会被强制 querystring.stringify 处理之后拼接到 URLquery 参数上。
// app/controller/http.js
+const fs = require('fs');
+const FormStream = require('formstream');
+
+class HttpController extends Controller {
+  async uploadByStream() {
+    const { ctx } = this;
+
+    // 上传当前文件本身用于测试
+    const fileStream = fs.createReadStream(__filename);
+
+    // httpbin.org 不支持 stream 模式,使用本地 stream 接口代替
+    const url = `${ctx.protocol}://${ctx.host}/stream`;
+    const result = await ctx.curl(url, {
+      method: 'POST',
+      // 以 stream 模式提交
+      stream: fileStream,
+
+      // 额外传递参数
+      dataAsQueryString: true,
+      data: {
+        // 一般来说都是 access token 之类的权限验证参数
+        accessToken: 'some access token value',
+      },
+    });
+
+    ctx.body = result.data;
+    // 响应最终会是类似以下的结果:
+    // {"streamSize":574}
+  }
+}
+

发送 XML

此时,可以用 content 参数代替 data 参数,框架会原样发送数据。

// app/controller/http.js
+class HttpController extends Controller {
+  async xml() {
+    const { ctx } = this;
+    const result = await ctx.curl('https://httpbin.org/xml', {
+      method: 'POST',
+      // 直接发送原始 xml 数据,不需要 HttpClient 做特殊处理
+      content: '<xml><hello>world</hello></xml>',
+      headers: {
+        'content-type': 'text/html',
+      },
+      dataType: 'json',
+    });
+    ctx.body = result.data;
+  }
+}
+

超时时间

请求超时时间,默认是 [ 5000, 5000 ],即创建连接超时是 5 秒,接收响应超时是 5 秒。

支持 Number[ Number, Number ] 格式,前者代表两个时间取同个值。

// app/controller/http.js
+class HttpController extends Controller {
+  async timeout() {
+    const { ctx } = this;
+    const result = await ctx.curl('https://httpbin.org/timeout', {
+      // 创建连接超时 1 秒,接收响应超时 30 秒,用于响应比较大的场景
+      timeout: [ 1000, 30000 ],
+      dataType: 'json',
+    });
+    ctx.body = result.data;
+  }
+}
+

处理重定向

有些时候,需要对后端的重定向进行跟进处理,框架提供了:

  • followRedirect:是否自动跟进 3xx 的跳转响应,默认是 false
  • maxRedirects:最大自动跳转次数,避免死循环,默认是 10 次。 此参数不宜设置过大。
  • formatRedirectUrl(from, to):跳转 URL 校正,默认是 url.resolve(from, to)
// app/controller/http.js
+class HttpController extends Controller {
+  async followRedirect() {
+    const { ctx } = this;
+    const result = await ctx.curl('/your_redirect_url', {
+      formatRedirectUrl: (from, to) => {
+        // 允许跟踪跳转
+        followRedirect: true,
+
+        // 最大只允许自动跳转 5 次。
+        maxRedirects: 5,
+
+        // 例如可在这里修正跳转不正确的 url
+        if (to === '//foo/') {
+          to = '/foo';
+        }
+        return url.resolve(from, to);
+      },
+    });
+    ctx.body = result.data;
+  }
+}
+

抓包调试

有些时候,我们需要抓包来调试对应的 HTTP 请求。

修改本地开发配置:

// config/config.local.js
+module.exports = () => {
+  const config = {};
+
+  // add http_proxy to httpclient
+  if (process.env.http_proxy) {
+    config.httpclient = {
+      request: {
+        enableProxy: true,
+        rejectUnauthorized: false,
+        proxy: process.env.http_proxy,
+      },
+    };
+  }
+
+  return config;
+}
+

使用环境变量启动你的应用:

$ http_proxy=http://127.0.0.1:8888 npm run dev
+

然后启动你的抓包工具,如 CharlesFiddler,就可以看到对应的 HTTP 抓包信息。

事件监听

在企业应用场景,常常会有统一 Tracer 日志的需求。

为了方便在统一监听 HttpClient 的请求和响应,我们约定了两个事件。

// 对请求做拦截,设置一些 trace headers,方便全链路跟踪。
+app.httpclient.on('request', req => {
+  const { requestId, url, args, ctx } = req;
+
+  console.log(req.url);
+  console.log(req.ctx); // 仅在 `ctx.curl()` 时才有值,方便记录上游请求信息。
+
+  // 例如我们可以设置全局请求 ID,方便日志跟踪
+  req.headers['x-request-id'] = uuid.v1();
+
+  // 开启 timing 统计
+  req.args.timing = true;
+});
+
+// 订阅事件来打印日志
+app.httpclient.on('response', result => {
+  const { requestId, ctx, req, res, error } = result;
+  console.log(req.url, res.status);
+  console.log(result.res.timing); // 统计请求各阶段的耗时
+  console.log(ctx); // 仅在 `ctx.curl()` 时才有值,方便记录上游请求信息。
+});
+

如何扩展

我们跟后端的接口协议,往往会在 HTTP 上做一层简单的协议封装,如加解密和校验。

如果每次调用 HttpClient 的时候,都要传递参数和解析协议,未免太麻烦。

此时可以扩展下:

// app/extend/context.js
+const rpc = require('../../lib/rpc');
+
+module.exports = {
+  async rpc(url, options) {
+    // 提供请求的默认值
+    options = Object.assign({
+      method: 'POST',
+      dataType: 'json',
+      contentType: 'json',
+    }, options);
+
+    // 发起 HTTP 请求
+    let result = await this.curl(url, options);
+
+    // 对后端返回结果进行预处理,如校验、解密等。
+    result = rpc.process(result);
+
+    return result;
+  },
+}
+

这样,在 ControllerService 等地方就可以直接使用了:







 










// app/controller/http.js
+class HttpController extends Controller {
+  async post() {
+    const { ctx } = this;
+
+    // 调用对应的扩展方法
+    const result = await ctx.rpc('https://httpbin.org/post', {
+      data: {
+        hello: 'world',
+        now: Date.now(),
+      },
+    });
+
+    ctx.body = result.data;
+  }
+}
+

编写测试

对于 HttpClient 这种关键的请求交互,单元测试就更必不可少。

框架通过 egg-mock 提供了 app.mockHttpclient(url, method, data) 的模拟能力。

describe('GET /httpclient', () => {
+  it('should mock httpclient response', () => {
+    app.mockHttpclient('https://eggjs.org/', {
+      // 模拟的参数,可以是 `Buffer/String/JSON`
+      // 会按照请求时的 `options.dataType` 来做对应的转换
+      data: 'mock eggjs.org response',
+    });
+
+    return app.httpRequest()
+      .get('/httpclient')
+      .expect('mock eggjs.org response');
+  });
+});
+

详见对应的 Mock API

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

常见错误码

ConnectionTimeoutError

  • 异常名称:创建连接超时ConnectionTimeoutError
  • 出现场景:通常是 DNS 查询比较慢,或者客户端与服务端之间的网络速度比较慢导致的。
  • 排查建议:请适当增大 timeout 参数。

ResponseTimeoutError

  • 异常名称:服务响应超时ResponseTimeoutError
  • 出现场景:通常是客户端与服务端之间网络速度比较慢,并且响应数据比较大的情况下会发生。
  • 排查建议:请适当增大 timeout 参数。

ECONNRESET

  • 异常名称:服务主动断开连接ResponseError, code: ECONNRESET
  • 出现场景:通常是服务端主动断开 Socket 连接,导致 HTTP 请求链路异常。
  • 排查建议:请检查当时服务端是否发生网络异常。

ECONNREFUSED

  • 异常名称:服务不可达RequestError, code: ECONNREFUSED, status: -1
  • 出现场景:通常是因为请求的 URL 所属 IP 或者端口无法连接成功。
  • 排查建议:请确保 IP 或者端口设置正确,目标网络是通的。

ENOTFOUND

  • 异常名称:域名不存在RequestError, code: ENOTFOUND, status: -1
  • 出现场景:通常是因为请求的 URL 所在的域名无法通过 DNS 解析成功。
  • 排查建议:请确保域名存在,也需要排查一下 DNS 服务是否配置正确。

JSONResponseFormatError

  • 异常名称:JSON 响应数据解析失败JSONResponseFormatError
  • 出现场景:设置了 dataType=json,但响应数据不符合 JSON 格式,就会抛出此异常。
  • 排查建议:确保服务端无论在什么情况下都要正确返回 JSON 格式的数据。

有些 CGI 系统返回的 JSON 数据会包含某些特殊控制字符(U+0000 ~ U+001F),可以通过 fixJSONCtlChars 参数自动过滤掉它们。

Options 参数详解

由于 HTTP 请求的复杂性,导致 HttpClientoptions 参数会非常多。

接下来讲解常用的可选参数的实际用途,更多的参数可以参见 urllib 文档。

默认全局配置

// config/config.default.js
+exports.httpclient = {
+  // 是否开启本地 DNS 缓存,默认关闭,开启后有两个特性
+  // 1. 所有的 DNS 查询都会默认优先使用缓存的,即使 DNS 查询错误也不影响应用
+  // 2. 对同一个域名,在 dnsCacheLookupInterval 的间隔内(默认 10s)只会查询一次
+  enableDNSCache: false,
+  // 对同一个域名进行 DNS 查询的最小间隔时间
+  dnsCacheLookupInterval: 10000,
+  // DNS 同时缓存的最大域名数量,默认 1000
+  dnsCacheMaxLength: 1000,
+
+  request: {
+    // 默认 request 超时时间
+    timeout: 3000,
+  },
+
+  httpAgent: {
+    // 默认开启 http KeepAlive 功能
+    keepAlive: true,
+    // 空闲的 KeepAlive socket 最长可以存活 4 秒
+    freeSocketTimeout: 4000,
+    // 当 socket 超过 30 秒都没有任何活动,就会被当作超时处理掉
+    timeout: 30000,
+    // 允许创建的最大 socket 数
+    maxSockets: Number.MAX_SAFE_INTEGER,
+    // 最大空闲 socket 数
+    maxFreeSockets: 256,
+  },
+
+  httpsAgent: {
+    // 默认开启 https KeepAlive 功能
+    keepAlive: true,
+    // 空闲的 KeepAlive socket 最长可以存活 4 秒
+    freeSocketTimeout: 4000,
+    // 当 socket 超过 30 秒都没有任何活动,就会被当作超时处理掉
+    timeout: 30000,
+    // 允许创建的最大 socket 数
+    maxSockets: Number.MAX_SAFE_INTEGER,
+    // 最大空闲 socket 数
+    maxFreeSockets: 256,
+  },
+};
+

应用可以通过 config/config.default.js 覆盖此配置。

method: String

HTTP 请求方法,默认是 GET,全大写格式,支持所有 HTTP 方法

data: Object

需要发送的请求数据,会根据 method 自动选择正确的数据处理方式。

  • GETHEAD:通过 querystring.stringify(data) 处理后拼接到 URL 的查询参数上。
  • POSTPUTDELETE 等:需要根据 contentType 做进一步判断处理。 +
    • contentType = json:通过 JSON.stringify(data) 处理,并通过请求 body 发送。
    • 其他:通过 querystring.stringify(data) 处理,并通过请求 body 发送。
// GET + Query, `/api/user?foo=bar`
+ctx.curl(url, {
+  data: { foo: 'bar' },
+});
+
+// POST + Form + body
+ctx.curl(url, {
+  method: 'POST',
+  data: { foo: 'bar' },
+});
+
+// POST + JSON + body
+ctx.curl(url, {
+  method: 'POST',
+  contentType: 'json',
+  data: { foo: 'bar' },
+});
+

contentType: String

设置请求数据格式,支持 jsonform,决定了请求数据的序列化格式。

如需要以 JSON 格式发送 data

ctx.curl(url, {
+  method: 'POST',
+  data: {
+    foo: 'bar',
+    now: Date.now(),
+  },
+  contentType: 'json',
+});
+

dataType: String

设置响应数据格式,默认不对响应数据做任何处理,直接返回原始的 buffer 格式数据。

支持 textjson 两种取值。

const jsonResult = await ctx.curl(url, {
+  dataType: 'json',
+});
+console.log(jsonResult.data);
+
+const htmlResult = await ctx.curl(url, {
+  dataType: 'text',
+});
+console.log(htmlResult.data);
+

注意

设置成 json 时,如果响应数据解析失败会抛 JSONResponseFormatError 异常。

dataAsQueryString: Boolean

如果设置为 true,那么即使在 POST 情况下,也会强制将 options.dataquerystring.stringify 处理之后拼接到 URL 的查询参数上。

可以很好地解决以 stream 发送数据,且额外的请求参数以 URL Query 形式传递的应用场景:

ctx.curl(url, {
+  method: 'POST',
+  dataAsQueryString: true,
+  data: {
+    // 一般来说都是 access token 之类的权限验证参数
+    accessToken: 'some access token value',
+  },
+  stream: myFileStream,
+});
+

content: String|Buffer

发送请求正文,如果设置了此参数,那么会直接忽略 data 参数。

ctx.curl(url, {
+  method: 'POST',
+  // 直接发送原始 xml 数据,不需要 HttpClient 做特殊处理
+  content: '<xml><hello>world</hello></xml>',
+  headers: {
+    'content-type': 'text/html',
+  },
+});
+

headers: Object

自定义请求头。

ctx.curl(url, {
+  headers: {
+    'x-foo': 'bar',
+  },
+});
+

timeout: Number|Array

请求超时时间,默认是 [ 5000, 5000 ],即创建连接超时是 5 秒,接收响应超时是 5 秒。

ctx.curl(url, {
+  // 创建连接超时 3 秒,接收响应超时 3 秒
+  timeout: 3000,
+});
+
+ctx.curl(url, {
+  // 创建连接超时 1 秒,接收响应超时 30 秒,用于响应比较大的场景
+  timeout: [ 1000, 30000 ],
+});
+

files: Mixed

文件上传,支持格式: String | ReadStream | Buffer | Array | Object

ctx.curl(url, {
+  method: 'POST',
+  files: '/path/to/read',
+  data: {
+    foo: 'other fields',
+  },
+});
+

多文件上传:

ctx.curl(url, {
+  method: 'POST',
+  files: {
+    file1: '/path/to/read',
+    file2: fs.createReadStream(__filename),
+    file3: Buffer.from('mock file content'),
+  },
+  data: {
+    foo: 'other fields',
+  },
+});
+

stream: ReadStream

设置发送请求正文的可读数据流,一旦设置了此参数,将会忽略 datacontent

ctx.curl(url, {
+  method: 'POST',
+  stream: fs.createReadStream('/path/to/read'),
+});
+

writeStream: WriteStream

设置接受响应数据的可写数据流,默认是 null。 +一旦设置此参数,那么返回值 result.data 将会被设置为 null, +因为数据已经全部写入到 writeStream 中了。

ctx.curl(url, {
+  writeStream: fs.createWriteStream('/path/to/store'),
+});
+

注意事项

请在你充分理解 Stream异步编程 的基础上,再使用。

streaming: Boolean

是否直接返回响应流。

开启后会在拿到响应对象 res 时马上返回,此时 headersstatus 已经可以读取到,但还没有读取 data 数据。

const result = await ctx.curl(url, {
+  streaming: true,
+});
+
+console.log(result.status, result.data);
+// result.res 是一个 ReadStream 对象
+ctx.body = result.res;
+

注意

若 res 不是直接传递给 body,那么我们必须消费这个 stream,并且要做好 error 事件处理。

beforeRequest: Function(options)

在请求正式发送之前,会尝试调用 beforeRequest 钩子,允许我们在这里对请求参数做最后一次修改。

ctx.curl(url, {
+  beforeRequest: options => {
+    // 例如我们可以设置全局请求 id,方便日志跟踪
+    options.headers['x-request-id'] = uuid.v1();
+  },
+});
+

gzip: Boolean

是否支持 gzip 响应格式,开启后将自动设置 Accept-Encoding: gzip 请求头, +并且会自动解压带 Content-Encoding: gzip 响应头的数据。

ctx.curl(url, {
+  gzip: true,
+});
+

timing: Boolean

是否开启请求各阶段的时间测量。

开启后可以通过 result.res.timing 拿到这次 HTTP 请求各阶段的时间测量值(单位是毫秒)。

通过这些测量值,我们可以非常方便地定位到这次请求最慢的环境发生在那个阶段,效果如同 Chrome Network Timing 的作用。

各阶段测量值:

  • queuing:分配 Socket 耗时。
  • dnslookupDNS 查询耗时。
  • connectedSocket 三次握手连接成功耗时。
  • requestSent:请求数据完整发送完毕耗时。
  • waiting:收到第一个字节的响应数据耗时。
  • contentDownload:全部响应数据接收完毕耗时。
const result = await ctx.curl(url, {
+  timing: true,
+});
+console.log(result.res.timing);
+// {
+//   "queuing":29,
+//   "dnslookup":37,
+//   "connected":370,
+//   "requestSent":1001,
+//   "waiting":1833,
+//   "contentDownload":3416
+// }
+

HTTPS 相关参数

包括 keycertpassphrase 等参数,都将透传给 HTTPS 模块。

其中 rejectUnauthorized 用于在本地调试时忽略无效的 HTTPS 证书。

具体请查看 https.request() 文档。

示例代码

完整示例代码可以在 eggjs/examples/httpclient 找到。

+ + + diff --git a/zh/guide/i18n.html b/zh/guide/i18n.html new file mode 100644 index 0000000..7892830 --- /dev/null +++ b/zh/guide/i18n.html @@ -0,0 +1,127 @@ + + + + + + 国际化 | Egg + + + + + + + +

国际化

使用场景

为了方便开发多语言应用,框架内置了国际化(I18n)支持,由 egg-i18n 插件提供。

i18ninternationalization 的缩写,代表 in 之间有 18 个字母。

  • 开发者需定义多个 locale 多语言文件。
  • 开发者在 ControllerView 中使用对应的语法糖渲染字符串。
  • 插件会根据约定,渲染指定的多语言字符串。

定义 locale

目录规范

多种语言的配置是独立的,统一存放在 config/locale/*.js 下。

showcase
+└── config
+    ├── plugin.js
+    ├── config.default.js
+    └── locale
+        ├── en-US.js
+        └── zh-CN.js
+

不仅对于应用目录生效,在框架,插件的 config/locale 目录下同样生效。

友情提示

注意单词拼写,是 locale 不是 locals。

文件格式

支持 jsJSON 两种格式:

// config/locale/zh-CN.js
+module.exports = {
+  Email: '邮箱',
+};
+

// config/locale/zh-CN.json
+{
+  "Email": "邮箱"
+}
+

占位符

支持类似 util.format()%s%j 等占位符语法。

// config/locale/zh-CN.js
+module.exports = {
+  'Welcome back, %s!': '欢迎回来,%s!',
+};
+
+ctx.__('Welcome back, %s!', 'Shawn');
+// zh-CN => 欢迎回来,Shawn!
+// en-US => Welcome back, Shawn!
+

同时支持数组下标占位符方式,例如:

// config/locale/zh-CN.js
+module.exports = {
+  'Hello {0}! My name is {1}.': '你好 {0}! 我的名字叫 {1}。',
+};
+
+ctx.__('Hello {0}! My name is {1}.', [ 'foo', 'bar' ]);
+// zh-CN => 你好 foo!我的名字叫 bar。
+// en-US => Hello foo! My name is bar.
+

使用 i18n

ctx.__(key, ...values)

插件提供了 ctx.__(key, ...values) 来获取语言配置。

它等价于 ctx.gettext(key, ...values)

class HomeController extends Controller {
+  async index() {
+    const ctx = this.ctx;
+    ctx.body = {
+      // zh-CN => 邮箱
+      // en-US => Email
+      email: ctx.__('Email'),
+
+      // zh-CN => 欢迎回来,Shawn!
+      // en-US => Welcome back, Shawn!
+      message: ctx.__('Welcome back, %s!', 'Shawn'),
+
+      // zh-CN => 你好 foo!我的名字叫 bar。
+      // en-US => Hello foo! My name is bar.
+      descriptions: ctx.__('Hello {0}! My name is {1}.', [ 'foo', 'bar' ]),
+    };
+  }
+}
+

在 View 中使用

插件也同时把对应的语法糖注入到 ctx.locals 上,因此也可以直接在模板引擎里面使用:

<li>{{ __('Email') }}: {{ user.email }}</li>
+<li>
+  {{ __('Welcome back, %s!', user.name) }}
+</li>
+<li>
+  {{ __('Hello {0}! My name is {1}.', ['foo', 'bar']) }}
+</li>
+

切换语言

默认语言

默认语言是 en-US。假设我们想修改默认语言为简体中文:

// config/config.default.js
+exports.i18n = {
+  defaultLocale: 'zh-CN',
+};
+

切换语言

插件也会根据请求参数不同,自动选择指定的语言。

修改后会记录到 locale 这个 Cookie,下次请求直接用设定好的语言。

优先级从高到低:

  1. Query: /?locale=en-US
  2. Cookie: locale=zh-TW
  3. Header: Accept-Language: zh-CN,zh;q=0.5

如果想修改 Query 或者 Cookie 参数名称:

// config/config.default.js
+exports.i18n = {
+  queryField: 'locale',
+  cookieField: 'locale',
+  // Cookie 默认一年后过期, 如果设置为 Number,则单位为 ms
+  cookieMaxAge: '1y',
+};
+

局限性

一般来说,国际化是需要有配套的运营后台的,该插件只是一个简化的实现,开发者根据具体情况选择使用。

+ + + diff --git a/zh/guide/index.html b/zh/guide/index.html new file mode 100644 index 0000000..d163e06 --- /dev/null +++ b/zh/guide/index.html @@ -0,0 +1,60 @@ + + + + + + 概述 | Egg + + + + + + + +

概述

在本篇中,我们会对每一个术语概念,逐一进行详细的讲解。

包括它的适用场景、如何使用、常用的方法和属性、如何扩展、如何测试等等。

Web 模型

框架奉行『约定优于配置』,因此我们首先需要了解下 目录规范 的约定。

其次,对于一个 Web 应用来说,一般会采用 MVC 模型。

对应的概念有:

  • MiddlewareKoa 的洋葱模型,类似 JavaFilter
  • Controller:控制器,处理和校验用户请求,然后调用业务逻辑层,最终发送响应给用户。
  • Router:路由,对用户请求进行分派。
  • Service:业务逻辑层。
  • Application:全局应用对象,通过它可以获取 配置文件 等信息。
  • Context:用户请求的上下文,用于获取请求信息和设置响应信息。
  • 此外,还有 CookieSessionHelper 等等。

功能模块

除此之外,还提供了很多研发过程中需要的 Utils

  • 使用插件:生态共建的基础,一分钟即可通过插件接入各自基础中间件服务。
  • 生命周期:方便开发者做一些初始化工作。
  • 日志:对应用的运行状态监控、问题排查等都有非常重要的意义。
  • 异常处理:程序健壮性的保障。
  • 安全:安全无小事。
  • 还有 文件上传国际化 等等。
+ + + diff --git a/zh/guide/lifecycle.html b/zh/guide/lifecycle.html new file mode 100644 index 0000000..a55db0d --- /dev/null +++ b/zh/guide/lifecycle.html @@ -0,0 +1,183 @@ + + + + + + 生命周期 | Egg + + + + + + + +

生命周期

使用场景

我们常常需要在应用启动期间进行一些初始化工作,在本文我们将一起理解下框架的生命周期。

框架约定可以通过 app.js 来编写 Boot 类来注入 Hook

提供了以下生命周期Hook

  • configWillLoad:配置文件即将加载,这是最后动态修改配置的时机。
  • configDidLoad:配置文件加载完成。
  • didLoad:文件加载完成。
  • willReady:插件启动完毕,用于定义前置操作。
  • didReady:应用启动完毕。
  • serverDidReadyServer 启动完毕,可以开始导入流量。
  • beforeClose:应用即将关闭。

定义生命周期

我们可以通过 app.js 来挂载各个点的 Hook

// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  // 配置文件已读取合并但还未生效,修改配置的最后时机,仅支持同步操作。
+  configWillLoad() {}
+
+  // 所有配置已经加载完毕,用于自定义 Loader 挂载。
+  configDidLoad() {}
+
+  // 插件的初始化
+  async didLoad() {}
+
+  // 所有插件启动完毕,用于做应用启动成功前的一些必须的前置操作。
+  async willReady() {}
+
+  // 应用已经启动完毕,可以用于做一些初始化工作。
+  async didReady() {}
+
+  // Server 已经启动成功,可以开始导入流量,处理外部请求。
+  async serverDidReady() {}
+
+  // 应用即将关闭前
+  async beforeClose() {}
+}
+

注意

在自定义生命周期函数中不建议做太耗时的操作,框架会有启动的超时检测。

详解生命周期

configWillLoad()

此时配置文件已经被读取并合并,但是还并未生效,这是应用层修改配置的最后时机

使用场景举例:

  • 对配置中的秘钥进行解密。
  • 修改框架内置中间件顺序。
// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  // 注意:此函数只支持同步调用
+  configWillLoad() {
+    // 此时 config 文件已经被读取并合并,但是还并未生效,这是修改配置的最后时机
+    // 例如:参数中的密码是加密的,在此处进行解密
+    this.app.config.mysql.password = decrypt(this.app.config.mysql.password);
+  }
+}
+

注意事项

Hook 现在只支持同步调用。

configDidLoad()

所有的配置已经加载完毕,此 Hook 可以用来加载应用自定义的文件,启动自定义的服务。

使用场景举例:

  • 初始化自定义的模块。
  • 自定义 Loader 加载规范。
  • 插入一个中间件到框架的 coreMiddleware 之间。
// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  configDidLoad() {
+    // 所有的配置已经加载完毕,可以用来加载应用自定义的文件,初始化自定义的服务
+    this.app.loader.loadToContext(path.join(__dirname, 'app/tasks'), 'tasks', {
+      fieldClass: 'tasksClasses',
+    });
+
+    // 例如:插入一个中间件到框架的 coreMiddleware 之间
+    const statusIndex = this.app.config.coreMiddleware.indexOf('status');
+    this.app.config.coreMiddleware.splice(statusIndex + 1, 0, 'limit');
+  }
+}
+

async didLoad()

Hook 可以用来插件的初始化。

把初始化逻辑拆分为 configDidLoaddidLoad 两个阶段的考虑在于:插件之间可能有服务依赖

// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  configDidLoad() {
+    // 初始化自定义服务
+    this.app.queue = new Queue(this.app.config.queue);
+  }
+
+  async didLoad() {
+    // 启动自定义的服务
+    await this.app.queue.init();
+  }
+}
+

async willReady()

所有的插件都已启动完毕,但是应用整体还未 Ready

在该 Hook 可以做一些必须的前置操作,这些操作成功才会启动应用。

  • 如做一些数据初始化等操作。
// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  async willReady() {
+    // 所有的插件都已启动完毕,但是应用整体还未 Ready
+    // 可以做一些数据初始化等操作,这些操作成功才会启动应用
+
+    // 例如:从数据库加载数据到内存缓存
+    this.app.cacheData = await this.app.model.query('select * from QUERY_CACHE_SQL');
+  }
+}
+

注意

在自定义生命周期函数中不建议做太耗时的操作,框架会有启动的超时检测。

async didReady()

应用已经启动完毕,可以用于做一些初始化工作。

willReady() 的区别在于: 该 Hook 的操作是可选的,失败不会阻塞应用启动。

// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  async didReady() {
+    // 应用已经启动完毕
+    // 该操作是可选的,失败也不影响应用对外服务。
+    const ctx = this.app.createAnonymousContext();
+    await ctx.service.Biz.request();
+  }
+}
+

async serverDidReady()

HTTP/HTTPS Server 已经启动成功,可以开始导入流量,处理外部请求。

此时可以拿到 app.server 实例。

// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  async serverDidReady() {
+    this.app.server.on('timeout', socket => {
+      // handle socket timeout
+    });
+  }
+}
+

async beforeClose()

应用即将关闭前的处理 Hook,一般用于资源的释放操作。

注意:该 Hook 将按注册的逆序执行。

// app.js
+class AppBootHook {
+  constructor(app) {
+    this.app = app;
+  }
+
+  async beforeClose() {
+    // do sth before app close
+  }
+}
+

注意事项

框架默认最多只会等到 5s 就会退出,不保证会等待所有的该 Hook 执行完毕。

+ + + diff --git a/zh/guide/logger.html b/zh/guide/logger.html new file mode 100644 index 0000000..b74349f --- /dev/null +++ b/zh/guide/logger.html @@ -0,0 +1,199 @@ + + + + + + 日志 | Egg + + + + + + + +

日志

使用场景

日志对于 Web 开发的重要性毋庸置疑,对应用的运行状态监控、问题排查等都有非常重要的意义。

框架内置了强大的企业级日志支持,由 egg-logger 模块提供。

主要特性:

  • 日志分级
  • 统一错误日志
  • 启动日志和运行日志分离
  • 多进程日志
  • 自动切割日志
  • 高性能
  • 可扩展,支持自定义日志

打印日志

在绝大部分的地方,你都可以获取到 Logger 实例。

以下介绍几个常用的获取方式,它们的对应的日志都会写入到 ${appInfo.name}-web.log 文件。

app.logger

应用级别的日志,记录一些业务上与请求无关的信息,如启动阶段。

// app/middleware/static.js
+module.exports = (options, app) => {
+  app.logger.info(`[egg-static] mount ${options.dir} as static root`);
+
+  return async function static() {};
+};
+

ctx.logger

用于记录请求相关的日志。

它打印的日志都会在前面带上一些当前请求相关的信息。

[${userId}/${ip}/${traceId}/${cost}ms ${method} ${url}]

// app/controller/user.js
+class UserController extends Controller {
+  async list() {
+    const { app, ctx } = this;
+    // 打印日志
+    ctx.logger.info('ctx.logger');
+    ctx.body = [ { name: 'TZ' } ];
+  }
+}
+

对应的日志输出为:

2019-02-03 11:18:56,157 INFO 46536 [-/127.0.0.1/-/5ms GET /api/user] ctx.logger
+

this.logger

ControllerService 等实例中可以获取该对象。

类似 ctx.logger,不同之处是它会额外加上该日志的文件路径,以便快速定位日志打印位置。

// app/controller/user.js
+class UserController extends Controller {
+  async list() {
+    const { app, ctx } = this;
+    ctx.logger.info('ctx.logger');
+    // 打印日志,会添加路径
+    this.logger.info('this.logger');
+    ctx.body = [ { name: 'TZ' } ];
+  }
+}
+

对应的日志输出为:

2019-02-03 11:18:56,157 INFO 46536 [-/127.0.0.1/-/5ms GET /api/user] ctx.logger
+2019-02-03 11:18:56,158 INFO 46536 [-/127.0.0.1/-/5ms GET /api/user] [controller.user] this.logger
+

日志级别

日志分为 NONEDEBUGINFOWARNERROR 5 个级别。

分别对应于:logger.debug() / logger.info() / logger.warn() / logger.error()

默认只会输出 INFO 及以上级别,可以通过对应的 logger.level 来配置。

// config/config.default.js
+config.logger = {
+  level: 'INFO',
+};
+

错误日志

为了更方便的进行错误追踪,框架默认会把所有 LoggerERROR 日志统一输出到 common-error.log 文件

另外,为了保证异常可追踪,请输出 Error 类型,从而获取到堆栈信息。

ctx.logger.error(new Error('whoops'));
+

将输出:

2019-02-03 14:23:25,481 ERROR 93655 [-/127.0.0.1/-/6ms GET /] nodejs.Error: whoops
+    at HomeController.index (/Users/tz/Workspaces/coding/github.com/atian25/egg-showcase/app/controller/home.js:13:23)
+

输出方式

文件日志

日志文件默认都放在 ${appInfo.root}/logs/${appInfo.name} 目录下。

值得注意的是:appInfo.root 会根据运行环境自动适配根目录。

  • localunittest 环境下为 baseDir,即项目源码的根目录。
  • prod 和其他运行环境,都为 HOME,即用户目录,如 /home/admin

这是一个优雅的适配,因为:

  • 为了统一管控,线上环境都统一写入用户目录,如 /home/admin/logs/${appInfo.name}
  • 本地开发时,为了避免冲突,不想污染用户目录,会倾向于直接打印在项目源码的 logs 目录。

终端日志

日志打印到文件中的同时,为了方便开发,也会同时打印到终端中。

开发环境下默认只会输出 INFO 及以上级别,可以通过对应的 logger.consoleLevel 来配置。

// config/config.default.js
+config.logger = {
+  consoleLevel: 'INFO',
+};
+

注意事项

基于性能的考虑,在正式环境下,默认会关闭终端日志输出。

正式环境

基于性能和统一管控的考虑,正式环境的日志配置,有以下默认约定。

落盘方式

通常 Web 访问是高频访问,每次打印日志都写磁盘会造成频繁磁盘 IO。

为了提高性能,我们采用的文件日志写入策略是:

日志同步写入内存,异步每隔一段时间(默认 1 秒)刷盘。

更多详细请参考 egg-loggeregg-logrotator

日志文件输出位置

为了统一管控,一般要求线上环境都统一写入用户目录,如 /home/admin/logs/${appInfo.name}

具体参见上面的 文件日志 章节相关描述。

禁止输出 DEBUG 日志

在生产环境,为了避免一些插件的调试日志打印导致性能问题,默认禁止打印 DEBUG 日志。

如果确实有需求,需要打开 allowDebugAtProd 配置项。(不推荐

// config/config.default.js
+exports.logger = {
+  level: 'DEBUG',
+  allowDebugAtProd: true,
+};
+

禁止输出终端日志

基于性能的考虑,在正式环境下,默认会关闭终端日志输出。

如有需要,你可以通过下面的配置开启。(不推荐

// config/config.default.js
+exports.logger = {
+  disableConsoleAfterReady: false,
+};
+

自定义日志

一般应用无需自己配置自定义日志,因为日志打太多或太分散都会导致关注度分散,反而难以管理和难以排查发现问题。

框架内置日志

  • ${appInfo.name}-web.log:应用输出的日志,通过上述的 ctx.logger 等打印。
  • egg-web.log: 用于框架内核、插件日志,通过 app.coreLogger 打印。
  • common-error.log:所有 Logger 的错误日志会统一汇集到该文件。
  • 还有很多内置插件输出的 Tracer 日志,详见对应的文档。

增加自定义日志

你也可以通过以下配置,增加自定义日志:

// config/config.default.js
+const path = require('path');
+
+module.exports = appInfo => {
+  const config = {};
+
+  // 自定义日志
+  config.customLogger = {
+    oneLogger: {
+      file: 'one.log',
+    },
+  };
+
+  return config;
+};
+

如果配置为文件名,则会自动转换为 path.join(this.app.config.logger.dir, file)

然后可通过 app.getLogger('oneLogger') / ctx.getLogger('oneLogger') 获取,获取到的 logger 会使用对应的 Logger 配置,并以 config.logger 为默认值。

注意

app.getLoggerctx.getLogger 获取到的 logger 实例是有区别的,前者拿到是应用级别的日志实例( 参考 app.logger ),后者拿到的是请求级别的日志实例( 参考 ctx.logger ),如果需要自定义日志中也有请求信息( 比如 userId、traceId 等 ),请选择 ctx.getLogger,否则选择 app.getLogger,请根据项目的日志实际使用场景选择合理的方法。

日志输出格式

你也可以通过自定义 formattercontextFormatter 来自定义日志输出格式。

// config/config.default.js
+config.customLogger = {
+  oneLogger: {
+    file: 'one.log',
+    formatter(meta) {
+      const { level, date, pid, message } = meta;
+      return `[${date}] [${level}] [${pid}] ${message}`;
+    },
+    contextFormatter(meta) {
+      const { level, date, pid, message } = meta;
+      return `[${date}] [${level}] [${pid}] [${meta.ctx.href}] ${message}]`;
+    },
+  },
+};
+

高级自定义日志

日志默认是打印到日志文件中,当本地开发时同时会打印到终端。

但是,有时候我们需要把日志上报到第三方服务,这时候我们就需要自定义日志的 Transport

Transport 是一种传输通道,一个 Logger 可包含多个传输通道。

默认的 Logger 均有 FileConsole 两个通道,分别负责打印到文件和终端。

举个例子,我们不仅需要把错误日志打印到 common-error.log,还需要上报给第三方服务。

首先我们定义一个日志的 Transport,代表第三方日志服务。

// lib/remote_transport.js
+const util = require('util');
+const Transport = require('egg-logger').Transport;
+
+class RemoteErrorTransport extends Transport {
+  // 定义 log 方法,在此方法中把日志上报给远端服务
+  log(level, args) {
+    let log;
+    if (args[0] instanceof Error) {
+      const err = args[0];
+      log = util.format('%s: %s\n%s\npid: %s\n', err.name, err.message, err.stack, process.pid);
+    } else {
+      log = util.format(...args);
+    }
+
+    this.options.app.curl('http://url/to/remote/error/log/service/logs', {
+      data: log,
+      method: 'POST',
+    }).catch(console.error);
+  }
+}
+

然后再对 Logger 添加 Transport,这样每条日志就会同时打印到这个 Transport 了。

// app.js
+app.getLogger('errorLogger').set('remote', new RemoteErrorTransport({ level: 'ERROR', app }));
+

上面的例子比较简单,实际情况中我们需要考虑性能,很可能采取先打印到内存,再定时上传的策略,以提高性能。

日志切割

企业级日志一个最常见的需求之一是对日志进行自动切割,以方便管理。

框架内置了 egg-logrotator 插件来提供支持。

按天切割

这是框架的默认日志切割方式,在每日 00:01 按照 .log.YYYY-MM-DD 文件名进行切割。

譬如当前写入的日志为 example-app-web.log,当凌晨 00:00 时,会对日志进行切割,把过去一天的日志按 example-app-web.log.YYYY-MM-DD 的形式切割为单独的文件。

按照文件大小切割

我们也可以按照文件大小进行切割。例如,当文件超过 2G 时进行切割。

譬如,我们需要把 egg-web.log 按照大小进行切割:

// config/config.default.js
+const path = require('path');
+
+module.exports = appInfo => {
+  const config = {};
+
+  config.logrotator = {
+    filesRotateBySize: [
+      'egg-web.log',
+    ],
+    maxFileSize: 2 * 1024 * 1024 * 1024,
+  };
+
+  return config;
+};
+

添加到 filesRotateBySize 的日志文件不再按天进行切割。

如果配置为文件名,则会自动转换为 path.join(this.app.config.logger.dir, file)

按照小时切割

我们也可以选择按照小时进行切割,这和默认的按天切割非常类似,只是时间缩短到每小时。

例如,我们需要把 common-error.log 按照小时进行切割:

// config/config.${env}.js
+const path = require('path');
+
+module.exports = appInfo => {
+  return {
+    logrotator: {
+      filesRotateByHour: [
+        'common-error.log',
+      ],
+    },
+  };
+};
+

添加到 filesRotateByHour 的日志文件不再被按天进行切割。

如果配置为文件名,则会自动转换为 path.join(this.app.config.logger.dir, file)

编写测试

框架提供了 expectLog()mockLog() 来简化测试工作。

后者会把对应的日志保留一份在缓存中,避免 IO 较高时,写入延迟导致的校验失败。

it('should work', async () => {
+  app.mockLog();
+  await app.httpRequest()
+    .get('/')
+    .expect('hello world')
+    .expect(200);
+
+  app.expectLog('foo in logger');
+  app.expectLog(/foo in coreLogger/, 'coreLogger');
+  app.expectLog('foo in myCustomLogger', 'myCustomLogger');
+});
+
+ + + diff --git a/zh/guide/middleware.html b/zh/guide/middleware.html new file mode 100644 index 0000000..10e47e9 --- /dev/null +++ b/zh/guide/middleware.html @@ -0,0 +1,169 @@ + + + + + + Middleware | Egg + + + + + + + +

Middleware

使用场景

一个 HTTP 请求进来后,会执行一系列的处理,然后返回响应给用户。

这个过程就像一条管道,管道的每一个切面逻辑,我们称之为 Middleware,也叫 中间件

框架继承于 Koa,在 Koa 里面有个更形象的术语:洋葱模型

Koa 中间件执行顺序:

编写中间件

我们约定把中间件放置在 app/middleware 目录下:

// app/middleware/response_time.js
+module.exports = () => {
+  return async function responseTime(ctx, next) {
+    const start = Date.now();
+    await next();
+    const cost = Date.now() - start;
+    ctx.set('X-Response-Time', `${cost}ms`);
+  }
+};
+

如上,需 exports 一个普通的 function,返回一个标准的 Koa Middleware 函数。

加载规则

框架会把 app/middleware 目录下的文件挂载到 app.middleware 上。

支持多级目录,注意:对应的文件名会转换为驼峰格式

app/middleware/api/auth.js => app.middleware.api.auth
+app/middleware/response_time.js => app.middleware.responseTime
+app/middleware/BlockBot.js => app.middleware.blockBot
+

使用中间件

由于中间件是洋葱模型的一部分,因此需要应用开发者显式挂载,决定它们的顺序

// config/config.default.js
+module.exports = {
+  // 注意是驼峰格式
+  middleware: [ 'responseTime' ],
+};
+

自定义配置

一般来说中间件也会有自己的配置。

我们可以把之前的中间件改造如下:

// app/middleware/response_time.js
+module.exports = (options, app) => {
+  return async function responseTime(ctx, next) {
+    const start = Date.now();
+    await next();
+    const cost = Date.now() - start;
+    // `options.headerKey` 等价于 `app.config.responseTime.headerKey`
+    ctx.set(options.headerKey, `${cost}ms`);
+  }
+};
+

如上,接受两个参数:

  • options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来。
  • app: 当前应用 Application 的实例。

对应的配置:

// config/config.default.js
+module.exports = {
+  middleware: [ 'responseTime' ],
+
+  // key 为驼峰格式
+  responseTime: {
+    headerKey: 'X-Response-Time',
+  },
+};
+

通用配置

中间件支持以下几个通用的配置项:

  • enable:控制中间件是否开启。
  • match:设置只有符合某些规则的请求才会经过这个中间件。
  • ignore:设置符合某些规则的请求不经过这个中间件。

enable

如果我们的应用并不需要默认的 bodyParser 中间件来进行请求体的解析,此时我们可以通过配置来关闭它。

module.exports = {
+  bodyParser: {
+    enable: false,
+  },
+};
+

match 和 ignore

如果我们想让 responseTime 只针对 API 请求开启,我们可以配置:

module.exports = {
+  responseTime: {
+    match: '/api',
+  },
+};
+

matchignore 支持多种类型的配置方式,两者互斥不允许同时配置。

  • 字符串:当参数为字符串类型时,配置的是一个 URL 的路径前缀,所有以配置的字符串作为前缀的 URL 都会匹配上。当然,你也可以直接使用字符串数组。
  • 正则:当参数为正则时,直接匹配满足正则验证的 URL 的路径。
  • 函数:当参数为一个函数时,会将请求上下文传递给这个函数,最终取函数返回的结果(true/false)来判断是否匹配。
module.exports = {
+  responseTime: {
+    match(ctx) {
+      return ctx.url.startsWith('/api');
+    },
+  },
+};
+

详见 egg-path-matching

修改内置中间件的配置

除了应用层加载中间件之外,框架自身和其他的插件也会加载许多中间件。

如果开发者期望自定义对应的配置,可以修改同名配置项进行覆盖。

如框架内置的 bodyParser 中间件,可以自定义配置如下:

// config/config.default.js
+module.exports = {
+  bodyParser: {
+    jsonLimit: '10mb',
+  },
+};
+

路由中间件

如果 match / ignore 不能满足你的需求,如你期望在不同的路由中使用不同的配置。

则可以在路由中单独初始化和挂载:

// app/router.js
+module.exports = app => {
+  // 初始化
+  const responseTime = app.middleware.responseTime({ headerKey: 'X-Time' }, app);
+
+  // 仅挂载到指定的路由上
+  app.router.get('/test', responseTime, app.controller.test);
+};
+

引入 Koa 生态

我们也可以非常容易的引入 Koa 中间件生态。

koa-compress 为例,在 Koa 中使用时:

const koa = require('koa');
+const compress = require('koa-compress');
+
+const app = koa();
+
+const options = { threshold: 2048 };
+app.use(compress(options));
+

在我们的应用中,会更简单一些,只需:

// app/middleware/compress.js
+// koa-compress 暴露的接口 `(options) => middleware` 和框架要求一致
+module.exports = require('koa-compress');
+

对应的配置:

// config/config.default.js
+module.exports = {
+  middleware: [ 'compress' ],
+  compress: {
+    threshold: 2048,
+  },
+};
+

如果使用到的 Koa 中间件不符合入参规范,则可以自行处理下:

// config/config.default.js
+module.exports = {
+  webpack: {
+    compiler: {},
+    others: {},
+  },
+};
+
+// app/middleware/webpack.js
+const webpackMiddleware = require('some-koa-middleware');
+
+module.exports = (options, app) => {
+  return webpackMiddleware(options.compiler, options.others);
+};
+

编写测试

类似于 Controller 的测试,通过 app.httpRequest 来测试。

// test/controller/home.test.js
+const { app, mock, assert } = require('egg-mock');
+
+describe('test/middleware/response_time.test.js', () => {
+  it('should response header', () => {
+    return app.httpRequest()
+      .get('/api/test')
+      .expect('X-Response-Time', /\d+ms/);
+  });
+});
+

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

+ + + diff --git a/zh/guide/plugin.html b/zh/guide/plugin.html new file mode 100644 index 0000000..0caa005 --- /dev/null +++ b/zh/guide/plugin.html @@ -0,0 +1,132 @@ + + + + + + 使用插件 | Egg + + + + + + + +

使用插件

使用场景

插件机制是我们框架的一大特色。它不但可以保证框架核心的足够精简、稳定、高效,还可以促进业务逻辑的复用,生态圈的形成。

我们在使用 Koa 中间件过程中发现了下面一些问题:

  1. 中间件是有先后顺序的,需要统一管控,但是它自身却无法管理这种顺序,只能交给使用者。这样其实非常不友好,一旦顺序不对,结果可能有天壤之别。
  2. 中间件的定位是拦截用户请求,并在它前后做一些事情,例如:鉴权、安全检查、访问日志等等。但实际情况是,有些功能是和请求无关的,例如:定时任务、消息订阅、后台逻辑等等。
  3. 一些复杂的初始化逻辑,需要在应用启动的时候完成,这显然也不适合放到中间件中去实现。

综上所述,我们需要一套更加强大的机制,来管理、编排那些相对独立的业务逻辑。

使用插件

举个例子,我们想引入 egg-validate 这个插件。

安装依赖

插件一般通过 npm 模块的方式进行复用:

$ npm install egg-validate --save
+

注意:我们建议通过 ^ 的方式引入依赖,并且强烈不建议锁定版本。

友情提示

有些插件是内置到框架中,但默认不开启的,此时无需手动安装依赖。详见下文。

挂载插件

config/plugin.js 中声明:

// config/plugin.js
+exports.validate = {
+  enable: true,
+  package: 'egg-validate',
+};
+

使用插件

然后就可以使用插件提供的功能:

// app/controller/user.js
+class UserController extends Controller {
+  async create() {
+    const rule = { name: 'string' };
+    ctx.validate(rule, ctx.request.body);
+
+    // ...
+  }
+}
+

了解插件

一个插件其实就是一个『迷你的应用』,和应用几乎一模一样

目录结构

my-plugin
+├── app
+│   ├── service
+│   |   └── user.js
+│   ├── middleware
+│   |   └── response_time.js
+│   └── extend
+│       ├── application.js
+│       ├── context.js
+│       └── helper.js
+├── config
+|   ├── config.default.js
+│   ├── config.prod.js
+|   ├── config.local.js
+|   └── config.unittest.js
+├── test
+|   └── service
+|       └── user.test.js
+└── package.json
+

Service

插件可以包含 Service,框架会自动挂载。

Config

插件可以包含 配置

插件一般会包含自己的默认配置,应用开发者可以自由覆盖对应的配置:

譬如 egg-static 插件默认的 prefix/public/

你可以在应用的配置里面覆盖掉它:

// config/config.default.js
+config.static = {
+  prefix: '/static/',
+};
+

具体合并规则可以参见配置

Middleware

插件可以包含 中间件

框架把插件的 app/middleware 目录下的文件,同样加载到 app.middleware 上。

大部分情况下,插件开发者会自动挂载中间件到对应的地方,无需应用开发者处理。

但某些情况下,插件仅提供了中间件定义,并不帮应用开发者决定挂载顺序。

此时,应用开发者只需遵循 中间件 文档来使用即可。

Extend

插件可以提供 ContextApplicationHelper 等的扩展。

譬如在插件里面提供以下扩展,对应的逻辑就可以共享给其他应用。

// {plugin_root}/app/extend/context.js
+const UA = Symbol('Context#ua');
+const useragent = require('useragent');
+
+module.exports = {
+  get ua() {
+    if (!this[UA]) {
+      // this 就是 ctx 对象,在其中可以调用 ctx 上的其他方法,或访问属性
+      const uaString = this.get('user-agent');
+      this[UA] = useragent.parse(uaString);
+    }
+    return this[UA];
+  },
+};
+

不支持的特性

  • 没有 RouterController
  • 没有 plugin.js,只能声明跟其他插件的依赖,而不能决定其他插件的开启与否。

插件配置

参数介绍

应用开发者通过 config/plugin.js 来声明插件的挂载。

除了上面我们使用到的 enablepackage 外,其他参数如下:

  • enable - 是否开启此插件,默认为 true
  • package - npm 模块名称,通过 npm 模块形式引入插件。
  • path - 插件绝对路径,跟 package 配置互斥。
  • env - 数组,仅在指定运行环境才开启,会覆盖插件自身 package.json 中的配置。

插件本身的 package.json 里面也会有一个 eggPlugin 属性来声明默认的属性。

开启框架内置插件

框架一般也会内置一些插件,它们有可能默认是开启或关闭的。

此时,应用无需配置 package,直接配置 enable 即可:

// config/plugin.js
+exports.cors = {
+  enable: true;
+};
+
+// 也可以简写为:
+exports.validate = true;
+

packagepath

  • package:通过 npm 方式引入,也是最常见的引入方式。
  • path:通过绝对路径引入。
  • 后者主要场景是:应用内部抽象了一个插件,但还没达到可以发布独立插件的阶段临时使用。
  • 关于这两种方式的使用场景,可以参见渐进式开发
// config/plugin.js
+const path = require('path');
+exports.mysql = {
+  enable: true,
+  path: path.join(__dirname, '../lib/plugin/egg-mysql'),
+};
+

根据环境配置

同时,我们还支持 plugin.{env}.js 这种模式,会根据运行环境加载插件配置。

比如定义了一个开发环境使用的插件 egg-dev,只希望在本地环境加载,可以安装到 devDependencies

譬如 egg-development-proxyagent 这个插件,只会在开发环境使用。

则我们可以只安装到 devDependencies

$ npm i egg-dev --save-dev
+

然后在 plugin.local.js 中声明:

// config/plugin.local.js
+exports.proxyagent = {
+  enable: true,
+  package: 'egg-development-proxyagent',
+};
+

这样在生产环境可以 npm i --production 不需要下载 egg-development-proxyagent 的包了。

注意:

  • 不存在 plugin.default.js
  • 只能在应用层使用,在框架层请勿使用。

常见问题

如何开发一个插件

恭喜你迈出这一步,可以回馈社区。

具体可以参见文档:

插件太多,每个应用都要开启怎么办?

此时应该考虑包装为一个上层框架

+ + + diff --git a/zh/guide/router.html b/zh/guide/router.html new file mode 100644 index 0000000..da7ce18 --- /dev/null +++ b/zh/guide/router.html @@ -0,0 +1,190 @@ + + + + + + Router | Egg + + + + + + + +

Router

使用场景

Router 也称之为 路由,用于描述请求 URL 和具体承担执行动作的 Controller 的对应关系。

框架通过 egg-router 来提供相关支持。

编写路由

我们约定 app/router.js 文件用于统一所有路由规则。

通过统一的配置,可以避免路由规则逻辑散落在多个地方,从而出现未知的冲突,可以更方便的来查看全局的路由规则。

假设有以下 Controller 定义:

// app/controller/user.js
+class UserController extends Controller {
+  async info() {
+    const { ctx } = this;
+    ctx.body = {
+      name: `hello ${ctx.params.id}`,
+    };
+  }
+}
+

则我们可以定义对应的路由如下:

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  // GET /user/123
+  router.get('/user/:id', controller.user.info);
+};
+

这样就完成了一个最简单的 Router 定义,当用户访问 GET /user/123 时,这个 UserController 里面的 info 方法就会执行。

路由定义

router.verb('/some-path', controller.action);
+

路由方法

即为上面的 verb,代表用户触发动作,支持 GET、POST 等所有 HTTP 方法。

  • router.head - 对应 HTTP HEAD 方法。
  • router.get - 对应 HTTP GET 方法。
  • router.put - 对应 HTTP PUT 方法。
  • router.post - 对应 HTTP POST 方法。
  • router.patch - 对应 HTTP PATCH 方法。
  • router.delete - 对应 HTTP DELETE 方法。
  • router.del - 由于 delete 是保留字,故一般会用 router.del 别名。
  • router.options - 对应 HTTP OPTIONS 方法。

除此之外,还提供了:

  • router.redirect - 可以对 URL 进行重定向处理,比如把用户访问的根目录路由到某个主页。
  • router.all - 对所有的 HTTP 方法都挂载。

路由路径

即为上面的 /some-path,并支持命名参数。

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/home', controller.home.index);
+  // 支持命名参数,通过 `ctx.params.id` 可以取出。
+  router.get('/user/:id', controller.user.detail);
+};
+

也支持正则式:

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+
+  // 可以通过 `ctx.params[0]` 获取到对应的正则分组信息。
+  router.get(/^\/package\/([\w-.]+\/[\w-.]+)$/, controller.package.detail);
+};
+

如果你有一个通配的路由映射,需注意顺序,放在后面,如:

router.get('/user/manager', controller.user.manager);
+router.get('/user/:id', controller.user.detail);
+

路径解析使用了 path-to-regexp 模块,更多规则可以参见其文档。

路由中间件

支持对特定路由挂载中间件。

router.verb('/some-path', middleware1, ..., middlewareN, controller.action);
+

如下示例:

// app/router.js
+module.exports = app => {
+  const { router, controller, middleware } = app;
+
+  // 初始化
+  const responseTime = middleware.responseTime({ headerKey: 'X-Time' }, app);
+
+  // 仅挂载到指定的路由上
+  router.get('/test', responseTime, controller.test);
+};
+

路由别名

支持对路由定义别名,用于生成路由链接。

router.verb('router-name', '/some-path', controller.action);
+router.verb('router-name', '/some-path', middleware1, ..., middlewareN, controller.action);
+

然后可以通过 Helper 提供的辅助函数 pathForurlFor 来生成链接。

// app/router.js
+router.get('user', '/user', controller.user);
+
+// 使用 helper 计算指定 path
+ctx.helper.pathFor('user', { limit: 10, sort: 'name' });
+// => /user?limit=10&sort=name
+

你可以通过 ctx.routerName 获取到当前命中的路由别名。

RESTful 风格的 URL 定义

RESTful 是非常经典的 Web API 设计规范,如 CRUD 的路由结构。

我们提供了 app.resources('routerName', 'pathMatch', controller) 来简化开发。

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.resources('posts', '/api/posts', controller.posts);
+  router.resources('users', '/api/v1/users', controller.v1.users); // app/controller/v1/users.js
+};
+

如上,我们对 /posts 路径设置了映射到 app/controller/posts.js

然后,你只需要在 Controller 里面按需提供对应的方法即可,框架会自动映射。

Method Path Route Name Controller.Action
GET /posts posts controller.posts.index
GET /posts/new new_post controller.posts.new
GET /posts/:id post controller.posts.show
GET /posts/:id/edit edit_post controller.posts.edit
POST /posts posts controller.posts.create
PUT /posts/:id post controller.posts.update
DELETE /posts/:id post controller.posts.destroy
// app/controller/posts.js
+class PostController extends Controller {
+  async index() {}
+  async new() {}
+  async create() {}
+  async show() {}
+  async edit() {}
+  async update() {}
+  async destroy() {}
+}
+

具体示例,可以参考 实现 RESTful API 文档。

Router 实战

下面通过更多实际的例子,来说明 Router 的用法。

获取查询参数

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/user/list', controller.user.list);
+};
+
+// app/controller/user.js
+class UserController extends Controller {
+  async list() {
+    // curl http://127.0.0.1:7001/user/list?name=tz
+    const { ctx } = this;
+    ctx.body = `name: ${ctx.query.name}`;
+  }
+}
+

获取命名参数

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/user/:id/:name', controller.user.detail);
+};
+
+// app/controller/user.js
+class UserController extends Controller {
+  async detail() {
+    // curl http://127.0.0.1:7001/user/123/tz
+    const { ctx } = this;
+    ctx.body = `user: ${ctx.params.id}, ${ctx.params.name}`;
+  }
+}
+

重定向

使用方式:router.redirect(source, destination, [code])

  • sourcedestination 可以是路径,也可以是路径别名。
  • code 默认 301,可选参数。
// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('index', '/home/index', controller.home.index);
+  router.redirect('/', '/home/index', 302);
+};
+
+// app/controller/home.js
+class HomeController extends Controller {
+  async index() {
+    // curl -L http://localhost:7001
+    const { ctx } = this;
+    ctx.body = 'hello controller';
+  }
+}
+

常见问题

路由映射太多?

一般来说,我们并不推荐把路由规则逻辑散落在多个地方,这会给排查问题带来困扰。

若确实有需求,可以如下拆分:

// app/router.js
+module.exports = app => {
+  require('./router/news')(app);
+  require('./router/admin')(app);
+};
+
+// app/router/news.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/news/list', controller.news.list);
+  router.get('/news/detail', controller.news.detail);
+};
+
+// app/router/admin.js
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/admin/user', controller.admin.user);
+  router.get('/admin/log', controller.admin.log);
+};
+

也可直接使用 egg-router-plus

另外,框架会在启动期把最终的路由映射 dump 到 run/router.json 中。

自动映射路由?

一般来说,如果符合 RESTful 风格的路由,直接用上述的 router.resource() 配置即可。

如果你的业务场景中,有其他约定的规则,则可以参考对应的 resource 源码,扩展自己的方法,封装为插件。

通过装饰器映射?

装饰器目前还不是 ECMA 的正式规范,框架未提供该功能。

开发者可以自行通过 TypeScriptBabel 转义对应的自定义装饰器。

+ + + diff --git a/zh/guide/service.html b/zh/guide/service.html new file mode 100644 index 0000000..cd7cdb5 --- /dev/null +++ b/zh/guide/service.html @@ -0,0 +1,101 @@ + + + + + + Service | Egg + + + + + + + +

Service

使用场景

Service 是在复杂业务场景下用于做业务逻辑封装的一个抽象层:

  • 保持 Controller 中的逻辑更加简洁。
  • 保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用。
  • 将逻辑和展现分离,更容易编写测试用例。

场景举例:

  • 复杂数据的处理,如从数据库获取信息后,需经过一定的规则计算,才能返回用户显示。
  • 第三方服务的调用,如调用后端微服务的接口。

编写 Service

我们约定把 Service 放置在 app/service 目录下:

// app/service/user.js
+const { Service } = require('egg');
+
+class UserService extends Service {
+  async find(uid) {
+    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
+    return user;
+  }
+}
+
+module.exports = UserService;
+

使用 Service

框架会默认挂载到 ctx.service 上,对应的 Key 为文件名的驼峰格式。

如上面的 Service 会挂载为 ctx.service.user

然后就可以在 Controller 里调用:

// app/controller/user.js
+const { Controller } = require('egg');
+
+class UserController extends Controller {
+  async info() {
+    const { ctx } = this;
+    const userId = ctx.params.id;
+    const userInfo = await ctx.service.user.find(userId);
+    ctx.body = userInfo;
+  }
+}
+
+module.exports = UserController;
+

生命周期

Service 不是单例,是 请求级别 的对象,它挂载在 Context 上的。

Service 是延迟实例化的,仅在每一次请求中,首次调用到该 Service 的时候,才会实例化。

因此,无需担心实例化的性能损耗,经过我们大规模的实践证明,可以忽略不计。

挂载规则

约定放置在 app/service 目录下,支持多级目录,对应的文件名会转换为驼峰格式

app/service/biz/user.js => ctx.service.biz.user
+app/service/sync_user.js => ctx.service.syncUser
+app/service/HackerNews.js => ctx.service.hackerNews
+

常用属性和方法

Service 实例继承 egg.Service,提供以下属性:

  • this.ctx: 当前请求的上下文 Context 的实例,可以拿到各种便捷属性和方法。
  • this.app: 当前应用 Application 的实例,可以拿到全局对象和方法。
  • this.service:应用定义的 Service,可以调用其他 Service
  • this.config:应用运行时的配置项
  • this.logger:logger 对象,使用方法类似 Context Logger,不同之处是通过这个 Logger 对象记录的日志,会额外加上该日志的文件路径,以便快速定位日志打印位置。

编写测试

可以通过 app.mockContext() 获取到 Context 实例来测试。

// test/service/user.test.js
+const { app, mock, assert } = require('egg-mock');
+
+describe('test/service/user.test.js', () => {
+  it('should get exists user', async () => {
+    // 创建 ctx
+    const ctx = app.mockContext();
+    // 通过 ctx 访问到 service.user
+    const user = await ctx.service.user.find('TZ');
+    assert(user);
+    assert(user.name === 'TZ');
+  });
+});
+

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

+ + + diff --git a/zh/guide/session.html b/zh/guide/session.html new file mode 100644 index 0000000..c5051cc --- /dev/null +++ b/zh/guide/session.html @@ -0,0 +1,120 @@ + + + + + + Session | Egg + + + + + + + +

Session

使用场景

在 Web 应用中经常用 Cookie 来承担标识请求方身份的功能,但浏览器给每个站点分配的空间是很有限的。

从而提出了 Session 的概念,用于用户身份识别,以及会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)。

最佳实践

对于 Egg 的用户来说,请不要直接操作 ctx.session,而应该:

  • 使用 用户系统 提供的统一登录方式,由它来操作 Session
  • 如果你有额外的用户信息需要存储,直接操作 ZCache(Tair) 提供的 API。

使用 Session

框架内置了 Session 插件,给我们提供了 ctx.session 来访问或者修改当前用户 Session

// app/controller/home.js
+class HomeController extends Controller {
+  async fetchPosts() {
+    const { ctx } = this;
+    // 获取 Session 上的内容
+    const userId = ctx.session.userId;
+    const posts = await ctx.service.post.fetch(userId);
+    // 修改 Session 的值
+    ctx.session.visited = ctx.session.visited ? (Number(ctx.session.visited) + 1) : 1;
+    ctx.body = {
+      success: true,
+      posts,
+    };
+  }
+}
+

Session 的使用方法非常直观,直接读取或修改它就可以了,如果要删除,直接赋值为 null

ctx.session = null;
+

禁止使用的 Key 值

需要 特别注意 的是:设置 session 属性时需要避免:

  • 不要以 _ 开头
  • 不能为 isNew

否则会造成字段丢失,详见 koa-session 源码。

// ❌ 错误的用法
+ctx.session._visited = 1;   //    --> 该字段会在下一次请求时丢失
+ctx.session.isNew = 'HeHe'; //    --> 为内部关键字, 不应该去更改
+
+// ✔️ 正确的用法
+ctx.session.visited = 1;    //   -->  此处没有问题
+

存储方式

默认配置下,会把用户的 Session 加密后直接存储在 Cookie 中的一个字段中,浏览器每次请求时会带上这个 Cookie,我们在服务端解密后使用。

Session 写入 Cookie 的默认配置如下:

config.session = {
+  key: 'EGG_SESS', // 存储 `Session` 的 `Cookie` 键值对的 key
+  maxAge: 24 * 3600 * 1000, // 1 天
+  httpOnly: true,
+  encrypt: true,
+};
+

可以看到,默认配置下,存放 SessionCookie 将会加密存储、不可被前端 js 访问,这样可以保证用户数据是安全的。

Redis

默认存储在 Cookie 时,如果 Session 对象过于庞大,就会导致:

  • 浏览器通常都有限制最大的 Cookie 长度,当设置的 Session 过大时,浏览器可能拒绝保存。
  • Session 过大时,每次请求都要额外带上庞大的 Cookie 信息,影响性能。

对于社区的用户,可以使用 egg-session-redis 插件来配置存储。

你需要:

  • 参考 egg-redis 插件的文档,来配置对应的 Redis 地址信息。
  • 安装并开启对应的插件。
// plugin.js
+exports.redis = {
+  enable: true,
+  package: 'egg-redis',
+};
+
+exports.sessionRedis = {
+  enable: true,
+  package: 'egg-session-redis',
+};
+

注意事项

一旦选择了将 Session 存入到外部存储中,就意味着系统将强依赖于这个外部存储,当它挂了的时候,我们就完全无法使用 Session 相关的功能了。

一般来说,建议只将必要的信息存储在 Session 中,保持 Session 的精简并使用默认的 Cookie 存储,用户级别的缓存不要存储在 Session 中。

注意事项

再次提醒,对于 Egg 的用户来说,请不要直接读取和写入 ctx.session

应该使用 用户系统 提供的统一登录方式,读取 ctx.user

Session 实战

删除 Session

ctx.session = null;
+

修改失效时间

虽然在 Session 的配置中有一项是 maxAge,但是它只能全局设置 Session 的有效期。

我们经常可以在一些网站的登陆页上看到有 记住我 的选项框,勾选之后可以让登陆用户的 Session 有效期更长。

这种针对特定用户的 Session 有效时间设置我们可以通过 ctx.session.maxAge= 来实现。

// app/controller/user.js
+const ms = require('ms');
+class UserController extends Controller {
+  async login() {
+    const ctx = this.ctx;
+    const { username, password, rememberMe } = ctx.request.body;
+    const user = await ctx.loginAndGetUser(username, password);
+
+    // 设置 Session
+    ctx.session.user = user;
+    // 如果用户勾选了 `记住我`,设置 30 天的过期时间
+    if (rememberMe) ctx.session.maxAge = ms('30d');
+  }
+}
+

延长有效期

默认情况下,当用户请求没有导致 Session 被修改时,框架都不会延长 Session 的有效期。

但是在有些场景下,我们希望用户如果长时间都在访问我们的站点,则延长他们的 Session 有效期,不让用户退出登录态。

框架提供了一个 renew 配置项用于实现此功能,它会在发现当用户 Session 的有效期仅剩下最大有效期一半的时候,重置 Session 的有效期。

// config/config.default.js
+module.exports = {
+  session: {
+    renew: true,
+  },
+};
+
+ + + diff --git a/zh/guide/upload.html b/zh/guide/upload.html new file mode 100644 index 0000000..530f7e0 --- /dev/null +++ b/zh/guide/upload.html @@ -0,0 +1,230 @@ + + + + + + 文件上传 | Egg + + + + + + + +

文件上传

使用场景

文件上传,是 Web 应用的一个常见的功能。

框架内置了 Multipart 插件:

  • 解析浏览器上传的 multipart/form-data 的数据。
  • 提供 filestream 两种处理接口供开发者选择。
  • 默认提供了安全的限制。

获取到用户上传的数据后,开发者可以:

  • 存储为本地文件。
  • 提交给第三方服务,参见 通过 HttpClient 上传文件
  • 大部分情况下,我们会转存给云存储服务,在本文中我们也会一并介绍到。

File 模式

虽然在 Node.js 的世界里面,Stream 才是主流。

但对于一般开发者来说,Stream 并不是很容易掌握,尤其是错误处理环节。

因此,框架提供了 File 模式来简化开发。

相关的示例代码参见:eggjs/example/multipart-file-mode

配置

// config/config.default.js
+config.multipart = {
+  mode: 'file',
+};
+

前端代码

前端可以通过 FormAJAX 等方式来上传文件。

譬如:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
+  title: <input name="title" />
+  file1: <input name="file1" type="file" />
+  file2: <input name="file2" type="file" />
+  <button type="submit">Upload</button>
+</form>
+

注意事项

文件上传需要通过 POST 协议,因此会受到 CSRF 安全的管控,具体参见对应文档。

获取上传的文件

框架在 File 模式下,会把获取到的文件挂载到 ctx.request.files 数组上。

关键代码:

  • ctx.request.files: 获取到的文件列表。
  • ctx.oss.put():示例代码,此处为上传到 OSS 云存储,下文会介绍到。
  • ctx.cleanupRequestFiles():处理完毕后,清理临时文件。
// app/controller/upload.js
+class UploadController extends Controller {
+  async upload() {
+    const { ctx } = this;
+    console.log(ctx.request.body);
+    console.log('got %d files', ctx.request.files.length);
+
+    try {
+      // 遍历处理多个文件
+      for (const file of ctx.request.files) {
+        console.log('field: ' + file.fieldname);
+        console.log('filename: ' + file.filename);
+        console.log('encoding: ' + file.encoding);
+        console.log('mime: ' + file.mime);
+        console.log('tmp filepath: ' + file.filepath);
+
+        // 处理文件,比如上传到云端
+        const result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath);
+        console.log(result);
+      }
+    } finally {
+      // 需要删除临时文件
+      await ctx.cleanupRequestFiles();
+    }
+  }
+};
+

Stream 模式

如果你对于 Node.js 中的 Stream 模式非常熟悉,那么你可以选择此模式。

相关的示例代码参见:eggjs/example/multipart

上传单个文件

框架同样提供了简化开发的语法糖:

  • ctx.getFileStream():获取上传的文件流,仅支持上传一个文件的情况。
  • stream.fields 获取其他表单字段。

注意事项

由于表单解析是有时序的,因此前端代码中,文件 fileds 必须在最后面。

否则在拿到文件流时,stream.fields 还没解析完,从而获取不到。

因此对应的前端代码:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
+  title: <input name="title" />
+
+  <!-- 只能有一个 File,且必须放在最后-->
+  file: <input name="file" type="file" />
+  <button type="submit">Upload</button>
+</form>
+

对应的后端代码:

const path = require('path');
+const sendToWormhole = require('stream-wormhole');
+const Controller = require('egg').Controller;
+
+class UploadController extends Controller {
+  async upload() {
+    const ctx = this.ctx;
+    const stream = await ctx.getFileStream();
+    const name = 'egg-multipart-test/' + path.basename(stream.filename);
+    // 文件处理,上传到云存储等等
+    let result;
+    try {
+      result = await ctx.oss.put(name, stream);
+    } catch (err) {
+      // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
+      await sendToWormhole(stream);
+      throw err;
+    }
+
+    ctx.body = {
+      url: result.url,
+      // 所有表单字段都能通过 `stream.fields` 获取到
+      fields: stream.fields,
+    };
+  }
+}
+

上传多个文件

同时上传多个文件的场景,不能通过 ctx.getFileStream() 来获取,只能通过以下方式:

const sendToWormhole = require('stream-wormhole');
+const Controller = require('egg').Controller;
+
+class UploadController extends Controller {
+  async upload() {
+    const ctx = this.ctx;
+    const parts = ctx.multipart();
+    let part;
+    // parts() 返回 promise 对象
+    while ((part = await parts()) != null) {
+      if (part.length) {
+        // 这是 busboy 的字段
+        console.log('field: ' + part[0]);
+        console.log('value: ' + part[1]);
+        console.log('valueTruncated: ' + part[2]);
+        console.log('fieldnameTruncated: ' + part[3]);
+      } else {
+        if (!part.filename) {
+          // 这时是用户没有选择文件就点击了上传(part 是 file stream,但是 part.filename 为空)
+          // 需要做出处理,例如给出错误提示消息
+          return;
+        }
+        // part 是上传的文件流
+        console.log('field: ' + part.fieldname);
+        console.log('filename: ' + part.filename);
+        console.log('encoding: ' + part.encoding);
+        console.log('mime: ' + part.mime);
+        // 文件处理,上传到云存储等等
+        let result;
+        try {
+          result = await ctx.oss.put('egg-multipart-test/' + part.filename, part);
+        } catch (err) {
+          // 必须将上传的文件流消费掉,要不然浏览器响应会卡死
+          await sendToWormhole(part);
+          throw err;
+        }
+        console.log(result);
+      }
+    }
+    console.log('and we are done parsing the form!');
+  }
+}
+

错误处理

Stream 模式下,在异常处理里面,必须将上传的文件流消费掉,要不然浏览器响应会卡死

如上示例,你可以使用 stream-wormholemz-modules/pump 模块来处理。

友情提示

如果你对 Stream 没有足够了解的时候,建议直接使用 File 模式。

安全限制

文件大小

为了避免恶意的攻击,框架默认对文件上传接口,限制了 FileField 的个数和大小。

默认配置如下,开发者可以根据需求修改对应的配置。

config.multipart = {
+  // 表单 Field 文件名长度限制
+  fieldNameSize: 100,
+  // 表单 Field 内容大小
+  fieldSize: '100kb',
+  // 表单 Field 最大个数
+  fields: 10,
+
+  // 单个文件大小
+  fileSize: '10mb',
+  // 允许上传的最大文件数
+  files: 10,
+};
+

其中,fileSize 支持 10mb 这种人性化的方式,具体参见 humanize-bytes 模块。

文件类型

为了保证文件上传的安全,框架限制了支持的文件格式。默认的后缀白名单参见源码

开发者可以通过配置 fileExtensions 来新增允许的类型:

module.exports = {
+  multipart: {
+    fileExtensions: [ '.apk' ] // 增加对 apk 扩展名的文件支持
+  },
+};
+

如果你希望覆盖框架内置的白名单,可以配置 whitelist 属性:

module.exports = {
+  multipart: {
+    // 覆盖整个白名单,只允许上传 '.png' 格式
+    whitelist: [ '.png' ],
+    // 也支持函数格式
+    // whitelist: (filename) => [ '.png' ].includes(path.extname(filename) || ''),
+  },
+};
+

友情提示

当重写了 whitelist 时,fileExtensions 不生效。

云存储

当获得上传的文件之后,我们一般会转存到云存储服务,尤其是在集群的情况下。

常用的服务有:

OSS

框架内置了 egg-oss 插件,默认未开启。

配置

首先需要开启插件:

// config/plugin.js
+exports.oss = true;
+

然后配置一下你的 OSSbucket, accessKeyId, accessKeySecret 等必要信息。

// config/config.default.js
+config.oss = {
+  client: {
+    accessKeyId: 'your access key',
+    accessKeySecret: 'your access secret',
+    bucket: 'your bucket name',
+    endpoint: 'oss-cn-hongkong.aliyun.com',
+    timeout: '60s',
+    // accessKeyId 和 accessKeySecret 是否经过 egg-bin 加密的
+    // encryptPassword: false,
+  },
+};
+

然后通过 ctx.oss.put() 方法即可上传,支持 FileStream 两种模式。

File 模式

class UploadController extends Controller {
+  async upload() {
+    // ...
+
+    // file 是拿到的上传的文件对象
+    const { url } = await this.ctx.oss.put(name, file.filepath);
+    console.info(url); // url 即为上传后的文件链接
+  }
+}
+

Stream 模式

class UploadController extends Controller {
+  async upload() {
+    // ...
+
+    // stream 是拿到的上传的文件流对象
+    const { url } = await this.ctx.oss.put(name, stream);
+    console.info(url); // url 即为上传后的文件链接
+  }
+}
+

前端直接上传 OSS

还有一种常见的需求:前端直接上传文件到 OSS,不经过我们的 Web 应用。

OSS 提供了 STS 临时授权方式

上述的 egg-oss 插件的底层是 ali-oss 模块,也提供了对应的支持,具体参见文档。

+ + + diff --git a/zh/index.html b/zh/index.html new file mode 100644 index 0000000..113aab7 --- /dev/null +++ b/zh/index.html @@ -0,0 +1,23 @@ + + + + + + Egg + + + + + + + +

Egg.js

为企业级框架和应用而生

+ 快速开始 → +

完善的生态

基于开源生态,专为泛蚂蚁生态定制,一分钟接入后端服务中间件,支持多种部署环境。

高效自然的研发体验

渐进式开发,学习曲线平滑,提供一站式开发套件,为研发全流程保驾护航。

高质量、可信赖

高质量,完备的测试,内置集团安全策略,双十一等线上大规模顶级流量压力考验。

灵活、高扩展性

约定优于配置,高度灵活的定制性,业界领先的插件机制和上层业务框架机制。

+ + + diff --git a/zh/quickstart/egg.html b/zh/quickstart/egg.html new file mode 100644 index 0000000..d0f2b45 --- /dev/null +++ b/zh/quickstart/egg.html @@ -0,0 +1,163 @@ + + + + + + 简单的 Egg 应用 | Egg + + + + + + + +

简单的 Egg 应用

在本章中我们先来学习如何写一个简单的 Egg 应用,通过它来了解一些基本的概念和术语。

友情提示

需注意的是,本文介绍的是 Egg 的基础使用。 +对于 Egg 的开发者而言,很多插件无需自行安装,已经内置到框架,直接开启即可。 +更多内容,在开发指南中可以了解到。

典型场景

我们以 TodoMVC 这个典型的前端应用场景为例,一步步从零开始搭建。

完整的源码参见 eggjs/examples/todomvc

逐步搭建

环境准备

  • 操作系统:支持 macOSLinuxWindows,推荐本地开发用 macOS
  • 运行环境:仅需要 Node.js,对应的安装参见文档

初始化项目

通过骨架来初始化

# 使用 `Egg` 的 `simple` 骨架来初始化
+$ mkdir demo && cd demo
+$ npm init egg --type=simple
+$ npm install
+

目录结构

框架奉行『约定优于配置』,所以我们首先来看看生成的目录结构,更多可以参见目录规范

demo
+├── app
+│   ├── controller # 控制器
+│   │   └── home.js
+│   └── router.js  # 路由映射
+├── config # 配置文件
+│   ├── config.default.js
+│   └── plugin.js
+├── test # 单元测试
+├── README.md
+└── package.json
+

Controller

Controller 负责解析用户的输入,处理后返回相应的结果

// app/controller/home.js
+const { Controller } = require('egg');
+
+class HomeController extends Controller {
+  async index() {
+    const { ctx } = this;
+    ctx.body = 'hi, egg';
+  }
+}
+
+module.exports = HomeController;
+

接着配置 路由 映射到对应的 URL 上。

// app/router.js
+/**
+ * @param {Egg.Application} app - egg application
+ */
+module.exports = app => {
+  const { router, controller } = app;
+  router.get('/', controller.home.index);
+};
+

本地开发

框架提供了本地开发的辅助工具。

  • 辅助本地启动应用,监控代码变更自动重启。
  • 自动生成 d.ts 文件,提供 智能提示代码跳转 等能力。

通过命令启动应用:

$ npm run dev
+

然后就可以访问 http://127.0.0.1:7001

模板渲染

绝大多数情况,我们都需要读取数据后渲染模板,然后呈现给用户。

Egg 并不强制你使用某种模板引擎,故我们需要引入对应的『插件』。

术语讲堂

插件机制是我们框架的一大特色。它不但可以保证框架核心的足够精简、稳定、高效,还可以促进业务逻辑的复用,生态圈的形成。 +详见开发指南 - 插件文档。

在本章中,我们使用 Nunjucks 来渲染,先安装对应的插件 egg-view-nunjucks

$ npm i egg-view-nunjucks --save
+

开启插件:

// config/plugin.js
+exports.nunjucks = {
+  enable: true,
+  package: 'egg-view-nunjucks'
+};
+

按照约定,在 app/view 目录下添加对应的模板文件:

<!-- app/view/home.tpl -->
+<html>
+  ...
+  <script src="/public/main.js"></script>
+</html>
+

对应的 Controller 改为:

class HomeController extends Controller {
+  async index() {
+    const { ctx } = this;
+    // 渲染模板 `app/view/home.tpl`
+    await ctx.render('home.tpl');
+  }
+}
+

静态资源

前端代码的发布,一般有:

  • 构建后发布到 CDN。(推荐)
  • 直接在应用中托管。

Egg 内置了 egg-static 插件,对后者提供了支持。

默认会把 app/public 目录映射到 /public 路由上。

在本例中,我们使用 Vue 来写对应的前端逻辑,可以直接参见示例代码。

注意事项

  • static 插件,线上会默认设置一年的 magAge
  • 框架默认开启了 CSRF 防护,故 AJAX 请求需要带上对应的 token
// app/public/main.js
+axios.defaults.headers.common['x-csrf-token'] = Cookies.get('csrfToken');
+

配置文件

写业务的时候,不可避免的需要有配置文件

框架提供了强大的配置合并管理功能。

如上述的 nunjucks 插件,添加对应的配置:

// config/config.default.js
+config.view = {
+  defaultViewEngine: 'nunjucks',
+  mapping: {
+    '.tpl': 'nunjucks',
+    '.html': 'nunjucks',
+  },
+};
+

注意事项

config 目录,不是 app/config!

Service

我们的业务逻辑一般会写在 Service 里,然后供 Controller 调用。

// app/service/todo.js
+const { Service } = require('egg');
+
+class TodoService extends Service {
+  /**
+   * create todo
+   * @param {Todo} todo - todo info without `id`, but `title` required
+   */
+  async create(todo) {
+    // validate
+    if (!todo.title) this.ctx.throw(422, 'task title required');
+
+    // normalize
+    todo.id = Date.now().toString();
+    todo.completed = false;
+
+    this.store.push(todo);
+    return todo;
+  }
+}
+

对应的 Controller 如下:

// app/controller/todo.js
+class TodoController extends Controller {
+  async create() {
+    const { ctx, service } = this;
+
+    // params validate, need `egg-validate` plugin
+    // ctx.validate({ title: { type: 'string' } });
+
+    ctx.status = 201;
+    ctx.body = await service.todo.create(ctx.request.body);
+  }
+}
+

RESTful

EggRESTful 这种常见的场景提供了内建的支持

// app/router.js
+module.exports = app => {
+  const { router, controller } = app;
+
+  // RESTful 映射
+  router.resources('/api/todo', controller.todo);
+};
+

对应的 Controller

// app/controller/todo.js
+class TodoController extends Controller {
+  // `GET /api/todo`
+  async index() {}
+
+  // `POST /api/todo`
+  async create() {}
+
+  // `PUT /api/todo`
+  async update() {}
+
+  // `DELETE /api/todo`
+  async destroy() {}
+}
+

单元测试

Web 应用中的单元测试非常重要,框架也提供了对应的单元测试能力支持

// test/app/controller/todo.test.js
+const { app, mock, assert } = require('egg-mock/bootstrap');
+
+describe('test/app/controller/todo.test.js', () => {
+  it('should add todo', () => {
+    return app.httpRequest()
+      .post('/api/todo')
+      .send({ title: 'Add one' })
+      .expect('Content-Type', /json/)
+      .expect('X-Response-Time', /\d+ms/)
+      .expect(201)
+      .expect(res => {
+        assert(res.body.id);
+        assert(res.body.title === 'Add one');
+        assert(res.body.completed === false);
+      });
+  });
+});
+
+ + + diff --git a/zh/quickstart/index.html b/zh/quickstart/index.html new file mode 100644 index 0000000..f4f3d04 --- /dev/null +++ b/zh/quickstart/index.html @@ -0,0 +1,26 @@ + + + + + + 快速开始 | Egg + + + + + + + + + + +