Learn Go With Pocket-Sized Projects
Learn Go With Pocket-Sized Projects
1. chào mừng
2. 1_Meet_Go
3. 2_Hello,_Earth!_Extend_your_hello_world
4. 3_A_bookworm's_digest:_playing_with_loops_and_maps
5. 4_A_log_story:_create_a_logging_library
6. 5_Gordle:_play_a_word_game_in_your_terminal
7. 6_Money_converter:_CLI_around_an_HTTP_call
8. 7_Caching_with_generics
9. 8_Gordle_as_a_service
10. 9_Concurrent_maze_solver
11. 10_Habits_Tracker_using_gRPC
12. Appendix_A._Installation_steps
13. Appendix_B._Formatting_cheat_sheet
14. Appendix_C._Zero_values
15. Appendix_D._Benchmarking
16. chỉ số
chào mừng
Cảm ơn bạn đã mua tìm Hiểu Đi với bỏ Túi dự Án! Chúng tôi hy vọng
anh sẽ vui vẻ và thực hiện ngay lập tức sử dụng các bài học của bạn.
Cuốn sách này là dành cho các người muốn học các ngôn ngữ trong một niềm vui và
cách tương tác, và được thoải mái đủ để sử dụng nó một cách chuyên nghiệp. Mỗi
chương là một độc lập bỏ túi dự án. Cuốn sách này bao gồm những
đặc tính của những ngôn ngữ như ngầm diện và làm thế nào họ giúp đỡ trong
thử nghiệm thiết kế. Kiểm tra mã là bao gồm trong suốt cuốn sách. Chúng tôi muốn
giúp người trở thành một phần mềm hiện đại phát triển trong khi sử dụng các
ngôn ngữ Đi.
Cuốn sách này cũng có hướng dẫn cho dòng lệnh diện, và cho cả
phần CÒN lại và gRPC microservices, thấy thế nào ngôn ngữ là tuyệt vời cho đám mây
tính toán. Nó kết thúc với một dự án mà sử dụng TinyGo, các biên dịch cho
các hệ thống nhúng.
Mỗi túi có kích thước dự án được viết trong một hợp lý, số dòng. Mục tiêu của chúng
tôi
là cung cấp cho các bài tập khác nhau vì vậy, bất kỳ nhà phát triển ai muốn bắt đầu với
Đi
hoặc để khám phá những nét đặc trưng của ngôn ngữ có thể thực hiện theo các bước
mô tả
trong
Chúngmỗitôi chương. Đây không
khuyến khích phải
bạn đặt câulàhỏi
một cuốn
của bạnsách để các
và gửi tìm phản
hiểu phát triểncótừ đầu và
hồi bạn
cácnội dung trong nhữngliveBook Thảo luận
về . Chúng tôi muốn cậu nhận được
chương được phân loại.
nhiều nhất của bạn đọc đến tăng sự hiểu biết của dự án.
Giới thiệu Đi ngôn ngữ và tại sao bạn sẽ muốn tìm hiểu nó
trình Bày cuốn sách này và làm thế nào để sử dụng nó
Chi tiết tại sao muốn viết cuốn sách này
Viết sạch và thử nghiệm mã
Cuốn sách này cung cấp cho bạn một tập hợp các dự án vui vẻ để dần dần khám phá
những
đặc tính của những ngôn ngữ Đi. Mỗi túi có kích thước dự án được viết trong một
hợp lý, số dòng. Mục tiêu của chúng tôi là cung cấp cho các bài tập khác nhau vì vậy,
bất kỳ
nhà phát triển ai muốn bắt đầu với Đi hoặc để khám phá những ngôn ngữ có thể thực
hiện theo
Chúng tôi muốn giúp người trở thành một phần mềm hiện đại phát triển bởi
cácdụng
sử bướccác môngôn
tả trong
ngữmỗi
Đi. chương.
Chúng ta sẽ sử dụng chúng tôi, kinh nghiệm như các kỹ sư
phần mềm để
cung cấp có ý nghĩa tư vấn cho người mới dày dạn và phát triển.
Cuốn sách này cũng có hướng dẫn cho thực hiện APIs với microservices,
chứng minh ngôn ngữ là tuyệt vời cho đám mây. Nó kết thúc với
một dự án mà sử dụng TinyGo, các biên dịch cho các hệ thống nhúng.
Nếu bạn là một người mới bắt đầu tại lập trình chúng tôi toàn tâm toàn ý đề nghị bắt
đầu https://www.manning.com/books/get-programming-
Đi với
với đi kinh
. nghiệm
1.1 là Gì Đi?
Đi là một ngôn ngữ lập trình được thiết kế ban đầu để giải quyết các
vấn đề trong phần mềm quy mô lớn phát triển trong thế giới thực, lúc đầu
trong Google và sau đó, cho phần còn lại của thế giới kinh doanh. Nó chỉ làm chậm
chương trình xây dựng, out-of-phụ thuộc kiểm soát ban quản lý, sự phức tạp của
mã, và khó khăn cross-ngôn ngữ xây dựng.
Mỗi ngôn ngữ cố gắng giải quyết họ theo một cách khác, hoặc là bằng cách hạn chế
các người dùng hoặc do làm cho nó mềm mại như có thể. Đi đội đã chọn để giải quyết
họ bằng cách nhắm kỹ thuật hiện đại. Đó là lý do tại sao nó đi kèm với một người giàu
có, công cụ
chuỗi.
Các công cụ chuỗi bao gồm và xây dựng mã định dạng, gói
quản lý phụ thuộc tĩnh mã kiểm tra, kiểm tra tài liệu
thế hệ và xem, hiệu suất, phân tích, ngôn ngữ chủ, chạy
chương trình theo dõi và nhiều hơn nữa.
Được xây dựng cho đồng thời, và nối mạng, máy chủ giải thích nhanh nhận con nuôi
của ngôn ngữ trong công ty phần mềm của tất cả các kích thước trong vài năm.
Ngoài ra, Đi được sử dụng bởi một cộng đồng lớn của phát triển, những người đã
được
chia sẻ mã nguồn của họ trên công nền tảng cho những người khác để sử dụng hay
được truyền cảm hứng.
Như phát triển, chúng tôi yêu, để chia sẻ và sử dụng lại những gì khác thông minh,
người đã
1.1.1
viết.
lịch Sử và triết
Đi bắt đầu vào tháng chín năm 2007 khi Robert Griesemer, Ken Thompson, và tôi
bắt đầu thảo luận về một ngôn ngữ mới để giải quyết các thuật thách thức chúng ta
và các cộng sự của tôi tại Google đã phải đối mặt với hàng ngày của chúng tôi làm
việc.
-- Cướp Pike
Đi được thiết kế để cải thiện năng suất trong một thời gian khi cách
mạng máy và lớn codebases đã trở thành chuẩn.
Các lựa chọn thiết kế được điều khiển chủ yếu do đơn giảnmà học
các ngôn ngữ một nhiệm vụ nhanh chóng. Chỉ có 25 dành riêng từ khóa trong toàn bộ
ngôn ngữ (trước phiên bản 1.18 - một phiên bản đó bạn sẽ đọc về rất nhiều). Các
phần còn lại là chỉ đơn giản là cảm giác bạn muốn để cho nó. Và thơ.
Thậm chí quan trọng hơn, để chúng tôi cung cấp cho các nhu cầu của các phần mềm
hiện đại
công nghiệp. Quản lý phụ thuộc, các công cụ để kiểm tra đơn vị, chuẩn và
fuzzing, định dạng, tất cả các công cụ bình thường của một nhà phát triển đang được
xây dựng và
chuẩn.
1.1.2 Sử dụng hôm nay
[1]
Theo 2021 Đi khảo sát ngôn ngữ được sử dụng bao la đặt/NHỮNG
dịch vụ. Những thứ hai cách sử dụng là bắt đầu chương trình với một dòng lệnh
diện (B), sau đó web dịch vụ trả lại HTML, thư viện và
khung tự xử lý dữ liệu, đại lý và linh thú sao? 8% của Đi
phát triển sử dụng nó vào hệ thống nhúng, 4% cho trò chơi và 4% cho máy
học hoặc trí thông minh nhân tạo.
Mặc dù nó chưa có thể gọi trên thập kỷ của thư viện như Nghiên dựa ngôn ngữ,
nó có lợi từ một rộng lớn, và chào đón cộng đồng của văn phòng giáo viên, mở
nguồn đóng góp, người tạo ra học tập tài liệu trong mỗi mẫu này rất
cuốn sách bao gồm.
Hiện tại có một nhu cầu về Đi các kỹ sư. Học các ngôn ngữ
do đó có thể cho phép một sự nghiệp lớn nhảy về phía trước. Từ những tác giả' cá
nhân
kinh nghiệm, việc tuyển dụng lĩnh vực, bao gồm các khu vực như công nghệ tài chính,
này, nra show,
chơi game, âm nhạc, tất cả các loại e-thương mại nền tảng hàng không vũ trụ nghiên
cứu
hình ảnh vệ tinh xử lý.
1.2 tại Sao bạn nên tìm hiểu Đi
Cuốn sách này nhằm nhận được đọc và chạy bằng cách sử dụng các Đi ngôn ngữ trên
công việc, trong bối cảnh của kỹ thuật hiện đại, có thể là cho cá nhân sự tò mò, như
một phần
của nghiên cứu một tập thể dục hoặc trong bối cảnh của một dự án công nghiệp.
Chúng ta sẽ không bao gồm mọi thứ để biết về các ngôn ngữ, nhưng tập trung
thay vào đó, chính điều chúng tôi cần, như phát triển, để được sản xuất
và hiệu quả.
Hãy xem tại sao Đi là một khoản đầu tư của bạn, thời gian học tập như là một nhà phát
triển.
1.2.1 thế Nào và ở đâu thể Đi giúp bạn?
Đi là một linh hoạt ngôn ngữ được thiết kế để bảo trì và dễ đọc. Nó là
tối ưu cho phụ trợ phần mềm và phát triển đã rất hợp với
hiện đại đám mây công nghệ.
Xem xét các doanh thu trung bình trong công ty công nghệ là việc thấp hơn mỗi
năm, một ít hơn một năm nay, điều quan trọng là mã được viết bởi một người
có thể được đọc bởi một người khác, sau khi họ rời khỏi công ty. Do đó, quan trọng
đó ngôn ngữ được lựa chọn bởi công ty này cho dễ đọc.
Đi là chìa khóa có làm cho nó đáng tin cậy, và an toàn ngôn ngữ, với một nhanh xây
dựng
thời gian.
Một số dụng đòn bẩy goroutines đó là một an toàn hơn và ít tốn kém cách
đối phó với tính toán song song với các chủ đề. Chủ đề dựa vào HỆ điều hành
mà có một giới hạn liên quan đến các kích thước và sức mạnh của LÝ trong khi
goroutines xảy ra tại các ứng dụng là cấp. Để làm cho stacks nhỏ, Hãy
sử dụng vật thay giáp stacks. Một mới được đúc goroutine được đưa ra một vài
tiện dụng này, đó gần như là luôn luôn đủ, và có thể phát triển và co lại, cho phép
nhiều goroutines để sống trong một số tiền khiêm tốn của nhớ. Nó là thực tế
tạo ra hàng trăm ngàn goroutines trong cùng một địa chỉ không gian.
Mặc dù nó không phải là một đối tượng-ngôn ngữ hướng và đã không thừa kế
hệ thống hỗ trợ nhất của tính năng qua phần tiềm ẩn và
diện. Các già lập luận của Đi mất tích thuốc, làm cho nó tiết và
đòi hỏi rất nhiều sự soạn bản sao dán, đã đi một chặng đường dài
trở không hợp lệ từ các nổi tiếng 1.18 phiên bản. Khi chúng ta viết thư, cuộc thảo luận
đang
vẫn còn trong tiến trình để làm cho nó như linh hoạt như nó là đơn giản.
Đi là một ngôn ngữ biên dịch, có nghĩa là tất cả lỗi sẽ được tìm thấy
trong nuốt hơn là lúc chạy. Tất cả chúng ta đều muốn biết về
những sai lầm trong sự an toàn của chúng ta, máy tính, chứ không phát hiện ra họ nói
sản xuất.
Nó là dễ dàng hơn để chạy các ứng dụng quy mô lớn với Đi so với nhiều
ngôn ngữ. Đi được xây dựng bởi Google để giải quyết vấn đề ở một Google-kích quy
mô.
Đó là lý tưởng lớn ứng dụng đồng thời.
Cloud nền tảng tình yêu Đi. Họ cung cấp hỗ trợ cho Đi là một ngôn ngữ.
Ví dụ, cloud chức năng và lambdas hỗ trợ Đi trong tất cả các sử dụng hầu hết
các nhà cung cấp. Đám mây lớn các công cụ, như Kubernetes hoặc Docker, được viết
trong
Đi.
1.2.2 Nơi có thể Đi KHÔNG giúp gì bạn?
Mặc dù nó cao sự linh hoạt, có vài trường hợp sử dụng đó Đi là không được thực hiện
để
che.
Đi dựa trên một thu gom rác cho phát hành những nhớ nó sử dụng. Nếu
ứng yêu cầu toàn quyền kiểm soát trí nhớ chọn một cấp dưới ngôn ngữ
như C gia đình có thể cung cấp. Đi có thể bọc thư viện viết trong C với
CGo, một bản dịch lớp tạo ra để giảm bớt sự chuyển đổi giữa 2
ngôn ngữ. Này CGo twist, bạn có thể bọc động có kết nối thư viện.
Các Đi ghi chủ yếu là sản xuất thực thi - tạo ra một biên soạn
thư viện là đau đớn có thể đạt được. Chúng ta sẽ không bao nó trong cuốn sách này.
Trong nhiều
trường hợp, cập nhật bản Đi phụ thuộc ngụ ý xây dựng lại
nhị phân với phiên bản mới. Này cũng có nghĩa là, để sử dụng một Đi
thư viện, bạn cần phải có quyền truy cập vào mã nguồn của nó.
Các Đi biên dịch hỗ trợ một danh sách của nền tảng khác nhau và
hệ điều hành, nhưng chúng tôi sẽ khuyên bạn không nên viết một hệ điều hành
với Đi, mặc dù nhiều linh hồn dũng cảm đã thực hiện nó. Lý do chính cho đây là
việc xử lý bộ nhớ khi nó được thực hiện trong Đi: thu gom rác
thường xuyên bỏ bit được không còn được sử dụng. Như với tất cả các thu gom rác,
nó là điều chỉnh, nhưng nó sẽ không phát hành nhớ chính xác như thế nào bạn có
muốn hay khi
bạn muốn.
Đi nhị phân các tập tin được biết đến là lớn hơn mức trung bình. Nó thường không phải
là một
vấn đề trong một môi trường đám Mây, nhưng nếu anh yêu cầu ánh sáng những
chương trình, xem xét
sử dụng TinyGo biên dịch. Xem chương cuối của cuốn sách này cho sự
giới
Cuốithiệu.
cùng, những khó khăn cho google lên các câu trả lời nghiêm túc, những người
tên của họ
ngôn ngữ như vậy với một từ chung? Rõ ràng, Google bản thân mình. Đây là
một chuyên mẹo: khi cố gắng để tìm thấy một câu trả lời, sử dụng "file", đó không phải
là sự thật
tên, nhưng là những gì tìm kiếm sẽ nhận ra. Đôi khi nó giống như đang cố gắng để
tìm
Chúngtài liệu trongcó
tôi cũng C thể
trênđề
dây – anh
cập đến không
nhữngcó được
khó khănnhững
trong gì bạn
việc đang
thuê Đi mong đợi. đó là,
phát triển,
thực sự, tốt cho chúng ta phát triển.
Chính lý do để sử dụng một ngôn ngữ khác hơn Đi, đến 2021, được sự
vắng mặt của một thiếu của sự trưởng thành trong hệ sinh thái. Đó là
trước khi 1.18, một phiên bản đó thay đổi trò chơi.
Chúng tôi tổng hợp một số năng phát triển xem xét trong sự lựa chọn của
một ngôn ngữ đến địa chỉ của họ dự án cần. Trong kinh nghiệm của chúng tôi, rườm rà
và
rác bộ sưu tập rất quan trọng chí ngày hôm nay để làm cho chúng ta hiệu quả hơn.
Bàn 1.1 So sánh bốn chương trình ngôn ngữ
C Python Java Đi
thủ tục, và
đối tượng cao cấp
cấp cao cấp cao dữ liệu hướng
thiết kế theo
đối tượng đối tượng lập trình,
triết lý thủ tục,
hướng hướng hỗ trợ nhất
multiparadigm
OOP năng
tiềm ẩn và
diện rõ ràng rõ ràng ngầm
rõ ràng
cao
tuyệt vời rất thích hợpthích nghi để web
suất
chính sử dụng cho dữ liệu cho web APIs và đám mây
trường hợp thấp trên không phân tích ứng dụng máy tính
được xây dựng
bêntrong
ngoài được xây dựng trong bản địa
bên ngoài
công cụ kiểm tra bản địa côngkhung
cụ công cụ (test, ghế dài,
khung
để kiểm tra JUnit lông tơ)
Đầu tiên và trước hết, chúng ta nghĩ về phát triển những người biết và sử dụng một
ngôn ngữ và muốn mở rộng của họ chuyên nghiệp, kỹ năng. Chúng tôi muốn nhận
được
bạn và chạy bằng cách chia sẻ thực tế sử dụng của ngôn ngữ.
Chúng tôi tập trung vào công chuẩn bao lâu dài cân nhắc và không
chỉ một dự án của dùng nữa mã mà anh không bận tâm để kiểm tra.
Chúng tôi cũng có suy nghĩ về những con người đang đánh giá Đi tiếp theo của
dự án. Lặn xuống Đi các sẽ giúp bạn quyết định rằng đó là tốt nhất
ngôn ngữ bạn có thể đầu tư vào.
1.3.2 những Gì bạn sẽ biết sau khi đọc cuốn sách (và viết
mã)
Đầu tiên, chúng tôi muốn chắc chắn rằng anh hiểu những gì cuốn sách giải thích. Cho
vấn đề này, chúng tôi sẽ hướng dẫn anh thông qua các cuộc hành trình của chương
mô tả những
thực hiện giờ mã lặp đi lặp lại, trên một cam kết-by-cam kết sở,
khi chúng tôi xem xét nó quan trọng để hiểu những gì đang xảy ra, từng chút một.
Thứ hai, chúng tôi cung cấp tốt và ví dụ rõ ràng viết cho ngành công nghiệp
trình độ Đi mã - đề nghị rằng áp dụng bên ngoài ví dụ của chúng tôi, và điều đó
sẽ giúp bạn dấn thân vào thế giới thực của sự phát triển. Tất cả của chúng tôi, ví dụ
có chức năng đó có thể tái sử dụng trong một công ty.
Cuối cùng, mục tiêu của chúng ta là làm cho bạn nhận ra rằng bạn có thể viết tuyệt vời
Đi mã của
mình khi bạn đã hiểu được những điều cơ bản.
Chúng tôi bắt đầu ở xin Chào-cấp trên thế Giới, khám phá những cú pháp của ngôn
ngữ,
và tiến hành tất cả các đường đến một vụ sẵn sàng để được triển khai trong đám Mây,
bạn qua quyết định kiến trúc
Ngữ pháp và cú pháp
Các chương đầu tập trung vào ngữ pháp cụ thể Đi. Ví dụ, làm thế nào tất cả
các vòng bắt đầu với cùng một từ khóa, nghỉ là tiềm ẩn trong công tắc, nhưng cũng
làm thế nào để lộ và không một số của bạn hằng và phương pháp (những gì Java gọi
công cộng hay tư nhân).
Những gì Đi đã chọn trong mã của nó đã được thiết kế để làm cho nó diện ngầm.
Trong nhất
của lớn khác ngôn ngữ, để có một thực thể (hoặc lớp) được coi
là thực hiện một diện, nó cần phải nói rõ ràng nó vào định nghĩa của nó. Trong
Đi, thực hiện các phương pháp là đủ. Bạn do đó có thể vô tình
thực hiện một sự giao mà, bạn vẫn chưa biết. Này, mở ra thế giới của mới
khả năng trong hình dung làm thế nào chúng ta chế giễu và stubbing, phụ thuộc
tiêm thuốc, và khả năng tương tác.
Mặc dù goroutines là một tính năng tuyệt vời của Đi, chúng tôi sẽ không dừng lại ở đó.
Trong
kinh nghiệm của chúng tôi, các bạn có chương trình hiệu quả trong Đi mà không có họ.
Chỉ có một
dự áncùng,
Cuối sử dụng chúng.
khi bạn sẽ học trong những cuốn sách, Đi không sử dụng trường hợp ngoại
lệ. Nó
thích xem xét lỗi như giá trị. Này thay đổi theo cách chúng ta đối phó với chảy
mà không tuân theo các hạnh phúc con đường, nơi mà không bao giờ thất bại. Mọi
chương trình đã
để đối phó với lỗi tại một số điểm, và chúng tôi sẽ yểm trợ này trong suốt các dự án.
Thử nghiệm mã
Tất cả chúng.
Không phát triển ngày hôm nay sẽ mơ về cung cấp mã trong sản xuất đó là
không được bảo hiểm bởi ít nhất một số thử nghiệm bất cứ điều gì mức độ của họ. Họ
không thể thiếu đối với bất kỳ tiến hóa phần mềm phát triển qua. Đó là lý do tại sao
chúng tôi
bao thử nghiệm đơn vị ở khắp mọi nơi.
Đi cũng là tuyệt vời tại chuẩn thuật toán khác nhau với một xây dựng-trong cuốn
lệnh. Nó cho phép phát triển để so sánh các phiên bản quá, mà có nghĩa là bạn
có thể sử dụng nó để kiểm tra mọi cam kết rằng mã-mức độ hiệu quả không
không giảm. Anh sẽ xem một số ví dụ trong suốt cuốn sách.
One last gần đây của các Đi dụng cụ kiểm tra chuỗi là fuzzing. Fuzzing là một
cách để kiểm tra một hệ thống bằng cách ngẫu nhiên giá trị vào nó, và nhìn thấy làm
thế nào nó
hoạt động. Nó là một tuyệt vời giúp trong việc kiểm tra cho các lỗ hổng.
Mã sạch sẽ thực hành tốt nhất
[2]
Bất kỳ mã của bạn đã không nhìn cho sáu tháng
có thể cũng đã được viết bởi một người nào khác.
Trong khi vài dự án đầu tiên phù hợp trong một tập tin chúng tôi, chúng tôi sẽ nhanh
chóng cần phải sắp xếp những
mã ở một cách làm cho nó dễ dàng để duy trì. Bởi duy trì, chúng tôi có nghĩa là nó
sẽ cho phép một người mới đến tìm cách của họ qua mã để sửa chữa một
lỗi hoặc thêm một năng. Hư cấu này mới có thể là bạn trong một rất ngắn
thời gian.
Chúng tôi đề nghị và giải thích về một số mã tổ chức thực tiễn. Chúng tôi tin rằng đó
Đi là tuyệt vời cho miền-hướng thiết kế và tổ chức của chúng tôi mã phù hợp.
Tất nhiên là không có thư mục duy nhất tổ chức cho một dự án, nhưng chúng tôi mong
cho những gì có ý nghĩa hơn.
Những gì để lộ và những gì để giữ cho mình đã bắt nhân loại cho
thiên niên kỷ, và mềm đã phát triển trong nhiều thập kỷ. Câu hỏi này là được bảo hiểm
như
vậy, chúng ta tạo ra cái gì đó vượt ra khỏi một gói.
Quyết định kiến trúc
Như là Đi được dùng cho dịch vụ viết được triển khai ở đám mây môi trường,
chúng tôi đã thêm hai người dự án để giúp bạn lựa chọn của bạn yêu thích giao thức:
một phục vụ
HTML hơn HTTP khác sử dụng protobuf hơn gRPC. Bạn sẽ viết đầy đủ
hoạt động dịch vụ mà bạn có thể dễ dàng triển khai để chơi xung quanh và xem những
gì
bạn muốn
Một khi họ và
có những gì phù
chạy, bạn cầnhợp
phảinhất
theovới
dõinhu cầu gì
những của bạn.
xảy ra trong chương trình của bạn.
Một trong những sớm và dễ dàng dự án là một ứng mà đi xa hơn những gì
mặc định thư viện tiêu chuẩn không. Một số khác đọc chuẩn và hoạt động như một
chất chống
tham nhũng lớp để chèn các dữ liệu từ đó API vào miền của bạn. Một thứ ba
là một đơn giản cân bằng tải của hệ thống giao thông mà bạn có thể tạo
phức tạp hơn theo nhu cầu của bạn.
IoT là vui vẻ
Các dự án cuối cùng được thiết kế để chạy trên dự án điều khiển bằng cách sử dụng
một
khác nhau biên dịch. Nó là không đủ để làm cho bạn một hệ thống nhúng chuyên gia.
Nó sẽ chỉ chơi như một giới thiệu cho một số ít được biết đến tính năng của Đi. Chúng
tôi
hy vọng để cù của sự sáng tạo và hy vọng bạn sẽ thích nó cả.
Tóm lại 1.4
Đi là một hiện đại công nghiệp định hướng, đơn giản và linh hoạt, ngôn ngữ, tốt
nhất
cho phụ trợ phát triển, sử dụng rộng rãi cho đám mây hướng công cụ tuyệt vời
cho
CLI và thậm chí thích nghi để nhúng các hệ thống.
Dễ dàng để tìm hiểu, đội có một cách nhanh chóng hiệu quả với nó, viết phức tạp
bực bội mã là có thể, nhưng không dễ dàng.
Cuốn sách này: tìm hiểu bằng cách làm túi dự án mà chỉ có một vài giờ,
bạn và chạy.
[1]
https://go.dev/blog/survey2021-results
[2]
Thành thật mà nói, 6 tháng là hào phóng.
2 Xin Chào, Trái Đất! Mở rộng xin
chào
thế giới
Chương này, bao gồm
Như phát triển, nhiệm vụ chính của chúng tôi là viết chương trình có giá trị. Các
chương trình này được
thực hiện trên máy tính và họ sẽ chấp nhận một số đầu vào (e.g: chìa khóa ép trên
bàn phím, nhận được một tín hiệu từ microphone), và sẽ tạo ra
(e.g: phát ra một tiếng bíp, gửi dữ liệu trên mạng). Các chương trình đơn giản của tất
cả
không có gì, và chỉ cần lối ra. Điều đó sẽ không được một rất hài lòng
giới thiệu để mã hóa, phải không? Thay vào đó, chúng ta có một nồng nhiệt chào đón
tin
Kể nhắn!
từ năm 1972, học lập trình khám phá của họ ngôn ngữ mới thông qua
biến thể của cùng một câu: . Một lập trình đầu tiên của
Xin chào thế giới
tự trị bước đi được, như vậy, thường đến sự thay đổi này chuẩn tin nhắn, và xem
những gì sẽ xảy ra khi các tin nhắn chúc mừng một chút thay đổi. Loại, biên dịch,
chạy, nụ cười. Đây là những gì đangXinphát triển
chào thế một
giớilà về.
Chương trình này chào mừng đã được phổ biến của Brian Kernighan và
Dennis Ritchie là "C ngôn Ngữ" cuốn sách được xuất bản vào năm 1978.
Câu ban đầu đến từ một công bố cũng bởi Brian
Kernighan, "Một hướng Dẫn giới Thiệu ngôn Ngữ B", được xuất bản vào năm 1972.
Điều này, trong tất cả sự trung thực, ví dụ thứ hai in nhân vật trong này
bố - cái đầu tiên có chương trình in chào!. Lý do là B
đã có một giới hạn trong số TÊN nhân vật, nó có thể ở trong một đơn
biến - một biến không thể giữ hơn 4 kí tự. Xin chào, thế giới!, như là một
kết quả, đã đạt được với một số cuộc gọi đến các chức năng in ấn. Thông điệp này
được lấy cảm hứng từ một con chim nở ra trứng của nó trong một truyện tranh.
Mục tiêu của chương này là để đi một chút ngoài những bước đơn giản. Chúng ta hãy
xem xét
tốt mã cần được cả hai tài liệu và thử nghiệm. Vì lý do này chúng ta sẽ có
để hiểu làm thế nào để kiểm tra một chức năng có mục đích là để viết để chuẩn
ra. Trên đó, nhờ Đi của hỗ trợ của hoa kỳ,nhân vật
này chương đầu tiên sẽ được cơ hội của chúng ta để chào đón mọi người bằng ngôn
ngữ khác
hơn
Nếu anh
bạn và hệ thống
không văn bản
có đường khác
đi biên hơntrên
dịch là tiếng Latin.
máy vẫn chưa cài đặt nó theo
các bước trong phụ Lục A. Chúng tôi sẽ giả định rằng, ngay từ, các thiết lập của bạn
phát triển môi trường đã được hoàn thành.
Yêu cầu
Viết một chương trình mà có ngôn ngữ của sự lựa chọn của bạn và in
liên quan đến chào
chương trình Này, phải được bảo hiểm bởi thử nghiệm đơn vị
hay
đi mod khởi learngo-túi/xin chào
Làm thế nào chúng ta có thể đạt được một chương trình để in một tin nhắn cho màn
hình của chúng tôi?
Chúng
chính.đi ta. hãy vào nó! Chúng ta cần viết mã sau trong một tập tin có tên là
gói chính
Chao ôi! Đó là rất nhiều cho một nhiệm vụ đầu tiên. Trước khi chúng tôi có một bước
vào những
dòng, bạn có thể ưa thích một số hài lòng và chạy chương trình đầu tiên này. Các đi
chính.đi
lệnh choHy
tập tin). đây là như
vọng bạnsau (chạy vui
sẽ được nó mừng
trong cùng mộtthấy
khi nhìn mụcdanh
như thế
dự kiến tin nhắn xuất hiện
trên
màn hình!
> đi chạy chính.đi
xin Chào thế giới
Yay!
Là lập trình viên, khi nói đến việc viết mã, thách thức lớn nhất chúng ta
mặt, hàng ngày, được đưa ra tên để biến hằng số loại gói
bí danh, chức năng, hoặc các tập tin. Hoặc lưu các tập tin, hoặc microservices, thiết bị
đầu cuối,
không gian tên, và vân vân. Danh sách này là vô tận. Ở đây là một số lời khuyên đó sẽ
giúp
bạn
· Nếutên biếnvitrong
phạm của tương
biến làlai
códự án:
hạn coi, chừng hai, hoặc ba đường, một hoặc
hai chữ giữ chỗ này là hoàn toàn hợp lệ. Tuy nhiên, đừng lựa chọn ngẫu nhiên.
Sử dụng một cái gì đó mà ngay lập tức nhắc nhở bạn của các mục đích của biến này.
Chúng ta sẽ sử dụng l cho ngôn ngữ và tc cho testCase sau đó trong chương này.
· Ở phù hợp giữa chức năng khác nhau: nếu biến đại diện cho
một thực thể, sử dụng cùng một tên.
· Đi của biến không cần phải diễn tả loại của họ. Các ký hiệu
không được sử dụng trong Đi. IDE của bạn sẽ được loại, đủ để làm cho bạn biết nếu
một
biến là một trỏ hoặc một giá trị.
· Cuối cùng, tên biến không thể bắt đầu với một số, cũng không có chức năng,
loại hay hằng số.
Bước tiếp theo là để hiểu những gì chúng tôi chỉ viết. Thật vậy, nhiệm vụ của chúng tôi
là Đi
triển sẽ khó có thể đạt được với chỉ là bản sao của những gì chúng ta có thể tìm thấy
ở xa nguồn tài nguyên. Đó là một phần trong mã hóa liên quan đến sự sáng tạo đó
không nên bỏ qua. Như là cho mọi thủ, luyện tập làm cho sự hoàn hảo, và rất
sớm, chúng ta có nên mua đủ thức, dám thay đổi đầu tiên này,
chương trình của chúng tôi để đáp ứng nguồn cảm hứng. Cuốn sách này sẽ hướng
dẫn chúng ta qua
khác nhau bước cuối cùng sẽ đảm bảo sự tự tin qua
sự hiểu biết.
Để bắt đầu, chúng ta sẽ tập trung vào dòng đầu tiên của chương trình.
gói chính
Mỗi Đi tập tin bắt đầu với tên của nó, gói trong trường hợp này chính.
Gói Đi là cách của tổ chức mã, tương tự như mô-đun hoặc thư viện trong
ngôn ngữ khác. Bây giờ, tất cả mọi thứ phù hợp trong những
chính.đi tập tin, mà phải
cư trú trong những
chính gói. Chúng ta sẽ thấy nhiều về làm thế nào để làm và sử dụng
gói trong Chương 3.
Nhữngchính gói là một chút đặc biệt, vì hai lý do. Đầu tiên, nó không tôn trọng
Đi của hội nghị đặt tên gói sau khi mục (hoặc cách khác
xung quanh). Thứ hai, điều này là làm thế nào các biên dịch biết chức năng đặc biệt
được
chính() gọi
sẽlàđược tìm thấy ở đây.chính()
Nhữngchức năng là những gì sẽ được thực thi
khi chạy chương trình.
Sau khi gói là tên sau danh sách các yêu cầu nhập khẩu tập tin này sẽ
sử dụng. Nhập khẩu gói được sáng tác của thư viện chuẩn gói và thứ ba,
bên thư viện.
Hầu hết Đi chương trình dựa trên phụ thuộc bên ngoài. Một đơn Đi tập tin, mà không
có
sự giúp đỡ của nhập khẩu gói, chỉ có thể xử lý một giới hạn của các công cụ. Cho
những
lợi ích của các ngôn ngữ của hối tiếc, đơn giản, những công cụ này không cung cấp
nhiều,
Để sử và viếtnăng
dụng cho như
các thiết bị bên
vậy phụ ngoài
thuộc bênlàngoài,
khôngchúng
có giớitôihạn.
cần để nhập
gói nơi họ sinh sống. Đây chính xác là những gì những nhập khẩu từ khóa
thực hiện - cho tầm nhìn qua các chức năng và biến cung cấp trong một
gói cụ thể, ở một nơi khác. Bên ngoài thư viện được xác nhận bởi các
địa chỉ để họ kho; thêm về điều này sau. Cho thời điểm này, điều quan trọng
thông tin để nhớ là bất kỳ nhập mà không trông như một địa chỉ là
từ thư viện tiêu chuẩn, có nghĩa là nó đi với các biên dịch.
Từ nhữngđạp gói Println chức năng viết cho các tiêu chuẩn ra. Nếu
bạn cho nó một số nguyên hay một phép biến, nó sẽ hiển thị các con người, -
có thể đọc được bản của tổ chức đó. là một người anh em của một đại gia đình của
Println
các chức năng trách nhiệm của dạng tin nhắn.
Lưu ý rằng thụt vào trong Đi được thực hiện với tab. Không cần phải bắt đầu một cuộc
tranh luận, nó
được viết trong những tài liệu, và tất cả mọi người làm theo cách đó.
Một câu hỏi vốn
Bạn có thể tự hỏi tại saoPrintln bắt đầu với một chữ. Những chương dài
về phạm vi và tầm nhìn là:
· Bất kỳ biểu tượng bắt đầu với một vốn được tiếp xúc với bên ngoài người của các
gói;
· Bất cứ điều gì khác không được truy cập từ bên ngoài gói. Chung
ví dụ của chưa phơi sáng tên, gồm những người bắt đầu với chữ
và những người bắt đầu với một gạch dưới.
Công việc tốt, bây giờ chúng ta đã viết những chương trình, chúng ta có thể thử nó!
Như chúng ta sẽ thấy, đây
không phải là cách duy nhất của việc phát triển - đôi khi, chúng ta có thể bắt đầu với
viết các
bài kiểm tra, và sau đó mã. Điều quan trọng là mã và kiểm tra đi tay trong
tay. Viết mã không có kiểm tra là nguy hiểm như suy nghĩ của bạn brand-new
lò nướng bánh sẽ trở về hoàn toàn giòn và vẫn dịu dàng trên bánh mì của nó sử dụng
đầu
Nhưngtiêncó gì trong một bài kiểm tra không? Bởi "kiểm tra", chúng tôi có nghĩa là bài
mà
kiểmkhông
tra tựcó kiểm
động tra bất
(hoặc kỳ của
ít nhất là các thiết lập của nó.
automatable), không phải dựa trên đánh giá con người. Các thử nghiệm có thể được
viết ở
shell, trong chương trình, vào Đi, hay trong bất kỳ ngôn ngữ của sự lựa chọn của bạn.
Nó có để có thể
nói với các người dùng con người mà mọi thứ đều ổn, hoặc cái gì đó không - trong
trường hợp đó, một số chi tiết luôn được chào đón. Cho dự án đầu tiên, chúng ta
hãy xem xét mà chạy mã và "thấy" đầu ra là Xin chào thế giớilà
không đủ, ít nhất không phải là người duy nhất kiểm tra mã của chúng tôi. Nếu những
gì mà không gian
nhân vật giữa những lời là không dễ vỡ nhân vật không gian, mà chúng
con người không thể phân biệt từ một thường xuyên không gian nhân vật? Đầu ra
chuỗi
sẽ
Và không
tại saogiống
chúngnhau, nhưng
ta nên kiểm chúng ta sẽ
tra? Sau tất không
cả, cácthể
mãbiết.
đã thực thi khi chúng ta muốn khi
chúng tôi cho nó chạy, phải không? Mặc dù điều này là sự thật, nó mới chỉ đúng một
lần. Và trong một
dự án lớn hơn, nơi một đoạn mã không phải là thực hiện một lần duy nhất, và
thường xuyên chốc, kiểm tra có một phương pháp của đảm bảo, chúng tôi không phá
vỡ
trước hành vi. Kiểm tra là một khối quan trọng của bất kỳ liên tục
nhập
Ví dụ đường
so kiểmống
Tra- nếu không phải là quan trọng nhất.
Một kỹ thuật nhỏ tựa là cần thiết ở đây. Trong khi Đi chức năng thường trở lại
giá trị rất ít viết đặc biệt để ra tiêu chuẩn. Các thử nghiệm chiến lược,
mà chúng tôi sẽ thực hiện đây chỉ là cần thiết khi kiểm tra các tiêu chuẩn
ra, đó có nghĩa là nó sẽ không được mặc định tiếp cận phần còn lại của mã.
Tuy nhiên, vì đây là lần đầu tiên chúng tôi chức năng, và như chúng tôi muốn kiểm tra
nó, đây là
cách dễ dàng. Chúng ta sẽ thấy thêm về chức năng kiểm tra rất nhanh chóng.
Ví dụ là không chỉ dùng để thử nghiệm đầu ra tiêu chuẩn, nhưng cũng như là của họ
tên cho các người sử dụng và bảo trì của mã của bạn tốt,
điểm khởi đầu. Họ sẽ xuất hiện trong các tài liệu được tạo ra bởi đi doc .
Đi cung cấp rất nhiều công cụ để kiểm tra mã, hãy sử dụng họ! Ở đây, chúng tôi, mục
tiêu sẽ đượcchính - một nhiệm vụ mà là khá phổ biến. Phần lớn của
để kiểm tra
Đi mã nằm trong các chức năng khác - nếu không khác gói - và đó là những
chức năng mà chúng tôi rất nhiều, bài kiểm tra. Hầu hết chức
chính thời gian,
năng sẽ gọi
những thử nghiệm chức năng và chỉ đơn giản là sẽ chịu trách nhiệm in một chuỗi hoặc
trở về trạng thái mã. Ngoài dịp này, các bài kiểm tra trong cuốn sách này sẽ
không được vào chính chức năng, mà là về chức năng, nó gọi.
Có hai cách tiếp cận để thử nghiệm. Trong một trường hợp chúng tôi kiểm tra từ của
người dùng điểm
của xem, vì vậy chúng tôi chỉ có thể kiểm tra những gì được tiếp xúc, chúng tôi tên này,
kiểm tra bên ngoài.
Kiểm tra
Trong lầncác
thứtậphaitin nên trong
trường hợp, {tên gói}_test
chúng tôi biếtgói.
tất cả mọi thứ mà đi vào bên trong và
chúng tôi muốn
kiểm tra các chưa phơi sáng chức năng. Kiểm tra các tập tin cần được ở cùng một gói
theo
các nguồn
Hai tiếp cậntin.
không được độc quyền, và cần được nhìn thấy như
bổ sung.
Làm thế nào để chúng ta kiểm tra nó? Làm thế nào chúng ta có thể chắc chắn rằng
một cái gì đó được gửi đến các
tiêu chuẩn ra từ bên trong một chức năng? Đi cung cấp một công cụ dựa trên
một chức năng kiểm tra tên đó có thể được sử dụng đểVíkiểm tra raNăng>
Dụ<Chức tiêu chuẩn của rằng
chức năng.trường
mô - trong Nếu một chức
hợp củanăng
chúngcủa
ExampleMain Đitên
-ta, sẽtrong một nó
xác định tậpnhư
tin kiểm
là đủtra,
tiêucác trận đấu
chuẩn,
ra xác minh. Mặc dù chính không được tiếp xúc, chức năng là trong
PascalCase cần một số vốn M ở đây.
gói chính
Để khẳng định rằng dự kiến sẽ ra tin nhắnXin chào thế giớiđã được gửi đến
chuẩn ra, chúng tôi sử dụng Đi
Ví dụ cú pháp, mà cho phép chúng tôi để viết một
bình luận dòng chứa Ra: . Bất kỳ bình luận đường ngay sau này
sẽ được danh dự kiến giá trị đó Đi kiểm tra của tiện sử dụng để kiểm tra ra
được tạo ra bởi những cơ thểVínày
dụ chức năng.
MộtVí dụ chức năng mà không có kết quả này sẽ được biên dịch nhưng không được
thực hiện
trong thời gian thử nghiệm. Nó sẽ xuất hiện trong các tài liệu và có thể rất hữu ích cho
người dùng mã của bạn.
Hãy chạy các bài kiểm tra
Đầu ra danh sách các bài kiểm tra các tập tin Đi đi qua. Mỗi dòng sẽ được tên của
module của bạn đã theo dõi theo các đường dẫn tới các gói bên trong nó.
Chú ý
Nó quan trọng để giữ trong tâm trí báo này của Edsger Trỏ: "Thử nghiệm có thể
chứng minh sự hiện diện của lỗi, nhưng không phải là sự vắng mặt của họ!". Một thử
nghiệm đơn sẽ không phải
chứng minh một đoạn mã là lỗi-bằng chứng gì cả. Các bài kiểm tra hơn, chúng ta có,
đáng tin cậy hơn mã được.
Đầu tiên, chúng ta có một automatable quá trình đó sẽ kiểm tra mật mã mà chúng ta đã
tạo ra một xác định ra. Thứ hai, với bài kiểm tra này, chúng ta có thể bắt đầu thay đổi
mã - và mỗi lần thay đổi, mọi một chút của chúng ta thay đổi, có thể
xác nhận với một chạy các bài kiểm tra trước. Cuối cùng, và điều này sẽ được bảo
hiểm trong Ví dụ đóng một vai trò quan trọng
nhiều chi tiết trong chương
một phần trong Đi tài liệu. sau, văn bản kiểm tra
2.1.3 Gọi chào chức năng
Mục tiêu của chương này không chỉ để in một thông điệp tốt đẹp để các người dùng.
Chúng tôi chính
muốnnăng
chức vài biến
nàothể,
khácmột sốhai
biệt môđiều:
đun.đầu
Mộttiên,
bước trởđịnh
xác lại, một cụ thể tin nhắn, và thứ hai,
in nó. Chúng tôi đã bắt tất cả mọi thứ trên một đường duy nhất trong các trước mã,
nhưng
điều đó không để lại bất kỳ không gian để thích nghi.
Kể từ khi chúng tôi mong muốn làm giàu cho các tin nhắn, chúng tôi cần một linh hoạt
ở đây. Chúng ta sẽ chức năng.
chào đón
bắt đầu
Này, trởbằng cách
về chức giải một
năng nén chuỗi
các tinmà
nhắn thế ta
chúng hệcóthành một tục
thể tiếp chuyên
trongchúc
mộtmừng
biến. chúng tôi
gọi
Dưới đây là các đầy đủ mã chỉnh với việc khai thác.
gói chính
// chào mừng trở về một chào mừng đến với thế giới.
chức năng chào() chuỗi { #B
// trở lại một lời chào hỏi đơn giản
trở lại "xin Chào thế giới"
}
// chào mừng trở về một chào mừng đến với thế giới.
chức năng chào() chuỗi {
trở lại "xin Chào thế giới"
}
Trong các chức năng chính chúng tachào
gọi là chức
ngườinăng
đón mới và cửa hàng của mình ra
những
chúc mừng biến chuỗi, mà chúng tôi in.
Chúng tôi chỉnh. Không kiểm tra vẫn còn chạy? Nó phải, nhưng nó không phải là đơn
nhất vì nó với rất nhiều linh hoạt hơn.
chào đón
có thể được. Chúng tôi có thể viết một bài kiểm tra xung quanh
2.1.4 Thử nghiệm một cụ thể chức năng với thử nghiệm
Sắp xếp, như chúng ta đã làm, không nên thay đổi mã là hành vi. Chúng ta có thể
vẫn còn chạy trước đây của chúng tôi kiểm tra, và nó vẫn cần phải vượt qua. Nhưng kể
từ khi chúng tôiđón
chào sẽ muốn
chức năng, đó là một trong chúng ta nên bao gồm với
làm giàu
dành riêng, kiểm tra như ít ỏi như nó được.
Đi, như một phần của nó thư viện chuẩn gói cung cấp khả năng sử dụng những
gói thử nghiệm. Chúng ta sẽ sử dụng nó rất nhiều trong cuốn sách này, cố gắng để
được hưởng lợi từ mọi khía cạnh rằng Đi nhà thiết kế đặt vào ngữ vì vậy chúng tôi
không cần phải viết riêng của chúng tôi, các công cụ, hoặc dành thời gian chuẩn độc
lập thử nghiệmgói này được viết
thử nghiệm thư viện.
để viết bài kiểm tra. Như tên độc đáo cho thấy,
Giai đoạn chuẩn bị, nơi mà chúng ta thiết lập tất cả mọi thứ chúng ta cần để chạy
các
bài kiểm tra đầu vào giá trị, kết quả mong đợi, biến môi trường toàn cầu
biến kết nối mạng, vân vân.;
Giai đoạn thực hiện, nơi chúng tôi gọi là thử nghiệm chức năng - bước này là
thường một dòng duy nhất
quyết định pha, nơi chúng tôi kiểm tra đầu ra để dự kiến ra -
đây có thể bao gồm nhiều so sánh đánh giá, và đôi khi
một số xử lý - và có thử nghiệm hoặc là không hay vượt qua,
Các tháo giai đoạn, nơi chúng tôi vui lòng sạch sẽ trở lại để bất cứ điều gì các
bang
ta từ
trì hoãn
là trước khi chúng khóa:
thực hiệnbất cứ điều
- bước nàygìđược
đó đã thayhiện
thực đổi,rất
hoặc
đơntạo ra
giản,
trong
nhờ Đithời gian để chuẩn bị nên cố định hoặc phá hủy ở đây.
Chúng tôi
TestGreet chức năng sẽ được viết trong cùng main_internal_test.đi
tập tin như trước đó, chủ yếu là vì các thử nghiệm chức
chào cũng
đón năng,
là ở cùng
chính.đi tập tin. Hãy nhìn vào những bổ sung, chúng tôi mang đến cho các tập tin.
gói chính
có := chào()
nếu có != muốn {
// mark thử nghiệm này là thất bại
t.Errorf("dự kiến: %q, có: %q", muốn, có)
}
}
Đầu tiên khác biệt với trước phiên bản các tập tin này là lúc dòng 03: chúng ta
cần đến nhập thử nghiệmgói, bởi vì chúng ta sử dụng một số loại
*thử nghiệm.T chúng TestGreet
tôi chức năng.
Đây là một đường mà sẽ xuất hiện trong mọi tập tin thử nghiệm của chúng ta sẽ xem
như Đi
phát triển. Sự vắng mặt của nó nên được một lá cờ đỏ khi xem xét công mã.
nhập khẩu, "kiểm tra"
Thứ hai thay đổi quan trọng trong file này, tất nhiên, người mới
TestGreet
chức năng.
Chúng tôi đã thêm ý kiến trong cơ thể này chức năng để nó theo
sách trước bước.
Những bước chuẩn bị, trong trường hợp của chúng ta, bao gồm trong xác định sản
lượng dự
chào kiến
chứccủa
đón năng gọi. Kể từ khi điều này không thay đổi môi trường, có
những
không có gì để quay lại sau khi thực hiện kiểm tra, và chúng tôi không cần phải hoãn
bất kỳ đóng cửa bước.
Giai đoạn thực hiện chỉ đơn giản là bao gồm trong gọi thử chức năng, và,
chào đón
tất nhiên, trong chụp nó ra vào một biến.
có := chào() #B
nếu có != muốn { #C
// mark thử nghiệm này là thất bại
t.Errorf("dự kiến: %q, có: %q", muốn chúc mừng)
}
Quyết định pha đây không phải quá khó khăn. Chúng ta cần phải so sánh hai dây, và
chúng tôi sẽ chấp nhận không thay đổi, do
!= đó,
so sánh điều hành hoạt động tốt
cho chúng ta ở đây. Chúng ta sẽ sớm mặt trường hợp so sánh hai dây, phải không đủ,
nhưng chúng ta không bỏ qua bước, như chúng ta vẫn còn có một dòng cuối cùng ở
đây mà cần nhiều
lời giải thích.
t.Errorf("dự kiến: %q, có: %q", muốn, có)
Một lần nữa, bạn có thể chạy các bài kiểm tra cùng lệnh như trước đi
đó,
.
kiểm tra
Trước khi chúng tôi chuyển đến phần tiếp theo, bây giờ là thời gian để chơi một chút.
Thay đổi muốnvà lại chạy các bài kiểm tra.
các nội dung của những
Lý do cho việc này sớm sắp xếp không thể xuất hiện ngay bây giờ. Bởi cuối
của chương này, tuy nhiên, như là, chúng tôi thực hiện các chức năng mới trong mã
của chúng tôi,
các tập tin sẽ tăng kích thước. Nó là thực hành tốt, trong Đi như trong nhiều người
khác
phát ngôn ngữ, để giữ phạm vi của một chức năng hẹp. Này phục vụ cho
nhiềulàm
mụcmãđích, nhưkiểm chứng;
có thể
làm lỗi mã dễ dàng hơn,
làm nhiệm vụ của một chức năng rõ ràng.
Nói chung, các nhận thức trách nhiệm của một chức năng nên tối thiểu. Không ai muốn
đối mặt với một bức tường của văn bản gồm có nhiều lớp của vết lõm.
Bây giờ chúng tôi đã viết một chương trình mà chào đón các người dùng với một tin
nhắn đáng yêu. Chúng tôi
biết nó hoạt động tốt, bởi vì ta đã viết bài kiểm tra để che mã. Nhưng có
một rượt đuổi nhỏ. Nó sẽ chỉ viết chúc mừng anh. Chương trình của chúng tôi có thể
được
cải thiện được sử dụng bởi những người dùng ngôn ngữ khác hơn là anh. Hãy tưởng
tượng
bạn đang áp dụng ở một công ty Canada, nơi nhân viên nói cả tiếng pháp
và tiếng anh. Làm thế nào nó sẽ là nếu họ có thể sử dụng nó quá, và được chào đón
2.2 bạn
với một ngônCó
ngữmột
của họthứ tiếng?
lựa chọn?
Chương trình của chúng tôi rất tĩnh. Nó sẽ luôn luôn chạy và in các tin nhắn tương tự,
bất kể người dùng. Hãy thích nghi mã của chúng tôi để hỗ trợ vài tiếng, và
có người quyết định cái nào họ muốn. Trong phần này, chúng tôi sẽ:
Hỗ trợ thêm cho một ngôn ngữ mới trong chàonhững
phương pháp
đón
Xử lý các người dùng ngôn ngữ của yêu cầu
Thích nghi với những thử nghiệm và đảm bảo, chúng tôi không phá vỡ trước hành
vi
Để màn hình Xin chào thế giớitrong một ngôn ngữ khác nhau, chúng tôi cần để có thể
nói với các chương trình mà ngôn ngữ chúng tôi muốn sử dụng. Này sẽ được thực
hiện trong
hai lần đầu tiên được hỗ trợ ngôn ngữ mới, và người thứ hai
là một mở chương trình của chúng tôi để các người là sự lựa chọn của ngôn ngữ.
2.2.1 bạn có nói tiếng pháp? Chuyển đổi góc
gói chính
// chào nói xin chào với thế giới trong ngôn ngữ quy định
chức năng chào(l ngôn ngữ) chuỗi {
chuyển l {
trường hợp "vi":
trở lại "xin Chào thế giới"
trường hợp "cha":
trở lại "Bonjour le monde"
mặc định:
trở lại ""
}
}
Rõ ràng qua gõ
Sử dụng đúng loại biến là quan trọng. Chúng ta cần phải biết những gì chúng ta
đang nói về. Và những gì chúng ta đang nói là có một ngôn ngữ
số đó sẽ được sử dụng để xác định tin nhắn chúc mừng nên được
trả lại bởi chức năng. Ngôn ngữ này thông số có thể là một chuỗi
chào đón
có ngôn ngữ mô tả. Nó có thể là một số nguyên đề cập đến một
chỉ số của ngôn ngữ hiện có. Nó có thể là địa chỉ để một từ điển. Nó có thể là
nhiều thứ. Bây giờ, chúng tôi sẽ giữ nó đơn giản và sử dụng một chuỗi.
Ngôn ngữ nhập sẽ có một chuỗi đó đại diện cho một ngôn ngữ . Loại này
nghĩa giúp chúng ta và các người dùng thư viện của chúng tôi hiểu những gì các giá trị
đang
mong đợi và làm cho trộn lên, các thông số thực sự khó khăn.
Lựa chọn đúng ngôn ngữ
Bây giờ chúng ta có một loại, chúng ta có thể vượt qua nó như một thông số vào
chào đón
chức năng.
Để gọi nó, chúng tôi đã thay đổi dòng đầu tiên của chúng tôi, chức năng chính:
Làm thế nào để trình biết nếu điều này là một hoặcngôn
chuỗi mộtngữ ? Nhìn xuống
chữ ký của các chức năng. chào đón yêu cầu một
ngôn ngữ vì vậy, nó sẽ gõ
liên tục như vậy.
chuyển l {
trường hợp "vi":
trở lại "xin Chào thế giới"
trường hợp "cha":
trở lại "Bonjour le monde"
mặc định:
trở lại ""
}
Lưu ý rằng giữa mỗi trường hợp, trái ngược với nhiều ngôn ngữ khác, chúng ta không
phá vỡ. Vi phạm là tiềm ẩn trong Đi bởi vì nó là một nguồn tiềm năng của
lỗi. Tất nhiên, khi chúng ta trở lại đây trong mỗi trường hợp, chỉ là tranh luận, nhưng
bây giờ
anh biết.
Trong chính
nhữngchức năng, chúng ta cần phải vượt qua ngônnhững
ngữ để
mong
chúng
muốn
tôi nâng cấp
chào đónchức năng và in ra. Ví dụ, "vi" cho tiếng anh.
2.2.2 Thích nghi với những thử nghiệm với kiểm Tra<chức Năng>
chức năng
Trước đây, chức năng chấp nhận không có số. Nó mất một,
chào đón
mà có nghĩa là chúng ta đã phá vỡ các hợp đồng, chúng tôi đã có người dùng mã của
chúng tôi. Vâng,
bây
chào giờ, chỉ năng
chức
đón dùng với
đượccácmột
đầubài kiểm tra, nhưng mà vẫn còn đếm. Bây giờ chúng tôi
vào.
muốn kiểm tra
Chúng ta sẽ thực hiện mộtchào
cuộcđónchức
gọi đến
năng
những
của đi qua những mong muốn đầu vào
ngôn ngữ và lưu trữ các ra trong một biến, vì vậy chúng tôi có thể xác nhận nó. Các
giai đoạn chuẩn bị bây giờ có hai biến: ngôn ngữ mong muốn, và
mong đợi tin nhắn chúc mừng.
gói chính
nhập khẩu, "kiểm tra"
có := chào(lang) #B
nếu có != muốn { #C
// mark thử nghiệm này là thất bại
t.Errorf("dự kiến: %q, có: %q", muốn, có)
}
}
có := chào(lang) #B
có := chào(lang) #B
nếu có != muốn { #C
// mark thử nghiệm này là thất bại
t.Errorf("dự kiến: %q, có: %q", muốn, có)
}
}
Như bạn đã thấy, chúng tôi đã thêm một chức năng để kiểm tra một ngôn ngữ không rõ
cho chương trình. Kiểm tra không phải luôn về việc chắc chắn là "tốt" đầu vào
cung cấp "tốt" kết quả. Đảm bảo an toàn mạng lưới đang ở nơi, gần như là
có giá trị như chắc chắn mã này hoạt động như dự định.
Hãy nhìn vào thực hiện việc mã bằng cách sử dụng một bản đồ
để lưu trữ những
cặp của ngôn ngữ và chúc mừng tin nhắn:
gói chính
nhập khẩu (
"đạp"
)
/ chúng tôi. giữ lời chào cho mỗi hỗ trợ ngôn ngữ
var phrasebook = bản đồ[tiếng]chuỗi{
"el": "Χαίρετε Κόσμε", // tiếng hy lạp
"vi": "xin Chào thế giới", // tiếng anh
"cha": "Bonjour le monde", // tiếng pháp
"ông": " " םולש םלוע, // Tiếng do thái
"yêu": " " ﮨ ﯿﻠﻮ, // Tiếng Urdu
"vi": "Xin chào Thế Giới", // tiếng Việt
}
// chào nói xin chào với thế giới trong ngôn ngữ khác nhau
và chào(l ngôn ngữ) chuỗi {
chúc mừng, ok := phrasebook[l]
nếu !ok {
trở lại đạp.Sprintf("không được hỗ trợ ngôn ngữ: %q" l)
}
Chúngbảntôi
nghĩa cộng sự lời chào đến tất cả các ngôn ngữ như một
đồ
cặp {ngôn ngữ, chúc mừng} . Cho chương này, chúng tôi sử dụng toàn cầu biến mà
giữ lời chúc mừng.
/ chúng tôi. giữ lời chào cho mỗi hỗ trợ ngôn ngữ
var phrasebook = bản đồ[tiếng]chuỗi{
"el": "Χαίρετε Κόσμε", // tiếng hy lạp
"vi": "xin Chào thế giới", // tiếng anh
"cha": "Bonjour le monde", // tiếng pháp
"ông": " " םולש םלוע, // Tiếng do thái
"yêu": " "ﮨ ﯿ ﻠﻮ دﻧ ﯿﺎ, // Tiếng Urdu
"vi": "Xin chào Thế Giới", // tiếng Việt
}
Bước tiếp theo là để sử dụng nói thay vì những chuyển trong những
chào đón
chức năng.
Danh sách 2.11 chính.đi: phương pháp
chào đón
// chào nói xin chào với thế giới trong ngôn ngữ khác nhau
và chào(l ngôn ngữ) chuỗi {
chúc mừng, ok := phrasebook[l]
nếu !ok {
trở lại đạp.Sprintf("không được hỗ trợ ngôn ngữ: %q" l)
}
Nó là cần thiết để kiểm tra các thứ hai trở lại giá trị của các truy cập vào các bản đồ -
nếu
các ngôn ngữ đã được hỗ trợ, chúng tôi muốn nhận được những zero-giá trị của một
chuỗi,
đó là chuỗi rỗng, không có kiến thức của cho dù các bản đồ đã có một
mục
Lưu ýcho ngôn
rằng ngữ
trong của
sản chúng
xuất sẵn tôi.
sàng mã, chúng tôi sẽ trở lại một lỗi vì
một chuỗi rỗng không thực hiện bất kỳ ý nghĩa. Chúng tôi đã chọn để giữ cho nó đơn
giản cho
bây giờ. Lỗi sẽ được bao phủ trong chương sau.
Nhiều trở lại giá trị: Chúng ta sẽ thấy nhiều chuyện của nhiều giá trị
chuyển nhượng, chủ yếu là trong bốn chung trường hợp:
Bất cứ khi nào chúng tôi muốn biết liệu một quan trọng là hiện diện trong một bản
đồ, như chúng ta làm
ở đây, nơi mà chúng tôi lấy giá trị, và thông tin của sự hiện diện của
chiếc chìa khóa trong khoảng
bản đồtừ(như chúng
khóa, ta đãchúng
cho phép làm trong phần
tôi lặp quanày của mã);
bất cứcác
tất cả Khicặp
nàogiá
chúng tôi sử
trị khóa dụng
trong những
một bản đồ, hoặc tất cả các chỉ số-giá trị của một
yếu tố
lát hoặc mảng (một ví dụ trong phiên bản tiếp theo của các tập tin thử nghiệm
nhiều hơn trong việc
tiếp theo chương); <- điều hành, đó trả một
bất trị
giá cứvàKhi nào
cho dùchúng tôi đọc
các kênh từ một
được đóngkênh
cửa với những
(ví dụ có thể được tìm thấy ở sau
chương);
cuối Cùng, thường xuyên nhất trường hợp là khi chúng tôi lấy nhiều giá trị
trả lại bởi một chức năng duy nhất. Cuốn sách này sẽ có rất nhiều
lần xuất hiện của trường hợp này, chủ yếu là do để Đi xử lý của lỗi.
Trước đây của chúng tôi đã kiểm tra tuyến tính - họ đã thử nghiệm mọi thứ ngôn ngữ
trong một tuần tự
cách. Bước trở lại, chúng tôiđón
chào nhận
chứcthấy mỗi
năng, vàkiểm
kiểmtratrachạy
chúccùng
mừng một
chochuỗi:
rằng có một
ngôn ngữ nhập gọiđợi một. Điều này có thể được tổng hợp trong sau
là mong
đoạn mã đó được thực hiện cho ngôn ngữ "vi" , "cha" hay "akk" chúng tôi
trước, ví dụ:
có := chào(tiếng(lang))
nếu có != muốn {
t.Errorf("dự kiến: %q, có: %q", muốn, có)
}
Đó là không có điểm tại sao lại đoạn mã này mỗi khi chúng tôi muốn
kiểm tra chúng tôi hỗ trợ đúng là một ngôn ngữ mới. Không phải là kiểm tra luôn luôn là
như vậy? Chúng tôi thực sự cần thêm một mười đường dây của chúng tôi để kiểm tra
các tập tin nếu chỉ có
hai trong số những dòng này thay
chào đổi? Đây
chức
đón năng, không phải
và đây là bền
cũng vững.
là động lựcĐó là chúng
của động lực
tôi để
của chúng tôi để
sử dụng bản đồ của chúng tôi kiểm tra! Chúng ta có thể sử dụng bảng điều khiển kiểm
sử dụng
tra để bản
tăng đồ trong
cường sự cơ thể của những
tái sử dụng và rõ ràng của chúng tôi kiểm tra tập tin, và có đẹp tác dụng của
nó thu hẹp lại rất nhiều! Hãy có một cái nhìn mới kiểm tra trước khi chúng tôi giải thích
nó.
Danh sách 2.13 main_internal_test.đi: Bảng điều khiển kiểm Tra
nếu có != tc.muốn { #C
t.Errorf("dự kiến: %q, có: %q", tc.muốn có)
}
})
}
}
Như chúng ta đã thấy trước đây, mọi kiểm tra, chúng tôi muốn chạy cần hai giá trị: các
ngôn ngữ của các điệp mong muốn, và dự kiến chúc mừng thông điệp sẽ
được trả lại bởi chào đón chức năng. Này, chúng tôi giới thiệu một cấu trúc mới
có ngôn ngữ đầu vào, và mong đợi chúc mừng. Cấu trúc Đi là
cách của tập hợp dữ liệu loại với nhau trong một ý nghĩa thực thể. Trong trường hợp
của chúng tôi, testCase . Chúng tôi
kể từ khi cấu trúc đại diện cho một trường hợp
cấu trúc chỉ cần để có thể truy cập trong những thử nghiệm, chúng
TestGreet chức năng (vàtakhông
sẽ đặtnơi
tênnào
nó
khác), vì vậy hãy xác định điều đó.
Điều này sẽ làm cho viết một bài kiểm tra hơn một cặp của ngôn ngữ và chúc mừng
thậm chí
đơn giản.
Bây giờ chúng ta có thể dễ dàng viết một bài kiểm tra trường hợp, chúng ta hãy xem
làm thế nào để viết rất nhiều. Trong Đi, các cấu trúc đó sẽ
bản đồ
chung cách viết ra một danh sách của trường hợp thử nghiệm
đề cập đến mỗi trường hợp thử nghiệm với một cụ thể mô tả quan là đểtrọng.
sử dụng một
Mô tả phải
được rõ ràng gì về trường hợp này các bài kiểm tra.
Bây giờ chúng ta có mọi thứ ta cần để viết một danh sách của trường hợp thử nghiệm.
Để kiểm tra các kịch bản, chúng ta có thể lặp lại những
kiểm trabản đồ. Như chúng ta sẽ
xem thông tin chi tiết trong bước kế tiếp, điều
cho +này
khoảng cú pháp trả lại chìa khóa
và giá trị của mỗi phần của bản đồ. Sau đó chúng tôi vượt qua những tên như là người
đầu tiên Chạymột phương pháp từ các thử nghiệm gói mà làm bài kiểm tra rất nhiều
tham
dễ số hơn để sử dụng: nếu một trường hợp thử nghiệm thất bại, các công cụ sẽ
dàng
cung cấp cho bạn tên của nó, do đó bạn
có thể tìm và sửa chữa nó. Ngoài ra, hầu hết mã biên tập viên cho bạn chạy một
trường hợp thử nghiệm
nếu
Hãy bạn
nhớ sử dụng
rằng, bảncúđồ
pháp
này này.
liên kết một mô tả đến một trường hợp thử nghiệm, do đó
tên của tc .
biến,
Danh sách 2.15 main_internal_test.đi: thực Hiện và khẳng định pha
nếu có != tc.muốn { #B
t.Errorf("dự kiến: %q, có: %q", tc.muốn có)
}
})
}
Trích trong Đi
Có lẽ bạn đã thấy, chúng tôi sử dụng một bộ khác nhau của trích dẫn trong những dự
kiến
lời chào cho người arcadia (akk). Có ba loại báo rằng được sử dụng trong
Đi, mỗi trong đầy đủ của nó bối cảnh:
· Những giá gấp đôi ": nó được sử dụng để tạo đúng nghĩa dây. Ví dụ: s := "xin Chào
thế giới"
· Những backtick `: nó cũng được sử dụng để tạo ra nguyên chữ dây. Ví dụ: s :=
`xin Chào thế giới`
· Những giá duy nhất ': nó được sử dụng để tạo ra runes. Ví dụ: r := '學'. Một rune là
một
duy nhất unicode điểm mã.
Bạn đã có thể nhận thấy các trước hai lựa chọn có thể được sử dụng để tạo đúng
nghĩa
dây. Sự khác biệt giữa nguyên chữ dây và không sống theo nghĩa đen dây
là, trong một nguyên chữ chuỗi, không có lối thoát trình tự. Viết một n trong một
nguyên chữ chuỗi sẽ dẫn đến một gạch chéo ngược nhân vật \ đã theo dõi bằng chữ n,
khi chuỗi được in. Nguyên chữ dây là một cách tốt đẹp của không phải
đối phó với thoát đôi giá, đó là rất tiện dụng khi nói đến
viết HỆT trọng tải.
Chúng tôi đã có một chương trình mà có thể quay trở lại một lời chào, trong ngôn ngữ
nào các
người, nhưng cách duy nhất dùng được để thay đổi các ngôn ngữ sử dụng được, vì
vậy
, đến nay, thay đổi mã của chương trình - đó không phải là ưu! Chúng tôi muốn nhận
được đi chạy chính.đi hoặc bằng cách thực hiện các biên soạn thực thi, đó là
những từ đầu
nhiều khả vào
năng, họngười dùngthông
sẽ muốn mà không thay
báo cho đổi mã
chúng mỗichọn
ta lựa lần yêu
củacầu
họ được
của ngôn ngữ.
gửi đi. Kể từ khi các người đang chạy chương trình từ các dòng lệnh, bởi
chạy
Thể 2,4 Sử dụng cờ gói để xem các người dùng
ngôn ngữ
Làm thế nào chúng ta có thể sử dụng các đầu vào để được mong muốn của người
dùng ngôn ngữ của chúc mừng? Đi và hành
hệ điều
hỗ trợ
cờ cho
những phân
gói. Cựutích cácgần
là rất dòng
đểlệnh
C's xử lậplýluận cả
luận - bạn có thể truy cập vào họ bởi vị trí của họ trên đường, nhưng cho dù
họ là của các hình thức
- chìa khóa=giá,trị-chính trị hay -lựa chọn là bên trái đến
phát triển để thực hiện, và nó thật đau, nếu bạn có lặp lại trường. Oh,
và đó là chỉ có phân tích cho họ, sau đó, chúng ta phải chuyển chúng đến của họ phải
loại.
Mặt khác, những cờ gói cung cấp hỗ trợ của một loạt các loại -
nguyên nổi số thời gian thời gian, dây, và các phép toán. Hãy cuộn với
cái này!
Điều đầu tiên chúng tôi cần làm, khi nói đến phơi bày một số của chúng tôi trên
dòng lệnh thực thi, là để cho nó một cách tốt đẹp và tên ngắn. Ở đây, chúng tôi sẽ
cung cấp cho người dùng một sự lựa chọn của ngônlang ngữ màràng,
khá rõ
sự lựa chọn.
Hãy nhìn vào mã cập nhật của những chính.đi tập tin:
gói chính
nhập khẩu (
"cờ"
"đạp"
)
/ chúng tôi. giữ lời chào cho mỗi hỗ trợ ngôn ngữ
var phrasebook = bản đồ[tiếng]chuỗi{
"el": "Χαίρετε Κόσμε", // tiếng hy lạp
"vi": "xin Chào thế giới", // tiếng anh
"cha": "Bonjour le monde", // tiếng pháp
"ông": " " םולש םלוע, // Tiếng do thái
"yêu": " "ﮨ ﯿ ﻠﻮ دﻧ ﯿﺎ, // Tiếng Urdu
"vi": "Xin chào Thế Giới", // tiếng Việt
}
nhập khẩu (
"cờ"
"đạp"
)
Bây giờ chúng ta đã nhập khẩu gói này, hãy sử dụng nó. Chúng tôi muốn đọc, từ
các dòng lệnh, tên của các ngôn ngữ trong đó các người dùng hy vọng của họ,
chúc mừng. Trong mã của chúng tôi, các loại cho rằng ngôn
tổ chức
ngữ sẽ
màcó một
gần nhất là một loạichuỗi .
Những
cờ gói cung cấp hai rất giống chức năng để đọc một chuỗi từ các
dòng lệnh. Đầu tiên, yêu cầu một con trỏ để biến nó sẽ điền vào.
Đầu tiên, chúng ta vượt qua các chức năng địa chỉ của chuỗi. Thứ hai, chúng ta vượt
qua những
tên của các tùy chọn, vì nó sẽ xuất hiện trên các dòng lệnh. Thứ ba, chúng tôi cung cấp
cho các
giá trị mặc định cho biến này. Mặc định giá trị được sử dụng nếu các người không
cung cấp cờ khi gọi chương trình. Cuối cùng, chúng tôi viết một
mô tả những gì lá cờ này tượng trưng cho một số ví dụ giá trị. Gợi ý
được
Trong bao
thờiphủ
giansâu trong
thực hiệncác
mộtphụ Lục Etrình
chương và sử dụng
biến trong
được lưuchương
trong bộsau.
nhớ ở một
địa chỉ cụ thể. Chúng tôi có thể lấy địa chỉ của một biến với địa chỉ
nhà điều hành &, sử dụng trên một con trỏ. Tương tự như vậy, khi chúng tôi có một
con trỏ và chúng tôi
muốn truy cập vào các giá trị, chúng tôi có thể lấy lại nó với những indirection điều
hành *
sử dụng
Trong Đi,trên những tôi
khi chúng congọi
trỏ.
một chức năng, các lập luận được thông qua sao chép. Điều
này
có nghĩa là nếu chúng ta muốn cho phép một chức năng để làm thay đổi một biến của
chúng tôi, các
đơn
Cuốigiản
cùng,nhất là để
không chocấp
cung chứctrỏnăng
sinh.đó mộtbạn
Nếu concótrỏ đểcon
một biếntrỏ
của
đểchúng tôi.
đầu tiên
tử của một mảng, nó không thể được sử dụng để truy cập vào các yếu tố thứ hai.
Đó là một điều quan trọng để hãy nhớ rằng, bất cứ khi nào chúngcờ tôi sử dụng những
gói, đó là tất cả những StringVar , IntVar , UintVar không quét các
dòng lệnh và trích xuất những giá trị của thông số. Điều này không lừa của
phân tích các dòng lệnh là chức năng cờ.Phân tích . Nó sẽ quét các đầu vào
và các thông số điền vào mỗi biến chúng tôi đã nói với nó sẽ là một nhận. Nếu bạn
cần một mnemotechnical câu phải nhớ nó, cố gắng "SunsetBoolVar bắt đầu
tại -chào mừng đại Dương".
Phân tích
Bây giờ chúng tôi đã hoàn thành mã, và nó là thời gian để chạy một số người dùng
cuối thử nghiệm.
Này, chúng tôi sẽ mô phỏng cuộc gọi từ các dòng lệnh. Chúng ta có nhiều
lựa chọn để bảo đảm cái này hoạt động như mong đợi. Đầu tiên trên danh sách của
chúng tôi là chỉ cần
thử nó ra! Sau tất cả, chúng ta đã dành rất tốt thỏa thuận của chạy
trangđiđảm bảo điều này
hoạt động .
chính.đi lang=en
như chúng ta muốn, vì vậy, một số sự an tâm là xứng đáng, một thời gian để phần còn
lại
Mộtcho
số các
ví dụ Đây là một của chạy chính trong tiếng hy lạp:
tế bào thần kinh. Chúng ta có thể vượt qua các thông số vào, các dòng lệnh với
> đi chạy chính.đi lang=el
Χαίρετε Κόσμε
Này kết luận án đầu tiên chúng tôi hy vọng cậu thích nó và đã học được
một số thông tin thực tế về Đi.
Kể từ khi phát minh của viết, người đã được sử dụng cụ để khắc của họ,
suy nghĩ thông qua nhiều thế kỷ. Cuốn sách đã được kiến thức và trở thành sở thích.
Chúng tôi đã đọc và thu thập chúng trên kệ. Với công nghệ, chúng ta
có thể chia sẻ thông tin hơn bao giờ hết, và cung cấp cho ý kiến của chúng tôi trên
tất cả mọi thứ, kể cả sách. Trong chương này, chúng tôi sẽ tham gia một nhóm các con
mọt sách
những người đã đọc cuốn sách. Miễn vận chuyển đa và Peggy đã bắt đầu đăng ký
sách, họ giữ trên kệ sách của họ, và họ tự hỏi, nếu chúng tôi có thể giúp họ
tìm thấy cuốn sách, cả hai đều đã đọc, và, có lẽ, đề nghị tương lai đọc.
Trong chương này, chúng ta sẽ củng cố những gì chúng ta học được về dòng lệnh
diện trong chương 2 bằng cách tạo ra một cuốn sách tiêu hóa từ con mọt sách' cuốn
sách
bộ sưu tập. Từng bước, từng từ một danh sách của cuốn sách mỗi đọc, chúng tôi sẽ
xây dựng một
chương trình trở về và in the cuốn sách được tìm thấy trên nhiều hơn một kệ. Như một
phần thưởng, chúng ta sẽ thực hành bản đồ và lát khác nhau để tạo ra một công cụ
giới thiệu
sách. Đầu của chúng tôi, thực thi là một MẢNG tập tin, và chúng tôi có thể tìm hiểu làm
thế nào để
đọc một file trong Đi và làm thế nào để phân tích một MẢNG sử dụng các tiêu chuẩn
Yêu cầu
thư viện từ
Đi. Vì lợi ích của đơn giản ở đây, chúng tôi sẽ cho mỗi cuốn sách chỉ có
Viết một CLI công cụ trong đó có một danh sách của con mọt sách và cuốn sách
một tác giả. Làm thế nào mỉa mai là, anh sẽ nói.
của họ
bộ sưu tập trong hình thức một MẢNG tập tin
Tìm chung sách trên kệ của họ
In chung sách trên con mọt sách' kệ để chuẩn đầu ra,
Như một phần thưởng, giới thiệu sách cho mỗi con mọt sách dựa trên của họ
phù hợp với sách với các con mọt sách
Giới hạn
Như một thực hành tốt, chúng tôi giới thiệu tới việc tạo ra mộttập
chính.đi newtin với một
đơn giản rỗngchính chức năng. Nó là một tiêu chuẩn bước đầu tiên và chúng ta sẽ làm
nó
trong suốt chương.
gói chính
Trong phần này, chúng ta sẽ tạo ra những đầu vào trong c tập tin và tải dữ liệu, nó
chứa.
Hãy nhìn vào một số ví dụ nhập vào dữ liệu. Đó là một danh sách những người có
tên và những cuốn sách của họ. Mỗi cuốn sách này có một trong những tác giả và một
tiêu đề.
Một vài lời về những MẢNG dạng
Các đối Tượng lưu ký Hiệu gọi rộng rãi giống HỆT như là một dạng file
lưu dữ liệu bằng cách sử dụng "chìa khóa":cặp giá trị. HỆT tên luôn dây,
đính kèm với đôi giá sách giá trị có thể là bất kỳ điều sau đây:
HỆT đối tượng' lĩnh vực không sắp xếp đặc biệt: trong ví dụ bên dưới, chúng ta
có thể có tác giả xuất hiện trước hay sau khi tiêu đề, và hàng sẽ
được như vậy. Mảng được lệnh đổi thành đầu tiên và các yếu tố thứ hai
sẽ thay đổi trọng.
Danh sách 3.1 testdata/con mọt sách.trong c: Dụ của đầu vào tập tin
[
{
"tên": "miễn vận chuyển đa",
"sách": [
{
"tác giả": "Margaret khả hãn",
"hiệu": "Người nữ tỳ của câu Chuyện"
},
{
"tác giả": "Sylvia-Plath",
"hiệu": "cái tháp Chuông"
}
]
},
{
"tên": "Peggy",
"sách": [
{
"tác giả": "Margaret khả hãn",
"hiệu": "Thuê và Giả"
},
{
"tác giả": "Margaret khả hãn",
"hiệu": "Người nữ tỳ của câu Chuyện"
},
{
"tác giả": "Nhiều",
"hiệu": "Jane Eyre"
}
]
}
]
Bởi vì chúng tôi không thích bị mất trong quá lâu các tập tin chúng tôi, chúng tôi đã
chọn để cắt các chính.đi biết là nó chạy trong một thiết bị đầu cuối
logic
và cócủa
thể dự
bàyán
vănvào 2 các
bản thứ tập
hai,tin:mọt
con đầu tiên,có logic kinh doanh, và có thể
sách.đi
được sao chép lại trong một thiết lập khác nhau. Đừng nghĩ về nó chưa.
Tại thời điểm này, sơ của cây nên nhìn như sau:
> cây
.
├ tượng con mọt sách.đi
├ tượng đi.mod
├ tượng chính.đi
└ tượng testdata
└ tượng con mọt sách.hệt
Đang tải dữ liệu sẽ được mục đích của một chức năng mới rằng chúng ta có thể gọi
loadBookworms . Nó sẽ có các tập tin con đường như một số và trả lại lát
củaCon mọt sách
các đại diện của các tài liệu HỆT. Nếu có điều gì sai
xảy ra (thấy không tìm thấy không hợp lệ TRONG...), và nó cũng có thể trả lại một lỗi.
Đừng
quên để cho nó một docstring.
Danh sách 3,2, và con mọt loadBookworms
sách.đi: chữ ký
// loadBookworms đọc các tập tin và trở lại danh sách của con mọt sách, và họ yêu sách, tìm thấy trong
đó.
chức quay
năng về
loadBookworms(filePath
con số không, nil #Mộtchuỗi) ([]Máu lỗi) {
}
Chúng tôi đã nói chuyện về zero, những giá trị mà bạn có thể tham khảo phụ Lục C.
của chúng tôi Trong nil như là giá trị của zero
trường
lỗi diện.hợp zero
Đó là giátại
lý do trịsao
của lát loadBookworms
là con mọt sáchtrở về nil và nil cho
lúc này.
Tạo ra tạo ra một tập tin với cả hai đọc và viết quyền (nhưng không thực hiện) cho tất
cảdụng (0666). Nếu các tập tin Tạo
người sử đã tồn
ra tại,
cắt nó, gửi nó
nội dung để quên lãng. Khi Tạo ra thành công, trả lại mô tả tập tin có thể
được sử dụng để viết dữ liệu cho các tập tin.
OpenFile là một cách tiếp cận chung, cho phép những người quyết định cho dù họ
muốn mở một tập tin cho viết hay đọc. Hầu hết thời gian, bạn sẽ không cần nó
- một cuộcMở gọi đến
hayTạo ra nên làm các thủ thuật. Tuy nhiên, có hai rất
cụ thể trong trường hợp đó, nó là hữu ích. Trường hợp đầu tiên là khi chúng tôi muốn
thêm Mở ở đây sẽ không - những
dữ liệu vào một tập tin, mà không
*Tập tinsẽ là chỉ đọc -cũng không vứt bỏ nội dung của nó. tin
Tạo ra - các tập Bằng
nội cách
dung sửcủadụng
sẽ
được xóa. Tham số thứ hai của nhữngOpenFile chức năng là một lá cờ
kiểm soát làm thế nào chúng tôi mở các tập tin. Danh sách đầyđiđủ doccó thể được tìm thấy
. Những lá cờ đang hằng và nên kết hợp của
hệ điều hành.O_APPEND
hương vị. Khi tạo ra hoặc thêm, sử dụng
. Thứ hai trường hợp là khi chúng ta muốn
hệ điều hành.O_APPEND hành.O_CREATE hành.O_WRONLY
để tạo ra một tập tin mà các quyền không mặc định người thân Tạo racủa
.
OpenFile là người duy nhất cung cấp khả năng thiết lập cụ thể truy cập vào
quyền cho những tập tin, qua cuối cùng của nó thông số.
Hằng số trong các hệ điều hành gói đang ở thủ đô, vì chúng là một phần của
điều hành hệ thống tiêu chuẩn. Nếu không, Đi thích hằng được xác định trong
PascalCase giống như mọi thứ khác.
Trì hoãn
Khi bạn đang thực hiện với tôi/O với một hoạt *Tập
độngtinbạn phải đóng nó bởi
bằng cáchGần
sử dụng
phương pháp của tập tin. Theo cách này, hệ thống nguồn lực được sử
dụng
tập tin được phát bởi
hành vànhững
anh không tạo ra rò rỉ với chương trình của bạn. Nếu bạn
không
đóng các mô tả, bạn có thể thải tất cả sẵn tập tin xử của
hệ thống khóa các tập tin có một số phức tạp tác dụng phụ trên cửa Sổ,
nơi mà bạn có thể kết thúc chặn mình từ viết hoặc xóa nó. Trong
lý thuyết, việc thu gom rác của Đi nên đóng các tập tin tại một số điểm (không
muộn hơn khi chương trình của bạn lối thoát hiểm), nhưng tốt hơn hết là biết khi nào
và
làm thế nào các tập tin mô tả được đóng lại. Nói cách khác, hãy lịch sự và rõ ràng
sạch,
sau
Làmkhisaomình.
chúng tôi biết khi chúng ta thực hiện với những hoạt động? Thông thường, đó
là bởi sự Gần() . Nhưng
kết thúc
hãy tưởngcủa các chức
tượng năng,khi
một ngày vàbạn
đó là nơicấu
phải chúng
trúctalạisẽcác
đặtchức
các cuộc
nănggọi đến bỏ rất nhiều
và bạn
mã - nếu bạn vô tình hủy bỏ cuộc gọi đến Gần hoặc nếu bạn quay trở lại
trước khi nó được gọi là? Cách tốt nhất để ngăn chặn nó từ bị mất trong phần còn lại
của Mở hay Tạo ra .
các chức năng là để giữ nó bên cạnh
trì hoãn là một từ khóa là trì hoãn một tuyên bố là để thực hiện rất kết thúc
các chức năng, ngay cả nếu trì hoãn xuất hiện ở đầu của nó. Những
điểm quan trọng là, bất cứ cách nào một trở về chức năng, mỗitrì hoãn các
thực hiện đã được thông qua, sẽ được thực hiện. Khi trở về chức năng, nó
hoãn lại cuộc gọi được thực hiện trong cuối, trong lần đầu tiên ra lệnh.
Bàn 3.1 chương Trình thực hiện với và không có trì hoãn
Trong trường hệ hợphoạthành
điều động, trì hoãn tuyên bố nằm ngay sau
kiểm tra lỗi trả lại bởi Mở . Nếu bạn nhìn thấyMở một cuộc
tronggọi
mã,đến
bạn
phải xem Gần trong cùng một mã block, hai dòng chỉ làm cho
ý nghĩa với nhau.
Những trì hoãntừ khóa là chủ yếu được sử dụng để đóng tập cơ sở dữ liệu kết nối,
đệm độc giả, etc. Đôi khi, bạn sẽ tìm thấy trì hoãn hữu ích để tính
thời gian trong một chức năng. Một trong những đi thư viện cho Kafka sự kiện
quản lý sử dụng Gần() bởi vì các máy chủ cần một duyên dáng ngắt kết nối để ngăn
chặn
nó từ tiếp tục cố gắng để gửi tin nhắn cho các khách hàng kết nối.
Hãy trở lại mã của chúng tôi. Chúng tôi muốn mở một tập tin để đọc. Nó có thể quay
trở lại
một lỗi mà chúng ta phải đối phó với: trong trường hợp này, chúng ta sẽ chỉ cần trả lại
nó để
gọi. Khi điều này được thực hiện, chúng ta biết rằng chúng ta có một tập tin hợp lệ mô
tả, vìsách
Danh vậy3.3chúng tasách.đi: mở một tập tin
con mọt
cần phải đóng nó.
f, err := hệ điều hành.Mở(filePath) #Một
khi err != nil { #B
trở lại nil, err
}
hoãn f.Gần() #C
Cấu trúc của Đi chuẩn của thư viện của gói không đại diện cho một cây của
phụ thuộc, nhưng thay vì miền. Bất cứ điều gì để làm với mạng sẽ được trong
những
net gói, hoặc trong một gói trong lồng net . Ở đây, mã hóa gói
là rất nhẹ - nó chỉ có nghĩa 4 diện - và chúng ta không phải
đưa nó để làm cho việc sử dụng các nội dung của những gói.
mã hóa/hệt
Xác định được cấu trúc liên quan đến các MẢNG tập tin
Ý tưởng chung là đường Đi cấu trúc rằng được sử dụng để giải mã phải kết hợp
các MẢNG cấu trúc. Ở đây chúng ta có một danh sách những người mà chúng tôi sẽ
gọi các, và mỗi người trong số họ có một cái tên
Con mọt sách vàs.
Cuốn một
Đểdanh
sách nói sách
Đi mà HỆT lĩnh vực này tương ứng với một lĩnh vực của chúng tôi, Đi cấu trúc, chúng
tôi sử dụng thẻ,
mà được bao bọc trong backticks. Loại sẽ có tên của các tiêu chuẩn,
theo tên của trường đó.
Loại sách của cuốn sách ở đây được gọi là một lát. Thêm vào đó rất sớm,
hãy tập trung vào các MẢNG đầu tiên.
Danh sách 3.4 con mọt sách.đi Chơi và cuốn Sách cấu trúc
// Một con mọt sách có trong danh sách của cuốn sách về một con mọt sách của kệ.
loại mọt sách cấu trúc {
Tên chuỗi `hệt:"tên"` #A
Sách []cuốn Sách `hệt:"sách"` #B
}
// Cuốn sách mô tả một cuốn sách về một con mọt sách của kệ.
loại Sách cấu trúc {
Giả chuỗi `hệt:"tác giả"`
Tiêu đề chuỗi `hệt:"tiêu đề"`
}
Nhìn vào hệt tags. Mỗi Đi trường được đánh dấu với tên của các MẢNG
lĩnh vực này. Lưu ý rằng các tên của trường không phải phù hợp với tên của
thẻ. Đó là hơn một hội nghị, và nhiều hơn nữa, có thể đọc được. Đây là một Đi
ước: lĩnh vực đó là lát nên được đặt tên theo một số nhiều lời.
Cuối cùng, và đây là phần quan trọng nhất của thẻ chương: giải mã mà
chúng ta đang về để mã sẽ cần viết thư cho lĩnh vực của cấu trúc của chúng ta. Này,
nó cần để có thể "xem" họ, có nghĩa là những trường này phải được tiếp xúc.
Nhiều một giờ của lỗi đã cố gắng để hiểu lý do tại sao một lĩnh vực
đã luôn luôn trống rỗng.
Danh sách 3.5 con mọt sách.đi: HỆT giải mã trong loadBookworms()
// Giải mã các tập tin và cửa hàng nội dung trong các giá trị con mọt sách.
err = hệt.NewDecoder(f). #B
Giải mã(&smith) #C
nếu err != nil { #D
trở lại nil, err
}
Thông báo làm thế nào chúng ta tạo ra và sử dụng một cái máy giải mã trên một
đường duy nhất. ChúngNewDecoder
tôi đã chỉ có một trở vềGiải mã (và không có lỗi).
có
Kể thể làmchúng
từ khi điều này nhờ sử dụng các bộ giải
tôi không mã đượcbất
NewDecoder trảcứ
lại nơi
bởi nào khác, đó là
thực tế phổ biến để tránh tuyên bố một biến cho nó, trừ khi có một cái gì đó khác
mệnh lệnh đó (ví dụ như chiều dài dòng, nghĩa). Thay vào đó, chúng ta chỉ sử dụng nó
bằng
Giải mãcách
. gọi
Có một vài tinh tế hơn cách giải mã lớn HỆT đầu vào hoặc các tập tin, ví
dụ qua một dòng cơ chế mà tránh tải toàn bộ
nội dung của tập tin. Bạn có thể nhìn chúng nếu bạn đang tò mò trong phần
Cải tiến vào cuối của chương này (3.5.2) nhưng cho các dự án, chúng tôi tin tưởng
rằng tập tin thử nghiệm của bạn sẽ không quá một vài người mà.
Sau đó, chúng ta chỉ cần để trả lại con mọt sách đã được giải mã. Các
chức năng hoàn thành nên nhìn một cái gì đó như thế này:
Danh sách bằng 3,6 con mọt sách.đi: loadBookworms() mở ra và giải mã Hệt tập tin
// loadBookworms đọc các tập tin và trở lại danh sách của con mọt sách, và họ yêu sách, tìm thấy trong
đó.
chức năng loadBookworms(filePath chuỗi) ([]Máu lỗi) {
f, err := hệ điều hành.Mở(filePath) #A
nếu err != nil {
trở lại nil, err
}
hoãn f.Gần() #B
// Khởi tạo các loại trong đó các tập tin sẽ được giải mã.
var con mọt sách []con mọt sách
// Giải mã các tập tin và cửa hàng nội dung trong biến con mọt sách.
err = hệt.NewDecoder(f).Giải mã(&smith) #C
nếu err != nil {
trở lại nil, err
}
Để thực hiện điều này cả tập tin biên dịch, bạn cần phải nhập hệ điều
và hành
mã hóa/hệt gói. Nếu bạn đang sử dụng một đủ thông minh, biên tập nó có thể
đã làm nó cho anh.
Ngay trước khi chúng ta viết một bài kiểm tra, là một đầu thưởng, chúng ta có thể tự
kiểm tra của chúng tôi, loadBookworms chức năng của bạnchính
như một
chức năngđơn giảncấp
cung in. cho
Gọi nó các MẢNG thấy là con đường như một số và in ra là kết
quả.
Danh sách 3.7 chính.đi: Gọi loadBookworms() trong chính()
gói chính
đạp.Println(smith) #D
}
Đầu ra là chuyện tầm phào, nhưng anh có thể nhận ra cấu trúc của các lát
con mọt sách:
[{Miễn vận chuyển đa [{Margaret khả hãn là nữ tỳ của câu Chuyện} {Sylvia-Plath Chuông Bình}]} {Peggy
[{Margaret khả hãn Thuê và gà nước} {Margaret khả hãn là nữ tỳ của câu Chuyện} {Nhiều Jane Eyre}]}]
Không phải là tốt nhất UI, nhưng đủ để gỡ lỗi.
Làm thế nào chúng ta hãy chắc chắn rằng rằng điều này đang xảy ra để làm việc sau
khi thay đổi tương lai?
Thực hiện một lệnh và kiểm tra kết quả, cố gắng để xem nếu xoăn
niềng răng đang ở vị trí chính xác phải không bền vững.
Hãy viết một bài kiểm tra cho các chứctestdata
năng này. thư
Những
mục là nơi hoàn hảo để
giữ khác nhau Hệt với các tập tin của chúng tôi khác nhau trường hợp thử nghiệm.
Chúng tôi đang thử nghiệm mộtcon mọt sách.đi tập tin, và vì lý do này chúng ta sẽ
bộ
gọi chức
tập tinnăng
của đó nằmtôi
chúng trong những
bookworms_internal_test.đi và viết một bài kiểm tra cho
loadBookworms tên là TestLoadBookworms .
Bước đầu tiên là để xác định được các thông số cần thiết và trở về giá trị cho các
chức năng. Chúng ta sẽ cần những con đường testdatacủacác
mộtkết
filequả,
trong
đó là một lát và bởi vì chúng ta cũng kiểm tra những người bất hạnh
Con mọt sách
đường, chúng tôi
sẽ thêm cho dù chúng tôi mong đợi một lỗi. Cho chương này, chúng tôi sẽ không thẩm
tra các
loại
Mỗi chính
trườngxác
hợpcủa
thửlỗi, nhưngcó
nghiệm chỉthể
cólàm
sự hiện
đượcdiện
mụcvới một
đích giámột
của trị lôgic.
chức năng khác nhau,
nhưng này, chiến thuật là
hiếm khi mở rộng. Thay vào đó, chúng ta sử dụng một bản đồ, mà chính là tên của
những
thử nghiệm cho con người để hiểu những gì chúng tôi muốn thử nghiệm, và các giá trị
là một
cấu trúc với tất cả những giá trị cụ thể cho thử nghiệm của chúng tôi trường hợp. Thêm
trên bản đồ ngay sau khi
kiểmloại
tra.testCase cấu trúc {
bookwormsFile chuỗi
muốn []con mọt sách
wantErr short
}
kiểm tra := bản đồ[chuỗi]testCase{
}
Bây giờ hãy viết các bài kiểm tra cho việc sử dụng thành công trường hợp. Chúng ta
sẽ cần một MẢNG
tập tin, hoặc chúng tôi có thể tái sử dụng một trong hiện tại - như bạn thích. Hoặc anh
có thểConnickmọtnhững
s, từng đôi tình nhân của họ tên và danh sách của họ
sách
từ khoĐây
sách. củalàchúng
một vítôi,
dụ:chúng tôi sẽ không khiếu nại. Chúng tôi lấp đầy các dự kiến quả
với các
danh
"tập tinsách
có": { của
bookwormsFile: "testdata/con mọt sách.hệt",
muốn: []mọt sách{
{Tên: "miễn vận chuyển đa", cuốn Sách: []cuốn Sách{handmaidsTale, theBellJar}},
{Tên: "Peggy", cuốn Sách: []cuốn Sách{oryxAndCrake, handmaidsTale, janeEyre}},
},
wantErr: sai,
}
Chúng tôi có thể xác định ít nhất hai lỗi trường hợp: đầu tiên, nếu chỉ thấy không tồn tại
, và thứ hai, nếu dạng của các tập tin là không hợp lệ.
Hãy phát minh ra một tập tin con đường đó không tồn tại. Anh có thể điền vào trong
mình điều gì
sẽ có những hành vi củaloadBookworms ?
Như ông có thể thấy, sự mong đợi quả là con số không như chúng ta mong đợi một lỗi
kể từ khi
mở các file sẽ thất bại đầu trong quá trình, và chúng tôi muốn làm một lỗi.
Thứ hai không hài lòng con đường mà chúng ta có thể đối mặt được nếu các tập tin là
không hợp lệ, các dạng
không được tôn trọng trong c ví dụ mất một khung hoặc một dấu phẩy:
} . Một lầntrong
nữa,thử
chúng ta
nghiệm của chúng tôi
muốn xác thực sự hiện diện của các trở về lỗi. Tạo một file trong testdata
trường
thư mục rằng có một số người không hợp lệ dạng và viết tương nó
hợp các tập tin đã được cắt ngắn, và do đó mất tích của ứngđóng cửahợp thử
trường
nghiệm.
"không hợp lệ trong c": {
bookwormsFile: "testdata/không hợp lệ.hệt",
muốn: nil,
wantErr: đúng,
}
Dễ dàng, phải không? Chúng tôi chải qua nó trong chương trước khi viết bảng-
kiểm tra hướng, nhưng để đúng vòng trên bản đồ, bạn sẽ cần phải
biết nhiều hơn về các vòng.
Tất cả lặp cú pháp ở sử dụng từ khóa Đicho . Tất cả chúng. Ngôn ngữ khác
có thể sử dụng , cho riêng ,mình
trong khi cho etc. Chúng ta hãy xem một vài ví dụ.
Đầu tiên, cổ điểncho . Không có gì hoàn toàn bình thường ở đây. Đếm từ một
số khác số chúng tôi biết.
Là một lưu ý, Đi khác với một số tiếng với các tố khai thác ++
và -- . Trong ngôn ngữ, i + + có nghĩa là "tăng tôi và 1 cửa hàng đó vào tôi".
Nơi Đi là khác nhau từ ngữ như C hay Java trong này
cú pháp đượci +mà + không phải là một trái giá trị. Nó không phải so sánh với bất cứ điều
viết i + + < 5 hayđgì. Chúng ta khôngtrong
ạp.Println(i++) thể Đi. Này cũng có nghĩa là Đi không
có một tiền tố điều hành - chúng ta không++tôicó
thểnghĩa
viết là "tăng giá trị của tôi
, và trả lại tăng giá trị".
Tiếp theo luận lý biểu hiện, được gọi là khi trong một ngôn ngữ.
Bất kỳ lôgic biểu hiện có hiệu lực tại nơi này, chỉ cần chắc chắn rằng bạn không kết
thúc
ở một vòng lặp vô hạn.
Nếu bạn không cần vô vòng trên mục đích, đó là một cách:
cho {
Đi đi, mãi mãi. Thông thường, những chứa cả một trở lại, một break, hoặc một cái gì
đó sẽ thoát khỏi chương trình hoàn toàn.
Cuối cùng, khi chúng ta cần phải lặp lại trên các mặt hàng trong một mảng, một lát, một
bản đồ hoặccho có thể được kết hợp với các từ khoảng
khóa . Trong trường hợp của một lát,
một kênh
ví dụ, các danh sách của con mọt sách chúng tôi muốn đọc từ nhữngkhoảngtập tin,
trở về các chỉ số, và một bản sao của các giá trị ở chỉ số này.
Tại mỗi lần lặp ở đây, tôisẽ tăng lên từ 0 trở đi đến len(smith)-1 ,
và botswana
là giống như con mọt sách[i] . Chính là sự khác biệt mà botswana
là một bản sao,
vì vậy, nếu bạn thay đổi nó, sẽ không có sự thay đổi trong nội dung của các
lát chính nó, mà có thể là một người tốt hay xấu, tùy thuộc vào những gì anh
mong đợi.
Nếu bạn không cần một trong hai số bạn có thể bỏ qua nó trong nhiều
cách. Tất cả các dòng dưới đây là hợp lệ, không có sự khác biệt cho các
máy giữa 2 và 3 phiên bản.
Dành thời gian để viết các bài kiểm tra đầy đủ một mình, sau đó so sánh các giải pháp
cho các
dưới đây.
Danh sách 3.8 bookworms_internal_test.đi: kiểm Tra LoadBookworms()
gói chính
nhập khẩu (
"suy nghĩ"
"kiểm tra"
)
var (
handmaidsTale = cuốn Sách{tác Giả: "Margaret khả hãn", tiêu Đề: "Sự nữ tỳ của câu Chuyện"}
oryxAndCrake = cuốn Sách{tác Giả: "Margaret khả hãn", tiêu Đề: "Thuê và Giả"}
theBellJar = cuốn Sách{tác Giả: "Sylvia-Plath", tiêu Đề: "cái tháp Chuông"}
janeEyre = cuốn Sách{tác Giả: "Nhiều", tiêu Đề: "Jane Eyre"}
)
Những gì bạn đã sử dụng để so sánh các dự kiến con mọt sách và trở về
người?
Các câu trả lời đơn giản sẽ phải viết một bằng chức năng để so sánh
các nội dung của hai danh sách của con mọt sách. Chúng tôi sẽ đặt tên cho các chức
năng
equalBookworms hãy xem những gì nó trông giống như trong từng chi tiết. Đầu tiên, các
chữ ký
nên có hai danh mụcsách
tiêu. của
Bởi con
vì nómọt sách,
là một chúng
chức tôitiện
năng sẽ đặt
ích,tên một tôi
chúng chống lại xác
có thể mà chúng
định để
tôi
đọc, nó có thể bỏ qua và không in line thông tin bằng cách thêm
muốn kiểm tra
t.Trợ giúp() lúc đầu của chúng tôi, chức năng trợ giúp. Để làm vậy, chúng ta cần phải
vượt*thử
quanghiệm.T như số bằng nhau. Chúng ta sẽ làm điều tương tự cho tất cả các
người giúp đỡ trong chương này.
Các nội dung của các chức năng khác nhau, bao gồm trong những con mọt sách và
so sánh mỗi trường đầu tiên tên đó khá là đơn giản và sau đó,
những cuốn sách.
Danh sách 3.9 bookworms_internal_test.đi: trợ giúp để so sánh con mọt sách
// equalBookworms là một người trợ giúp để kiểm tra sự bình đẳng của hai danh sách của con mọt sách.
chức năng equalBookworms(smith, mục tiêu []mọt sách) short {
nếu len(smith) != len(mục tiêu) {
trở lại sai #A
}
trở về true #D
}
Liên quan đến việc thực hiện, đừng quên rằng chúng ta có thể thoát ra sớm bởi
so sánh chiều dài của hai danh sách. Sau đó chúng ta có thể nhiều hơn những cuốn
sách và
so sánh hai danh sách và trở lại sai nếu họ là khác nhau.
Danh sách 3.10 bookworms_internal_test.đi: trợ giúp để so sánh Sách
// equalBooks là một người trợ giúp để kiểm tra sự bình đẳng của hai danh Sách.
chức năng equalBooks(sách, mục tiêu []cuốn Sách) short {
nếu len(sách) != len(mục tiêu) {
trở lại sai #A
}
trở về true #C
}
Một cách khác để làm nó bằng cách sử dụng các tiêu chuẩn
phản gói
ánh cung cấp
một đơn giản nhưng xấu thực hiện chức năng để so sánh diện:
phản ánh.DeepEqual đó chúng ta sẽ khám phá sau đó trong cuốn sách. Nó không phải
là không
đề nghị sản xuất mã, bởi vì nó không được thiết kế cho suất
nhưng trong trường hợp của chúng ta, nó sẽ làm các thủ thuật: ít mã để viết luôn luôn
là một điều tốt.
nếu !phản ánh.DeepEqual(có testCase.muốn) {
t.Fatalf("kết quả khác nhau: có %v, dự kiến sẽ %v", có, testCase.muốn)
}
Bây giờ chúng ta đã đọc và phân tích các đầu vào tập tin vào một cấu trúc Đi, chúng tôi
có
thể tìm được cuốn sách trong nhiều hơn một bộ sưu tập.
3,2, và Tìm chung sách
Hãy nhớ rằng toàn bộ mục đích của chúng tôi là công cụ để tìm được cuốn sách đã
được
đọc bởi cả hai miễn vận chuyển đa và Peggy, hoặc các con mọt sách. Trong phần này
chúng ta sẽ đi
qua tất cả các con mọt sách' kệ, đăng ký sách, chúng tôi tìm thấy ở đó, và sau đó
lọc trêntôi
Chúng những điều
sẽ viết mộtđóchức
xuấtnăng
hiện findCommonBooks
nhiều hơn một lần.
cho rằng: . Hãy viết chữ ký của mình,
đầu tiên. Nó có các dữ liệu chúng tôi có, đó là một danh sách của con mọt sách và họ
bộ sưu tập, và trả lại cuốn sách ở phổ biến trong hình dạng một miếngCuốn .sách
// findCommonBooks trở về cuốn sách nhiều hơn một con mọt sách của kệ.
chức năng findCommonBooks(con mọt sách []mọt sách) []cuốn Sách {
trở lại nil
}
Làm sao chúng ta biết rằng một cuốn sách xuất hiện nhiều lần trên kệ? Vâng, chúng ta
cần phải đếm số lần xuất hiện của mỗi đăng ký trên tất cả các con mọt sách'
kệ.
Nhưng đó là đủ, thực sự? Chúng ta nên làm gì nếu một người duy nhất có cùng một
cuốn sách nhiều hơn một lần trên kệ của họ? Chúng tôi tác giả đã có một cuộc trò
chuyện
về rằng: nó không bao giờ xảy ra? Ai có nhiều bản sao của cuốn sách này?
Nó chỉ ra rằng một trong chúng tôi có cùng một tiểu thuyết loạt trong ba khác nhau
ngôn ngữ. Một phiên bản khác nhau trong cùng một cuốn sách. Thứ ba là
ngạc nhiên.
Dù sao, chúng ta làm gì? Hãy cho phép, trong thời gian này, mỗi
người chỉ có một ví dụ của mỗi cuốn sách. Nó sẽ hơi đơn giản hóa các
thuật toán.
Làm thế nào chúng tôi đếm tất cả những cuốn sách? Chúng ta có thể truy cập vào một
lát con mọt sách, do đó,
chúng ta sẽ bắt đầu ở đó: nhìn vào mỗi con mọt sách của bộ sưu tập, và "đăng ký" mỗi
cuốn sách, chúng tôi tìm thấy ở đó. Để "đăng ký" một cuốn sách, bây giờ, chúng tôi có
thể sử dụng một truy cập
đại đồ
Bản diện
Đi cho số lần mà cuốn sách đã được nhìn thấy trên kệ vì vậy, đến nay.
Đi cung cấp một số xây dựng trong các loại. Mảng, lát, bản đồ và đến một
mức độ thấp hơn, kênh là cốt lõi gạch cho phép dữ liệu bộ sưu tập. Một bản đồ
trong Đi là một cách có thứ tự mảng kết hợp đó có cặp của khóa và giá trị.
Mỗi chính là liên kết với một giá trị (nhưng hai chìa khóa có thể có một
giá trị). Bản đồ được Đi là thành ngữ, cách tạo ra bộ sưu tập của duy nhất chìa khóa,
như chúng ta sẽ xem trong chương này. Một bản đồ của chìa khóa có thể là bất cứ
điều gì mà là
"có thể so sánh". Hãy nghĩ về nó như "chúng ta có thể viết key1 == key2?". Mặc dù, ở
cái nhìn đầu tiên, nó có thể sẽ dễ dàng để suy nghĩ tất cả mọi thứ có thể so sánh trong
Đi, các khó
thật là không phải tất cả mọi thứ được. Lát, bản đồ, và chức năng loại không, và điều
này
có nghĩa là bất kỳ cơ cấu đó có chứa một lát, một bản đồ hoặc một chức năng loại
Viết
không.một bản đồ
Chúng ta sẽ phải đối mặt với điều này thôi.
Chúng ta sẽ đến cửa hàng bên trong dữ liệu của chúng tôi bản đồ. vTrong
để những
Đi, kết hợp
những
chìa kkhóagiá trịbản đồ được thực hiện với dòng sau đây:
trong
myMap[k] = v
Trong cùng một cách mà lấy một mục từ một lát tại số 3 được thực hiện với
ngoặc vuông, lấy một mục từ một bản đồ ở key 3 trông chính xác là
giống nhau:
Sự khác biệt duy nhất là một bản đồ cũng sẽ trở về một phép, nói cho bạn
cho dù nó tìm thấy chìa khóa. Trong trường hợp của lát, bạn biết rằng có một
giá trị tại số 3 như là chiều dài của lát ít nhất 4 (vâng, chúng tôi vẫn
đếm từ zero) - cách khác, anh đang phải đối mặt với một lỗi dẫn đến loạn
hoảng .một
Trong
trường hợp của bản đồ, nếu điều quan trọng là không tìm thấy, sự trở về giá trị là chỉ
đơn giản là không- bản đồ[quốc]chuỗi các chuỗi rỗng "" ,
giáluận
và trị của loại hình
lý được thiếtcủa
lậpnó
đểởsai.
đây, trong trường hợp
v, ok := ánh xạ[3]
nếu ok {...
Hoặc ở một phiên bản nhỏ gọn hơn, làm giảm phạm vi của
v biến
những
để bên trong
những
nếu:
Bây giờ chúng ta biết làm thế nào để truy cập vào các yếu tố trong một bản đồ, đó là
thời gian để đếm
sách.
Khởi tạo phản
Truy cập sẽ được lưu lại trong một bản đồ, mà chính là cuốn sách và những
giá trị là một
uint một số nguyên dấu. Trong khi đó, nó có thể có vẻ kỳ lạ khi có
nhiều bản sao của cùng một cuốn sách (rõ ràng), có ít hơn bản sao không
là hết sức không thể.
Những 2 dòng có giống hệt nhau hành vi, bạn có thể lựa chọn để được là rõ ràng
hay ngắn gọn:
Đầu tiên chúng ta cần phải lặp hơn chúng tôi, con mọt sách.
choChúng
từ khóa
tôimà
sẽ sử dụng
chúng tôi đã nói trước đó. Trong trường hợp này, chúng tôi sẽ tận dụng giá trị của các
lặp, và chúng tôi không cần các chỉ số. Hãy cho chúng tôi lặp hơn một chút
tiết một tên hơn mặc dù.
botswana
Bên trong vòng này, chúng ta có thể lặp với chính xác cùng một cú pháp hơn những
cuốn sách này
người đã đọc.
cho _, cuốn sách := phạm vi con mọt sách.Sách {
}
Nhưng đợi đã, chúng ta không bao giờ đặt nó vào 0 ở nơi đầu tiên. Nên chúng tôi
không phải
1 nếu nó thiết lập mặt
là vắng truy và
cậpchỉ ++
sửnếu
dụng
nó đã tồn tại? Vâng, không, chúng ta không có
đến
đến. Đây là vẻ đẹp của zero-giá trị trong Đi.
Thấy,đếm[sách] trở về các giá trị trong bản đồ xuống cáccuốnchỉ sách
hoặc,
số nếu không có,
chiếc zero loại giá trị. Ở đây các loại giá trị đượcuint vì vậy, nó có 0nghĩa
.
Này, ít đếm logic là nguyên tử đủ rằng nó sẽ được hưởng lợi từ sống trong
chức năng riêng của mình. Hãy gọi nó và gọi nó trong
booksCount findCommonBooks() .
Nó nên bây giờ trông như thế này:
// booksCount đăng ký tất cả những cuốn sách, và họ xuất hiện từ sự giáo kệ.
chức năng booksCount(con mọt sách []mọt sách) bản đồ[Sách]uint {
đếm := làm cho(bản đồ[Sách]uint) #A
Chúng ta có thể kiểm tra nó? Nó nên được khá đơn giản.
Kiểm tra nó
Viết các bài kiểm tra cho nhỏ này chức năng đặc biệt là không khó khăn.
Đầu tiên, chúng ta có thể viết một người trợ giúp để so sánh các bình đẳng của hai bản
đồ của cuốn sách bởi
xác minh đầu tiên mà chìa khóa trong
muốn bản đồ có mặt ở những gì chúng. Hãy
đã nhậnta
nhiều hơn những
muốn và gọi chìa khóa trong .
đã nhận
// equalBooksCount là một người trợ giúp để kiểm tra sự bình đẳng của hai bản đồ của cuốn sách đếm.
chức năng equalBooksCount(có muốn bản đồ[Sách]uint) short {
nếu len(có) != len(muốn) { #A
trở lại sai
}
trở về true #F
}
Lưu ý rằng trong phiên bản này, nil và trống rỗng bản đồ được coi là bình đẳng.
Các người trợ giúp được viết, hãy di chuyển đến phần dài nhất: suy nghĩ của tất cả các
bài kiểm tra
trường hợp. Các thử nghiệm đầu tiên trường hợp, chúng tôi có thể nghĩ là danh nghĩa
sử dụng trường hợp, và người
thứ hai là không có con mọt sách ở tất cả. Chúng tôi cũng có thể có một con mọt sách
mà không
Một có viết các bài kiểm tra một mình và so sánh các giải pháp.
lần nữa,
cuốn sách, có lẽ không phải là một con mọt sách hay một người không hạnh phúc.
Danh sách 3.14 bookworms_internal_test.đi: kiểm Tra booksCount
Khởi động các bài kiểm tra. Tất cả mọi thứ là đi qua? Tốt, điều này có nghĩa là chúng
ta có thể sử chức
booksCount dụng năng.
những
Danh sách 3.15 con mọt sách.đi: findCommonBooks() với booksCount() gọi
// findCommonBooks trở về cuốn sách nhiều hơn một con mọt sách của kệ.
chức năng findCommonBooks(con mọt sách []mọt sách) []cuốn Sách {
booksOnShelves := booksCount(smith) #A
Lưu ý rằng nó không nên biên dịch bởi vì chúng ta không được sử dụng những
booksOnShelves
biến cho thời điểm này. Nhưng đó là thời gian để sử dụng nó!
Bây giờ chúng ta đã tính số lượng bản sao của mỗi cuốn sách về mọi
kệ sách, bước tiếp theo là vòng qua tất cả chúng và giữ cho những người có nhiều
hơn 1 bản sao.
Hãy tuyên bố một lát rằng sẽ có tất cả những cuốn sách đã được tìm thấy nhiều
lần trong các bộ sưu tập của tất cả các con mọt sách.
Chúng ta có thể sử
hãydụng
được
những
xây dựng trong chức một lần nữa. Làm thế nào?
Chúng tôi giữ sử dụng từ lát cho một danh sách của các giá trị của cùng loại. Nhiều
ngôn ngữ sẽ chỉ đơn giản gọi đây là một mảng, vì vậy thỏa thuận này là gì, là nó chỉ là
một
ưa thích từ mới? Bạn đã biết làm thế nào để nhiều hơn một lát, nhưng có một
chút về lý thuyết yêu cầu ở thời điểm này.
Các loại [n]T là một mảng của n giá trị của loại
T. Ví dụ: một var
[5]chuỗi tuyên bố một biến mộtnhư là một mảng của năm dây. Một mảng của chiều dài
là một phần của loại hình của nó, vì vậy mảng không thể thay đổi kích cỡ. Rất hạn chế.
Trong đời thực, chúng tôi
thực tế không bao giờ sử dụng mảng trực tiếp.
Một lát, ngược lại, là một động vừa mềm dẻo, xem vào các
yếu tố của một mảng, như mô tả bởi đường Đi chính thức trang web. []T Cáclàloại
một lát của các yếu tố củaTloại
được xây dựng dựa trên một mảng. Như ông có thể thấy,
chúng ta không
xác định kích thước của nó.
Lát có 3 lĩnh vực bất kỳ phát triển cần phải biết về cơ bản
mảng, lưu trữ như một con trỏ, chiều dài của lát, và năng lực. Chiều dài
là số nguyên tố mặt trong lát, trong khi khả năng là
số nguyên tố có thể được lưu trữ khi một việc là cần thiết. Bạn
có thể bắt được họ thông
len vàquacap chức năng, và đặt chúng khi bạn
khởi lát với hãy . Lưu ý rằng việc giữ chiều dài như lĩnh vực làm
tiếp cận thông tin này một O(1) hoạt động.
Cuối cùng, rất hữu ích gì bạn có thể làm cho một lát được thêm một món hàng bằng
cách sử dụng
thêm được xây dựng trong chức năng.
những
Hãy nhìn vào một số ví dụ.
var sách []Cuốn
sách = thêm(sách, cuốn Sách{...})
Lúc đầu, những khả năng và chiều dài là cả hai 0, lát được không và cơ bản
mảng không phải là khởi tạo. Sau khi chúng tôi nối, số mục trong lát là
một trong, vì vậy là 1, dễ dàng. Lát được không nil nữa. Nhưng phức tạp hơn, các
len(sách)
mảng mới mà chúng ta điểm để có một năng lực của 1 yếu tố.
Lưu ý rằng thêm trả lại một lát. Chúng tôi trải những gì xảy ra trong nội các
phụ Lục E, nhưng, bây giờ, các thông điệp quan trọng là, khi cách thêm vào một
lát, nó luôn luôn an toàn để ghi đè lên các mở rộng với lát thêm 's ra.
Một ví dụ khác của một lát khởi động, nơi chúng tôi tạo ra một lát với một
chiều dài 5, một năng lực của 5, và một cơ bản mảng số 5 zero-giá trị sách.
Tất cả năm sách đều được tạo ra và nhắm, mà có nghĩa là chúng ta có thể truy cập
trực tiếp và viết vào chúng.
Cuối cùng, nếu tôi biết các kích thước cuối cùng của lát, nhưng
thêm muốn
chúng
sử tôi
dụngcó thể
xác định cả chiều dài ban đầu, và những khả năng cần thiết.
Bây giờ chúng ta biết mọi thứ về việc tạo ra lát, hãy nhìn vào những mã
chúng ta đã viết cho đến nay. Trước đó, trong phần 3.1.3, chúng ta giải mã các MẢNG
tin nhắn var cú pháp
mô tả những
ở trên. Chúnggiá
tôi sách vào một
đã không thựclát biến
hiện
hãy và tuyên
một
vì vậy
cuộcbố vớiđến
chúng
gọi những
tôi đã không gây ra bất kỳ
phân bổ. Vấn đề là chúng tôi đi qua các địa chỉ của lát đến Giải mã
chức năng, sau đó có thể lấp đầy nó với giá trị. Chúng tôi khám phá những điều bí ẩn
của
đi một lát bởi địa chỉ, hoặc sao chép trong phần phụ Lục E.
Nhiều hơn một bản đồ
Để điền vào lát, chúng ta cần phải lặp trên bản đồ
booksOnShelves trả lại bởi
booksCount() và kiểm tra giá trị của cuộc phản đối với mỗi cuốn sách. Sách với
một truy cập lớn hơn so với 1 đã được đọc ít nhất hai con mọt sách - trong trường hợp
của chúng tôi
đều miễn vận chuyển đa và Peggy.
cho cuốn sách, đếm := phạm vi booksOnShelves {
nếu đếm > 1 {
commonBooks = thêm(commonBooks, cuốn sách)
}
}
// findCommonBooks trở về cuốn sách nhiều hơn một con mọt sách của kệ.
chức năng findCommonBooks(con mọt sách []mọt sách) []cuốn Sách {
booksOnShelves := booksCount(smith) #A
Kiểm tra nó
Thử nghiệm này nên khá dễ dàng. Chúng tôi có một con mọt sách trong một số
sách ra. Một số trường hợp thử nghiệm dễ dàng và mày có thể viết nó mà không có trợ
giúp của chúng tôi:
Tất cả mọi người có đọc những cuốn sách cùng một
Người đã hoàn toàn khác nhau danh sách
Nhiều hơn 2 con mọt sách có một cuốn sách ở chung
Một con mọt sách không có sách (oh những nỗi buồn!)
Không ai có bất kỳ cuốn sách (oh đau đớn!)
Đây là phiên bản của chúng tôi kiểm tra.
Chạy các bài kiểm tra. Bây giờ nó chạy lại một vài lần. Anh có thấy cái gì
kỳ lạ?
Nếu bạn chạy mã của bạn một vài lần anh sẽ thấy rằng thứ tự của các ra
luôn thay đổi. Đầu ra, có thể không phải luôn luôn được ở cùng một thứ như chúng ta
mô tả những kết quả như mong đợi trong[]muốn
lát .
Khi chúng tôi lặp lại, một bản đồ không có đảm bảo từ Đi ngôn ngữ đó
chìa khóa và giá trị sẽ được trả lại theo thứ tự. Tùy thuộc vào
tình huống, đó có thể là tốt hơn để được xác định, và luôn luôn trở về cùng một kết quả
trong việc theo thứ tự. Nó đơn giản hóa cuộc thử nghiệm, cho một điều.
Trong trường hợp này, phân loại thế lát sách sẽ làm cho cuộc sống sắp dễ dàng
xếp hơn.
Những
có một gói Lát chức năng đặc biệt thiết kế cho tình huống này: nó cần một
lát và một chức năng. Các chức năng phải quay lại, cho dù các mục đầu tiên của
2 chỉ số phải xuất hiện trước khi các mục chỉ số thứ hai. Chúng ta có thể sử dụng một
chức năng vô danh xác định ở mức này, cuộc gọi nó là dễ dàng hơn để phát triển hơn
đặt tên nó ở một nơi khác. Bây giờ, chúng ta sẽ sắp xếp sách của tác giả đầu tiên, và
sau đó
theo tiêu đề. Nếu tại một số điểm sau đó, bạn muốn sắp xếp bởi hiệu đầu tiên, và sau
đó, tác giả,
đây
Nhưlàphân
nơi. loại logic không phải là một phần của thuật toán để tìm chung sách, chúng
tôi sắp xếp.Lát .
muốn đặt nó trong một chức năng khác nhau mà kết thúc tốt đẹp các cuộc gọi đến
Danh sách 3.18 con mọt sách.đi: sắp Xếp sách
// sortBooks loại sách của tác Giả và sau đó, tiêu Đề.
chức năng sortBooks(sách []cuốn Sách) []cuốn Sách {
sắp xếp.Lát(sách, và(i, j quốc) short { #A
nếu sách[i].Tác giả != sách[j].Giả {
trả sách[i].Giả < sách[j].Giả #B
}
trả sách[i].Tiêu đề < sách[j].Tiêu Đề #B
})
trả sách
}
Lưu ý rằng các ban đầu lát được sửa đổi, sắp xếp.Lát chức năng không
tạo ra một sắp xếp bản sao của các mảng. Nó có thể là một điều tốt, tùy thuộc
vào tình hình. Chức năng này là chữ ký có thể được sortBooks([]cuốn Sách) với
không trở lại giá trị. Chúng tôi sẽ yểm trợ các chi tiết này trong phần phụ Lục E.
Như chúng tôi đã đề cập runes trong chương trước, đây là một nhiệm: sử< dụng
để so sánh chuỗi sẽ nhìn vào các KÝ hóa của các tiêu đề. Hy lạp danh hiệu cho
dụ do đó sẽ luôn luôn xuất hiện khi những người được viết bằng tiếng Latin
nhân vật.
Để sử dụng chúng tôi, chức năng phân loại, chúng ta chỉ cần phải quấn trở về giá trị
của
findCommonBooks :
3.3 In
Trở lại trongchính
nhữngchức năng, chúng tôi đã nạp các dữ liệu, và không có gì hơn. Hãy
gọi findCommonBooks . Bây giờ chúng tôi có một miếng sách. Làm thế nào chúng ta có
thể trưng
đó chính xác? đạp.Println này,bày
nhưng chúng ta cần phải lặp lại các bộ sưu tập của
cuốn sách.
Hãy viết một chức năng in ra một danh sách của cuốn sách. Bạn có thể làm
nó trong năm dòng,
Nếu bạn muốn kiểm tra cái này này, bạn phải hoặcVí là
dụviết một
hoặc cung cấp một
io.Nhà văn . Chúng ta sẽ xem làm thế nào để cung cấp cho nhà văn chương tiếp theo.
commonBooks := findCommonBooks(smith)
đi chạy .
Tập thể dục 3.1: Sử dụng cờ từ chương 2 để vượt qua các tập tin con đường như một
số
chương trình của bạn.
Bạn đã biết làm thế nào để viết mộtVí dụ kiểm tra.
gói chính
Bây giờ bạn đã tập hợp tất cả dữ liệu của bạn, bạn có thể làm một số giá rẻ dữ liệu
phân tích. Biết những gì cuốn sách của bạn, bạn và bạn cả đọc là một
khởi đầu cuộc trò chuyện, nhưng chúng ta có thể đi sâu hơn và viết một chương trình
đó sẽ
đề nghị một danh sách đọc từ những gì giống như mọi người khác đọc, và hy vọng
đã thích. Suy nghĩ của những phần trên các cửa hàng sách trực tuyến: "khác độc giả
mua",
mặc
Trongdùphần
mua,này,
đọcchúng
sách và
ta thích đượcphương
sẽ đi một ba rất khác
phápnhau.
đơn giản: hãy xem xét của chúng tôi
mọt sách chỉ giữ trên kệ sách họ đánh giá cao. Chúng tôi không biết
những gì xảy ra với cuốn sách khác - nhưng chúng tôi hy vọng họ đang giao dịch cho
thậm chí còn nhiều hơn
cuốn sách, hoặc đưa ra cho tổ chức từ thiện. Chúng ta có thể giả định, từ bây giờ, mà
cuốn sách về một
kệ đang yêu và yêu mến, và vì lý do này chúng ta sẽ đi đường tắt
của xem xét rằng, nếu miễn vận chuyển đa đã giữ cùng một cuốn sách trên kệ cô ấy là
Peggy đã làm,
cô ấy có thể quan tâm đến những gì cuốn sách khác Peggy đã giữ cô ấy.
Cho một người được đọc mục tiêu, chúng tôi đi qua tất cả độc giả khác, và tính toán
một
, như đầu óc điểm, hoặc tương tự. Sau đó, nếu có sự giống nhau nhiều hơn 0, chúng
ta
có thể thêm rằng điểm số mỗi cuốn sách mà không được đọc bởi đọc mục tiêu nhưng
đã
Danhđược
sách 3.22 kiến nghị.đi: có thể thực hiện
đọc bởi một tương tự người. Viết mã đôi khi có thể được rõ ràng hơn.
loại đề Nghị cấu trúc {
Cuốn Sách, Cuốn Sách
Điểm float64
}
chức năng đề nghị(allReaders []Đọc Đọc mục tiêu, n quốc) []đề Nghị {
đọc := mới(mục tiêu.Sách...) #A
Chúng tôi cần một loại cho những cuốn sách đọc bởi mục tiêu của chúng ta. Nó phải
có khả năng một cách nhanh chóng
cho chúng tôi biết nó có một cuốn sách, và chắc chắn rằng mỗi cuốn sách là chỉ
có một lần.
loại thiết lập bản đồ[Sách]cấu trúc{}
Lưu trữ trong một bản đồ, như chúng ta thấy, là tốt nhất (hiểu nhanh nhất) cách để nói
cho dù danh sách (của chìa khóa) có một giá trị nhất định. Chúng ta đang sử dụng các
rỗng
cấu trúc chứ không phải là một phép, vì một phép tung lên một chút
trí nhớ, và trống rỗng cấu trúc mất zero. Nó có nghĩa là bản đồ sẽ không mất
nhiều bộ nhớ hơn một lát cùng kích thước.
Hãy dành thời gian để viết phần còn lại của mã, kiểm tra nó, và chơi với nó.
Để sắp xếp những cuốn sách vào ra của các chức năng chính, chúng tôi sử dụng các
loại ngắn.Lát, trong đó có một chức năng như phân loại chiến lược. Đó là một
lựa chọn thứ hai mà bạn có thể thích.
Gói cung
sắp xếp sắp xếp.Diện có thể được thực hiện để sắp xếp
lát hoặc xác định bộ sưu tập. Nó trở nên rất tiện dụng khi thực hiện
chỉnh phân loại, trong trường hợp của chúng tôi mỗi giả
sắp và tiêu đề. Những
xếp.Diện
diện cho thấy nhiều phương pháp 3 nơi yếu tố được chỉ bởi một số nguyên chỉ.
Lưu ý rằng bạn cần để hoàn toàn thực hiện diện, có nghĩa là cả ba
phương pháp, thậm chí nếu bạn không sử dụng tất cả chúng.
Như này chỉ áp dụng cho bộ sưu tập, chúng ta thêm một trung gian tùy loại
đại diện cho một bộ sưu tập của cuốn Sách và thực hiện các phương pháp trên nó.
Đây
là loại được đặt tên sau cuộc cách nó sắp xếp.
Danh sách 3.23 thực Hiện sắp xếp.Diện trên Sách
// Sách là một danh sách của cuốn Sách. Xác định một loại tùy chỉnh để thực hiện sắp xếp.Diện
loại byAuthor []cuốn Sách
// Len thực hiện sắp xếp.Diện bởi trở về chiều dài của bộ sưu tập.
chức năng (b byAuthor) Len() quốc { trở lại len(b) } #A
// Trao đổi thực hiện sắp xếp.Diện và hoán đổi hai cuốn sách.
chức năng (b byAuthor) trao Đổi(i, j quốc) {
b[i], b[j] = b[j], b[i] #B
}
// Ít thực hiện sắp xếp.Diện và trả lại cuốn sách được sắp xếp bởi Giả và sau đó, tiêu Đề.
chức năng (b byAuthor) Ít(i, j quốc) short {
nếu b[i].Tác giả != b[j].Giả { #C
trở lại b.LessByAuthor(i, j)
}
trở lại b[i].Tiêu đề < b[j].Tiêu Đề #D
}
Các chức năng mới sortBooks bây giờ có thể gọi sắp trựcxếp.Sắp
tiếp xếp
thực hiện. Nó quan trọng để thông báo rằngsắp xếp.Sắp xếp đó không trở lại
bất cứ điều gì. Thay vào đó, nó cập nhật các lát là nội dung với các yếu tố tương tự,
nhưng
trong một sắp xếp trật tự.
// sortBooks loại sách của tác Giả và sau đó, tiêu Đề theo thứ tự chữ cái.
chức năng sortBooks(sách []cuốn Sách) []cuốn Sách {
sắp xếp.Sắp xếp(byAuthor(sách))
trả sách
}
Bước đầu tiên chúng tôi đạt được trong chương này là để đọc nội dung của một tập tin.
Hãy có một cái nhìn gần hơn làm thế nào chúng ta đã làm điều này.
Đầu tiên chúng ta mở được các tập tin, mà quay trở lại một mô tả tập tin, mà thực hiện
những
io.Đọc diện.
Truy cập tập tin, hoặc là để đọc và viết làm cho hệ thống cuộc gọi. Hệ thống
gọi đang ở giữa chương trình của chúng tôi và hệ điều hành.
Hệ thống gọi là đắt tiền, và chúng ta thường muốn giảm số lượng của họ
đó, may mắn thay, có thể kiểm soát khi đọc và viết một tập tin.
Đầu tiên, chúng ta cần phải hiểu được vấn đề: bao nhiêu cuộc gọi hệ thống làm, chúng
tôi đi
qua, khi đọc một tập tin của kích thước 10 theo dõi với hiện tại của chúng tôi thực
hiện? và chúng tôi đã không nói giá trị của nó. Thậm chí tồi tệ
hệ điều hành.Mở
Câu thế
hơn trả lời là không,
- những rõloại
rànglàđó
hệ điều hành.Tập làđiều
hơn
hệ
tin ẩn trong định
hành-cụ đệm
thể. kích
Vậy làmthước củachúng
thế nào các tập
ta tin
có thể cải
mô tả được trả lại bởi thiện điều này?
Câu trả lời nằm trong bufio gói. Gói này cung cấp một
NewReaderSize chức năng đó có mô tả sau đây: NewReaderSize
trả về một Đọc mới có đệm có ít nhất định kích thước. Nếu các
lập luận io.Đọc đã là một người Đọc lớn đủ kích thước, nó trả về
cơ bản Đọc. Điều này có nghĩa là, nếu chúng NewReaderSize
tôi gọi với bất kỳ
io.Đọc và cho nó một kích thước của 1 theo dõi chúng tôi được đảm bảo rằng việc
đọc sách
sẽ xảy ra bằng cách làm cho hệ thống cuộc gọi với khối 1 theo dõi. Bằng cách này,
chúng ta có
thể sửa lại chương trình của chúng tôi để có nó cư xử đúng như chúng ta muốn. Lưu ý
rằng
việc tìm kiếm tốt nhất kích thước cho đệm của một người đọc không phải là một công
việc dễ dàng - cho nó 1
GiB
Hãy rõsửràng
dụngsẽtính
làmnăng
cho này
hệ thống gọivà
tốt đẹp, ít thường xuyên
quyết định rằng- nhưng nó chúng
đệm của cũng sẽ
tôi nên của
tiêu thụ 1 GiB của nhớ điều đó - có lẽ - không hoàn toàn được sử dụng.
"một kích thước trung bình" cho một tập tin, một cái gì đó trong đơn vị bộ. Đây là một
ví dụ:
Danh sách 3.24 đọc một tập tin với một đệm đọc
Cuối cùng, bufio gói cũng cung cấp một thực hiện io.Nhà văn . Người
này rất hữu ích khi viết bản các tập tin, rõ ràng như nó làm giảm số
hệ thống gọi mạnh. Thay vì viết từng dòng mở để dễ dàng thấy,
chúng tôi có thể làm việc với lô số 1, tiệc tùng.
Nhưng đó là một điểm rất quan trọng để giữ trong tâm trí ởbufio.Viết
đây:
phương pháp sẽ chỉ viết dữ liệu khi nội bộ của mình đệm là đầy đủ. Hầu hết thời gian,
các cuộc gọi cuối cùng đến sẽ không chính xác điền vào phần còn lại của nó đệm,
bufio.Viết
và điều này đoạn cuối cùng có thể bị mất! Nhưng đừng hoảng sợ, đó là một cách để xả
còn lại nội từ đệm đến đích của nó, và nó bao gồm trong một
đơn giản gọi đến
nhà văn.Tuôn ra() .
The night is dark. Your colleague Susan and you have been working on
trying to fix this bug for 2 hours straight. You don't understand what's
happening with that count variable that should have the value1 , but the result
of the program seems to indicate that the value there is 2 instead. It’s late.
You try to read the code, but the problem isn’t obvious. Iscount really 2?
You decide to add a small line in the code, and relaunch everything, to get a
better insight as to what's going on. The line you add will help you, at least,
understand what the variable’s value is. You use:
We’ve all been there. Having the code say something we can understand at
specific steps is our easiest way of following the program as it executes.
Then Susan notes - wouldn’t it have been nice to have this information from
the initial run, without needing to redeploy an updated code? But then, would
you also want to deploy this unconditional fmt.Printf (hint: the answer is an
absolute no)? Were there other options that could have made your life
simpler?
Debugging isn’t the only time when we want to know what’s happening in
the entrails of our program. It is also valuable to inform the user that
"Everything is going extremely well". Or that something bad has happened,
but the system recovered. Any trace of what’s happening might be useful -
but that’s also a lot of messages, some aren’t as important as others.
Keeping track of the current state or events via readable messages is called
logging. Every piece of tracked information is a log, and to log is the
associated action.
What is a logger?
This chapter will cover a specific need: write a piece of code that other
projects can use. It is extremely common, as a programmer, to use existing
code that we didn’t write. Think of it - it would be painfully tiresome to write
over and over again simple functions, such ascosine or ToUpper , when
they’ve already been written, thoroughly tested, and documented. Instead of
copy-pasting code from other people, developers came up with the notion of
“libraries” : code that one uses, but didn’t write. In Go, libraries come in the
shape of packages that we import. Go libraries are, of course, written in Go,
and are made of (always) exported and (almost always) unexported types and
functions. The exported part of the library (both functions and types) is called
its application programming interface, which is always shortened down to
API.
Now, let’s write a library that anyone can use, and that you can reuse in any
of your future projects. Susan will take care of the user code, and she will be
interacting with our code via its API. First, we want to define the API -
exported functions and types - and agree with any already identified user that
it covers their needs. Then we will be able to publish the API, even before
implementing the logic. Indeed, the sooner your library is out in the world,
the sooner you can get feedback and improve it. Finally, we’ll write the
logging functions and test them.
Requirements
In order to make our users happy, the exported types and functions should be:
easy to grasp - people don’t want to spend hours trying to figure out how
to use it. For this, making it small and simple is usually the better
option: there should only be a single function to achieve each specific
functionality
stable - if you make evolutions, fix bugs or add functionalities, users
should be able to take the latest version without changing their own
code.
Package summary
In Go, packages are the way we isolate the scope of functions and types. As
previously mentioned, when a symbol’s name starts with a capital, it is
visible outside the package. Exported symbols are available to users, the rest
remains inside the package. If you know Java, the package has roughly the
same level of importance as a Java class when it comes to what you can do
with it, but it is close to a Java package in that it gathers together related
types. What is public or exported should change as little as possible from
version to version. This is especially important for packages intended to be
consumed by other people. We want to preserve backward compatibility and
avoid breaking our users’ code. If you want to improve performance or fix
bugs, what is private or unexported can change.
Rules of a Go package
· A package is a collection of files located in the same folder that all share
the same package name. Each Go file starts with the package declaration.
Before you can start coding, don’t forget to go mod init learngo-
pockets/logger your module (see Appendix A.4 if you forgot how).
Go modules
Before we can start adding fields or methods to it, we need to think about the
logging levels we want to support.
We can declare these levels as an enumeration: they are a finite and defined
list of possible values.
Don’t be afraid to keep your files small. When a type is starting to support
more and more methods, think about splitting them into multiple files: the
scope for declaring methods on a type or accessing its unexported fields is the
package in which this type is declared. You can split by usage, and business
logic or keep exported methods together for example. Make sure reading
your file does not get overwhelming. Incidentally, it reduces conflicts in your
version control.
Create a file namedlevel.go . Again, the first line of this file will be the
package we’re developing: package pocketlog . Considering the targeted
size of this new file, we could very well keep everything in the logger.go
file, but we find it easier to open a file named level when looking for levels.
In this file, we declare a named typeLevel , of the underlying type byte . We
could use int32 as an underlying type as all we want is a number, but this
would take 4 times more memory for no good reason. Other packages can use
the type Level , as it is exported.
Wait! Experience and code reviews will tell you something is wrong. Any
exported symbol requires a line of documentation - a commented sentence
that starts with the name of the type, function or constant that you are
documenting. Let’s fix this.
Once we have a type for our levels, we can export them. Logging levels are
constants that we declare as an enumeration - a finite list of entities of the
same kind. This list of Level s belongs in thelevel.go file.
const (
// LevelDebug represents the lowest level of log, mostly used for debugging purposes.
LevelDebug Level = iota
// LevelInfo represents a logging level that contains information deemed valuable.
LevelInfo
// LevelError represents the highest logging level, only to be used to trace. errors
LevelError
)
Enumerations
The syntax here is to use= iota to let the compiler know that we are starting
an enumeration.iota allows us to create a sequence of numbers incremented
on each line. We don't need to assign explicit values to these constants, the
compiler does it automatically for us thanks to the iota syntax. iota can be
used on any type that is based on an integer. The behaviour of iota is to
increase on every line, which means we need to sort our levels by order of
importance.
We now have 3 log levels; each one will have its own purpose. If we decide
to add a level later, we will only need to add a line and not worry about
renumbering everything. Feel free to add more such as Warn (between Info
and Error) or Fatal (guess where).
Let’s head back to thelogger.go file. What we have created here for our
logger is the definition of a structure. We want it to log lines of text at
different levels. The user will then be able to pass this object as a dependency
to any function that needs to log something.
Let’s take a look at two approaches that would fulfil the expectation of
exposing methods on a variablel of type Logger , each with a signature
similar to that of fmt.Printf :
We picked the second option as we consider it clearer and simpler than the
former, which requires a lot of dots and text before we reach the interesting
part of the line. Remember that code needs to be easy to read.
// Debugf formats and prints a message if the log level is debug or higher.
func (l *Logger) Debugf(format string, args ...any) {
// implement me
}
// Infof formats and prints a message if the log level is info or higher.
func (l *Logger) Infof(format string, args ...any) {
// implement me
}
Variadic functions
Your Logger does nothing, but it can already be called from Susan’s code.
Before you publish it to her, who is jumping on her chair, impatient to use it
in her service, there is one thing we want to add.
4.1.3 The New() function
At the moment, creating a new logger can be done in these two completely
equivalent lines of code:
The former is explicitly defining a zero-value logger, the latter leaves room
for initialisation, if we later want to add exported fields and give them a
specific value. Picking one over the other is a question of how you think the
code might need to change.
But as your logger evolves, there will be mandatory parameters, such as the
threshold where it should start caring about messages. To gently convince
users to stay up to date with evolutions, Go does not provide any constructor
mechanism, but we can write aNew() method that builds a new instance.
People can still use the above syntax (you must make sure it is safe, as it will
set every field of the structure to its zero value) but they should preferably
not. We don’t need to specify Logger in the name of that function, because
users will be calling the name of thepocketlog package first, making it clear
that we are creating a new pocket logger. This way we avoid the stuttering
pocketlog.Logger where log appears twice.
We add the threshold of the logger to the struct and define theNew()
method.
Our logger still does nothing, but it can be used on Susan’s development
branch and she won’t need to change anything while you make it work. You
can commit.
Go’s zero-values
Every type in Go has a zero-value. This includes basic data types, struct
types, functions, channels, interfaces, pointers, slices, and maps. Basically,
any type for which you can declare a variable has a zero-value. The zero-
value of a type is the value held by a non-initialised variable of that type. You
can refer to Appendix C for details.
Exercise 4.2: What is the logging level of a logger defined with thevar log
pocketlog.Logger syntax?
We just committed, but we have no test. This is subpar! Early is always the
best moment to write a unit test.
How can we test this? We have a very clear definition of how theLogger
should behave from the point of view of the user, but we don’t know much
yet about how it will work internally. This is the perfect situation for closed-
box testing, where we test a system from the outside. “Outside”, here, means
“from another package”. We could test it from the same package, but we’d be
able to access fields or functions that an external user won’t be able to access.
+ pocketlog/
||
| + -- level.go #A
| + -- logger.go #A
| + -- logger_test.go #B
+ -- go.mod
+ -- main.go
Go will complain if we write two packages in the same directory, but there is
an exception to this rule that allows for tests to be written close to the source
code: we can have afoo_test package alongside afoo package. This is what
we’ll use here:
package pocketlog_test
import "learngo-prockets/logger/pocketlog"
From this pocketlog_test package, we only have access to what the package
pocketlog exports - hidden functions, variables, constants, types, and fields
of exported types aren’t accessible. As the logger is currently writing to the
standard output, we can start with anExampleXxx function to test it. We are
testing the Debug method of the Logger struct, so the signature of the testing
function is ExampleLogger_Debugf . We can optionally add details about the
expected output or the test scenario after yet another underscore, i.e.
ExampleLogger_Debugf_runes or ExampleLogger_Debugf_quotes .
func ExampleLogger_Debugf() {
debugLogger := pocketlog.New(pocketlog.LevelDebug)
debugLogger.Debugf("Hello, %s", "world")
// Output: Hello, world
}
Run the test. It should be returning an error, because ourLogger still does
nothing. Fixing this error will be our next task. Then we can add test cases,
because this one is not covering enough of the use cases.
The doc.go file contains no Go code, only one uncommented line: the
package. And before that line, a verbose description of what the package is
about. This is where we can tell how to properly use the package, in which
order to call functions, and what we shouldn’t forget to defer , if need be.
/*
Package pocketlog exposes an API to log your work.
One of the tools Go is shipped with is thego doc command. We’ve already
mentioned it earlier to inspect the contents of standard packages. This
command will give you the documentation of a package or symbol that thego
command can find in the subdirectories. There is a minor limitation: go doc
won’t go looking on the internet - it’s a local tool. This means that, in order
to use it, you need to be working inside a project (with ago.mod file) for
which the dependencies will have been downloaded - something that is
achieved silently by some IDEs, but that can always be done manually with a
go mod download command. In our case, we retrieve the documentation of
the pocketlog package, and of theNew function in the pocketlog package by
running the following commands:
In any case, documentation should always be part of what you deliver. It can
take the form of comments, examples or of package headers. See more about
it here: https://go.dev/doc/comment.
Now that we’ve explained how to use our library, it’s high time to make it
usable!
The first implementation is really the easy part. Think about how you would
write the Debugf() method before spoiling your pleasure with the following
solution. Remember thatDebugf() should only log if the threshold level is
Debug or lower.
// Debug formats and prints a message if the log level is debug or higher.
func (l *Logger) Debugf(format string, args ...any) {
if l.threshold > LevelDebug { #A
return
}
_, _ = fmt.Printf(format+"\n", args...) #B
}
When calling the Debugf function, the user expects the message to be printed
if the level of the logger allows for it. This means the first thing to do in this
function is to make sure that we should be logging a message. The enum we
declared for the levels allows us to compare two levels together, since the
underlying type of the Level type is an integer.
This method could be just 3 lines if we chose to log inside the if and invert
the condition, but always prefer to align the happy path unindented. Deal
with errors and early exits inside your if blocks and keep real business logic
as left as possible. This helps a lot when reading the code and makes
extending it way easier.
Once we’re sure we need to handle this message, let’s log it. For now, we’ll
use thefmt.Printf function. This whole library might look like a verbose
wrapping of this Printf call, but rest assured, there’s more to it than meets
the eye.
Now your previous test should be green. Let’s discreetly postpone the testing
of the other methods: we want to write TestXxx methods, which give more
flexibility, so we need to write to non-standard outputs.
4.2.2 Interfacing
Go has a set of standard interfaces for the most common uses, so that
everyone who produces code that writes can match the same format and
leverage intercompatibility.
io.Writer
Among the most commonly cited interfaces in the standard library, theio
package holds the two famousio.Writer to write to any destination and
io.Reader to read from any source (e.g. an array of bytes, a file, a json
stream).
We want the user of our logger to define the destination. We can ask for an
io.Writer and simply write into it. They will be responsible for providing an
implementation of their choice.
Implicit interfaces
We can already add the output to the structure, and the standard
Writer to
our New() builder.
Last but not least, each of the methods will need to use this new field. And
let’s make sure that users who don’t follow our recommendation of usingNew
to create aLogger don’t get nil pointer exceptions!
// Debug formats and prints a message if the log level is debug or higher
func (l Logger) Debugf(format string, args ...any) {
// making sure we can safely write to the output
if l.output == nil {
l.output = os.Stdout
}
if l.threshold <= LevelDebug {
_, _ = fmt.Fprintf(l.output, format, args...)
}
}
In Go, the underscore symbol represents the Void. In other words, assigning a
value to it will just discard the result.
What is the point? Go likes to be explicit. Here we explicitly say to the next
developer, including future-us: I know there are values returned by this
function, but I do not need them.
Here, the function returns anint , the number of written characters, and
sometimes an error. There is nothing we want to do about that error at the
moment, so we explicitly ignore it.
4.2.3 Refactoring
You might have noticed when implementing the Info and Error methods,
that we’re calling the same function fmt.Fprintf as our writing function.
You might also have noticed that, as opposed to its siblingfmt.Fprintf ,
fmt.Fprintln appends an end-of-line character at the end of the string. In a
way, the printf function allows you to do very fine craftsmanship, while the
println function won’t let you format exactly everything as you’d like it: no
left-padding before numbers or strings, no hexadecimal representation of
numbers, etc.
A new line is the guarantee that your log messages will be easily
distinguishable in a console. As we want to export thePrintf toolbox, we
must add an explicit \n when we write the messages.
In our case, we need to add that new line three times, once in each of the
functions. And whenever we want to make a change to the log message - for
instance, add the logging level - we need to write the same lines three times.
Should Warn or Fatal also be implemented, the count goes even higher. This
(loudly) calls for a minor refactoring - we don’t want to maintain the same
code more than twice. Let’s group all of them together and alter a single line
every time we want to adapt the logger.
Create alog() method on the Logger . For now, it will have the same
arguments asDebug , Info and Error . These three will call it: log is now the
one method responsible for formatting and printing. The other three, the
exported ones, are responsible for their log level and nothing more. There is
zero good reason to export this one.
// Debugf formats and prints a message if the log level is debug or higher.
func (l *Logger) Debugf(format string, args ...any) {
if l.threshold > LevelDebug {
return
}
l.logf(format, args...)
}
Run the tests. They should fail by now, as we haven’t updated them with the
os.Stdout parameter for theNew function. Once this is done, you can
commit, and inform your colleague that the code is ready to be used.
However, Susan tells you that her logs should be written to a specific file
rather than on the standard output, because she already makes use of the
standard output. Can theLogger achieve that by itself ?
When we start developing, say, a new web service, we want to focus on the
business logic. We want to make sure our logic works locally before making
your service production-ready. In order to decrease the cognitive load, we
start with the default version of the logger - architecturing a better logger can
come later. Before writing the deployment code, we don’t need anything but
the standard output.
But very quickly, we deploy to a cheap cloud provider so that we can pitch
our prototype and show it to the world. Reading the standard output is not so
trivial anymore. We pick a tool, like an aggregating database, that happens to
publish a Go driver. The developers of this driver were smart enough to have
a structure in their library that implements the io.Writer interface.
This function takes a pointer on our logger so that it can change it directly: in
our case, change the default output to whatever the user gave us.
return lgr
}
Next time you want to add an option to your logger (e.g. a date formatter),
just create a newOption and you’re set. There is an important point to notice
here: adding configuration functions is quite easy, and lets the user set
specific behaviours without altering the API of our library. Our New function
accepts as many configuration functions as the user needs, from the list we
implement in this package.
Usage example
Susan wants to know how to use your library. There is a documentation file,
but human interaction is always so much more efficient. You write a small
example and send it to her.
Outside of the library, init a new module and create a main.go file. Define a
func main() , as you did in the previous chapter. In this function, instantiate a
new logger and call a few methods to showcase your work.
package main
import (
"os"
"time"
"learngo-pockets/logger/pocketlog"
)
func main() {
lgr := pocketlog.New(pocketlog.LevelInfo, pocketlog.WithOutput(os.Stdout))
We are already using the logger, but it is not fully tested! How
unprofessional! Susan can use our library, but we don’t want her to come
back with possible bugs.
The magic of interfaces means we can write a test helper that implements
io.Writer , and give it to our Logger under test.
Test helper implementation
This structure can be passed to the functional option higher in our test. At the
end of the test, we can then check that the writer’s contents are what we
expect.
Now that we are not forced to check the standard output anymore, we can
write a TestXxx function, one that will test all of the logging methods
together, sequentially. We can have one test case per required logging level
and check that the outputs are different and theDebugf() call is mostly
ignored.
const ( #A
debugMessage = "Why write I still all one, ever the same,"
infoMessage = "And keep invention in a noted weed,"
errorMessage = "That every word doth almost tell my name,"
)
tt := map[string]testCase{
"debug": {
level: pocketlog.LevelDebug,
expected: debugMessage + "\n" + infoMessage + "\n" + errorMessage + "\n",
},
"info": {...}, #B
"error": {...,
}
testedLogger.Debugf(debugMessage)
testedLogger.Infof(infoMessage)
testedLogger.Errorf(errorMessage)
if tw.contents != tc.expected {
t.Errorf("invalid contents, expected %q, got %q", tc.expected, tw.contents) #C
}
})
}
}
Exercise 4.4: The test, as we have written it here, only tests the calls to
functions in one order: Debugf , then Infof , then Errorf . What if we decide
to add a buffer, and only think about writing everything in the Errorf()
method? We will not see it in this situation, and Debug and Info messages
might stay stuck.
Your logger is ready, fully functional, documented and tested. The rest of the
company starts using it. Yet you keep dreaming up new functionalities for it.
Let’s explore a few and see where they lead.
Your service runs locally, with the lowest possible level of logs. You know
everything that happens just by looking at your console. But now you would
like to see the errors in red. You add an old awk command to your log tailing,
but how do you know what to colour?
How can we know while reading the logs which message has which level?
Well, let’s add that as an exercise.
Exercise 4.5: Add the log level to your output. Hint: change the contents of
the format variable before printing, as we did when we added the current
time.
We chose from the start to export as many functions as there are logging
levels, for it makes the user’s code easier to read.
Now imagine yourself in a situation where you only know what level to pick
at runtime. Imagine you are logging the email address of your app’s user, but
on one platform all the users are internal and the admins need to know who
did what, and on another platform email addresses are covered by data
protection laws and should not appear in logs, even in case of errors. You
choose to have this information in your app’s configuration and would like to
pass it directly to the logger. But you can’t change the logging level for the
whole application, as this might discard some of your unrelated and
important messages.
For this, we can add an exportedLogf() function that takes a logging level as
its first parameter.
// Logf formats and prints a message if the log level is high enough
func (l *Logger) Logf(lvl Level, format string, args ...any) {
if l.threshold > lvl {
return
}
From there, why not refactor so that all the other exported methods simply
call this one? Of course, having both options will make your APIs more
cluttered and harder to understand and maintain. And the user can always
write her own function with a simple switch, using a variable defined in her
own domain.
Logs are the trace of the past execution of a program, and as soon as the need
for logs arise (usually “what was the value of count , at this moment?”, “how
long did this request take to process?”), the need to safekeep these logs also
appears on the stage. Logs, when stored in a file, a database, a bucket in the
cloud, or anywhere persistent, bluntly, cost money. The more logs you have,
the easier it will be to understand what went wrong and quickly fix it - but all
the more expensive it will be.
Every company will have its policy regarding what should - and shouldn’t -
be logged, and the level at which they should be. However, here are a few
recommendations we can share.
Although it might be very tempting to log “Step 1”, “Step 2”, etc. inside a
function, these messages won’t help you on the next day. Think what
happened at step 1 - was the document inserted in the database? Was the
email sent? Help your future self with clarity in messages. When a function
has only one possible execution, the only value of a log message is the
comforting reassurance that we’ve finished this or that piece of logic. Some
valuable information here would be to know how long it took to process it, or
something similar.
The amount of data written to the logs is directly related to the amount of
money that will be spent to keep these messages. If your variable is a map
with potentially thousands of keys, printing the map will be costly. Instead,
wouldn’t having its size, or whether a specific key is present, be as valuable?
If your data is a piece of an image or a song recording, the logger is not the
place to keep a copy of the bytes that are being processed. Instead, write a
function to save the image or the song.
Exercise 4.7: Ensure the logged message doesn’t exceed 1000 characters (or
1000 bytes, up to you), or, better, a value that would be optionally set. If it
would and the limitation is activated, trim the end off to make sure all logged
messages have a reasonable size.
Functions can be complex and span over hundreds of lines. When this
happens, the most important question is to identify which sections really
deserve a log and which simply don’t. If we return early because we found no
item to process, should we say so? Maybe not, but we could always log the
number of items found instead.
Logging shouldn’t be a tool to debug the code. Most of the time, when you
scratch your head wondering what’s going on when the input of this or that
function has this or that value, it’s because the code is unclear. There are
three ways of addressing this:
Structured messages
In the recent world, most logs are, in fact, not processed by humans. They are
mostly read by programs that use the logs to generate information displayed
in dashboards - for instance, representing the number of errors that happen
per minute over the course of time, or the time it took to process a request.
For this, we need to tell these computers how to parse the logs - which piece
of information is valuable, which is not to be taken into account, etc. And the
simplest solution, here, is to format the log messages into structured entities.
A common structured log message format is JSON (displayed here on several
lines for readability by humans):
{
"time": "2022-31-10 23:06:30.148845Z",
"level": "warning",
"message": "platform not scaled up for request"
}
Exercise 4.8: (The concepts here are explained in Chapter 5.) Update the log
function to print logs of the format above (you can ignore the"time" part
now, as we need a bit more than what we’ve covered to validate it in the
tests). This will require making use of the encoding/json package and the
Marshal function it contains. This function will have to be called on a new
type, which we’ll define as a structure that contains aLevel and a Message .
One of the most common traps, when using theMarshal (or Unmarshal )
function, is to forget that the json package needs to access the fields of the
structure. It is tempting, for newcomers, to keep these fields unexported, but
this makes them precisely unexported to the functions in charge of reading /
writing them. We’ll have more opportunities to cover this when we start
implementing services.
4.6 Summary
A library is a list of exported types and functions, the API, in a package,
which a client can use out of the box.
A library must only export what the user needs.
Using explicit names, and reproducing existing signatures to help the
caller of your library.
Define domain types over primary ones for readability and
maintainability of the code.
Enumerations using
iota are perfect for a type that has a small and
finite number of possible values.
Creating a New() method enables you to force the necessary parameters
at the object initiation and guarantee the client of your library will use it
properly, as it forces a clean initialisation of your object.
Use the functional options pattern to set fields that have a default value.
Implement and test the library using closed-box testing.
Smaller files make your code easier to read.
Be mindful of what you want to log and what not.
5 Gordle: play a word game in your
terminal
This chapter covers
This chapter is about a love story. During the 2020 pandemic, Mr Wardle, a
passionate software developer, created a new game named Wordle for his
partner Ms Shah, a word-game addict. After introducing the game to his
relatives and seeing how well it was welcome, he decided to publish it. This
is how this famous game began its journey before going public and rising like
a rocket. There is now a daily release of a new word to find, mostly
referenced throughout the world as “today’s wordle”. Since then, there have
been lots of variations, based on geography, maths, terminology from
Shakespeare, Tolkien, or Taylor Swift, and even more adaptations in different
languages throughout the world (beyond time and space - a list offers ancient
Greek, Quenya, and Klingon).
The goal of this chapter is to create our own game named Gordle (did you get
the pun?). It will be a configurable version of Wordle - the official version
has 5 characters per word, but we can imagine passing longer or shorter
words, and changing the number of attempts before a player’s game is over.
Lucio will be our developer, while Claudio, the player, will execute a
command that will start the game. In our code, we will progress step by step,
starting with a simple function reading from the input and printing Claudio’s
attempt. Then, we will iterate and have it evolve to give feedback to the
player. Adding a corpus (a list of words) from where to pick a random
solution will make the game more replayable. Finally, we will have the
opportunity to support more languages and tweak the parameters as we want.
For the sake of simplicity, this version will only support writing systems
where one character never needs more than one code point in Unicode.
Supporting other writing systems is out of the scope of this chapter, but you
can find extending ideas in the extras at the end.
Requirements
Then we create a new package named after the game gordle next to the
main.go file. This package is where we’ll implement the game. Our project
will have the following structure:
.
├── go.mod
├── gordle
│ └── files in package gordle
└── main.go
As we’ve seen in Chapter 4 with the logger, there are several ways to create
an object. Here, we expose aNew() method, which will be the recommended
entry point into the library, guaranteeing the creation of theGame object with
all its dependencies. Note that it is a good habit to ensure proper behaviour of
your library.
return g
}
We voluntarily did not write return &Game{} because we will add some code
before this return g line to configure our game.
Then, we attach aPlay method to the Game type. Play will run the game. In
our first implementation, let’s simply print the instructions. Creating a
method on an object, in Go, is achieved by writing a pointer receiver on the
Game structure.
fmt.Printf("Enter a guess:\n")
}
This is enough for a very first version. Let's call these new methods in the
main function. For this, we need to import the gordle package in themain.go
file.
import (
"learngo-pockets/gordle/gordle"
)
In the main function, we need two steps to start the game: create a new
Gordle game and launch it!
func main() {
g := gordle.New()
g.Play()
}
package main
import (
"learngo-pockets/gordle/gordle"
)
func main() {
g := gordle.New()
g.Play()
}
After these initial steps, we can run our program and verify that it behaves as
expected. Now is also a good time to commit these files to your favourite
version control system, before you add some contents into the Game structure.
We are now ready to wait for Claudio’s guess of a secret word!
Since this is a game, we have a player, Claudio. Let’s ask him for a
suggestion. Claudio has access to the keyboard, and we’ll be reading his
attempts through the standard input. After reading it, we can check it against
the solution.
Game structure
There are several ways of reading from the standard input, depending mostly
on what we want to read. Some functions read a slice of bytes. Some read
strings. In this case, the player will type characters and then press the Return
key. We, therefore, want to read a line until we hit the first end of the line
character.
The bufio package has a useful method to achieve this on its Reader
structure: “ReadLine tries to return a single line, not including the end-of-line
bytes”, reads the documentation. It also states that it’s not the best reader in
the world for most reading use cases, but for one word from the standard
input, it is perfect. The good thing is that the bufio.Reader implements the
io.Reader interface! We don’t want to over-engineer our solution.
Our Game object will hold a pointer to a bufio.Reader . Why a pointer and not
a simple object? Simply because thebufio package exposes aNewReader
function that returns a pointer to abufio.Reader . Also, since we’ll be calling
ReadLine a lot, it’s useful to immediately have a variable of the type of that
method’s receiver - a pointer.
return g
}
Go natively uses Unicode. All the source files need to be encoded in UTF and
it even has a specific primitive type called rune that serves to encode a
Unicode codepoint.
If we take for example the default line that appears on the Go playground and
look at the length of the string (including the comma and the whitespace):
fmt.Println(len("Hello, 世界"))
This prints out 13. Indeed, UTF-8 requires 3 bytes to encode each of these
non-latin characters. On the other hand,
fmt.Println(len([]rune("Hello, 世界")))
This outputs 9. We are measuring the number of runes and not the number of
bytes necessary to encode them. Keep that in mind whenever iterating over a
string’s elements: you can either access its byte representation with
[]byte(str) , or access its rune representation with[]rune(str) , which is
the default behaviour.
package main
import "fmt"
func main() {
fmt.Println("Hello, 世界")
fmt.Println(len("Hello, 世界"))
fmt.Println(len([]rune("Hello, 世界")))
}
Console Output:
Hello, 世界
13
9
We have a variable that allows us to read from the standard input once the
game is set. Let’s politely ask Claudio for his next word. Since the feature of
retrieving an attempt provided by the player through the reader is something
we can summarise in a sentence without having to explain how it works, it’s
a great candidate for a function! We’ll call it ask , for clarity and simplicity.
This method will accept a Game receiver, since it needs to read from its
reader, and will return a slice of runes - the word proposed by the player. It
will guarantee we have a valid suggestion.
The experienced reader will have noticed that we use a pointer receiver here.
There are two reasons for this. The first is simple: we’ll be modifying the
state of our Game structure via many of its methods, so they will all require a
pointer receiver. It is good Go practice to avoid having both pointer and non-
pointer receiver methods on a type, for consistency. The second is a bit more
complex, and is motivated by the fact that theGame structure has a field that is
a pointer: the output field. Appendix E covers the issues that can happen
when using copy-receivers with pointer fields.
Inside this ask method, we read the line using the reader. Should an error
occur, we’ll print it using Fprintf , but we decide to continue anyways and
wait for a new attempt. That is: a jammed line won’t cause the game to crash,
but merely to ask for another word. Fprintf will allow us to write to the
standard error.
We’ll see, when completing the Play method, how to better deal with errors.
The hard truth is that they shouldn’t be ignored, most of the time. However,
deciding that an error is not blocking is a good moment to leave a note for
future-self explaining this decision, in the form of a comment.
We can add an easy check on the length of the word. For the moment, we
play with the same parameters as the original Wordle, with 5-character long
words. We can define a constant at package level and use it everywhere we
need it.
const solutionLength = 5
for {
playerInput, _, err := g.reader.ReadLine() #A
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Gordle failed to read your guess: %s\n", err.Error())
continue #B
}
guess := []rune(string(playerInput))
The ReadLine method will give us the user’s input as a slice of bytes. We
will then need to convert this byte slice into a rune slice. Converting each
byte into the rune representing that byte would be a very bad mistake.
Everything not ASCII would break. To properly convert a slice of bytes that
we know represents a string to a slice of runes, we need to first convert the
byte slice into a string and then into a rune slice.
The built-in method len() returns the length of a slice (or array). We can use
it to compare the length of Claudio’s word against thesolutionLength
constant. We should be polite and return a message if it fails. However, in
order to make it clear that we aren’t returning “happy path” information,
we’ll use the standard error output - available viaos.Stderr .
if len(guess) != solutionLength { #B
_, _ = fmt.Fprintf(os.Stderr, "Your attempt is invalid with Gordle's solution! Expected %d characters,
got %d.\n", solutionLength, len(guess))
} elsereturn
{ guess
}
The ask method uses its receiver's reader and returns a slice of runes. Let’s
declare these in the test case definition.
Think of a few original test cases that use your favourite alphabet, abjad,
syllabary, or even emoji from the Unicode list of supported characters.
package gordle
import (
"errors"
"strings"
"testing"
"golang.org/x/exp/slices"
)
got := g.ask()
if !slices.Equal(got, tc.want) {
t.Errorf("got = %v, want %v", string(got), string(tc.want))
}
})
}
}
You might have noticed that the first line of our test function is somewhat
different from those in the previous chapters. Indeed, we used to declare a
testCase structure, that would encapsulate all the fields we needed - and,
now, it’s gone! Or, rather, it’s been replaced with what is called an
anonymous structure. This implementation is very common in Go tests, and
we’ll be using it from now on. However, if you prefer the previous way of
declaring the testCase structure, that’s also perfectly valid. For comparison,
here are both:
Note that with the current implementation of ask , if we have an input of only
3 runes, theReadLine method waits forever, after ignoring the invalid 3-
character-long line and waiting for more. What’s happening here is that
ReadLine , when hitting the end of the input, will return a specific error to let
the caller know that there is nothing to be read.
Why are we not using == to compare slices? We can for arrays, after all!
Remember that slices hold a pointer to their underlying array. Array values
are comparable if values of the array element type are comparable. Two array
values are equal if their corresponding elements are equal. But when it comes
to slices, structs and maps,== will simply not work. It’s not that it will
produce random results - no! Instead, Go will simply not let you compare two
slices. Not even a slice with itself. The only entity that we can compare with
a slice is the nil keyword.
This might sound a bit harsh, but it should be considered a safeguard rather
than a restriction. It is possible, in tests, to use the method
reflect.DeepEqual , but it was not designed for performance; you should
avoid it in production code. Instead, write the simple loop.
Remember your module? To add the dependency, let the go tool look for the
latest version with the following command:
go get golang.org/x/exp/slices
You can see that ago.sum file has appeared. This is typically something that
should not be committed to your version control, but generated locally. You
can also notice that yourgo.mod has changed, and it now refers to the new
dependency.
Is your code compiling and your test passing now? We can move on.
Play
We are now able to read Claudio’s guess. Let’s make great use of this ability,
and plug it in the Play() method which looks like this:
Listing 5.12 game.go: Play method
There is one missing update in the main, can you spot it? Since New()
method takes a reader as parameter, we need to pass os.Stdin to wait for the
player’s input.
package main
import (
"os"
"learngo-pockets/gordle/gordle"
)
func main() {
g := gordle.New(os.Stdin)
g.Play()
}
$ go run main.go
Welcome to Gordle!
Enter a 5-character guess:
four #A
Your attempt is invalid with Gordle's solution! Expected 5 characters, got 4. #B
apple #C
Your guess: apple #D
You may have noticed the ask() method is now responsible for both reading
the input, standardising it and validating the guess. It is best to separate the
concerns, let’s refactor!
5.1.3 Isolate the check
Let’s move the word length validation to another method, adequately named
validateGuess . Notice that we did say method, and not function. This
validateGuess will have a receiver over the Game type. The reason for this
won’t be visible here, but in the next pages, we’ll want to get rid of that
solutionLength constant, in favour of a test against the real secret word’s
length, which will be part of the Game structure. This validateGuess method
is in charge of the validation, it takes the guess as a parameter and returns
whether the word is valid.
There are two common ways of informing of the success of a check - either
we can return a boolean value, or an error which is in Go a value,
representing the issue we found (ornil , if everything was fine). Returning a
boolean is simple, but it doesn’t allow for fine behaviour. What if we need to
specify that we faced an unrecoverable (at least for thisvalidateGuess ’s
concern) error? While there is no granularity with booleans, errors offer a lot
more variations that will allow the caller - in our case, the ask method - to
decide the behaviour if there is any error. We will also see how it makes the
code easier to test.
Error propagation
The following snippet of code holds the new method and its attached error. It
is declared outside of thevalidateGuess method to enlarge its scope and use
it in unit tests later, validating we retrieve the proper error.
// errInvalidWordLength is returned when the guess has the wrong number of characters.
var errInvalidWordLength = fmt.Errorf("invalid guess, word doesn't have the same number of characters
as the solution") #A
// validateGuess ensures the guess is valid enough.
func (g *Game) validateGuess(guess []rune) error {
if len(guess) != solutionLength {
return fmt.Errorf("expected %d, got %d, %w", solutionLength, len(guess),
} errInvalidWordLength)
return nil
}
Make sure to replace the validation with the call tovalidateGuess in the ask
method like below. :
err = g.validateGuess(guess)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Your attempt is invalid with Gordle's solution: %s.\n", err.Error()) #A
} else {
return guess
}
Testing validateGuess()
Extracting the validation into a dedicated method is one way to test unitary
behaviour. As we did previously, we will use Table-Driven Tests to cover
several cases without too much repetition. Let’s test this new function!
Now you are familiar with Test-Driven Tables, you should be able to write
the first test scenario without checking the solution, which we provide
anyway:
package gordle
import (
"errors"
"testing"
)
err := g.validateGuess(tc.word) #D
if !errors.Is(err, tc.expected) { #E
t.Errorf("%c, expected %q, got %q", tc.word, tc.expected, err) #F
}
})
}
}
Exercise 5.1: Here we cover the happy path case with a five-character word
and one unhappy path when the word is too long. Add new test cases to cover
more invalid paths. What happens if the attempt has fewer characters, is
empty, or is nil ?
Input normalisation
There is a line left in this ask method that could be reused later.
guess := []rune(string(suggestion))
We accept all kinds of upper and lowercase mixes and it will later be simple
to take care of the not-yet-supported writing systems if we put this into a
small function.
We have built the foundations of your game. The next step is to verify if
Claudio’s attempt is the solution. If it isn’t, he gets to try again. We will also
limit the number of attempts to make the game more challenging. Indeed,
there are two ways to end a game of Gordle: either the word was found, or
the maximum number of attempts was reached.
Let’s update the New function by passing both the solution and the maximum
number of attempts as parameters, for the time being.
return g
}
We take the solution as a string, which is easier to use, and reuse the function
we just wrote before. We are also normalising the solution given to our
package by setting all letters to uppercase, something which again only
makes sense in a limited number of alphabets.
In the Play method, we can add a loop to let Claudio suggest a second word,
a third word, and so on. The criterion to end the loop will be that Gordle has
received a number of attempts equal to the maximum allowed. That loop
starts by asking for a word, ensures its validity, and then checks if the attempt
is equal to the solution.
if slices.Equal(guess, g.solution) {
fmt.Printf(" You won! You found it in %d guess(es)! The word was: %s.\n",
return currentAttempt, string(g.solution))
}
}
Using emojis
With the solution now embedded in a Game object, we can remove the
constant solutionLength everywhere and replace it with the length of the
solution - len(g.solution) .
Well, for now, they don’t even compile, because we changed the signature of
New. As you can see, it forces the user to provide the mandatory fields:
g := New(strings.NewReader(tc.input), string(tc.want), 0)
The ask method does not use the max number of attempts, so we can give the
zero value as parameter and tell the next maintainer that it is useless in this
context. It makes our call a bit wacky, but this weird zero will be fixed in part
4 when we make it optional.
This should do it! We can continue to update the rest of the code, starting
with the main. Indeed, as we mentioned earlier, we need a solution word to
play. For now, we’ll hardcode this in the main like the following snippet of
code
Listing 5.21 main.go: Main with hardcoded soluton and updated New()
package main
import (
"os"
"learngo-pockets/gordle/gordle"
)
const maxAttempts = 6
func main() {
solution := "hello"
g.Play()
}
Here is an example of the game when the player finds the solution. This
illustrates the game when Lucio plays his game - an easy win on the first
attempt! Remembering what he wrote and hardcoded inmain a few minutes
earlier did help here…
$ go run main.go
Welcome to Gordle!
Enter a 5-character guess:
hello
You won! You found it in 1 attempt(s)! The word was: HELLO.
However, if Lucio lets his friend play the game, it’s a lot more difficult to
win! With no hints to guide Claudio towards the solution, this game is almost
impossible to win (unless one plays it twice, but changing the solution every
time the game is played is work for later).
$ go run main.go
Welcome to Gordle!
Enter a 5-character guess:
sauna
Enter a 5-character guess:
pocket
Your attempt is invalid with Gordle's solution: expected 5, got 6, invalid guess, word doesn't have the
same number of characters as the solution.
[...]
Enter a 5-character guess:
phone You've lost! The solution was: HELLO.
The game would be quite a bore if it didn’t give the player some information
about how close they are to the solution, in the form of hints as to which
characters are properly located, and which are misplaced. It’s time to give
Claudio some feedback!
A good feedback should return a clear hint for every character of the input
word, explicit about the correctness of the character in this or that position.
The initial Wordle uses background colour, behind each character of the
player’s input. While this was great for most of us, an application that
provides feedback to the user should take into account user accessibility. A
common impairment is colour vision deficiency, where making a difference
between green and orange isn’t as obvious as it would seem. An option was
added to Wordle that would allow players to use colours with high contrast
instead of the default ones. Let’s see what we can do here!
We’ve determined that a feedback will be a list of indications that can have
three values - correct, misplaced, and absent. In order to easily manipulate the
feedback for a character, we create the type hint to represent these hints, of
the type byte - the smallest type Go offers, regarding memory usage. The
iota keyword allows us to automatically number them from 0 to 2. Using
underlying numbers will make it easier for us when it comes to finding the
best hint we can provide the player. Define thishint type in a new file,
hint.go , in the packagegordle .
In our example, we have 3 values that we want to list in an enum. There are
3! (“factorial 3”, equal to 3 * 2 * 1) overall possible permutations of 3
elements, which is 6 ways of ordering them. In Go, the best practice is always
to make best use of the zero-value, and to sort the elements of the enum in a
logical way - in our case, from worst to best. We could have had an
unknownStatus as the zero-value of our enum, but as we’ll see later, using
the zero-value for absentCharacter will come in handy.
These hints will be printed on the screen to help Claudio make the best guess
he can on his next attempt. We need to find a representation of these hints
that is both simple and explicit. Since this is the 21st century, what better
than emojis to convey a message that we can all understand and agree upon?
We want to attach one emoji to each hint, and the Go way of implementing
this is through a switch statement.
Let’s now think about how this method that will provide a string
representation of ahint is to be called. Both literally and practically: how do
we want to name it, and how do we want to make calls to it.
One of the important interfaces to keep in mind while writing Go code is the
Stringer interface defined in the fmt package. Its definition is simple:
String() string . This means any type that exposes a parameterless method
named String that returns astring implements this interface. So far, so
good - but there is a key aspect that still has to be mentioned here. If we have
a look at the fmt.Printf functions, we can read that “Types that implement
Stringer are printed the same as strings”. This means, in order to print a
variable of a type that implements Stringer, we only need to use%s, %q, or %v
in a Printf call, and this will, itself, call the String() method.
Implementing the Stringer interface will save a lot of time - reusing a well-
known convention is better than trying to be smart, and it won’t require an
extra layer of knowledge from future developers who will later work on this
code.
Note that if your terminal does not display emojis properly, you can replace
them with numbers or regular characters such as. "" for absent, "x " for
misplaced, and "O" for correctly placed characters. It is less fun, but, at least,
more readable than squares.
Providing a hint for a single character is good, but we’ll need to do so for
every character of the word. We’ll represent the feedback of a word as a
structure. It will hold the hint for each attempted character compared to its
position in the solution. We name this new typefeedback . We could place
the definition of a feedback in a feedback.go file, but since it’ll be very
tightly linked to a hint , and that these two types won’t have more than one
method over them, we can place them in the same file.
Our first and naive implementation of the String method on the feedback
type is to create astring , and append the status representation as we go
through the feedback’s statuses.
In Go, strings are immutable. Constant. We cannot alter them. We can’t even
replace a character in a string without casting something to a slice, and
something back to a string. This makes string manipulation quite painful,
especially for what would seem the simplest task - sticking two strings
together. When we use the + operator on two strings, Go will allocate
memory for a new string of the correct size and copy the bytes of each
operand into that new string.
While this is simple and clear when concatenating two strings together, it
becomes slower as soon as we have several strings to merge. Keep in mind
that, when the number of strings to connect exceeds two, there are two quite
common alternatives that are worth checking:
The strings package provides the typeBuilder that lets you build a string
by appending pieces of the final string, while minimising the number of
memory allocations and reallocations every time we add some characters.
In order to use theBuilder , we declare a new variable and to fill the string.
This type exposes several methods that can be used to append characters to
the string being built: WriteString , WriteRune , WriteByte and the basic
Write , which takes a slice of bytes. In our case theWriteString method is
the most appropriate, since we know how to make astring from a status .
Once we’re done feeding data to the builder, callingString() on it will
return the final string.
Want to check the difference? See Appendix D.1 for how to benchmark your
code. Once we’ve selected which implementation we’d rather use, let’s not
forget to test this method. Testingfeedback.String() will cover
hint.String() , which will be enough - therefore, no need to also test the
hint.String() method.
We are now ready to send feedback to Claudio - but we are missing a small
piece of information here. We don’t know yet which characters are correctly -
or incorrectly - located! This will be our next task before the game can be
enjoyed.
This section is about approaching a new problem. Whatever the language you
use, there will be times when you need to roll away from the screen, take a
piece of paper and pen, and think about the best way to solve your problem.
In our case, we want to make sure the hints we give are accurate. A letter in
the correct position should always be marked as in the correct position. A
letter in the wrong position should only be marked as such if it appears
unmatched elsewhere in the word. We need to make sure we cover double
letters properly - for instance, what should be the feedback to the word
“SMALL” if the solution is “HELLO” ?
As this book is not about algorithms, we’ll start with the pseudo-code of the
check function that implements our solution. Feel free to think about it
yourself before jumping to our solution.
Our pseudo-code’s syntax will be close to that of Go, with curly braces,
because we think it makes more sense in this book. We had previous drafts of
pseudo-code that used boxes, arrows and loops.
Once we’ve written the pseudo-code, we can shoot some examples at it and
see how it behaves. By first iterating over correctly placed characters, and
then over those that are misplaced, we get the expected output for “SMALL”
vs “HELLO”:
SMALL
HELLO
⬜⬜ ⬜
We need to think about how to implement the different parts that are still
“pseudo-code magic”. How do we mark a character with a hint? How do we
mark a character as seen in the solution? There are lots of ways of
implementing this, we’ll go with a simple approach here: we’ll use a slice of
hints to mark characters of the guess with their appropriate hint, and we’ll use
a slice of boolean to mark characters of the solution as either seen or not yet
seen.
We recommend you give it a try before checking the solution.
if len(guess) != len(solution) {
_, _ = fmt.Fprintf(os.Stderr, "Internal error! Guess and solution have different lengths: %d vs
return result #C len(solution))
%d", len(guess),
}
return result
}
A tricky part here is handling the case if the guess and the solution have
different lengths. Since this is our code, we know this can’t happen - because
it’s been checked earlier. But if somebody changes the code later (including
future Lucio who forgot everything he wrote and why), it will end in a
segfault, during runtime; we can’t even warn him with a unit test. For this
reason, we decide to re-check the length of the guess against the length of the
solution here.
Another option would have been to return a feedback and an error in the
computeFeedback function. These assumptions are tolerable in internal
functions, but they would absolutely not be accepted in exposed functions,
because we don’t control the range of values that can be passed to functions
available to the rest of the world.
Congratulations, you implemented the most difficult part! Now, let’s add
some tests.
Testing computeFeedback()
return true
}
You now know how to write a test in a table-driven way. First, we define our
structure holding the required elements for our test case, inputs and expected
outputs. Then, we write our use cases, and finally we call the method and
check the solution. Here, for the sake of clarity and to avoid unnecessary
clumsiness, we’ve decided to use strings instead of slices of runes in the
structure of our test case for the guess and the solution. The conversion from
a string to a slice of runes is simple and safe enough to be performed in
execution of the test. On the other side, we want to explicitly check the
contents of the returned feedback, and, for this reason, we have a
feedback
field in the test case.
package gordle
import "testing"
Exercise: Part of the fun of a project is to come up with some edge case
scenarios. Try and find some that would push the logic to its limits.
Listing 5.31 game.go: Update the Play() function to display the feedback
[...]
for currentAttempt := 1; currentAttempt <= g.maxAttempts; currentAttempt++ {
guess := g.ask()
fb := computeFeedback(guess, g.solution) #A
fmt.Println(fb.String()) #B
if slices.Equal(guess, g.solution) {
fmt.Printf(" You won! You found it in %d guess(es)! The word was: %s.\n",
return currentAttempt, string(g.solution))
}
}
[...]
$ go run main.go
Welcome to Gordle!
Enter a 5-character guess:
hairy
⬜⬜⬜⬜ #A
Enter a 5-character guess:
holly
⬜ #B
Enter a 5-character guess:
hello
#C
You won! You found it in 3 attempt(s)! The word was: hello.
We now have a solution checker and we are able to give Claudio some well-
deserved feedback! Feedback makes it a lot easier for the player to find the
solution. However, there is a small final detail we still need to address that
will provide even more fun: how about Claudio getting a different word
every time he plays Gordle? We proved that our implementation works with
a hardcoded solution, it is time to add a corpus and add randomisation to our
game.
5.3 Corpus
In linguistics, a corpus is a collection of sentences or words assumed to be
representative of and used for lexical, grammatical, or other linguistic
analysis. Our corpus will be a list of words with the same number of
characters.
Until now, we have been using a hardcoded solution and ensured our
algorithm was working as expected. In this section, we will focus on adding
randomisation to our game by picking a word from a given list. Let’s first
retrieve a list of words and then pick a random word in it as the solution of
the game.
First, we create acorpus directory with a file named english.txt . This file
contains a list of uppercase English words, one per line. Our corpus was built
while playing other versions of the game. Feel free to use the adequate list for
your own game. Adding a new corpus for a different language, or for a
different list of words (6-character long, for instance) is now simple: all we
have to do is add a file here and have the program load it.
Parsing a file is a very common task that most programs face. It could be a
configuration file with default values to load, an input file as we have here, a
database query, an image, a video file, or anything that comes to your mind.
If it exists on a disk, a program is going to read it. In our case, we want to
read the corpus file as a list of words that we will store in a slice of strings.
Start by creating a new file, corpus.go , where all methods related to the
corpus will live.
It’s good to keep in mind that files, when written on disk, are nothing but a
chunk of bytes. Nice characters, spaces, tabulation, tables, etc. are rendered
by file editors. This book was saved as some 0’s and 1’s. This is why we
don’t immediately have a list of lines out of the ReadFile function. That
logic has to be implemented by us, at reading time. We know that some of
these bytes are the new line character - but let’s not rush to an easy solution
that would be to split this slice of bytes on \n. Indeed, how if the byte
representation for \n (0x0a ) was in fact a byte part of a representation of a
non-ASCII longer character? Or, what if the file was encoded differently,
with a new line character not only represented by \n, but rather a \r\n ?
if len(data) == 0 {
return nil, ErrCorpusIsEmpty
}
Sentinel errors
Sentinel errors are a type of recognisable errors. In Go, “errors are values”,
meaning that they carry a meaning. Sentinel errors must behave like
constants, but Go will only accept primitive types as constants, and not
method calls. Unfortunately for us, the two default ways to build an error are
by calling fmt.Errorf or errors.New . And these don’t produce constant
values - they produce the output of a function, which isn’t known at compile
time, only at execution time. This implies that errors generated by
fmt.Errorf or errors.New will always be variable. So, how do we get the
constant errors we’d like? We declare our own type and have it implement
the error interface:
package gordle
Small note: if you look at io.EOF in the code, you’ll realise it is a global and
exposed variable - it was generated at execution time by a call toerrors.New .
Don’t do that at home. Imagine a pesky colleague were to do this:
io.EOF = nil
...
if err == io.EOF { // oops
package gordle_test
if tc.length != len(words) {
t.Errorf("expected %d, got %d", tc.length, len(words))
}
})
}
}
We are now happy, we have our corpus in a handy form, it is reading from a
file that can be updated in the simplest way possible - just add a new word to
it as a new line. Gordle now knows a list of words. If we pick one - and try to
make it different every time - Claudio will face a different challenge every
time he plays the game!
Every game of Gordle needs a random word for the player to guess. We have
a corpus, all that's left is to select one word from our list.
Now that we know how to get a random number, picking a random word in a
list is straightforward - simply get the word at the random index.
index := rand.Intn(len(corpus))
return corpus[index]
}
You know the importance of testing the core methods to make sure they are
working properly before calling them into higher methods. pickWord will
follow that trend, but there is a minor issue. When we execute tests, usually,
we want to compare an output to a reference.pickWord , by design, has a non-
deterministic output. When this happens, we have two solutions. We can
change the behaviour of the random number generator from the test (but then
we’re not testing anything). Or we can assert a truth about the output: what
we really want to test is whether the method returns a word from the list, or
the results we get when calling “a lot” of times the random function follows
the expected distribution. So, we will go for the second approach, and ensure
that the word
pickWord returns was indeed in the initial list. For this, we
won’t use Table-Driven Test, as we won’t have a wide variety of cases.
Let’s first write a helper function to verify a word is present in a list of words.
There is no special trick here, we have to range over the list and, if the word
corresponds to the input, immediately return true. Otherwise, we return false.
This function, similarly to the previous two that compared slices, is also a
very common one that we can make more generic.
With the help of this small function, we can now add a test that will ensure
pickWord returns a word from the input slice.
if !inCorpus(corpus, word) { #A
t.Errorf("expected a word in the corpus, got %q", word)
}
}
Now we have done the implementation and covered the testing, we are ready
to wrap it up! Do you remember that nasty hardcoded solution in theGame
structure creation? It’s time to replace it by calling the pickWord method and
passing the corpus as a parameter of New() .
We are now also reaching the moment whereNew() does a lot. Not only does
it create a Game, but it also initialises it. We won’t push it any further, and
instead consider that it might be time to split it into two distinct functions,
each with its responsibilities. For now, let’s just add that final cherry on top
of the New() function:
return g, nil
}
Now, we have everything ready. Claudio's been waiting a long time to play,
let’s adjust the call in the main function and give him the keyboard!
There is very little left to do before the game is complete. Only a few changes
in the main function remain to apply - we’ve got a corpus, and we need to
parse it and feed it to Gordle’s New() function. Since this New() function now
returns an error, we should take care of it. Let’s write a message on the error
output and leave themain() function with a return .
package main
import (
"bufio"
"fmt"
"os"
"learngo-pockets/gordle/gordle"
)
const maxAttempts = 6
func main() {
corpus, err := gordle.ReadCorpus("corpus/english.txt") #A
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "unable to read corpus: %s", err)
return
}
That’s enough typing from our side, time to let Claudio smash these keys
frenetically, in search of one of Gordle’s secret words.
$ go run main.go
Welcome to Gordle!
Enter a 5-character guess:
sauna
⬜⬜⬜⬜⬜
Enter a 5-character guess:
waste
⬜⬜⬜⬜
Enter a 5-character guess:
hello
⬜
⬜⬜⬜
Enter a 5-character guess:
terse
⬜ ⬜
Enter a 5-character guess:
crept
⬜ ⬜⬜
Enter a 5-character guess:
freed
The word combination here also is important - even though the spelling न +
म + स् + ते would pronounce the same sound, the rules of Devanagari
combine the last two symbols, “s” and “te”, into one: ◌े “ste”.
Now, let’s see how Go deals with the “नम ◌े” string:
func main() {
s := " नम ◌े"
for _, r := range []rune(s) {
fmt.Print(string(r)+" ")
}
}
न म स ◌◌् त ◌◌े
We can see that Go did indeed split the string into six runes. We’ve already
seen four of them, those with the shirorekhā: they are called swars in Hindi.
The other two are a bit cryptic - they represent a dotted circle with a
decoration - something called a diacritic. In Devanagari, this is one way of
representing matras (which include, but aren’t restricted to, vowels).
Diacritics are alterations to existing characters, they don’t have an existence
on their own. English has some diacritics, mostly in borrowed words such as
déjà-vu or señor: the accents on the first word’s vowels can’t be written
without their supporting vowel, and the same goes for the tilde, which needs
a letter to sit on.
As we can see, Go won’t merge the diacritic ◌् with the character स, when
splitting the string into runes. This character remains two different runes for
Go. So, what can Lucio do to help Claudio? Unfortunately, this is the limit of
what the native rune type of the Go language can support. But this is
precisely what the golang.org/x packages are for - extending the limits of
what Go natively accepts. In our case, the package
golang.org/x/text/unicode/norm provides a type Iter that can be used for
these strings. With a bit more work on the code, Mithali will be able to play
Gordle too!
5.5 Conclusion
Finally, we’ve completed our objective! We’ve written a command-line game
that lets a user interact with it via the standard input. Our game reads data
from a file containing words, selects one at random, and has the player guess
the word. After each attempt, we provide visual feedback to help the player
towards the solution. Whatever happens, we’ve tried to print clear messages
to the player so they don’t get lost with what to do next.
5.6 Summary
A switch / case statement is a lot more readable than a long sequence of
if / else if / else statements. Aswitch can even be used instead of an
if statement. We think that, if you need anelse statement, you’re better
off with a switch block.
A command-line tool often needs to read from the console input. Go
offers different ways of doing it, in this chapter, we used the
bufio.ReadLine method, which reads an input line by line.
Sentinel errors are a simple way of creating domain errors that can be
exposed for other packages to check. It is a cleaner implementation than
creating exposed errors witherrors.New() or fmt.Errorf() . To declare
a new sentinel error type, declare a new type that is defined as string a
(this makes creating new errors simple).
Propagating an error to the caller is the way Go handles anything that
steps out of the happy path. Functions that propagate errors have their
last return value of their signature be an error. In the implementation of
these functions, fmt.Errorf("... %w", … err) is the default way of
wrapping errors. The w in %wstands for “wrap”.
Any structure with a method with the following signature: String()
string implements the fmt.Stringer interface. Any structure that
implements the Stringer interface will be nicely printed by fmt.Print*
functions.
The os package provides aReadFile function that loads a file’s contents
as a slice of bytes. This function can be used for plain-text files, media
files, files in XML or HTML format, etc.
The golang.com/x/exp/slices package contains useful tools such as
the Equal function or the Contains function. However, the
documentation mentions that they could move out of/x/exp at any
point. As we’ve seen, implementing the function for a specific use case
isn’t too complex.
A Go string can be parsed as either a slice of bytes, or as a slice of
runes. The latter is recommended when iterating through the characters
that compose it. Use[]rune(str) to convert the str string to a slice of
runes. However, even this solution isn’t perfect and won’t always work.
Best to first check the language you’re dealing with to select the best
libraries to parse any text.
All receivers of a specific type should be either pointer or value
receivers. Using value receivers is only interesting if the structure is
small in memory, as it will copy it. When in doubt, use pointer-receiver
declarations.
When writing table-driven tests, it is a very common practice to use a
map[string]struct{...} . The key of the map, thestring , describes
the test case, and thestruct is an anonymous structure that contains the
fields necessary for your test case.
Getting a random number can be achieved by both the math/rand and
the crypto/rand packages. Anything related to security, cyphering, or
cryptographic data should use thecrypto/rand package, while the
math/rand is cheaper to use.
When working with random numbers, make sure you’re using Go 1.20
or more. Otherwise, be explicit about setting the seed with a call to
rand.Seed() . An usual value for the seed used to be the current
nanosecond, retrieved withtime.Now().Nanosecond() .
Taking a step back, away from a screen, and writing pseudo-code with
potatoes and arrows is valuable, and helps to see the bigger picture and
imagine tricky scenarios that might prove or disprove an algorithm.
6 Money converter: CLI around an
HTTP call
This chapter covers
Writing a CLI
Making an HTTP call to an external URL
Mocking an HTTP call for unit tests
Grasping floating-point precision errors
Parsing an XML-structured string
Inspecting error types
A long list of websites nowadays exposes useful APIs that can be called via
HTTP. Common examples are the famous open-source system for
automating deployment Kubernetes, weather forecast services, international
clocks, social networks, online databases such as BoardGameGeek or the
Internet Movie Database, content managers like WordPress, the list is long. A
small number of them also provide a command-line tool that calls these APIs.
Why? Even though nice and clickable interfaces are wonderful, they are still
very slow. Here's an example: when we look up a sentence in our favourite
search engine, it still takes an extra click to access the first link that isn't an
advert, or the first one we haven't opened yet. The terminal shell, on the other
hand, allows us to manipulate inputs and outputs of programs - and even to
combine them, which reduces the number of command lines and helps
automate more of our work.
In this chapter, we will create a CLI tool that can convert amounts of money.
Starting with a broad view of what we want our tool to achieve, we will begin
by defining the main concepts: what is a currency, and how do we represent
it? What does it mean to convert money? We’ll need a change rate, how do
we get it? What should our input and our output be? How do we parse the
input? As we’ll see, some precaution is required when manipulating floating-
point precision numbers. An early disclaimer is required here: this project is a
tutorial project and shouldn’t be used for real-life transactions.
Requirements
Limitations
The input amount must be defined with digits only, and one optional dot
as a decimal separator. We could extend later with spaces, underscores,
or apostrophes.
We only support decimal currencies. Sorry, ariaries and ouguiyas.
By now, you know how to initialise a new module. Create your folder,
initialise it:
As long as we're only having a single binary, it's fine to have the main.go file
at the root directory. For tools that expose several binaries, the common place
for main function is in cmd/{binary_name}/main.go .
We now create a folder namedmoney containing one file that will expose the
converter’s entrypoint of the package, theConvert function. We can start
writing the contents of convert.go : it has to be an exposed function.
package money
In order to make our project compile, we need to define the two custom
types: Amount and Currency . We can already anticipate that they will hold a
few methods, e.g.String() to print them out. This calls for a file for each of
the types, ready to hold their future methods.
Currency
Create acurrency.go file in the same package and add the following
structure.
Immutability
The code string is hidden inside the struct for any external user. We will
continue building all of our types so that they stay immutable, meaning that
once they are constructed, they cannot be changed.
We do that to make the code more secure for the package’s users (that is, us):
if we have 10 euros, they will not suddenly become 19.56 Deutsche Mark
because we called a function on them. Immutability also makes the objects
inherently thread-safe.
Why is quantity not simply a float? For a start, if we want to attach some
methods to it, it needs to be a custom type. Second, we will see in the next
part that floats are dangerous - there are many possible ways to save this
number and if we want to make room for later optimisation, we need to hide
the entrails behind a custom type.
As we don’t know yet how we’ll write these internal details, let’s leave it
empty for now. It is not necessary to have one struct per file, or file named
after the main struct they contain, but it is a good way for maintainers to find
what they are looking for.
At this point your project’s tree should have one directory and a total of 5 go
files:
$ tree
.
├── go.mod
├── main.go
└── money
├── amount.go
├── convert.go
├── currency.go
└── decimal.go
Testing Convert
Testing a function that does nothing is pretty preposterous, you might think.
We would like to argue that if you can’t write a test that is easy to understand
and to maintain, your architectural choices are on the wrong path. If writing
the test is a mess, rethink your code organisation even before starting to work
on the business logic. Unfortunately, easy testing is not a guarantee of a good
architecture, or the world would be a better place.
More for learning reasons than anything else, we chose to use a validation
function here.
Validation function
A validation function is a field from the test case structure and takes as
parameter *testing.T and all necessary parameters for the check. It does not
return an error but fails directly if something wrong happens. For our
Convert function, we will need the value we got and the error.
tt := map[string]struct {
// input fields
validate func(t *testing.T, got money.Amount, err error)
}{
Now wait a second. Is that field actually a function? Yes! Go allows for the
definition of variables of many types, including functions of specific
signatures. You can see examples of what it looks like in test cases below.
package money_test
import (
"testing"
"learngo-pockets/moneyconverter/money"
)
A first idea could be to simply use a float. Unfortunately, there are two
problems in this naive approach.
First, we are not preventing anyone from declaring 86.32456 CAD, which
bears no real-world meaning. The smallest subunit of this Canadian dollar is
the cent, a hundredth of a dollar. Anything smaller than 0.01 CAD must be
rounded one way or another. We want to prevent this nonsense from
happening and prevent it by design. This means that the way we build this
Decimal struct should prevent it from ever happening, not because of
safeguards that we may accidentally remove, but because it should simply be
impossible.
Second, the precision of the floating point numbers is worth diving into.
Floating-point numbers
0.0123456789 - the first non-zero digit is the 1 in the hundredth (second after
the decimal separator) position. If we write fmt.Printf("%.10f",
float32(0.0123456789)) , we get the output 0.0123456791 . Again, only the
first seven non-zero digits were safely encoded, the rest is lost.
Some numbers will have an exact representation in IEEE 754 - numbers that
are combinations of inverses of powers of 2 - up to a certain point. For
instance, 0.625, which is ½+⅛, prints as 0.625000… - and all digits after the
5 are zeroes. But most fractions can’t be written as sums of inverses of
powers of two, and thus, most decimal numbers will be incorrectly
represented, when usingfloat32 or float64 .
We can reach the limits of float32 rather early: the following line doesn’t
print the expected 1.00000000. Even though the first 7 digits are correct
(0.9999… is equal to 1), the eighth isn’t.
fmt.Printf("%.8f", float32(1)/float32(41)*float32(41))
Back to money
When it comes to operations, there are a few things we can take for granted,
and others that we should not.
fmt.Println(math.Sin(math.Pi))
This returns a very small, but clearly not null, value - 1.2246467991473515e-
16. Any mathematician would be offended by this result. However, as
computer scientists, we know that, instead of comparing this number to the
exact 0, we should compare it to 0 within the range of the precision of a
float64 . This is how we could check if sin(π) is close enough (to the
precision of 15 digits) to 0 that we can consider them non-distinguishable:
That was a lot of theory. Knowing all this, there are a number of different
possibilities for implementing this Decimal struct. What we chose to do here
was to split the integer and decimal parts. Fortunately, as the contents of the
struct are private to the package, users don’t depend on our implementation
and it should be possible to come back on this decision anytime without
breaking our exposed API.
This is another reason why hiding the internal details behind a custom type is
generally a good idea. In practice, we could start with imprecise floats and
refactor later. Let’s not, though - we already know that floats can introduce
imprecision, and we wouldn’t want that, right?
Integer and decimal parts are two different numbers. But how do we know
what this decimal represents in the currency? The satoshi is currently the
smallest unit of the bitcoin currency recorded on the blockchain and it is one
hundred millionth of a single bitcoin (0.00000001 BTC), far from the
generally accepted hundredth of euros, francs, hryvni or rupees. We will keep
this precision of the decimal part as a power of ten. This precision is a
number that will range between 0 (we’ll always want to be able to represent
1.0) and a value that isn’t too big. Since we don’t need to represent numbers
bigger than 10^30, we don’t need to store an exponent of 10 that is bigger
than 30. For small numbers such as this, using a
byte is a safe choice. A
byte ’s maximum value is 255, and we’re definitely not going to need that
power of 10.
The fields of the structs are not exposed, and we really want to keep it that
way. We need a building function for Decimal and Amount .
What will it take as parameters, though? If we ask for three ints for integer
part, decimal part and precision, there will be no way of changing this int-
based implementation later. We can expect the amount to be expressed as a
string in the caller’s input, in order to avoid floating point imprecision from
the start.
There are several ways of splitting the string “18.95 ” into “ 18 ” and “ 95 ”, and
the strings package offers two: Cut and Split . Why are we using
strings.Cut and not strings.Split ? We appreciate the simplicity of the
former, and it is a lot more convenient to use when the separator is not
present in the input string.
On one hand,Cut will break the string into two parts, right after the first
instance of the given separator, and return a boolean telling whether the
separator was found. If the string does not contain the separator, the function
returns the full string, an empty string andfalse .
Output of Output of
Value fmt.Println(strings.Cut(" fmt.Println(strings.Split("
{{Value}}", "p") {{Value}}", "p")
You can see on the grape example forstrings.Split that the length of the
resulting slice is the number of “p” +1. It is also interesting to notice that
Strings.Split will not discard empty strings, as you can note on the “apple”
example.
Since ParseDecimal can return an error, let’s take some time to go through
what are error types and how to check them.
Error types
In our case, we are the writers of the library and as polite people, we will
expose a domain error type in the package money and implement the
interface Error from the standard errors package.
Listing 6.7 errors.go: Custom error type for the package money
package money
// Error defines an error.
type Error string
Not a lot of effort, and worth the simplicity in usage. The consumer can now
check whether a returned error is from this package.
Finally, before we start writing code, let’s think of errors that consumers will
be able to understand. The first would be returned if the string to parse is not
a valid number. The second will be raised if we try to deal with values that
are too big. Having a limit is a good idea. It will help ensure that we don’t
exceed the maximum value of anint64 , especially when multiplying to
Decimal variables together, which is bound to happen.
const (
// ErrInvalidDecimal is returned if the decimal is malformed.
ErrInvalidDecimal = Error("unable to convert the decimal")
// ErrTooLarge is returned if the quantity is too large - this would cause floating point precision
ErrTooLarge
errors. = Error("quantity over 10^12 is too large")
)
Now that we’ve exposed the errors we could think of, let’s write the function.
precision := byte(len(fracPart)) #D
How does our precision variable work? Let’s look at a few examples.
5.23 2
2.15497 5
1 0
As you can see, the precision of the parsed number is simply the number of
digits after the decimal separator. If the user gives us 1.1 dollars, it’s a bit
weird but it’s valid. Note that converting it to dollars will give $1.10 back,
with one more digit, because dollars are divided in hundredths.
Testing ParseDecimal
In order to check the results, we need to access the unexposed fields of the
Decimal structure. To achieve this, our test needs to reside in the same
package and be aware of the implementation.
Let’s have a look, in particular, at the test named “suffix 0 as decimal digits”.
In it, we parse the value 1.50, and it gets converted to a Decimal with 150
subunits, and a precision of 2. This is correct, but is it really the best we can
do? We’re dealing with a decimal number here, there is no point in keeping
these extra zeroes, as they don’t bring any information. Let’s simplify a
decimal, with the use of a new method for theDecimal type, called simplify .
This method will be tested via the tests onParseDecimal . simplify will
remove zeroes in the rightmost position as long as this doesn’t affect the
value of the Decimal. 32.0 should be simplified to 32, but 320 should remain
320.
That was an important first step. Remember to run tests and commit - with
explicit messages - regularly, especially upon completion of a piece of the
deliverable. After this complex logic, writing the Currency builder is going to
be easy.
First, add the currency’s precision to the struct. It’s going to be a value
between 0 and 3. Abyte is again a good choice here.
We can use theParse prefix, as we take astring in and return a valid object
or an error. Let’s have a thought about the error(s) we might have to return. If
an invalid code currency is given, we should be able to return an error. Let’s
create a new constant,ErrInvalidCurrencyCode , of moneyError type. As
you can see, the proposed name of the error begins with a capital, meaning it
is exposed. This allows this package’s consumer to check against it. Then, we
can create a function namedParseCurrency that will take the given currency
code, as a string, and return aCurrency object and an error . The first
validation consists of checking if the code is composed of 3 letters; if it isn’t,
we can directly return our new error. Otherwise, we’ll switch on the possible
code currencies and return theCurrency object with their respective
precisions. We will assume that the default case is 2, as most of the
currencies have a precision of 2 digits.
// ErrInvalidCurrencyCode is returned when the currency to parse is not a standard 3-letter code.
const ErrInvalidCurrencyCode = moneyError("invalid currency code") #A
// ParseCurrency returns the currency associated to a name and may return ErrInvalidCurrencyCode.
func ParseCurrency(code string) (Currency, error) {
if len(code) != 3 { #B
return Currency{}, ErrInvalidCurrencyCode
}
switch code {
case "IRR":
return Currency{code: code, precision: 0}, nil
case "CNY", "VND": #C
return Currency{code: code, precision: 1}, nil
case "BHD", "IQD", "KWD", "LYD", "OMR", "TND": #C
return Currency{code: code, precision: 3}, nil
default:
return Currency{code: code, precision: 2}, nil #D
}
}
Again, don’t trust this tool in production. Validating the currency in real life
should be done against a list that can be updated without touching the code.
What we could do without making this project too big to fit in a pocket: we
could go further and make sure the letters are actually capitals of the English
alphabet. Actually, let’s make it an exercise.
Exercise 6.1 Make sure the currency code is made of 3 letters between A and
Z. You can use theregex package if you want to make things complicated, or
check that each of the 3 bytes is between ‘A’ and ‘Z’ included.
Have we properly tested everything? Not yet. Try writing a test for the parser
yourself before looking at our version.
package money
import (
"errors"
"testing"
)
if got != tc.expected {
t.Errorf("expected %v, got %v", tc.expected, got)
}
})
}
}
This time we are not using validation functions but separating the success
cases from the one error case. It is mostly a matter of taste - the only criteria,
as usual, are whether the next reader will understand what we are testing and
find it easy to add or change a test case. By callingt.Run we also make sure
that all test cases can be run separately.
We have a number, we have a currency, we can put them together and make
an Amount of money.
6.2.3 NewAmount
const (
// ErrTooPrecise is returned if the number is too precise for the currency.
ErrTooPrecise = moneyError("quantity is too precise")
)
The test should be quite straightforward, so we won’t give you our version
here. Of course you can still find it in the book’s code repository. Don’t
forget to cover the error case and run the test with coverage to validate you
did not miss anything.
Now, the last step before writing the actual conversion is to update the test of
Convert . As it is testing Convert and neither ParseDecimal , ParseCurrency
nor NewAmount (which are already covered by their own internal tests), what
will we do with the potential errors? It is not the role of the TestConvert
function to check the different values that ParseNumber and friends can
return, but it needs to deal with the errors.
One option to avoid dealing with these errors would be to write a function
that builds the required structures without checking anything, because you
know that your test cases are valid. In order to build them, it needs to live in
the money package and be exposed to the money_test package. But then what
would prevent consumers from using that test utility function and send you
invalid values? It completely invalidates the actual builders that we wrote in
this chapter section. Let’s not choose this dangerous option.
number, _ := money.ParseDecimal("23.52")
package money_test
import (
"testing"
"learngo-pockets/moneyconverter/money"
)
return currency
}
n, err := money.ParseDecimal(value)
if err != nil {
t.Fatalf("invalid number: %s", value)
}
return amount
}
As you can see we are not usingt.Fail but t.Fatal , which stops the test run
immediately.
We can now give actual values to theConvert function’s test. The return
value is still nothing, though.
Does it compile?
Does it run?
Of all the different entities that we built, which is responsible for applying a
change rate?
This logic could belong to the Amount structure. It would know how to create
a new Amount with a new value. Of course, amounts should be immutable and
we need to make sure that the input amount is not modified by the operation.
But does this option make sense conceptually? Would you expect a sum of
money in a given currency to tell you what it is worth in another? Would you
expect your 10 Sterling pound note to tell you “Hey, I’m worth roughly 10
US dollars today”? Probably not. You would go to an exchange office, give it
your note and expect another back with a handful of coins. If it doesn’t make
sense conceptually, then it will be harder to understand for future
maintainers.
Instead, let’s write the exchange office as a function that will be called by
Convert .
Implement applyExchangeRate
As mentioned earlier, we don’t want to use float64 for this piece of the logic.
It’s the most sensitive, and we want to ensure we do exact maths, without
losing any precision on the values we handle. On one side, we have our
Amount ’s quantity field of type Decimal , and on the other side, we have an
exchange rate that will be retrieved in a remote call. We must also provide
the target currency, as that is where the precision of the output amount is
stored.
How should we express that rate? Exchange rates published by the European
Central Bank have up to 7 figures, which means we could safely store it in a
float64 variable. A float32 might not be enough for currencies that use 7
digits - who knows why an eighth digit wouldn’t be added. We’ve already
created a specific type for floating-point numbers with high precision,
Decimal . It would be better to use that. Since this variable won’t represent a
“normal” decimal number, we might as well use a specific type for it, in order
to best describe its purpose. We can even push the zeal to the point of
creating a new file for it, but at this point even the most adamant advocate for
small files among these authors will admit that it can also live in the
convert.go file, just after the exposed method.
A note on code organisation: you always want to have the exposed method
first in a file, as it is easier to read code from its entrypoint. If you have
multiple exposed functions in the same file, you may want to start with an
exposed function and keep the private functions it calls just after.
The first point to notice, here, is that we have obtained “80” by using a lot
more digits than necessary. Indeed, 80 is equal to 80.000, but we don’t really
need this precision. We can make use of the method simplify here again,
when performing the multiplication
The second point to notice is that we have a precision that doesn’t yet take
into account any information about currencies. All we’ve done so far is
multiplying decimal numbers. In applyExchangeRate , we’ll therefore need to
adjust the result of multiply to give it the precision of the target currency.
For this, we’ll have to multiply (or divide) by the difference of precision
between our target currency and the result of the exchange rate
multiplication. Of course, we could have a direct call to math.Pow(10.,
precisionDelta) here, but this would be costly, with lots of casting to and
from floats or integers. Instead, we’ll delegate that task to a function named
pow10 . In the function, we’ll hardcode some common powers of 10 as quick-
win solutions, and default to the expensive call tomath.Pow only for values
out of the expected range of exponents. Overall, thispow10 function could be
implemented with an exhaustivemap or a switch statement. We decided to go
with the latter, but both options are valid.
Let’s write the code for this first part, and then we can implement the
mysterious multiply function. The switch is here to adjust the result with
the precision of the target currency.
// applyExchangeRate returns a new Amount representing the input multiplied by the rate.
// The precision of the returned value is that of the target Currency.
// This function does not guarantee that the output amount is supported.
func applyExchangeRate(a Amount, target Currency, rate ExchangeRate) (Amount, error) {
converted, err := multiply(a.quantity, rate) #A
if err != nil {
return Amount{}, err
}
switch { #B
case converted.precision > target.precision: #C
converted.subunits = converted.subunits / pow10(converted.precision-target.precision)
case converted.precision < target.precision: #D
converted.subunits = converted.subunits * pow10(target.precision-converted.precision)
}
converted.precision = target.precision
return Amount{
currency: target,
quantity: converted,
}, nil
}
The returned Amount is not constructed using the function that validates it.
Instead, we prefer to return an amount that theConvert function has to
validate before returning it to the external consumer. Note that we are being
explicit in the documentation: if a future maintainer (you included) wants to
start exposing this function for some reason, they will need to refactor it to
return an error if needed.
Finally, the core of this chapter resides in the multiplication function, so let’s
implement it! Remember, we don’t want to multiply floats together, as this
could lead to floating-point errors. This means we’ll have to convert our
ExchangeRate into a Decimal . The rest is quite straightforward.
dec := Decimal{
subunits: d.subunits * rate.subunits,
precision: d.precision + rate.precision,
}
// Let's clean the representation a bit. Remove trailing zeroes.
dec.simplify()
This is the heart of the logic. It requires a lot of testing to make sure that
everything works fine and keeps working fine if we ever decide to change
any implementation.
Before writing the test, you can start thinking about all the test cases
imaginable. Here are a few examples:
Table 6.3 Possible test cases
TargetCurrency
Amount Rate What are we checking?
precision
5.05935e-
265_413.87 2 Very small rate
5
1.33 *
2 5 Rate is too high
10^16
The number of different test cases, and how fast we can think of new corner
cases, calls for a table or map-based test.
Of course, as the function is not exposed, the test will have to be internal and
you need a new file for that. The implementation of the test can look like this.
package money
import (
"reflect"
"testing"
)
Finally! Convert can return something useful to the consumer. We don’t have
exchange rates right now, so let’s hardcode a rate of 2 for now and keep the
fetching of exchange rates for later in this chapter.
Congratulations, you broke the test for this function! We are now returning
something so you can fix it by calling mustParseAmount to define the
expected output.
We now trust the heart of the conversion, we can make sure that what we
return to the consumer can be used again by our own library.
return nil
}
This is what the Convert function finally looks like. It is pretty small: not
much would need to be tested internally.
Check your tests: do you have a convincing coverage of the finalised library?
You can check the coverage of your test.
Now that we have the whole structure and logic of our library, now that it is
tested, let’s plug it in, because how is code fun if you don’t run it? After that,
we will fetch some real-life change rates and finish the tool. Having an
executable in which we keep adding features allows us to showcase an early
version of our product that we can improve.
But before we implement all these safety nets, we want to run our
application!
Take a step back. What should our program do? Let’s look at the Usage we
wrote in the requirements.
Note that we have 2 flags - the in and out currencies - and an argument - the
amount we want to change.
Currency flags
flag.String takes as an argument the name of the flag, the default value
(which can be empty) and a brief description. It returns the contents of the
flag -from as a variable of type*string . As we’ve already mentioned in
Chapter 2, calling the Parse method is necessary after all flags are defined
and before values are accessed. Here, we leave the default value empty for
the -from flag, but we set it for the -to flag to the string EUR. Should the user
not provide the -from flag on the command-line, the value of thefrom
variable will be an empty string. Similarly, if the -to flag is absent, the value
will be EUR.
package main
import (
"flag"
"fmt"
)
func main() {
from := flag.String("from", "", "source currency, required")
to := flag.String("to", "EUR", "target currency") #A
flag.Parse()
fmt.Println(*from, *to) #B
}
Now, we can run it. Do you remember how to run a program from the
terminal after all this library development?
With this first implementation of the main, we’re not calling the Convert
function, but we’re printing the source and destination currencies. They
should appear on the screen.
Value argument
When running an executable, most of the time, we need to specify the input,
the behaviour, the output, etc. These parameters can be provided either
explicitly, via the command line, or implicitly, via pre-set environment
variables, or configuration files at known locations. When it comes to explicit
settings, there are two ways of passing user-defined values to the program:
arguments, and flags.
Flag parameters, on the other hand, aren’t sorted. They can appear in any
order in the command-line without altering the behaviour of the program.
They can have default values (used when the flag is absent from the
command-line), as our -to has. An example of a flag that controls behaviour
that you might have been using is the-o {binaryPath} option of the go
build .
In this line, the os.Args would return a list of 6 strings, each entry
representing a word of the command:{"./convert", "-from", "EUR", "-
to", "JPY", "15.23"} . The flag.Args , on the other hand, would return
only the arguments to the command line that weren’t in flags:{"15.23"} .
func main() {
from := flag.String("from", "", "source currency, required")
to := flag.String("to", "EUR", "target currency")
flag.Parse()
value := flag.Arg(0) #A
if value == "" {
_, _ = fmt.Fprintln(os.Stderr, "missing amount to convert") #B
flag.Usage() #C
os.Exit(1)
}
The inputs are in. They are strings, and we are not sure that the values are
valid. Fortunately, we have the perfect functions for that already.
The Convert function is taking as parameters values that are already typed
for its usage, and the package exposes ways to build them. This strategy
optimises flexibility in the consumer’s logic, as main is free to use the type
through any other logic that it could add, or use its own different types and
Parse at the last minute, or use strings and parse whenever it needs it.
We are not doing much more than converting in this chapter (feel free to add
to it later). We just need to parse them all.
package main
import (
"flag"
"fmt"
"os"
"learngo-pockets/moneyconverter/money"
)
func main() {
from := flag.String("from", "", "source currency, required")
to := flag.String("to", "EUR", "target currency")
flag.Parse()
Run it and enjoy the show. You should have some gibberish, something like
this:
For someone who doesn’t know the structures we use, this is hard to
understand. It is therefore polite for the library to expose some Stringers on
its types.
6.4.3 Stringer
If you look into the fmt package, you can find a very useful interface that all
of the package’s formatting and printing functions understand: theStringer .
It follows a Go pattern where interfaces with only one method are named
with this method followed by -er, as in Reader , Writer , etc. Let’s look at how
it is defined:
In order to implement an interface in Go, a type only needs to have the right
method(s) attached to it.
The target currency is now properly readable. Let’s do the same with
Decimal . We already have a method on the type Decimal, and it receives a
pointer - the simplify method. Go doesn’t really like having both pointer and
non-pointer receivers for methods of a type, so let’s haveString() accept a
pointer receiver - we need a pointer receiver forsimplify .
However, not all currencies have a precision of 2 digits, and we must build
this %02 string using the precision of the currency. For this, we can use a
function provided by the package in charge of string conversions - adequately
named strconv . The function we use is strconv.Itoa , which you can think
of as the reversestrconv.Atoi .
We have all the bricks to write the implementation of the Stringer interface
for Decimal type. The output of pow10 gives us the number of subunits in a
unit of the currency, which means we can retrieve the fractional part and the
integer part by simply dividing by the number of subunits. Finally, we can
return the printed output using the formatting decimalFormat .
centsPerUnit := pow10(d.precision) #B
frac := d.subunits % centsPerUnit
integer := d.subunits / centsPerUnit
// We always want to print the correct number of digits - even if they finish with 0.
decimalFormat := "%d.%0" + strconv.Itoa(int(d.precision)) + "d" #C
return fmt.Sprintf(decimalFormat, integer, frac)
Even if Currency ’s String method can arguably skip the unit test
requirement, this one needs one. Take a minute to write it and check your
coverage. Remember that coverage does not check that you are covered, you
could have perfect coverage and still miss a lot of cases; instead, it tells you
where you are not, and you can decide whether it is worth the effort to extend
coverage.
Finally, Amount should also implement the Stringer interface. This could be
adapted to different language standards but we chose
22.368 KWD as the
output format.
Now we have all the Stringer implemented, we can call the Convert function.
6.4.4 Convert
func main() {
// ...
There is a final issue we need to address here. Despite our heavy testing,
we’ve missed something quite obvious. If you’ve tried running the tool, you
might’ve noticed it. When we pass the input amount with a lower number of
decimal digits than the currency’s precision, we display that amount with its
input number of digits, and not its currency’s!
Here’s an example:
If we check the switch in the ParseCurrency code, we see that there are 1000
fulūs in 1 Bahraini dinar - we should be writing 12.500 BHD = 25.00 CHF .
The root for this problem resides in theNewAmount function. Let’s fix it by
taking into account the currency’s precision, and add a test to cover this bug.
There’s a teeny tiny problem, though. This code doesn’t work properly: it
applies a constant conversion rate of 2, regardless of currencies we set on the
command-line. We need real exchange rates. We are ready to call the bank.
6.5 Call the bank
We have a working solution, but for one problem: we are not using the real
exchange rates. We need to call an external authority to get them. Here, the
authors chose to implement a solution based on the API of the European
Central Bank, because it is free of charge, it does not need any identification
protocol, and it is very likely to still be running with the same API in a year
or even two. An unreliable API from a data provider is something that we
don’t want to face.
Fetching and using the data are two separable concerns and any separable
logic should be indeed separated in order to make testing and evolving easier.
The bank is going to be a dependency of our program: an external resource
on which it relies in order to work. It is an accepted best practice in software
design, whatever the language you use, to use inversion of control (IoC).
Inversion of control serves multiple design purposes:
More concretely, the money package should not know where the exchange
rate is coming from, this is beyond its scope. Another package will be
responsible for calling the bank when needed, deal with the bank-specific
logic and return the required info. This other package is therefore a
dependency that the consumer (here our main function) is giving to it, via a
contract in the shape of an interface. This way, the consumer decides what
source of data is the best, and the money conversion is not touched.
Let’s take an example. Let’s say that while you are writing the tool,
somebody else in another team is writing the banking service. You cannot
access it yet. What you can do is create a dependency that Convert can
understand, where you simply return hard-coded values. And when the
service is finally here, you just need to replace the plug with a call to the new
API. Everything else is already tested and runs smoothly. Replacing
dependencies with stubs during development is just one use of this pattern.
Another could be adding a cache: replace a call to the API with a similar
function that checks in memory whether the value is already known and
avoids a network call.
In our case, the dependency’s role is to fetch the exchange rate between two
currencies. Think of an errand boy cycling to the bank a few streets away and
returning with the info, while the clerk responsible for computations is
waiting. Even though the API returns the whole list of currencies that it
knows and exchange rates for 1 euro, the tool doesn’t need the full list, only
the to and from currencies; knowledge about the details of the API should
stay inside the dependency’s package.
Object dependency
The first option requires the consumer to have in hand a variable of a type
that implements an interface. If you know any object-oriented language, such
as the Java family, you will be familiar with this approach.
func main() {
ratesRepo := newRatesRepository() #C
money.Convert(..., ratesRepo)
}
Function dependency
The second option is more verbose but it also works and can be preferred in
some cases. TheConvert function’s last parameter is a function’s definition
rather than an object implementing an interface. The rest is relatively similar.
func main() {
ratesRepo := newRatesRepository() #C
money.Convert(..., ratesRepo.FetchRates) #D
}
The main function is passing directly the FetchRates method that Convert
will be calling.
Alternatively, the consumer is free to create any function on the fly, relying
on variables of the outside scope if needed:
func main() {
config := ...
money.Convert(..., fetcher)
}
As you can see the function dependency option is a bit less intuitive for
beginners - why would a function be a parameter? - but also leaves more
room to the consumer to implement the dependency. It doesn’t fix the name
of the function, nor does it require it to be a method on an object, and
mocking it for tests is slightly easier. As often, it leaves more freedom for the
implementation, which means that mistakes are easier to make.
For example, you can have two different methods on one object and pick the
one you want to use depending on the context. Imagine an API where you
can have daily exchange rates for free, or rates updated every minute when
you are logged in. Both functions have the same signature with different
names, and they are methods of the same object, which contains
configurations valid for both calls.
func main() {
ratesRepo := newRatesRepository()
apiKey := getAPIKey() #A
fetcher := ratesRepo.FreeRates #B
if apiKey != "" {
fetcher = ratesRepo.WithAPIKey(apiKey).LoggedInRates #C
}
money.Convert(..., fetcher)
}
In the rest of the chapter, we choose to implement the rate retriever with the
first approach presented, the interface dependency, mostly because it is easier
to read, explain, and understand.
Let’s create a new package that will be responsible for the call to the bank’s
API. There is no point in trying to make it sound generic: it will only know
how to call this one API from the European Central Bank, so let’s call it
ecbank .
As we’ve seen, the new package should expose a struct with one method
attached, and probably a way to build it.
Let’s talk a little about the method’s signature. We assume that it will take
two currencies and return the rate or an error. It should not return aDecimal ,
becauseDecimal represents money values and has the associated constraint
that nothing exists below the cent (or agora or qəpik). It should return an
exchange rate, for which we happen to already have a business type.
What are we going to call the structure? In real physical life, in order to get
the information, your errand boy would walk to the bank and ask. In our
code, this object does what the bank does, so we can safely call it a bank.
// FetchExchangeRate fetches the ExchangeRate for the day and returns it.
func (ecb EuroCentralBank) FetchExchangeRate(source, target money.Currency)
(money.ExchangeRate,
return 0, nil error) { #A
}
What will this FetchExchangeRate method do? Build the request for the API,
call it, check whether it worked and, if it did, read the response and return the
exchange rate between the given currencies. The whole logic is articulated
around the HTTP call. Let’s see how Go natively deals with these.
The European Central Bank exposes an endpoint that lists daily exchange
rates. You can first try calling the API in your favourite terminal to see what
it looks like.
curl "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
As you can see it returns a large XML response. We will have to parse it and
find the desired value. But first, let’s retrieve this message in our code!
Go’s net/http package provides server and client utilities for HTTP(S) calls.
It uses a struct calledClient to manage the internals of communicating over
HTTP and HTTPS. Clients are thread-safe objects that contain configuration,
manage TCP state, handle cookies, etc. Some of the package’s functions
don’t require a client, for example, the simple Get function, and use a default
one:
http.Get("http://example.com/")
Anything that calls something that isn’t your code should be handled with
extreme care and precaution. Hope for the best, but prepare for the worst.
Here is your chance to be creative: what’s the worst that could possibly
happen when calling someone else’s code? Making a call to a library could,
potentially, lead to a panic, in the worst case scenario.
When it comes to network calls, we could get errors from the network (for
instance, we couldn’t resolve the URL of the resource), or server-related
problems (timeouts, unavailability).
You can declare the constant just before the function, but you can also reduce
its visibility and prevent anything else inside the package from reaching it by
declaring it inside the FetchExchangeRates function. The compiler will still
replace it wherever it finds it.
In case of an error, we return the zero value of the first return value’s type.
This type is ExchangeRate , which is based on afloat64 , so its zero value is
simply 0. We add the dot afterwards to specify to the compiler that we are
declaring a float and not an integer. The compiler will know that it should
actually return an ExchangeRate .
Note that in production code, it is considered a bad idea to stick to the default
client which http.Get is using. See more about clients at the end of this
chapter.
Errors, again
Keeping in mind that the consumer - themain function - should not have to
deal with implementation details, we should not directly propagate the
net/http package’s error to our consumer: if they want to check what type of
error is returned, they would also have to rely on thenet/http package -
then, if we change the implementation and use another protocol, we break the
consumer’s code. Not nice.
We will instead declare the same 4 lines as the money package’s errors, but
these will be specific to our ecbank package: consumers will be able to check
the value of the error with errors.Is() and know its meaning.
Here, we don’t really need to expose the error type we define - it brings no
value to the customers. We only need to expose the sentinel errors, as we
want to allow for error checking.
const (
ErrCallingServer = ecbankError("error calling server")
)
defer resp.Body.Close()
Before parsing the body of the response, we first want to check the status
code. The status code describes how the remote server handled our query.
There is no point in reading the response if we know that the call was
unsuccessful.
Check status
The standard of the hypertext transfer protocol defines a long list of possible
status codes distributed in five classes:
In order to carry on, we need something that starts with 2 - more specifically,
we know that we want a 200. But we also want to check for 4xx and 5xx: in
the first case we made a mistake with our query, in the second it’s not our
fault.
Because we currently only really care about the class of the status code, in
case of a problem, we can use a division to check just the first figure. It’s
perfectly fine to have a function dedicated for only this division.
const (
clientErrorClass = 4
serverErrorClass = 5
)
The FetchExchangeRate function can call this checker and forward the error
without wrapping it: we already made sure we knew what type of error we
were returning. When calling functions from the same package, it is your
responsibility to decide whether you want to wrap the error or not. Errors
coming out of exposed functions should all be documented and of known
types, but you have the choice of where you create them.
We now know that the http call caused no error, and we can ensure that the
server returned a valid response. Let’s now have a look at the XML contained
in this response.
XML parsing
p := person{}
if err != nil {
panic(err)
p := person{}
err := dec.Decode(&p)
if err != nil {
panic(err)
decoder := xml.NewDecoder(resp.Body)
What exactly is this “right” structure? Something that looks like the response
we got, and states what XML field should be unmarshalled into what Go
field, using tags.
To define this structure, let’s start by looking at the response. The European
Central Bank being responsible for euros, everything is based on euros.
We can keep the naming of the response and create a structure called
envelope . However, the XML node name Cube is not explicit enough, so
we’ll use currencyRate s.
The way Go tells the encoding/* packages from and to which node a field
should be decoded or encoded is by defining a tag at the end of the line
declaring this field’s name in the structured language. Tags are always
declared between back-quotes and composed of their name followed by a
column and a value in double quotes. If you need multiple tags on the same
field, separate them with commas inside the backquotes. For example:
To retrieve the attributes of an XML node you just need to tell the decoder to
look for an attribute. Go offers the possibility to “unnest” nodes by using the
> syntax. Here, we don’t want to retrieve thetime attribute of the
intermediate Cube node, only its inner Cube nodes. We “skip” from the root to
the level that contains the data we want withCube>Cube>Cube , where the first
one is a child of the Envelope , and the last one contains our exchange rate.
Now, we need to compute the exchange rate between the source and target
currencies. Remember that the European Central Bank’s exchange rates are
all answers to “which quantity of currency X do I get for 1 euro?”. This
means that the euro can be used as a “transition” currency, or, even better,
that the rates to convert from a currency to another is simply computed with a
hop in euro-world. The rate from CAD to ZAR is, by transitivity, the rate
from CAD to EUR multiplied by the rate from EUR to ZAR. We only have
access to the EUR to CAD exchange rate, but we’ll assume, in this project,
that the CAD to EUR exchange rate is the inverse of the EUR to CAD
exchange rate.
How do we retrieve our two change rates from the decoded list? One
approach would be to go through the list and retrieve them. We would need
to stop as soon as we found both of them. If, when we reach the end of the
list, we didn’t find them, then we can send an error. This implementation
would work, but it doesn’t make the easiest code to read.
The map key is the currency and the value is the rate. Note that we could
improve readability by naming the currency code something else than
string , but as the money package did not deem it necessary to expose the
type, let’s follow suit and roll with a simple string .
We could write a function that takes an envelope as its input and returns the
map, or we could write a method. Both implementations would be clear here.
Using a method implies that we may be changing the object that holds it,
whereas using a function should not. It is more a convention than a real
constraint.
rates[baseCurrencyCode] = 1. #B
return rates
}
rates := e.mappedChangeRates()
We are using the shortened syntax for currencies in input, where multiple
parameters have the same type: this type is only declared once at the end of
the list. Compare:
Don’t forget to test! You don’t need us for this one. While sometimes it is ok
to skip a unit test on some intermediate layers, this kind of computation
should raise a test flag and sirens: coming back to change an implementation
detail may result in this division being switched around and the next thing
you know the FBI is after you for illegal money. But switching a division
around, who would do that, it never happens! You would be surprised.
Once you are done, let’s write the function that reads from the response body
and returns the exchange rate.
As you can see, we are limiting the scope of the arguments to string s and
io.Reader . It could be tempting to send the full money.Currency and
*http.Response that the main function actually has in hand, but it makes
testing harder and blocks future changes for no good reason.
The last thing we need to do for the exposed method of the package is, call
this last function. Easy, right?
The ISO codes of source and target are not exposed, though. They are
accessible via theString() method, so it would be tempting to use that, but
what is the guarantee that the stringer will always return the ISO code? What
if somebody wants to make the CLI nicer and print the full name in English,
they would just have to change the stringer. Boom, nothing works anymore.
We can instead add anISOCode method in the money package, one that
provides the ISO code and whose behaviour is not going to change for the
sake of the presentation.
The final exposed function should look something like this.
// FetchExchangeRate fetches the ExchangeRate for the day and returns it.
func (ecb EuroCentralBank) FetchExchangeRate(source, target money.Currency)
(money.ExchangeRate, error) {
const path = "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
Now, this part is pretty important for our tool, so how do we test it? The code
is explicitly calling a hard-coded URL. Do we really want to make an HTTP
call every time we run a unit test? What if the remote server is not
responding, what if we lost the connection? Unit tests should be local and
fast. We certainly don’t want to have a real call during unit testing.
Add a field path to the object. Ideally, a New function would be tasked with
taking this path as a parameter and creating the object, but we can take a
small shortcut.
if c.url == "" { #B
c.url = euroxrefURL
}
This should work the exact same way if you test it manually. The only
difference is that now you can automate the test.
ecb := Client{
path: ts.URL, #B
}
//...
Of course the example test that we are giving here is just one test case. Don’t
forget to add more cases, including error cases. Instead of writing a pretty
XML response into the ResponseWriter, try this line:
w.WriteHeader(http.StatusInternalServerError)
Good. Now, we can retrieve the rate. Back inmain , how do we use the
previous section with Convert ?
Interface definition
Interfaces
Well, we just do. We already have a function signature that is generic enough
to be mocked in tests. Let’s put it in an interface for Convert to use. As usual,
put it where you need it: you can declare it next toConvert itself.
Why is it not exposed, how can other packages use the interface? Actually,
we don’t want anyone else to rely on this interface, it’s ours, in this package.
If someone else needs to call the same API, they will define their own one-
line interface and mock it the way they want. It reduces coupling quite
drastically.
Use in Convert
We can now add the dependency to Convert’s signature, and call it to retrieve
the current rate. As the caller is responsible for providing the implementation
of the rates provider, it will know about all the kinds of errors that it can
return. If anything wrong happens, we can simply wrap the error that we get
and bubble it up.
Time to fix your test. If you want to be fast, the smallest thing that
implements your local interface is nil . Let’s try:
It compiles. But if you try to run it, you will get a full-fledged panic attack:
This message means you are trying to access a field or a method on a pointer
that is clearly wrong, which is the case of ournil value. It is what the C
family of languages calls a segmentation fault and what the Java family calls
NullPointerException. At runtime, your machine is trying to access a
function on an object whose address is nothing (recognisable by the value
zero). Result: boom.
Wasn’t that fun to watch? At least, this happened in a test environment, and
not on a production platform. Meeting with these runtime errors is always
valuable. Once you’ve experienced a few, you know precisely what you’re
looking for when investigating an issue. Did we access a slice past its
bounds? Did we dereference an invalid pointer? Did we just divide by 0?
Let’s fix this by implementing a stub in the test file: a very minimal struct
that implements our interface and returns values that we require for testing. If
in a bigger project you require a mock, there are a handful of tools out there
that can generate one from the interface definition, e.g. minimock or
mockgen (
https://github.com/golang/mock). The difference between a mock
and a stub is that the mock will check whether it has been called and make
the test fail if the expected calls don’t match the actual ones. A stub will only
imitate the expected behaviour when called, but cannot validate anything.
// FetchExchangeRate implements the interface exchangeRates with the same signature but fields are
unused for tests purposes.
func (m
return
stubRate)
m.rate,FetchExchangeRate(_,
m.err _ money.Currency) (money.ExchangeRate, error) { #A
}
Exercise: Update the rest of the unit test. You need to add the stub to the test
case scenario and give the rate that you expect to get from the dependency.
The last missing piece consists in building the EuroCentralBank and passing
it to Convert .
[...]
rates := ecbank.EuroCentralBank{}
And tada ! Try it, go on. Just because we’re all tired by now, here is an
example command again.
You can try invalid currencies and numbers, have fun. Play, it’s not your
money anyway.
While “ go run . ” is nice, sometimes you don’t want to share the source code
with other people, and only the compiled binary. Generating the executable
file in Go is achieved with the following command:
6.6 Improvements
There are a lot of tiny problems with the implementation we presented here.
The goal of the chapter was to reach a working solution, not a perfect one.
Let’s go over a few ideas and implement one.
6.6.1 Caching
First, we are calling the bank for every run of the tool, which is a waste of
resources. For example, you could be tempted to write a script that reads a
long list of amounts from a file, and, for each line, changes the amount to
Philippine pesos. Currently, there would be an identical HTTP call for each
line. This is quite time-consuming.
A solution would be to dump the rates in a temporary file with the date in the
name. The ecbank.Client struct would have a pointer to that file. If the file
doesn’t exist, fetch the rates and dump them. If the file is too old, same.
Otherwise load from the file.
You would then need to provide a way to flush the cache with a different
flag.
6.6.2 Timeout
You might someday be in the situation of one of the authors of this book:
trying to get the change rate between euros and British pounds, but there’s
some sea above your head and you’ve lost the 4G signal. Fun fact, the
world’s longest undersea section for trains is 37.9 km and the fastest train can
only go at 160km/h inside. What happens when you make a http call and the
server never answers? Nothing, unless you plan for it by the means of a
timeout.
In this code, we’re calling http.Get . Under the hood, this makes use of the
default client available in the net/http package. While this is perfectly fine for
a small example such as this chapter’s, it is certainly not good enough for
production code. Running go dochttp.Client shows that one of the fields of
the Client structure is Timeout. As you would expect, setting this will take
care of interrupting calls exceeding a given amount of time. The default value
of this field, which is the default value of the http.DefaultClient , is zero,
which, as the doc reads, means “no timeout”. Usinghttp.Client{Timeout:
5*time.Second} would, for instance, create a client with a specific timeout,
which can be safely used instead of the default client.
If you look at how the client is defined in the code, you will see a lot of
default zero values:
// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}
// NewBank builds a Client that can fetch exchange rates within a given timeout.
func NewClient(timeout time.Duration) Client { #B
return Client{
client: http.Client{Timeout: timeout},
}
}
// FetchExchangeRate fetches the ExchangeRate for the day and returns it.
func (c Client) FetchExchangeRate(source, target money.Currency) (money.ExchangeRate, error) {
const path = "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
We are now ready to handle undersea tunnels. The http.Get function will
immediately return an error (and an unusable response) if the timeout is
reached, and it’ll be up to the caller to decide what to do. Thenet/http
package warns us, in the documentation ofhttp.Get , that the errors returned
are of type *url.Error (the pointer information is very important), and that
we can use that to determine whether the call timed out. This is a nice
opportunity to discover a useful function of the errors package.
We’ve already seen that we can test if an error is of a specific flavour, with
errors.Is . Sometimes, we want to inspect the error a bit further, especially
when we know there is something more than an error message that can be
extracted from the error. In this case, we are informed that the error returned
is, in fact, of a specific type. This means we could cast it to that type:
urlErr, ok := err.(*url.Error)
This would then allow us, provided the ok variable is true, to access fields
and methods of the*url.Error structure. Let’s have a look at what’s over
there: go doc url.Error .
As we can see, there are several exposed fields in that structure - the
operation that was attempted, the URL that was requested, and the error
itself. But what is interesting for us, here, is that we can call aTimeout
method that returns a boolean value. This is how we can ensure we did
indeed reach a timeout.
if urlErr.Timeout() {
This is nice, but there is a nicer and more idiomatic way of performing this
operation: we can make use of theerrors.As function. Its signature is
simple: it takes an error, a target, and it returns a boolean - whether it
succeeded. When it did, the target now contains the value of the original
error.
It’s now up to you to decide what should be done when a timeout is reached.
It could be interesting to retry after a few moments - maybe the 4G coverage
is now better and we’re out of that undersea tunnel. Or we could decide that
any error we face is fatal for the process of converting money, and it’s not
our converter’s responsibility to choose how to deal with network errors.
Beyond timeouts
As we’ve seen, our http.Client structure can be tuned with a timeout. But a
timeout isn’t the only value we can set for our client - for instance, here,
we’ve not overwritten the Transport field of our http.Client , which means
we’ll be using the http.DefaultTransport in our client. The arguments for
using a specific http.Client applies here again - we might also want to tune
the Transport within our Client .
We can’t re-use the same test as previously, since we were only passing the
URL of the bank to the Client . This time, we need to use a client that will
proxy to the server’s URL. We can do this with the Transport.Proxy field of
the http.Client structure. Here’s the implementation for this:
ecb := Client{
client: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL), #A
},
Timeout: time.Second, #B
},
}
The rest of the test is the same as before. But this is only testing the happy
path, let’s also test the case where a timeout occurs! For this, we’ll change the
behaviour of the NewServer we build in the test, and, instead of writing an
XML to the response, we’ll instead simulate a long wait with time.Sleep .
We’ll re-use a similar client as in the successful test, and this time, we’ll
check the error that is hopefully returned!
ecb := Client{
client: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
Timeout: time.Second, #B
},
}
Timeout’s value
When it comes to choosing a “good” timeout value, you want to think about
the call you’re executing. Your timeout is your patience, you don’t want to
think about what the remote service has to execute your query, because you
shouldn’t know. Their implementation and performance might change
without you having to change your code. A rule of thumb, though, when
making calls to external resources is that a call that leaves your environment -
be it a local network, or a cloud platform - should be allowed up to a few
seconds. You need to account for all these time-consuming network
handshakes. When running locally, a few seconds are only tolerable for big
processes, and the usual value is less than a second. These numbers aren’t set
in stone, as they need to be tuned for your own use cases. Allowing only 20
milliseconds for a call over the internet is too short, and if your timeout for a
local query is 30 minutes, there is something fishy in the architecture.
In your everyday developer life, you will be led to import external libraries
from the open-source world. This is very frequent for most libraries. In
general, they are organised with the exposed types at the root of the module
as it minimises the path to reach the required package for users. Compare
github.com/learngo-pockets/money instead of github.com/learngo-
pockets/moneyconverter/money .
We have created a folder namedmoney containing one file, exposing all the
methods and types to the users and have everything at the root.
Congrats! You are done! It was a tough chapter with different concepts that
we will practice again over the following chapters.
6.7 Summary
The flag package exposes functions that allow us to retrieve both
arguments and optional parameters from the command line. We can
even set default values to our flags. Remember to callflag.Parse()
before checking the values of the flags!
When implementing a functionality, it is good to declare types that
mirror the core entities that we will have to handle. In our case, we
created aCurrency , a Decimal , an Amount , and we defined whatConvert
should do before writing a single mathematical operation or calling a
single function. We also knew from the start how to organise our code,
which types and functions should be exposed.
The fmt.Stringer interface is a simple interface that will make printing
complex structures nicely.
Floating point numbers are inaccurate, at best. There should always be
room for a margin when comparing two floating point numbers.
Operations on floats will sometimes seem nonsensical, because of the
precision limitation. Knowing their precision and the precision of the
computation they’re used for will help make a choice of float32, float64,
big.Float - or to go for something else.
A package is most often built starting with its API, and finishing with its
unexposed functions.
Go’s net/http package offers functions to perform HTTP calls, such as
GET or POST requests, over the network. It offers a default client,
which can be used for prototyping, but shouldn’t be kept in production-
level code, for security reasons.
A HTTP call will return a response that contains a status code. Checking
this status code is mandatory - the code informs how meaningful the
body of the response is. Status codes are divided into 5 classes, but the
most important are 200 (and 2xx), which means everything went fine,
400 (and 4xx) which means the request might be incorrect - some fields
could be missing, some authentication could be wrong, … -, and 500
(and 5xx), which mean the server faced an issue and couldn’t process
the request.
Testing HTTP calls should be agnostic from external behaviour. Go
provides a httptest package to mock HTTP calls by providing an
infrastructure to set up an HTTP server for tests. a useful NewServer
function.
A clean code separates the responsibilities of each package. Retrieving
data isn’t the same task as computing data, and should be handled in a
separate piece of code. Go offers extremely simple interfaces that allow
for dependency injection. Dependency injection makes code simple, and
tests even simpler.
Stubs are a very nice way of implementing interfaces. Simply declare a
struct where you need to implement an interface - usually in a _test.go
file - and have it implement the interface. A stub is very useful when
trying to improve test coverage, but it can only be used for unit tests, as
they don’t check the whole logic of the call.
Exposed functions, in a package, should return sentinel errors. This
makes using a package clean and simple. Within the implementation of
the package, the decision of creating the sentinel errors in exposed
functions or in unexposed functions is left to the developer.
Using
errors.Is how we test if an error is a sentinel. Usingerrors.As
is how we access an error’s fields and methods.
Go’s encoding/xml package provides a function and a method to decode
an XML message. In order to be able to decode some bytes into a
structured variable, Go’s syntax requires that the fields we want to
decode be described with the XML path where their value can be read. It
is even possible to skip layers by using the> character in that path.
The toolchain offers the go build -o path/to/exec . command,
which generates an executable file at the specified location.
7 Caching with generics
This chapter covers
Using generics in Go
Not using generics when they are not needed
Creating type constraints
Goroutines, parallelism and concurrency
Race conditions
Mutexes
Some Go proverbs
Computers, on the other hand, won’t judge you for taking shortcuts. A cache
is such a shortcut: it’s a key-value storage which can access data that has at
least been computed once in the past - as long as it makes sense to access it.
Caches are often used when we know a slow function returning a value will
be called several times with the same input - and that the output value should
be the same every time. There are cases when we want to use a cache - for
instance, you know that “the city with the longest name is: Bangkok”. This
isn't something that you need to check every morning. There are cases when
we specifically don’t want to use a cache - “The current exchange rate of the
Algerian dinar to Euros is: ??” - in these cases, an outdated response could
lead to confusion. And sometimes, there are situations where using a cache
could be acceptable - “The total population of Ethiopia is: 123,967,194”
doesn’t really require an instantaneous answer, the value from last week
having the same magnitude as that of this week.
In this chapter, we will present generics and implement a naive cache.
Through tests, we will show our first approach isn’t good enough and needs
to be strengthened. Then, we will add a “time to live” to values in our cache,
to make sure we don’t store outdated information. Finally, we’ll cover some
good practices of using caches.
Requirements:
There are two important notions regarding a cache that need to be evoked
here.
A cache should be able to store any new pair of key and value;
When retrieving a value using a key, the cache should return what was
previously stored in it;
Usually the whole reason for it is speed: a cache needs to be fast.
Here are some examples of pairs of keys and values a cache could hold:
Through these examples we would like to show that a key can take many
forms, and that a value can take even more. We’ll cover this in detail later,
but for now, we need to understand generics and how to write them in Go, so
we can write our first implementation of a cache.
We need to start with a bit of theory, but we will try to keep it short. We are
here primarily for hands-on projects, after all.
prettyPrint(.25)
This is nice and clear, but how can we write a function that prints anint ? Do
we have to copy the tree lines and change a few characters? Well, believe it
or not, ancient generations of Gophers remember the time when yes, you
indeed had to copy all of these little functions around. But generic types, also
known as generics, arrived and saved us from so much boilerplate.
Generics let you write code without explicitly providing the type of the data a
function takes or returns. The types are instead defined later when you use
the function. Functions are just an example. You cannot fathom the amount
of boilerplate code (and bugs) that got swept away by this great feature. But
like all good things, we should not overuse them, and we should know what
we are doing with them.
How does it work? Instead of specifying a real type, like float64 above, we
define a generic type and give it a constraint. The least-constraining type
constraint is the keyword any , which really means what it says: any type at
all. Here is an example of a single function that first accepts afloat64 as its
parameter t , and then accepts astring as the same parametert . You can
notice that the signature of the function is somewhat different - we’ve used
square brackets between the name of the function and its parameters. We’ve
declared in these square brackets that T is a placeholder for the type
constraint any for the scope of this function’s declaration. The typeT is set
when we call the function.
prettyPrint[float64](.25) #B
prettyPrint[string]("pockets")
In this example, we can no longer use the%f verb in Printf , as this is only
usable with floating point numbers. We specify, when we call the function,
what the type T should be.
Type inference
prettyPrint(.25) #A
prettyPrint("pockets") #B
Type inference might make generics seem a bit magical. It’s worth noting
type inference will only happen on input parameters, not on returned types.
Type constraints
We saw that any , introduced by Go 1.18 along with generics, is a keyword
that can be used as a type constraint for, actually, no constraint at all. It is
equivalent to interface{} . There is only one other built-in constraint:
comparable is implemented by all comparable types, that is all types that
support comparison of two elements using== or != . Into this category fall
booleans, numbers, strings, pointers, channels, arrays of comparable types,
structs whose fields are all comparable types. Slices, maps, and functions are
not comparable. If you create a map and want its key to be generic, it will
have to becomparable . More on that soon.
We have seen in the previous chapters how interfaces work, and how any
structure can implement an interface as long as it has all methods attached
with the right signature.
Type constraints are interfaces, and, of course, you can declare your own. As
%v is not exactly formatting prettily, here is a different version:
The difference here is that now we cannot give floats and integers to our
function anymore because they don’t implement theStringer interface, but
we can give any structure that does implement it. If you remember theAmount
from our previous chapter’s money converter - it did implement this
interface, and we can therefore call:
Generic types
Let’s say we want to define a group of Amounts so that we can perform some
specific operations on them.
func main() {
var g Group
g.PrettyPrint()
}
But of course, suppose we wanted to do the same to pencils and clouds and
dresses. So, we would decide to make this group generic:
var g Group[Cloud]
g.PrettyPrint()
Here we are: the compiler can now create a version of theGroup that supports
Clouds, and only clouds.
One last thing you need: what if you want to support multiple integer types?
Or support int and your own PocketInt but nothing else? You cannot make
primary types implement an interface, but you can define union interfaces.
type summable interface {
int | int32 | int64
}
This means that any function that can take a summable as parameter will
accept any of int, int32 or int64, and will be able to use the + operator on it.
However, now, if you define a new type that is an int (in other words, a new
type whose underlying type is int), it won’t be included. For example:
To support all things that are actually int s, we use the~int (with a tilde)
syntax to include all types whose underlying type isint . The following
interface includes the typeage above.
If you are talking about stars and specifically need an int64 for ages, you can
change your type and everything will fall into place.
Since this project is about creating a library, we’ll use a common organisation
of our files. In Chapter 4, we exposed a module that contained a package
pocketlog . Although this was nice when we needed to introduce packages,
most open source libraries will expose their types at the root of the module,
as this prevents having cumbersome import paths. Here, we want our users to
import our cache package with the minimum effort and this means placing
our cache as early as possible.
learngo-pockets/genericcache
├── cache.go
└── go.mod
This will allow anyone to use our library by importing our module, and then
using genericcache.Cache . We will, of course, require other files - but these
are the bare necessities that our users will need.
7.1.3 Implementation
As we’ve seen earlier, a cache is a place to store data in a way we can retrieve
it easily. In our case we decided to have a key value storage that could be
used for almost any type of key, and any type of value. We need our keys to
be comparable - that is, we need to know if two keys are considered equal.
This is achieved with the constraint comparable .
In our tests, we use simple cases where the key is an integer and the value a
string - but feel free to use other types instead.
As you can see, we have defined two types, giving them one-capital-letter
names as per convention.K for key and V for value seem as self-explanatory
as we can get.
New
Since our cache contains a map, we need to initialise it. A side effect of using
a map in a structure is that the zero value of variables of that type should be
treated with caution. Not being able to use a zero value is an argument to
keep a type unexposed.
Read
The most common operation executed on a cache is usually to read from it, as
that’s the whole point of our cache. Let’s write a method to achieve this. This
method accepts a key of the adequate type, and returns the value - also of the
adequate type. It is up to us to decide what to return when the key isn’t found
in the cache, which is the case when the value hasn’t been stored there yet.
As a reminder, Go’s default map implementation returns a boolean and a
value, and that’s what we’ll be using here. Should you want to use errors,
you’ll have to decide how to return the error: via a constant and a local type,
or via an exposed variable. You can also, as usual, call
errors.New directly
in your return line, but it will be harder for users to compare with a known
value and decide what to do next. We simply think having the same interface
as a map makes things clearer for the end user.
The most-used function is written. We could unit-test it, but in this situation,
an integration test involving multiple operations seems a better idea. If we
write a unit test now, it will be extremely tied to the implementation choices
and will not help us in any future refactoring. We would end up testing
whether Go can read from a map, which is already covered by the Go
developers.
In order to write the integration tests, in order to read from the cache, we
need to first be able to write something into it.
Upsert
A question to be raised early here is “should we let the user insert the same
key several times ?”. In most caches, a “recent” value is usually more
interesting than an “old” value. For this reason, we decided to silently
overwrite any previously existing value in our cache - but other
implementations might decide to return an error if the key is already present
when we try to add it in the map.
Since we’re overwriting any potentially existing data, we can name our
method Upsert - a combination of “insert” and “update”. It guarantees the
key will be present in the cache, associated with the specified value.
Upsert could return an error. For instance, we might want to limit the number
of elements in our cache - hitting a limit would be a valid reason to divert
from the happy path. Let’s keep this door open from the start. After all,
returning an error is perfectly normal in Go.
Nearly there. You can start writing a unit test that writes, reads, checks the
returned type, checks the returned value for an absent key, writes another
value for the same key, etc. There are a lot of different situations that can
already be covered.
Delete
The last operation we need is deleting a key from the cache, for when we
know that this value is stale. For example, say we are pre-computing the list
of group conversations that each user is part of. Somebody creates a new
conversation and invites 5 people. Each of them will need a new computation
of the listing, but only when they open the messaging app. We can invalidate
their keys and let the system re-compute next time the list is required.
Most caches grow with no real limit. At the end of this chapter, we’ll expose
a few ways to keep the cache manageable.
Unit-testing a one-liner like this is a question that a dev team needs to solve
as a group: what is the level of testing we want to have on this, are we testing
our own code or the Go map itself? Since we didn’t add any logic on top of
the map, we decided that our code - so far - didn’t need unit tests. This
doesn’t prevent us from writing some small functional tests - a list of calls
that ensures that we indeed insert values in our cache, and that we’re able to
retrieve them.
Our first implementation of the cache seems to cover our needs - we can store
data, we can retrieve values using the keys that were used to insert them, we
can remove some data, if need be. The world seems perfect. That’s precisely
the moment when someone in your team makes a comment in the code
review - “Is this thread-safe?”
A processor is in charge of running the binary code that was generated by the
compiler. Each program, when launched, is loaded in the memory, and then
executed on the processor. But, wait, does that mean that a processor can
only run a single program at a time? The answer is no, for two reasons. The
first one is that programs run on cores, which are parts executing the binary
code in the processor. In the 2000s, processor manufacturers started shipping
their processors with 2 or more cores. Each core can dedicate its activity to
only one task at a time. If you have more cores, they can run multiple tasks at
the same time independently on a single computer. The other reason is that
our operating system, which is also a program, coordinates different tasks and
programs to run on these cores. The user interface has to run somewhere.
There must be some running piece of code that reads input from the
keyboard. There must be something that communicates with your hard drive.
For many programmers, the fact that several cores were present on a machine
meant that there were more resources that could be used to run a program.
After all, if the load could be balanced on two cores instead of one, maybe
the program could run twice as fast! Let’s douse your hopes right now: in
most cases, this doesn’t work.
How can we use this feature? Pieces of a program that run independently at
the same time are called threads, coroutines, fibers, or in Go, goroutines.
In this section, we’ll see what goroutines are, how to create them, and how to
manage them.
a = taskA()
b = taskB() #A
This is what goroutines address. They allow you to run several tasks
simultaneously - provided you can launch them. This last bit is usually not an
issue - goroutines are really light to handle, and unless you start creating
millions, you should be fine.
Now, there is a word that has been used in this section that needs a closer
look. We’ve used “in parallel”, “simultaneously”, “in the background” or
“concurrently” to represent the idea that a goroutine doesn’t block its caller.
Over the years, these words have sometimes been used interchangeably.
Fortunately for us, Rob Pike wrote some proverbs for Go https://go-
(
proverbs.github.io/), and one of them deals with this specific topic. It also
helps us getting clear definitions of what each of these words mean, as we
explain right after:
Go proverb
This proverb highlights that having two (or more) goroutines does not
guarantee any simultaneous execution on parallel cores, but that they will be
executed independently, for better or for worse. Concurrency should focus on
how to write code to support goroutines, while parallelism is what happens
when the code is executed.
Let’s remember that Go was created with, in mind, the idea that running
goroutines should be simple. The creators of Go made it extremely
straightforward: if you want a function to run in the background, you simply
prefix its call with go . That’s it. It doesn’t require any specific import or
compilation options. Here is a simple example:
package main
import (
"fmt"
"time"
)
func main() {
// Run two goroutines
go printEverySecond("Hello") #A
go printEverySecond("World") #B
go cookCurrySauce()
go cookRice()
// how do I know the food is ready?
There are two major ways of dealing with this - the first one is to use
channels, and the second one is to use a library that solves the problem.
Go has a specific type called “channels” that it can use for communication
between goroutines.
Go proverb
A channel can be seen as a conduit to which data can be sent - and from
which data can be retrieved. A channel, in Go, is declared for a specific type
of message it will contain. For instance, if a channel were to be used to
convey integers, we’d write the following line:
Channels, like maps and slices, need to be instantiated with the make
function. When instantiating them, we can decide whether we want a channel
to be buffered - it will only be able to contain up to X elements - or
unbuffered - with no limit to the number of elements it contains at any given
time.
c := make(chan int, 10) #A
c := make(chan int) #B
c := make(chan int) #A
c <- 4 #B
i := <- c #C
The power of channels, in Go, comes from the fact that items are read from
the channel in the same chronological order they were sent. In other words,
first in, first out.
c := make(chan int)
c <- 4
close(c) #A
i := <- c
c := make(chan struct{}, 1) #A
_ = <- c #E
We introduced two commonly used notions in this example. First, a channel
can be used to notify its listeners. Here, we only want to notify that we’re
done - and for this, we use the Go trick of empty structures:struct{} ,
because empty structures are very light (they have a memory footprint of 0
bytes). We don’t need a convoluted structure that would transport data
around, and so we don’t use one. There’s no point in overdoing it here.
The second interesting part is the signature of the function we run as our
goroutine. A small arrow <- squeezed its way between the wordschan and
struct{} . When we declare a function, we can be a bit more specific than
“here’s a channel for you to use”: we can specify in the signature of the
function whether a channel should be used for reading messages from it, for
writing messages to it, or for both. If a function should only read from a
channel of strings, its signature can be written asfunc read(c <- chan
string) . Visually, the arrow points out of the channel, an indication that
messages will be read from the channel. If we want to specify that we want to
write to a channel in a function, we can use thefunc write(c chan <-
string) syntax. Visually, the arrow helps us understand that strings will be
sent into the channel.
If we wanted to both read and write from a channel, the syntax is simplyfunc
rw(c chan string) . No arrows this time. However, we discourage passing a
channel for both reading and writing to a function - this suggests the
function’s scope is too big, and we should be able to extract the reading and
the writing into two different functions.
Finally, a channel should be closed when the job is done, to tell listening
goroutines that no more data will arrive. When a single function is in charge
of writing to a channel, that function should be in charge of closing the
channel. Leaving it open is not a problem if you don’t want to signal listeners
that you’re done.
Let’s have a final look at how we’d write our synchronisation point if we
have to handle several goroutines:
numRoutines := 2
c := make(chan struct{}, numRoutines) #A
go cookRice(c) #B
go cookCurry(c) #B
Bon appétit.
Using sync.WaitGroup
Let’s have a look at the sync package, in particular its WaitGroup type. go
doc sync.WaitGroup tells us that WaitGroup s can be used to wait for
goroutines to finish - which is exactly what we’re trying to do here. The
WaitGroup type exposes three methods:
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{} #A
wg.Add(2) #B
go cookRice(wg) #C
go cookCurry(wg) #C
wg.Wait() #D
}
The second step is to set the number of goroutines that this WaitGroup will
be in charge of. Here, we made a single call to Add, but it’s perfectly fine to
call Add(1) several times. This is quite common when you have to deal with
loops. We could have written our code this way, which makes it easier to
refactor, if you want bland rice or just the sauce:
wg.Add(1)
go cookRice(wg)
wg.Add(1)
go cookCurry(wg)
Finally, we call wg.Wait() , which will return after the same number of
Done() have been called as the sum of all theAdd(n) we’ve performed on
this WaitGroup .
wg := &sync.WaitGroup{}
wg.Add(2)
errChan := make(chan error, 2)
go cookCurry(wg, errChan)
go cookRice(wg, errChan)
wg.Wait()
As you can see, we can retrieve some errors from the goroutines with an error
channel. Unfortunately for us, we had to pass a channel around to read errors,
and the whole point of using a WaitGroup was to not have to use channels in
the first place… Well, guess what? There is a library that allows us to handle
errors when we’re using goroutines.
Using golang.org/x/sync/errgroup
errgroup is, as you can see, a package that is not in the standard Go library.
This means that, if we want to use it, we need to start by importing it as a
dependency of our module:go get golang.org/x/sync/errgroup .
go doc golang.org/x/sync/errgroup.
We can find a type Group in there, and four methods - we’ll only cover three
of them here, as they’re the most commonly used. But, first, how do we
create aGroup ? Well, we can either use a zero value -eg :=
errgroup.Group{} , or we can use theerrgroup.WithContext(ctx) function.
In our simple example, we don’t have contexts and we will go with the first
option, but, in the vast majority of cases, using the second option is
recommended, as you’ll have a variable of typecontext.Context closeby.
We will cover contexts in a later chapter. Internally, an errgroup.Group is a
sync.WaitGroup with extra fields to handle - mostly - context and errors.
Now, what can our Group do? It has aSetLimit(n) method, which reminds
us of the Add(n) method of the WaitGroup . They are different, though, in that
when we called Add(n) , we needed to have n equal to the number of
goroutines we were launching (and for which we’d later call Done() ).
SetLimit doesn’t work the same way: instead of immediately defining how
many goroutines will be launched (the errgroup.Group tracks this
internally), we specify a maximum number of goroutines allowed to be
running at the same time. Most of the time, you will want this value equal to
the number of goroutines you are running, which is the default value, but
sometimes your goroutines make use of a resource that doesn’t scale well
with load - maybe each of your goroutines calls the database, and the
database can only handle 10 calls at a time. In such cases, it’s perfectly valid
to have a hardcoded limit in your
Group .
It has a Wait() method, also quite similar to that of the WaitGroup type,
except that it returns an error. This is quite important, as we’ll soon see. And
finally, it has a Go method that takes, as its parameter, a function returning an
error. This Go method is in charge of launching the goroutine - it is also in
charge of letting the Group know when this function finishes.
package main
import "golang.org/x/sync/errgroup"
func main() {
var g errgroup.Group #A
g.SetLimit(2) #B
g.Go(func() error { #C
cookRice()
return nil
})
g.Go(cookCurry) #C
err := g.Wait() #D
if err != nil {
// handle error
}
}
func cookRice() {
// cook rice here
}
Edsger Dijkstra
Program testing can be used to show the presence of bugs, but never to show
their absence !
First, let’s have a look at the test we currently have and notice one thing: it’s
extremely linear. It validates that, if we do a specific operation before another
one, then the output is predictable. Does it run anything in goroutines? No -
which means it proves absolutely nothing about thread-safety.
Our cache could possibly be used by several goroutines during the execution
of a program - for instance, several incoming requests could be processed at
the same time, causing the cache to be updated in a very short window. Let’s
start by writing a test that simulates these “simultaneous” calls.
Using goroutines
For this, we’ll use the sync.WaitGroup - we need to run goroutines and we
want to make sure they’ve all finished before we can return from the test. In
order to make things “problematic”, let’s have each of the goroutines write a
different value in the same cache, every time for the same key. Here is what
we write:
const parallelTasks = 10 #B
wg := sync.WaitGroup{}
wg.Add(parallelTasks) #B
wg.Wait() #E
}
Using t.Parallel()
The gist is as follows: if a test function contains the line t.Parallel() , the
Go test framework will run it along with other functions in the same scope
that also have thet.Parallel() line. In other words, the execution of this
function won’t be blocking for the execution of other test functions.
Let’s write a test using the t.Parallel() feature. In our test, we want the
same index of our cache to be written at by two different calls, with different
values in each case.
Now let’s run it and see what happens:go test . - everything seems fine.
However, we’re cheating here - we’ve written this test because we know
something should go wrong. We know that upserting two different values “at
the same time” is precisely a data race, and we want it to be caught. But how
can we achieve this?
The go test command comes with several flags, here’s how to find them.go
help test returns a short list - namely, -args , -c , -exec , -json and -o - but
it also informs us that the flags from the build command are inherited by the
test command. Let’s have a look at the output ofgo help build , then. One
of the first flags provided is -race , which “enables race detection” - precisely
what we’re looking for.
Let’s run our test again, but this time with the -race flag: go test -race .:
we get the following output
As you can see, Go was able to detect that we were writing at the same index
twice, at the same time. This constitutes a data race, and this is what would
make our cache not thread-safe.
You might notice that we’ve eluded describing the --trimpath flag here. The
default behaviour of Go’s test framework is to output the absolute path of
failing tests (and the stack that leads there). Usingtrimpath
- , we tell Go to
only output the path from the root of our module. This makes the output
clearer when sharing it.
We can now answer our collegue’s remark: our implementation of the cache
is not thread-safe. This is a severe flaw in design and security. We need to
work on it.
var mu sync.Mutex
Before we dig into how to use our mutex, it is important to remember that a
mutex is always used to protect the access to a resource. Place it in your code
as close as possible to the resource the mutex protects.
Let’s have a look at go doc sync.Mutex . We see there that aMutex exposes
Lock() , Unlock() , and TryLock() . While the first two methods are quite
explicit, one might be tempted to useTryLock . A quick glance at its
documentation, through go doc sync.Mutex.TryLock tells us that if we
resort to using this method, we have a deeper problem.
We can lock our mutex when we want a piece of code to have exclusive
access to the resource, and unlock it afterwards. We’re almost ready to use
our mutex - there is a final line of the documentation that is worth engraving:
“a Mutex must not be copied after first use”. Copying a mutex by passing it as
a parameter to a function is a mistake that usually leads to unexpected
behaviours when locking or unlocking the mutex. They and the structures
containing them need to be passed as pointers.
Let’s return to the code and add a mutex to our cache. First, we’ll add a
mutex next to the resource we want to protect - thedata map, within the
Cache structure.
Each method on theCache type will ensure only a single goroutine can enter
it at a time by having the same two lines:
c.mu.Lock()
defer c.mu.Unlock()
We can now re-run go test -race . : we should no longer see any data race
detected. The mutex seems to have done the job. However, using mutexes
isn’t free - there is a cost in time execution every time we lock (and unlock).
For this reason, it is worth checking we weren’t overzealous in our usage of
mutexes. In our example, while we are ensuring that no two goroutines
update the contents of the cache “simultaneously”, we’re also preventing two
goroutines from reading from our cache, which is not a conflicting operation.
In order to address this specific need, the standard library exposes another
mutex: the RWMutex, a read-write mutex, also in thesync package. This
mutex is very similar to the basic Mutex - it also exposesLock() and
Unlock() - but on top of that, it also has aRLock() and a RUnlock() methods
that are used when we only want to use the mutex to read data. Any number
of goroutines can call RLock() without blocking each other, but as soon as
Lock() is called, no goroutine can access the resource - neither for reading,
nor for writing.
We can update our code - the mutex in the cache should beRWMutexa . The
Read method should only call RLock and RUnlock , as it doesn’t modify the
contents of the cache.Upsert and Delete will still need a regular Lock and
Unlock call. As a general rule, sync.Mutex is the way to go, and
sync.RWMutex should only be considered if you are facing performance
issues - and even then, caution should be the rule. Because of its richer
interface, accidentally calling RLock instead of Lock will have a disastrous
impact on the code - and the compiler won’t tell you. Don’t blindly believe
that RWMutex is faster thanMutex - instead, benchmark it for your specific
usecase, and use the appropriate one.
mu sync.Mutex
data map[K]entryWithTimeout[V]
}
Let’s have a thought about what will happen in our Read() , Upsert() , and
Delete() methods. The easiest one isDelete : there’s nothing to change
there. A key can be removed, regardless of whether the associated value has
reached its expiration date. Then, let’s have a look atUpsert . We used to
either insert the data, or override the value. Well, things aren’t very different
now - upon insertion, we’ll add the data with the correct expire value, and
upon updating, we’ll not only override the value, but also its expire field.
c.data[key] = entryWithTimeout[V]{
value: value,
expires: time.Now().Add(c.ttl), #A
}
}
Finally, we’re left with the trickier Read() method. This is where we’ll check
whether an entry is no longer valid. We need to add a second check on top of
the present one that verifies our cache has a value for the requested key. If the
value is still valid, we can return it. But what if it’s not? In this case, in our
implementation, we decided that the user doesn’t need to know why the value
isn’t in the cache - after all, what matters is that it couldn’t be found.
var zeroV V #A
e, ok := c.data[key]
switch {
case !ok:
return zeroV, false
case e.expires.Before(time.Now()):
// The value has expired.
delete(c.data, key)
return zeroV, false
default:
return e.value, true
}
}
By implementation, our Read() method now has to alter the contents of the
map. As a result, we can’t rely on aRWMutex as we did in section 3. Instead,
we use a regularsync.Mutex . This will have a small impact on performance -
two Read() can no longer be executed simultaneously.
time.Sleep(time.Millisecond * 200)
assert.False(t, found)
assert.Equal(t, "", got)
}
We start our test with a call to t.Parallel() . Indeed, we’re fine running this
test along with others. We recommend using this in every “light” test. If a test
requires a lot of resources - CPU, RAM, disk, network, then you might not
want to have it run with others. In our case, we’re absolutely fine.
Schrödinger’s conundrum
You might have noticed that we discard expired items only when we try to
access them viaRead() . This means that items could expire way before we
look at them, unbeknownst to us. The side effect is that our cache might be
using chunks of memory for useless data. How do we deal with that?
In order to prevent too many items from being added to the cache, we will set
a limit to our cache’s size. This will be a property of the cache, an unexposed
unsigned integer keeping track of how many items were added and removed.
Architectural decisions
We’ll need to make a decision when we try to add a new value into the cache
and the maximum number of items is reached.
For this, we need to keep track of the order in which items were inserted.
Let’s look at which options Go offers to implement this:
if len(c.data) == maxSize
if len(c.data) == cap(c.chronologicalKeys)
This last parameter is here to tell the capacity of our slice at execution. When
an element is appended to a slice, if that slice’s length is equal to its capacity,
the whole slice needs to be reallocated elsewhere in memory. Setting the
correct capacity to our slice prevents these reallocations.
Now, just as we did for the TTL, let’s have a look at the impact of having this
slice in our cache for each of our exposed functions.
Implementation
New() should take another parameter: the maximum size of the cache. Having
a default value doesn't really make sense here - a cache of 10 integers
wouldn’t be the same size as a cache of 10 extremely complex structures with
lots of fields. The package reflect could help us set a maximum memory
size to our cache based on the memory footprint of a single item, but this
would be overkill. Instead, let’s have the user specify a size they think is
good enough. Then, any memory consideration is left to them.
mu sync.Mutex
data map[K]entryWithTimeout[V]
maxSize int
chronologicalKeys []K
}
Next, we notice that adding an entry to our cache will no longer be as simple
as adding a key-value pair to a map: indeed, we now need to update the
chronologicalKeys slice - either by adding, removing, or moving one of its
elements, everytime we update the map - respectively by inserting, deleting,
or updating one item.
// deleteKeyValue removes a key and its associated value from the cache.
func (c *Cache[K, V]) deleteKeyValue(key K) {
c.chronologicalKeys = slices.DeleteFunc(c.chronologicalKeys, func(k K) bool { return k == key })
delete(c.data, key)
}
Now that we’ve got these helping functions, we can update the code in
Read() first - all we have to do is update how we remove an entry when it
had reached its TTL:
c.deleteKeyValue(key)
}
And finally, we have to update the Upsert() function. This one is slightly
tricker, as, this time, this is where the core of the feature we want to
implement resides - we want to limit the number of items that are stored in
our cache at a given time. Since this number only grows when we upsert
items, it makes sense that this function will be the most affected one. Let’s
have a look at all possibilities when the user callsUpsert() :
The cache already has a value for that key: in this case, we want to reset
the whole entry with the new value - and the new TTL. We need to also
update the position of the key in our chronological slice. We can achieve
this by deleting the old pair and adding the new one.
The cache doesn’t have a value for this key: in this case, if we’ve not
reached the maximum capacity of our cache, then we can simply insert
the new pair. However, if we have reached the maximum capacity, we
need to clear some space for the new entry - this means discarding the
item that has been there for the longest. This item is at the beginning of
our slice of keys.
Now that we know how our method should behave, let’s implement it:
_, alreadyPresent := c.data[key]
switch {
case alreadyPresent:
c.deleteKeyValue(key) #A
case len(c.data) == c.maxSize: #B
c.deleteKeyValue(c.chronologicalKeys[0])
}
c.addKeyValue(key, value) #C
}
There is one last chance for optimisation here. When we need to replace an
existing entry, but the cache is at maximum capacity, we know we don’t need
to discard the oldest entry - we can discard the value we’re about to replace to
create enough room for the new entry. Go’sswitch/case statement has a
very specific behaviour that we used in our implementation: when several
case statements are valid, only the first eligible one will be executed. That’s
an implicit rule that most people know without knowing it - it also applies to
default : if we enter a case statement, we won’t execute thedefault block.
We used that behaviour here to delete only the pair we need to update.
Should you ever need to enter more than one case statement, you could
consider using the keyword fallthrough . But, in our opinion, it would
probably be clearer to write a list of if statements, in that case.
Test it
Our cache is no longer a plain map. We;ve added logic with our list of items
by age, and this new logic is invisible to the end-user. As a result, it’s worth
adding a few internal tests to just make sure we’re doing everything right.
Finally, let’s think of an end-user test scenario for our new feature. We can
validate it by adding items to our cache beyond its limit. It would also make
sense to check that updated items have their insertion timestamp updated. For
this, we’ll create a cache with a small maximum capacity, insert items to the
brim, upsert the oldest, and then insert a new key. We should then be able to
retrieve the upserted value, and we should no longer be able to retrieve the
second value we added in our cache.
c.Upsert(1, 1)
c.Upsert(2, 2)
c.Upsert(3, 3)
Congratulations! We’ve now written a generic library that we can share with
other developers. We started with a naive implementation that covered our
needs, and then we strengthened it by adding thread-safety on it. Even though
there was quite a lot of theory presented in this chapter, we managed to cover
practical requirements for a cache.
Channels are something very specific to Go, which means developers new to
the language do not get them as easily as the rest of the language. They are,
arguably, the one feature that requires a learning curve and some practice in
the entire language.
Because of this, because they can be tricky at first, do not use them if you
don’t need them. They might seem shiny, your situation might look like a
good place to use them, but think twice. Channels should be used when you
need to communicate in or out of a goroutine.
Take for example a program that counts the number of lines in a bunch of
files. Opening and closing files would be the bottleneck here: it is IO-bound.
The number of goroutines that can work in parallel will be determined by the
operating system’s limit, or the rules of your server if the files are remote -
you don’t want to crash it or get banned by hitting it too much.
On the other hand, if you are encoding a video on a single machine, a task
that is typically high on CPU, then you need to look at the architecture of
your machine. The GOMAXPROCS environment variable is an interesting hint.
Its default value is the number of cores of your CPU. It represents the
maximum number of goroutines that could actually run simultaneously. Any
extra goroutine will have to share a CPU with existing goroutines. It can (too
often) happen that parallelising the work actually makes things worse,
because you have already hit the max load of your CPU.
Goroutines are easy to start, but don’t let them leak. As we have seen, a
program should only exit when all of its child goroutines are finished. Once
you’re done writing to your goroutines, close them so that their readers know
when to stop listening.
Explore the sync package for tools to make your life easier. Most of the types
there should not be copied, though, be careful.
7.6 Summary
A cache is a key-value storage facility. They are commonly used when
getting the value associated with a key is costly (timewise or in the
amount of resources) and when getting a previously retrieved ro
computed value is OK.
When writing a library, one question that should always be raised is “is
this library thread-safe ?”. The answer is either “yes, and I know why”,
or “No, it’s not”. There is no middle ground - the worst case scenario is
usually also the most dangerous.
Go implements genericity with, well, generics. Structures, variables, and
functions can be declared using generics.
A constraint is a requirement for a generic type. Common constraints are
any (quite explicit), comparable , which allows the use of == between
two values, or golang.org/x/exp/constraints.Ordered ., which
allows the use of > , < , >= , <= between two values.
Constraints are passed in square brackets after the name of the generic
entity:
type fieldWithName[T any] struct { value T, name string
}
var hashIndex[T myConstraint] map[uint]T
func sortSlice[T constraints.Ordered](t []T) ([]T,
error)
Constraints can be omitted in the declaration of functions when the
compiler can infer which type it should be using:
sortSlice([]int{1,4,3,2}) .
Go uses goroutines for concurrency. Goroutines are similar to what
other languages usually call threads, except that they’re not. Threads
live at an OS-level, while goroutines live farther from your silicon - they
exist in the runtime environment of Go.
In Go, goroutines are launched with the keywordgo : go do() runs the
do function in a new goroutine.
Channels to communicate data between different goroutines. When
passing a channel to a function, make it explicit in the signature that the
function will either read from the channel, with the syntax func f(c <-
chan string) , or that it will write to the channel, with the syntax func
f(c chan <- string) . If you need to both read from and write to a
channel in a single function, there is probably a design flaw.
Two types are commonly used when we need to synchronise goroutines:
sync.WaitGroup and errgroup.Group . When using sync.WaitGroup ,
start by calling Add(n) with the number of goroutines that will be
executed. Each goroutine is in charge of callingDone . The
synchronisation is achieved by calling Wait() . When using
errgroup.Group , start by setting a maximum number of parallel
goroutines with SetLimit(n) . Launch each goroutine with a call to
Go(...) . The synchronisation is achieved with a call toWait() , which
returns an error if one of the goroutines returns an error.
The choice of sync.WaitGroup or errgroup.Group is often driven by
the necessity to check for errors in at least one goroutine: use
sync.WaitGroup when errors don’t need a specific treatment. Use
errgroup.Group if you want to handle errors.
Mutexes are used whenever we want to protect a variable from
concurrent writing - or reading. In Go, we can create a mutex variable
by using the sync.Mutex type: var myMutex sync.Mutex . A mutex
should never be exposed - instead, use it in exposed functions. A mutex
should always be written close to the variable or field it protects.
You can call mu.Lock() on a mutex, but we highly recommend
immediately following this with defer mu.Unlock() . Debugging locked
mutexes is a pain.
Use t.Parallel() in your tests to let the framework know that a test is
not blocking for the execution of other tests.
Use the -race flag when testing to try and detect data races - but
remember, the failure to detect a data race doesn’t mean there are no
data races.
Use the -trimpath flag when testing to only output paths relatively to
the root of the module.
A switch/case statement will only execute the first valid case - any
subsequent case will be ignored.
8 Gordle as a service
This chapter covers
In the first part of this chapter, we’ll create the REST API, and test it with
simple tools such as an internet browser or a command. In the second part,
we’ll integrate the game of Gordle - which will require a few updates to
comply with how we want to use it in the server. Finally, we’ll mention a few
security tips.
Requirements:
Play Gordle on a web service. Run a service that exposes at least the
following endpoints:
NewGamecreates a session and returns a gamer ID. This will be used for
counting the number of attempts.
GetStatus returns, well, a game’s status: how many guesses are still
allowed, what were the previous hints, etc.
Guess takes a word as a parameter and returns the feedback and the
status of the game.
Before we begin writing a few lines of code, it’s important to introduce some
vocabulary and understand a few theoretical notions.
8.1.1 Server, service, web service, endpoints, and HTTP
handlers
It is important to keep in mind, for later steps of this chapter, that a service
isn’t supposed to end its execution, in other words, it is running until the user
decides to stop it.
Endpoints are the access points for the exterior into a service. For web
services, endpoints are mapped to specific URLs as we’ll soon see. A web
service, behind the scenes, will use an HTTP handler to deal with requests.
HTTP handlers receive requests and generate responses.
You should be aware that the terms webservice and endpoint are used in
different contexts with slightly different definitions, but we will use the one
above in this chapter.
Let’s start by creating the module for our service. Keeping in mind that we
will be running Gordle in an HTTP server, we can come up with a relevant
name. Remember that if you are pushing your code to a code repository, it is
always better to declare the full path of your repository as a module.
Once we’ve created thego.mod file, we can start writing the main function. It
will be responsible for creating a server and running it. For this, we’ll need
some help from the net/http package that we’ve already seen in Chapter 6.
Its documentation is quite long, but if we read only the first lines of go doc
net/http , we see that Package http provides HTTP client and server
implementations. For now, we are only interested in the server side.
A server listens to a specific port, so find any free port on your host machine.
The default port for HTTP is 80, but for development purposes, we prefer to
use another, such as 8000 or 8080, as 80 will very probably be used on your
machine by something else.
package main
import "net/http"
func main() {
// Start the server.
err := http.ListenAndServe(":8080", nil) #A
if err != nil {
panic(err) #B
}
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Let’s run it!
$ go run .
You might notice that the execution hangs. That’s because the
ListenAndServe function never returns - after all, that’s exactly how we want
it to behave: the service is running!
How do we test it manually? There are many tools that can be used to test an
HTTP server, we’ll mention four of them:
The nil handler we provided can’t really do much, but still, we can see it in
action. If you open your favourite web browser and enter the URL
localhost:8080 , you’ll see a response from the default HTTP handler - a
404 message.
Should you want to usecurl , here is the same request as a command line: it
will also return a 404 message. We’ll make more extensive use ofcurl in our
next tests.
$ curl http://localhost:8080
Some of these endpoints - GET, PUT and DELETE, in this list - should be
idempotent - that is, several consecutive calls should all return the same
response, and should all leave the resources in the same state as calling them
just once. If your API is not idempotent, you need to explicitly tell your users
in the documentation. GET, PUT and DELETE should simply not be used for
non-idempotent endpoints - use POST instead.
Creating a game is the first thing a player will do. Let’s start by having a look
at what goals we need to achieve. We don’t really need any input to create a
game of Gordle. If you remember Chapter 5, we launched a game withgo
run main.go . As we’re adding a new endpoint, we need a pair of a path and
an HTTP method. The resources we’ll want to use are the games - the /games
path seems perfect.
Which HTTP method should we use? In this case, since we are creating a
game, we should use aPOST. It could happen that you already know the
identifier of the resource to create. For instance, if we were dealing with
books, we could use the ISBN to create a book resource with the method PUT
on the following address: /books/9781633438804 .
We’ve now defined the API for this endpoint. We know which path we want
to use, which verb should be associated with it, and what the response should
be - an identifier. The documentation would start like this:
testdata:
We’ve already mentioned in Chapter 3 that directories namedtestdata
won’t be examined by the go tool - code inside it won’t be compiled by
go build or go run , tests written inside won’t be executed bygo test ,
and documentation won’t be visible through go doc .
internal:
It’s now time to introduce another special name for a directory:
internal . An internal directory can contain code for the current
module to use, but this code won’t be visible to other modules. For
instance, the modulegolang.org/x/text has aninternal package,
where the type InheritanceMatcher is defined. However, even though
this type is exposed (because of its capital letter), we can’t create a
variable of this type in our module: the scope of types and functions
defined in a directory named internal - or a subdirectory of an
internal directory - is limited to the current module (in our example,
the golang.org/x/text module). An internal directory is a good place
to put code you don’t want other people to use. In the case of a service,
most of the code will reside there.
vendor:
We’ll mention this one for historical reasons - there is a third directory
name to know about: vendor . We won’t go through the whole history of
the language, but earlier versions of Go used to have “versioned”
dependencies - copies local to each module. These copies would be
placed inside avendor directory - and it was a good idea to always
ignore the contents of that directory in your favourite versioning tool.
It’s best to simply never name a directory vendor , for compatibility
matters. If you really must, use vendors instead.
pkg:
You might encounter packages located in apkg package, at the root of
the module : module/pkg/my_package . In pkg , you can expect to find
libraries that could be used outside of your project. We do not encourage
the use of pkg , since it is not a Go standard. It is rather a historical
artefact or a golang-standards/project-layout, which is not the official
standard from the Go team.
These were strict rules, and we can add some suggestions that you are free to
follow. We like to expose the API of a service in an api package - a directory
at the root of the module.
We are now prepared to organise our code and can create an api directory at
the root of our module, and aninternal directory into which we’ll write all
sorts of things, including our HTTP handlers in a subdirectory. Indeed, how
we implement an endpoint won’t be of any use to external developers, and
that’s why we might as well hide this within an internal/handlers
directory.
The HTTP API of our service can be exposed in anhttp.go file, while the
initial handler for a new game will be in a newgame/handler.go file. We’ll
bind the API to the handler in the router.go file.
.
├── go.mod
├── internal
│ ├── api
│ │ ├── doc.go
│ │ └── http.go
│ └── handlers
│ ├── doc.go
│ ├── newgame
│ │ └── handler.go
│ └── router.go
└── main.go
Let’s start with the http.go file. It should contain everything that we need to
expose to allow someone else to use the NewGameendpoint that we’ll next
implement. By everything, we mean which URL should be used and which
method. If there is anything more, such as parameters to the query, we
include them in this file. Let’s create the http.go file in the package
internal/api .
package api
import "net/http"
const (
NewGameRoute = "/games"
NewGameMethod = http.MethodPost #A
)
What should they expect in return? Sometimes creation endpoints only return
an ID. Here we would like to be more verbose and return the full game that
we created: the client of our Gordle game needs to know the number of
characters in the secret word and the maximum number of attempts allowed.
As we’re defining what a Game is in the API, we should think of every field
that we want to include. We can also tell the status of the game to let them
know whether they can keep playing and whether they won or lost already.
Finally, having a list of the previous attempts will help in the display.
{
"id": "1225482481867118141",
"attempts_left": 4,
"word_length": 5,
"status": "Playing",
"guesses": [
{"word":"slice","feedback":""}
],
}
This translates easily into a Go struct, with JSON tags as seen in previous
chapters.
package api
// ...
// Guess is a pair of a word (submitted by the player) and its feedback (provided by Gordle).
type Guess struct {
Word string `json:"word"`
Feedback string `json:"feedback"`
}
Note that all types are primary types, we are not imposing any strong typing
to our consumers.
This would do the trick. However, there is a minor issue here: creating a
handler this way only allows for one endpoint to be defined. As we know we
want to implement several endpoints - at least 3.
There are two other important points to highlight with the ServeMux : first, it
allows the registration of endpoints with the HandleFunc method, which is
what we want to achieve. Second,ServeMux has a methodServeHTTP , with
the correct signature: it implements theHandler interface, and we can pass a
ServeMux to the ListenAndServe function!
Multiplexer
Let’s start by writing the multiplexer. We will then look at the signature of
what it accepts to register an endpoint.
What the function does is simply to create a new instance, make it listen to
our future endpoint and return it. We have not defined yet what the endpoint
will look like, so let’s put a placeholder first. If you want to compile to check
that everything else makes sense,nil is perfectly acceptable; although if you
use nil , don’t expect a request to your service to do anything but panic.
package handlers
import (
"net/http"
"learngo-pockets/httpgordle/internal/api"
"learngo-pockets/httpgordle/internal/newgame"
)
// Mux creates a multiplexer with all the endpoints for our service.
func Mux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc(api.NewGameRoute, newgame.Handle) #A
return mux
}
We can finally use this Mux in the main function, replacing the previous nil
handler:
package main
import (
"net/http"
"learngo-pockets/httpgordle/internal/handlers"
)
func main() {
err := http.ListenAndServe(":8080", handlers.Mux())
if err != nil {
panic(err)
}
}
package newgame
import "net/http"
This is the very first version. We’re not even checking errors - but we will, as
we get closer to our final version.
Right, we’re getting there! We can now go run . this code and check how it
behaves. There is in fact a final step we need to achieve before we can move
on to the next section. Let’s discover it together.
Even though it might sound evident, a server should be able to serve several
clients at a time. The fact that someone is using an endpoint should not
prevent others from also calling it. Behind the scenes, this means that the
server must be able to not wait till a task is complete before serving a new
call. In Go, this is achieved with goroutines. Goroutines are Go’s version of
concurrent programming - the closest equivalent notion, in other languages,
is usually called threads, coroutines, fibers or green threads.
Goroutines, however, are different from threads, and we will cover
goroutines more extensively in a later chapter.
When a server receives a request, it starts a goroutine that will execute the
handler. Even though this might seem wonderful and extremely handy, we
will see that it comes with limitations. The last section of this chapter,
covering correctness, will present two important topics - race conditions, and
ensuring the server doesn’t explode when attempting to serve two requests at
the same time. As seen in the previous chapter, instead of running simply go
test . we will add the flag -race .
The answer to the first question is clear: we should reject the message. There
is actually a specific HTTP code for wrong verbs, so we might as well use it:
http.StatusMethodNotAllowed (see Table 8.1). And as to where we should
make this check, the only logical place is within the Handle function.
Let’s run our previous test of starting the service and checking the
http://localhost:8080/games page through various tools. Depending on
your browser, you could see a 405 error. However, this could also not appear
- Firefox didn’t display anything in our tests, while Google Chrome did. Let’s
have a look with curl :
$ curl localhost:8080/games
$ curl -v localhost:8080/games
Output:
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /games HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> Accept: */*
> < HTTP/1.1 405 Method Not Allowed
< Content-Length: 0
<
* Connection #0 to host localhost left intact
Now that’s more like it. Lines starting with > are header data sent by the
client. Lines starting with < are header data returned by the service. The first
line to notice here is the first header data sent - we did send GET
a method.
Indeed, curl uses a default verb when sending a request, if none was
explicitly provided - in this case, a GET method. The other interesting line
from this output is the first found in the response section: we can see the
server returned a 405 status code and its explicit meaning “Method Not
Allowed”- which is what we are expecting.
Output:
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /games HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 19
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
Creating a new game
This is almost exactly what we wanted. Almost, because there’s a rule in the
HTTP protocol: an action that creates a resource should return a status
describing that the resource was created. Here’s what raised our attention in
the pair of exchanged headers: the status code of the response200
is , which
stands for “OK”. This status code shouldn’t be used when creating a game;
instead, according to HTTP standards, we should be using 201 , which stands
for “Created”.
Here is a table of the most common HTTP status codes, as described in the
the RFC9110 documentation at
https://datatracker.ietf.org/doc/html/rfc9110#name-status-codes
. This
documentation contains the full details regarding HyperText Transfer
Protocol (HTTP) protocols, standards and status codes. Make sure to look up
the meaning of 418 and where it comes from.
Code Meaning
200 OK - the server is returning what the client asked for
201 Created - server processed the request and sent the
newly created resource as response
202 Accepted - the server will treat the client’s demand
later - typically used for asynchronous processes
204 OK, nothing to return - the server correctly fulfilled the
request and there is no content to return. It is used by
POST, PUT and DELETE commands
Let’s bring this final change to the code before we can wrap it up and move
on to the next endpoint.
The function in charge of writing this status code is the handler. So, let’s
open newgame/handler.go and include the call to write the status code. This
code appears in the response, and we’ll use the WriteHeader method, which
only takes a single parameter - the status code to be carried with the response
body, in the file internal/handlers/newgame/handler.go .
Listing 8.8 handler.go: Set the status code of the successful response
We should see that the response status code is now 201, shouldn’t we? Well,
unfortunately, it’s not. It’s still 200. What’s happening? Well, if we have a
look at the terminal in which the server is running, we should see a line
similar to these lines:
Great! After restarting it, we can observe that the server now behaves as
expected: it returns the correct status code and tells us it’s creating a game -
shouldn’t we believe it? We’ve checked that it only accepts POST requests.
The pesky line about superfluous calls toWriteHeader is now gone.
We can happily commit and move forward, but there is a way to make our
code shorter.
Open-source multiplexers
This is one of the comments that can be made against the http.ServeMux
type: creating several endpoints (for different verbs) for the same path is
cumbersome. For this reason (and a few others that we’ll cover as we meet
them), writing a personal implementation of the http.Server interface is
quite common, and many of the most-starred Go projects on github are about
this. At the time we are writing, there is an open proposition by the Go team
to add this feature into the standard libraries
(https://github.com/golang/go/discussions/60227). Here are some popular
picks:
We picked chi for its conciseness and the rate of maintenance by the
community. Let’s quickly rewrite the router with this library. First, we need
to get the dependency. For this, we need to tell the go tool to add it to our
go.mod file with the following command:
$ go get -u "github.com/go-chi/chi/v5"
You might notice that there is a v5 trailing here. If you try to access this URL
in a browser, it will return an error. However, this github repository has been
tagged with v5.0.0 at some point (and with more tags as time passes), and
using /v5 in the go get command is how we ensure we use a version
compatible with v5 - it could be v5.0.0 or v5.0.8, both offer the same API.
package handlers
import (
"github.com/go-chi/chi/v5" #A
"learngo-pockets/httpgordle/internal/api"
"learngo-pockets/httpgordle/internal/handlers/newgame"
)
// NewRouter returns a router that listens for requests to the following endpoints:
// - Create a new game;
//
// The provided router is ready to serve.
func NewRouter() chi.Router { #B
r := chi.NewRouter()
r.Post(api.NewGameRoute, newgame.Handle) #C
return r
}
This is more verbose but asserts that the server uses what its API exposes.
Otherwise, just remove the constant and let users read the documentation.
Exercise 1: Use a walk function to print for each handler the method, and
route.
We can remove the check for the method in our handler because chi makes
sure we never get called with any other method.Handle is now done in 2
lines. We can even use the occasion to actually return a game, as defined by
the API.
apiGame := api.GameResponse{} #B
err := json.NewEncoder(w).Encode(apiGame) #C
if err != nil { #D
// The header has already been set. Nothing much we can do here.
log.Printf("failed to write response: %s", err)
}
}
This new shorter version of the handler should be easier to test: fewer lines of
code means fewer bugs. What could be blocking is that it takes two rather
complicated parameters that we need to mock or stub.
Lots of people are writing services, and this is why they wrote libraries for
making the job faster. Lots of people are testing these libraries, so of course
we don’t need to stub these ourselves. It’s always good to limit the tests to
the code you’ve written.
We are making use here of two well-used testing packages require and
assert . Their use can be controversial since some purist will recommend to
call only the standard library nevertheless we will use them so you can
familiarise with them. They are found in the module
github.com/stretchr/testify , so let’s start by adding this module to our
project with go get github.com/stretchr/testify .
This helps drive which of assert or require we want to use: the former when
we need to check several values, and the latter when we know there is no
point in continuing the test if something is wrong.
You should have everything in hand now to write the test to the nominal
behaviour.
recorder := httptest.NewRecorder() #B
Handle(recorder, req) #C
This was our first endpoint - our service now supports the creation of (empty)
games. That was a big step, but we’ve covered a good many important
aspects of web services. Before we start playing, our next task is to ensure we
can get the status of a game given its identifier.
Here’s the picture so far: we have a service that allows for the creation of
Gordle games. Of course, the end goal is to have players make guesses, but
the second endpoint we’ll describe here is theGetStatus one. It contains a
tiny bit more than the first one, as it introduces only a single new notion:
reading a variable input from the user. This time, we can’t just always return
“a game” - we need to be able to identify which game the user wants to view.
There are four main ways for a user to communicate parameters (or variables)
to an HTTP web service. We will see that they are each appropriate for
specific use cases.
Path parameters;
Query parameters;
Request bodies ;
Headers .
Path parameters are used when we want to target a single resource. In our
Gordle server, an identifier can be used to target an instance of a game. The
path to target a game should be/games/{gameID} .
Query parameters are used to filter the resources we want to target with our
request. A filter is a list of pairs of keys and their associated value. There
could be 0, 1, or many results - we don’t know, and we can’t make any
assumptions. These query parameters are passed in the URI of the request,
but at the end of it, separated from the URL by a? character. These
parameters aren’t specific to an endpoint and could be used in several places
of the API. Their syntax is {{path}}?key1=value1&key2=value2 . The most
common example is Google’s search engine: as there can’t (reasonably) be a
dedicated resource per possible query that people ask Google, each query is
sent to their servers as a query parameter, where the key qisand the value
was keyed in by the user: https://www.google.com/search?q=金継ぎ . We
will show how to use a query parameter at the end of this chapter to improve
the NewGame endpoint by allowing the caller to specify which language they
want to use.
While path and query parameters should be used to specify which resources
we want to operate on (retrieve, delete, update), or what characteristics these
resources should have, we sometimes need to provide parameters inherent to
the request itself. When we need to send data to the service, we use body
parameters. This name derives from the fact that they will be transmitted to
the service as part of the request’s body. So far, we haven’t seen request
bodies, but this will be the point of the third endpoint - .
Guess
As we did earlier, we need to declare the path to this new endpoint, and the
method that we expect when it is called. We place these two values in the api
package to make them visible to other users. We want to retrieve the status of
the Game resource without changing it - aGET will do. But the path is a bit
more complex! In a REST API, every request must contain all the
information to identify which resource is targeting. In our case, this means
the request needs to contain the ID of the game. As we’ve seen previously, a
path parameter is a common way of providing this identifier:
/games/{game_id}
Indeed, how do we represent a path that is not constant? That’s where the
default net/http package is a bit too strict - and this is one of the reasons
that pushed developers into writing their own routing libraries, such aschi .
We want to be able to access a game’s status via the path /games/8476516 ,
where 8476516 would be the game’s identifier. Obviously, we can’t create
billions of routes - one per identifier - so, instead, we’ll let the chi library
determine how a path parameter should be handled.
Let’s have a look at chi ’s documentation. Since chi isn’t a package of Go’s
SDK, we need to be in a module to be able to read it. If we rungo doc
github.com/go-chi/chi/v5 , we can read that curly braces around a word are
used to represent placeholders in a path. This means we can use
/games/{game_id} and chi will be able to extract the identifier in the
handler. Achieving this with Go’s SDK would require lots of security checks,
as we’d be splitting the full path into bits separated by slashes. It’s doable,
but it would take a lot more lines than what these libraries offer.
const (
NewGameRoute = "/games"
GameID = "id"
GetStatusRoute = "/games/{" + GameID + "}" #A
)
Defining a constant for the GameID placeholder will be useful when we want
to extract it from the request, in the handler. This is our next step.
Before we start listening to this route, there is one more definition we want to
specify in our documentation: what is the expected status code? This request
is asking for a resource, so the possible responses should be "200 here it is",
or "404 not found" if the game doesn’t exist. Of course, 500 internal server
error" is always a possibility but we want to avoid it as much as possible.
Implement getStatus
Add the path to the router. There is no specific priority when adding handles
to a mux. Grouping things based on resources and relater behaviour is usually
what makes the most sense.
...
r.Post(api.NewGamePath, newgame.Handle) #A
r.Get(api.GetStatusPath, getstatus.Handle) #B
...
apiGame := api.GameResponse{
ID: id,
}
// ... encode into JSON
}
For the sake of simplicity, we decided to use the standard log package which
is not thread-safe. So keep in mind that it can lead to unordered logs and
complicate later testing.
Run the server and call the endpoint. Does it return the ID you passed in the
URL? Congratulations. You can commit. But wait, what about the test? Good
thing you asked.
Testing getStatus
The test here is not much different from thenewGame version. We only need
to add the ID to the list of URL parameters. This requires dipping our first
toes into the notion of context, which will be covered in more detail in a later
chapter. For now, what needs to be understood is that a context is where
chi
reads URL parameters - and this means it’s how we need to add them.
recorder := httptest.NewRecorder() #C
handle(recorder, req)
We can now create games and retrieve them. This allows us to make sure
everything is now ready for our third and last endpoint - guessing!
8.2.3 Guess
Finally, in order to play, the player must be able to send a query with their
word and get a feedback message. What will the API of the third endpoint
be?
Request definition
Adding a new endpoint means choosing a new pair of path and method. What
method are we going to use? We are changing an already-existing resource,
so PUT is in order. The path is fairly straightforward - it’ll be
/games/{game_id} , similarly to the getStatus endpoint, as that’s the
resource that we’ll be interacting with.
But then, the endpoint needs to receive parameters. It will read the guess
from the request body, encoded in JSON because we are following HTTP and
REST standards. In some cases, updating a resource requires sending its full
description - in that case, we would have the same JSON structure for the
response of the POST and GET and the request of the PUT. Here, changing
the status of a game requires only sending a word, as long as we’re providing
the ID, so we will go for the simplest JSON object.
{"guess":"hello"}
Add the path to the endpoint to this file. The path is the same as for
GetStatus, but nothing proves that it will always be, so we need another
constant. Using the same path constant means forcing them to always be
identical, by design. We don’t want that - each endpoint deserves to have its
path as a constant.
We have an API. Time to write a new handler in a new package and plug it
into the router. Nothing needs new explanations, so we can wait while you
prepare the handle function. You can even run it with a simplelog.Printf to
make sure it works as expected.
What will this new handler do? First, it should parse the ID of the game,
exactly like we did in the previous endpoint. No surprise here.
Second, it should parse the body of the request. You already know from
previous chapters how to decode JSON messages into a Go structure. In a
flash of genius, somebody thought that theBody field of a http.Reques t
should implement the io.Reader interface - let’s make use of that!
// Read the request, containing the guess, from the body of the input.
r := api.GuessRequest{} #A
err := json.NewDecoder(request.Body).Decode(&r) #B
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest) #C
return
}
Print out your findings into the logs and check what happens when you shoot
a curl at the service:
Note the -d flag for passing a body. You can also [email protected] if your
request body is saved in a file.
Does everything show properly on the logs? Did you write a test?
If we run the same test on this handle function as we have in GetStatus, it will
panic: the body of our request isnil , so we are trying to decode from anil
reader, and this doesn’t end well. The only change that needs to be made is
quite short: the http.NewRequest function that we’ve used to create requests
so far takes, as its third parameter, anio.Reader , which is quite simple to
create from a string in Go (remember to use backticks̀ to wrap a string that
contains double quotes" without having to escape them):
body := strings.NewReader(`{"guess":"pocket"}`)
req, err := http.NewRequest(http.MethodPut, "/games/123456", body))
We have the full structure of our service. We have 3 endpoints that return
something. This point is a good time to deploy the service to a testing
environment and have other people play with it. It’s the situation where you
send a link to your shiny new service to the rest of the team with a long
message asking them to try it, warning them that it does nothing yet, and
somebody will reply saying “Hey, nice work but I found a bug: how come it
always returns an empty game?” .
We want this project to fit in a pocket, so we will go for this option for now.
In a bigger project, we’d use a proper database.
Separation of concerns
One of the main ways to tell whether a project’s code is clean is the
separation of concerns. In theory, each package, each structure, should have
a defined role that can be explained in one sentence, and this sentence is the
first line of its documentation. Most of the time, one sentence is enough to
cover everything. In practice, this rule can introduce complicated
communication between highly coupled ideas, as mentioned before there is
always a tradeoff to find between separating and keeping things together.
Create a new package for the data repository. Do you want other modules -
other developers - to use it? No. That meansinternal/repository will do.
This package is responsible for storing and retrieving games. Here, that was a
one-sentence documentation.
This is a bit too complex for the size of the service, so we will keep the
domain logic inside the handlers package, but keep in mind that the core
logic and the API details should be two distinct things, with an easy-to-draw
boundary. Each different layer of our design should serve a specific purpose.
What we call the domain here is the core of the service, the business logic of
our program. All adapters should be able to rely on it, and it should rely on
none of them. This way, we prevent circular dependencies in the code and
circular knots in our brains.
The package can be simply called “domain”, “core”, or with a more specific
but limiting name. In our case, we find that it deals with a player’s session.
// Status is the current status of the game and tells what operations can be made on it.
type Status string #B
const (
StatusPlaying = "Playing" #B
StatusWon = "Won"
StatusLost = "Lost"
)
We also want to expose the guesses, as we need to carry them around, store
them, and return them.
Listing 8.20 game.go: Define the type Guess
// A Guess is a pair of a word (submitted by the player) and its feedback (provided by Gordle).
type Guess struct {
Word string
Feedback string
}
Good start. It is not yet enough to play, but that will happen in the next
section.
One thing we can already add to the domain is a business error: if the player
sends a new game after the game is over, we should be explicit about the
problem. You can either define a custom error type or do it the short way:
One of your authors really doesn’t like exposing global variables. You are old
enough to decide for yourself.
NewGame
Take for example the first endpoint: NewGame. This is how the handler
currently creates the API version of the game:
apiGame := api.GameResponse{} #A
err := json.NewEncoder(w).Encode(apiGame) #B
if err != nil //...
}
What is the responsibility of the Handle function? Dealing with the API
details: if the client needs a different flag or a different format, this is where it
should happen.
We decided to keep the business logic in the same package, but it doesn’t
mean we should keep it in the same function. Instead, we want a function that
creates and saves the game somewhere, and another to reshape it into what
the clients want.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) #E
apiGame := response(game) #D
// ...
}
This way, each function has a well-defined job, and we can test units rather
than big blobs. It also becomes easier to parallelise the work inside a team,
merge changes made by multiple people, and most importantly, understand
what we are reading.
GetStatus
if g.AttemptsLeft == 0 { #C
apiGame.Solution = ""// TODO solution #D
}
return apiGame
}
Testing this presents no trick: we need to check that an input game would
come out as expected in a new shape. You can use go test -cover or your
IDE’s UI to make sure that you are covering all code branches.
Update the GetStatus handler and run its tests. Still happy? If in the previous
version, your guess slice wasnil and it is now initialised, it should fail. You
should be able to fix it by replacing the json valuenull by an empty array [] .
Guess
Finally, the last endpoint. What does the handler do? Let’s have a look.
apiGame := api.GameResponse{
ID: id,
}
apiGame := api.ToGameResponse(game) #B
w.Header().Set("Content-Type", "application/json")
// ...
}
The order of things is always the same: decode the request, validate any input
that requires validation, call the business logic, convert the returned domain
type into API-readable structures and encode the response.
If your tests pass, you can commit. Try running the service and shooting a
few curls at it to see how it behaves.
If you deploy again to the testing environment, that unhappy teammate who
did not read your warning will still be unhappy: the games are still as empty
as before.
8.4 Repository
At this point, we can ask the question of priorities: we still have two main
tasks at hand: saving the game and allowing clients to play. Which one
should we tackle first?
One way of answering is, which one will show the most progress? If we start
with the storage, the first two endpoints will be finished, we will see how
they integrate together in an end-to-end test (or at least end-to-midway,
because we won’t be able to play). If we start with playing, we will be
playing on a new empty game at every guess, so testing will be difficult and
flaky. Storage it is, then.
If we were using some proven technology for data storage, for example, a
SQL database, we would need some extra fields related to storage that the
domain does not need: think of fields like created at, deleted at, versioning…
In that case, we would create a newGame structure, and adapters between the
domain and the repository, just like we did for the API. That way, the schema
of our database would be able to evolve independently from the domain or
API.
Because we are aiming for the fastest storage option, we don’t have any
technology-related constraints. It means we can keep the domain structures.
There are loads of database options out there, all of them ideal for a limited
set of situations. As we explained before, in-memory is ideal for no situation,
but fast to write.
What it means is that we will keep a variable to store the games. All the
operations we do on games rely on their ID, so a key/value storage is perfect.
In Go, this takes the form of a map.
Let’s create a package for it. Same question as before: do you want external
modules to use your repository? Please, no. It would invalidate the whole
point of the service and its API if clients went directly to the DB. Any
additional security or logic that you would add (sending events, leaderboards,
etc.) would be immediately buggy.
Simplest repository
We have covered in previous chapters how to create an object that will work
as a dependency. If we were using an external database, we would initialise a
connection when our server starts and keep it as a dependency of the whole
service. Here we will initialise the map instead and keep it in the same way.
Let’s create the repository structure. It will hold methods such asFind and
Update .
gr.storage[game.ID] = game
return nil
}
We will come back to this error. If we want to check for it specifically in the
calling code, it is currently difficult.
On Your Own: You can write the Find and Update methods. The former
needs to retrieve from the map and return some kind of error if nothing is
there with the given ID. The latter should also prevent insertion and only
accept overwriting an already-existing value.
You can also write unit tests on the four functions. Here we would useNew in
the other 3 tests and consider the job done for it: there is no particular trick to
it.
func main() {
err := http.ListenAndServe(":8080", handlers.NewRouter())
if err != nil {
panic(err)
}
}
We can easily add the initialisation and pass the new variable to the router.
func main() {
db := repository.New() #A
How does the router pass this to the handlers? Our NewRouter function does
not call the handlers, it only gives the router a reference to them, so we
cannot simply add a parameter. What we can do instead is turn our Handle
functions into anonymous functions that are created on startup.
Let’s anonymise the Handle functions and wrap them instead in aHandler
function that takes a repository as a parameter and returns the previous
http.HandleFunc .
// ...
}
}
The contents are the same so far. We can now update the router.
r.Post(api.NewGameRoute, newgame.Handler(db)) #A
r.Get(api.GetStatusRoute, getstatus.Handler(db))
r.Put(api.GuessRoute, guess.Handler(db))
return r
}
How do we test this? We can only pass a concrete repository to our Handler
function. As soon as we use a real external database, this means we need to
spin an instance and connect to it to run unit tests. That is absolutely not
sustainable. Let’s abstract it with an interface.
assert...
}
You can adapt this logic to the other two endpoints.GetStatus only needs a
finder , and Guess needs to call two methods.
Now before we rejoice, there is one thing: remember how our server accepts
requests in different goroutines and treats them concurrently? Writing into a
map is not thread-safe: it means that if two different routines write in the
same map, we cannot guarantee which one will win, if any. To fix this
problem, we can use a concept we saw in the previous chapter, a mutex. The
goal is to avoid concurrently accessing the map and to ensure the sanity of
our server.
First, we need to add the mutex next to the resource we want to protect, the
storage map, inside theGameRepository structure.
Then we are able to access the mutex from the receiver on each method, here
is the sample of code for theAdd method.
_, ok := gr.storage[game.ID]
if ok {
return fmt.Errorf("%w (%s)", ErrConflictingID, game.ID)
}
gr.storage[game.ID] = game
return nil
}
You can now update the other methods by yourself and run the tests! If the
tests pass, your code is safely committed and you’ve had a good glass of clear
water, let’s play!
Now we need to separate the concerns of the session and gordle packages.
The gordle library does not need an ID, but thesession does. The library
does not need the previous guesses. It must tell the status of the game.
That should cover it. Add a fmt.Stringer on the feedback, and we are good.
In our case, because we copied most of the logic from a previous chapter, we
chose option 2, which keeps the old code together until we prove that it
should be refactored further. Therefore, we expose aReadCorpus function
that returns a list of strings, and this is what theNewGamefunction takes to
randomly select a word. Note thatNew taking a single solution simplifies the
tests, because we don’t have to go through randomisation.
We didn’t want to weigh this book with lots of copy-pasted code from a
previous chapter. You can find the resulting simplified package in our
repository, or play around to see what you need. Have fun going through the
exercise of reducing code yourself. For the sake of continuity, and to make
sure that we are working with the same code base, here is the final API of our
package:
$ go doc internal/gordle
Reading the doc shows that option 3, having a corpus reader somewhere else,
would have made this API easier to understand. Feel free to refactor this way.
If you are happy with the test coverage of the library, it is time to commit and
use it.
8.5.2 Usage in the endpoints
At this point, we have everything we need to write the logic of the endpoints.
We have already created a function to isolate that logic in each of the three
endpoints, so we just need to fill this function up in each location.
With this new type ready (and tests passing), we can complete the endpoints.
NewGame
Here, we need to create a game, generate a random ID for it and save it, then
return it.
Why random? Incremental IDs are a terrible security flaw, as anyone can
create a game and play around to mess up with other people’s games (note
that authentication is mentioned later in this chapter).
There are other alternatives, like Universal Unique ID (uuid), for which
Google’s library is most generally used in Go, or Universally Unique
Lexicographically Sortable Identifier (ULID). We picked this last one, and
we will be using the generative library found here: github.com/oklog/ulid .
If you are using a relative path to the corpus, it needs to be defined relative to
the compiled binary, not the file where it is defined, not necessarilymain.go
and not the path of execution.
//go:embed corpus/english.txt
var englishCorpus string
There is a trick that can be used here: we can force the import of a package
by aliasing it to _ (underscore). This way, the package won’t be dropped and
will be available where we import it. Importing a package as _ is a neat trick
which is usually done when we need to call theinit functions of some
libraries. Here, it ensures that the embed package will properly load the
contents of the file located at the pathcorpus/english.txt , this location is
relative to the source.go file. We now have loaded the contents of the file
into a variable.
Listing 8.36 newgame/handler.go: Endpoint logic
g := session.Game{
ID: session.GameID(ulid.Make().String()), #B
Gordle: *game,
AttemptsLeft: maxAttempts, #C
Guesses: []session.Guess{},
Status: session.StatusPlaying,
}
err = db.Add(g) #D
if err != nil {
return session.Game{}, fmt.Errorf("failed to save the new game")
}
return g, nil
}
Additional optimisation: here we are reading the corpus every time the
endpoint is called. This looks like a waste of resources. What would be ideal
would be to load it on startup, deal with any error at this point (e.g. file not
found) and fail to start if we have nothing. If the service cannot access any
list of words, it might as well not start at all.
Now that we have access to the solution, we can also update the API adapter
to add the WordLength , and other fields that we may have left out so far.
Additionally, this hard-coded path to the corpus will become a pain as soon
as we start testing.
When testing the handler itself, we can replace the ID with a known string.
For this, we isolate the generated ID in the JSON output and replace it using
strings.Replace .
// idFinderRegexp is a regular expression that will ensure the body contains an id field with a value that
contains
// only letters (uppercase and/or lowercase) and/or digits.
idFinderRegexp := regexp.MustCompile(`.+"id":"([a-zA-Z0-9]+)".+`) #A
id := idFinderRegexp.FindStringSubmatch(body) #B
if len(id) != 2 { #C
t.Fatal("cannot find one id in the json output")
}
body = strings.Replace(body, id[1], "123456", 1) #D
The expected body contains the known string, so we can now use
assert.JSONEq or some equivalent. If the ID does not match the expected
format, FindStringSubmatch will not find it and return only one item: the
full string. The test will fail.
g, err := createGame(gameCreatorStub{nil}) #A
require.NoError(t, err)
Regular expressions are extremely powerful, but also very hard to understand
when you don’t know what you are looking at. Whenever you write one, do
not expect the next maintainer (including yourself) to find it easy to parse:
add a comment to tell them what it is looking for. Systematically.
Check that your tests are passing and properly covering your code, and you
can move on to the second endpoint.
GetStatus
Here we only need to call the DB and return the game. That’s it. And, of
course, deal with any error.
Ah. How do we deal with the errors? How do we know if the game was not
found or if there was another unexpected error (e.g., connection error in the
situation of a real database)? We want to return aStatus Not Found if the
game doesn’t exist, butInternal Error otherwise.
Note that here we choose not to bubble up the errors: the http.Error
message doesn’t contain theerr value. Indeed, this would expose the
internals of our service to clients, and this is rarely a good idea. The words
sent back along with the error are hiding the true error’s details, so we need
to log it for debugging purposes.
Guess
Finally, let’s play!
Here we need to fetch the game, play the word, save the result and return it.
Possible errors:
What can possibly go wrong? Problematic scenarios could be: the game is
not found, the storage is not responding, the proposed word is not valid, and
the game is over, either lost or won. One thing we didn’t add yet was a
sentinel error in the domain (thesession package), to tell us that no, you
cannot play a game that you already won (or lost).
Let’s first see what the function of achieving all the work must do. We will
omit the errors first, then think about each situation.
game.AttemptsLeft -= 1 #E
switch { #F
case feedback.GameWon():
game.Status = session.StatusWon
case game.AttemptsLeft == 0:
game.Status = session.StatusLost
default:
game.Status = session.StatusPlaying
}
err = db.Update(game) #G
return game, nil
}
That’s a long function. In each case, what should the error be?
When we look for the game, we can simply wrap the error with some context.
Not much context here, but it is an example. This error will always be of type
repository.Error , and this is where therepository.ErrNotFound can be
returned.
err = db.Update(game)
if err != nil {
return session.Game{}, fmt.Errorf("unable to save game: %w", err)
}
Because errors are values, we know what happened when we receive an error,
and we can adapt the status code and message of the HTTP response.
Don’t forget to test, there is no trick but there are a lot of edge cases.
Before we leave you, though, we need to list a few warnings about the
shortcuts we took.
One of the most frequent attacks against a server is called a DDoS attack -
it’s a process in which the goal is to overload the server with too many
requests. This kind of attack doesn’t extract any information from the server,
but it causes it to crash, which makes it unavailable for other users. This
attack is usually performed by having lots of computers send thousands of
requests to a server. Each request will cause the server to allocate memory to
process it - a parallel task (thread or routine), some stack allocation, etc.
Since servers have limited resources, at some point, a vast number of requests
will cause these resources to be depleted, and the server won’t be able to
handle anything at all, in the best cases.
Fortunately for us, chi offers a simple way to control how many concurrent
requests can be processed simultaneously on our web service with the
Throttle function. This function takes, as its parameter, the maximum
number of requests allowed to be processed simultaneously. It should be
called as we declare the mux.
r := chi.NewRouter()
r.Use(middleware.Throttle(10))
r.Post(...
Of course this number must be declared as a constant at the very least, but
ideally configurable.
Depending on your needs, you can decide the level where you want to
authenticate the clients: indeed you can require an identification for each
player, or authenticate each application connecting to your service. In the
second option, a website and a mobile app would have different IDs and
keys, and each would have a request rate limit, regardless of the number of
players that they serve.
Authentication and the security problems that it solves could be the subject of
a full chapter but not in the scope of our book. Read OAuth2 in Action, by
Justin Richer and Antonio Sanso, for more.
8.6.3 Logging
In this chapter, we used the native and very basiclog package. This is a
terrible idea in production: it mangles the log output in a concurrent
environment, typically a service. There are lots of great logging libraries out
there that protect your output and offer formatting options. With Go 1.21
came a standard library structured logger in the form of theslog package -
we recommend using it over thelog package.
{
"error": "game over"
}
This way, your clients can use the same decoder whatever the status code of
the response.
Syntax
Query parameters are appended after the path and separated from it by a
question mark ?. To use them, you put the key first, followed by =, the sign
equal, and the associated value with which you want to filter. In case we have
multiple parameters, we add an ampersand & sign between each pair. The
whole list of pairs of key=value after the ? sign forms the query string.
http://localhost:8080/game?lang=en
First, we define the constant for the key, and we declare it next to the path
parameter constant:
const (
// GameID is the name of the field that stores the game's identifier
GameID = "id"
// Lang is the language in which Gordle is played.
Lang = "lang" #A
// ...
)
That was the interesting and very easy part. We will now see how to decode
the query parameter from the request. All the query parameters are retrievable
from the URL thanks to the url library’s URL.Query() method, which we can
access from therequest.URL field in our handlers. If you check the return
type, you will see that it is an url.Values , which exposes (amongst others) a
Get(key string) string method.
$ go doc url.Values
// Values maps a string key to a list of values.
// It is typically used for query parameters and form values.
// Unlike in the http.Header map, the keys in a Values map
// are case-sensitive.
type Values map[string][]string
...
So let’s use theGet method on the Values to retrieve the language in the file
internal/handlers/newgame/handler.go
Congratulations, you now know pretty much everything about REST APIs
and how to decode all kinds of parameters!
8.7 Summary
A web service is a program that continuously listens to a port and knows
what to do with requests based on a set of handlers.
We can usehttp.NewServeMux() to create a multiplexer that will route
requests, based on the URL they were sent to, to specific handlers.
Otherwise, use open-source libraries.
A handler’s task is to fill an http.ResponseWriter . It should Write to
it, and, sometimes, set headers withWriteHeader .
The default status code set byResponseWriter.Write is
http.StatusOK . If we want to return a different code, we need to call
WriteHeader before we start writing the contents of the response via
Write .
If an error happens in an endpoint, the handler should not return this
error. If you really want to know what’s gone wrong, a log is the place
where the error is last displayed.
Some directory names have specific meanings in Go. Files inside
testdata/ will not be compiled by go build or go run . Files inside
internal/ will not be import -able by other modules.vendor/ is a name
that should be avoided for historical reasons.
Types that we define for the API should only be used in the endpoint
handlers. The rest of the time, we should be using types of our domain.
Domain - or model - types shouldn’t be visible from outside the
service’s module. Usually, they hide inside aninternal directory.
A “repository” - oftentimes called “repo” - offers access to the data,
which can be stored in a physical database, in memory, or in any form at
all.
It is always a good idea to check whether your code is thread-safe. Write
tests and make use of mutexes when necessary.
The embed package can be used to load the contents of a file (or
directory) at compilation time. This is useful, for instance, if you want to
keep your SQL queries in .sql files, or when, as we did, you want to load
a set of hardcoded values.
Some, if not most, open-source packages use semantic versioning. Go
will natively use the latest v1.x.y version of a package if nothing is
specified. In order to enforce using v4.m.n, one should explicitly
"go
get path/to/package/v4" and useimport "path/to/package/v4" in
the .go files.
Regular expressions are a very powerful way to match patterns. You
will find yourself using them in various situations and validating
randomised values is one of them. As it can be quickly unreadable, do
not forget to explain in a comment any regular expressions you write.
REST API (REpresentational state transfer) or RESTful API is a set of
constraints defining an interface between two systems. REST APIs
communicate through HTTP and can exchange data through JSON,
HTML or even plain text.
HTTP statuses are useful to communicate precisely what happened on
the server side to the client. HTTP status code is a three-digits from 1xx
to 5xx, which could mean everything went well like 200, or resources
are not found, such as the well-known 404. Returning the proper code
means that the client can better understand what happened. When in
doubt, return 500. When at a loss, return 418.
9 Concurrent maze solver
This chapter covers
Requirements
Find the path through a maze that has no loops (there is only one path to
reach any pixel).
The maze is a PNG RGBA image.
The command-line tool should take an input image’s path and write
another image with the pixels from entrance to the treasure highlighted.
As a bonus, it should also generate a GIF image of the exploration
process.
Below you can see an example of a small maze with a treasure corresponding
to an exit on the bottom edge.
Figure 9.1 Example of a small maze with a treasure (exit) on the edge
But the treasure does not have to be on an edge, the following figure shows
an example of a bigger maze with a treasure inside.
Since it’s nice to have a preview of the maze, we decided to encode ours as
an image. An image is a very convenient way of representing a two-
dimensional grid - it has a fixed size, and we can encode the information of
whether a grid element is a wall, a path, an entrance or a treasure by using
colour values. In the examples above, walls are painted black, while paths are
painted white.
In computer science, 2D images are mostly of two kinds - vector images and
bitmap images. Vector images are similar to mathematical entities -
regardless of how much you zoom in, lines have no thickness, points don’t
look bigger on your screen, etc. SVG (Scalable Vector Graphics) is a
common format for vector images.
On the other hand, bitmap images, also called raster images, contain a 2D
grid of picture elements. These picture elements, or “pixels”, each bear a
colour. When zooming in a raster image, pixels are simply displayed larger.
Common formats for raster images are PNG (Portable Network Graphics)
and JPEG (Joint Photographic Experts Group). JPEG images offer lossy
compression, which means they will usually require fewer bytes to store the
information - but they might also modify the image while compressing it. The
information encoded at the pixel positions is usually colour - but sometimes,
we use pixels for something else, such as heat map or density (this is how
MRI uses images to represent internal tissues), or for palettes, where each
colour has a specific meaning (for instance, a map of the world in which each
country is represented with a different colour).
Of course, Go has a package for image manipulation - and also has a package
for most common image formats.
$ go doc image
package image // import "image"
[...]
$ go do image.Point
type Point struct {
X, Y int
}
A Point is an X, Y coordinate pair. The axes increase right and down.
One of the types we see in theimage package is theRGBAImage. This offers
access to theRGBAAt(x, y) method, which allows us to retrieve the colour of
a pixel at a given position in the image.
no loops - there is exactly one path from the entrance to any given point
of the maze
the generated image should be a PNG image using the RGBA colour
model
When writing the maze generator, you can also add a complexity constraint
on the length of the path from entrance to treasure to avoid straightforward
answers. In our implementation for example, that path - the solution - must
have a length of at least the height of the image plus its width.
We decided to use the following colours, but feel free to be more artistic and
colour-blind friendly:
9.2.1 Setup
As usual, start by setting up your module and creating amain.go file at the
root. This project being a simple command-line tool, we can have the
main.go file at the root of the module and the rest will live in the internal
folder.
This means the first thing our main() will do is read these 2 arguments.
package main
import (
"fmt"
"log”
"os"
)
func main() {
if len(os.Args) != 3 { #A
usage()
}
inputFile := os.Args[1] #B
outputFile := os.Args[2]
// usage displays the usage of the binary and exits the program.
func usage() {
_, _ = fmt.Fprintln(os.Stderr, "Usage: maze_solver input.png output.png")
os.Exit(1) #C
}
You can already run it and check various scenarios.
We then open the first image, containing the maze. What kind of errors can
happen at this point? There could be no file at all, or it could be a non-PNG
image. In each case, we want to print an explicit error.
Let’s call our new function openMaze . It will check that the file exists, open it
- don’t forget to defer the call to Close - and decode the PNG. The last step is
done by calling Decode from the image/png package, which takes an
io.Reader and returns animage.Image , which is an interface.
Go offers image.RGBA , but doesn’t offer image.RGB . For this reason, it’s
simpler to consider RGBA images in this chapter, even though we only chose
colours with 100% opacity.
import (
"fmt"
"image"
"image/png"
"os"
)
rgbaImage, ok := img.(*image.RGBA) #D
if !ok {
return nil, fmt.Errorf("expected RGBA image, got %T", img) #E
}
We have the image. Don’t forget to call the function in your main, handle the
error properly, and you can test manually what happens in different scenarios.
In the code above, there are 2 error cases that are easy to test automatically:
unable to open the file, and unable to load the PNG image. Testing whether
the function is unable to type assert it as a RGBA image requires a PNG
image that wasn’t encoded as a RGBA image. We’ve provided such an image
in our repository: mazes/rgb.png . You should expect an output like this:
Finally, we need to handle theos.Open error when we can’t open a file that
we are able to detect. This case is quite rare - on Unix, it requires execution
rights on a directory and no read rights on a file in that directory. Still, it may
happen and we will be happy to know if it does.
Solver structure
Solving the maze will be done by a dedicated object, one that can be
constructed by giving it the image and that carries aSolve() method. Why?
The object will be able (later) to hold settings such as the colours of the path,
walls, entrance, treasure and solution (the path from entrance to treasure). As
you will quickly see, it will also hold the channels for communication
between the goroutines and the solution at the end. For now, let’s keep it
simple.
The Solver is the heart of the tool and would benefit from living in a
dedicated package:internal/solver . Remember - packages insideinternal
can’t be used by anyone else than your module. Create a file solver.go in
the internal/solver package.
package solver
import "image"
// Solver is capable of finding the path from the entrance to the treasure.
// The maze has to be a RGBA image.
type Solver struct {
maze *image.RGBA
}
Before we go on, let’s define the API of this object. It needs to solve the
maze and write the solution image. That makes 2 operations, so that makes 2
exposed methods.
Listing 9.4 solver.go: Solve API
// SaveSolution saves the image as a PNG file with the solution path highlighted.
func (s *Solver) SaveSolution(outputPath string) error {
return nil
}
New function
Actually, our implementation is highly tied to the image package. Why not
delegate the opening of the PNG image to aNew function? We will need that
function anyway.
Move the file imagefile.go containing the openImage function along with its
test to the solver package and call the function in a new function calledNew,
do not forget to remove it from the main function. It takes the path as a
parameter and returns a pointer to aSolver and an error. Note that, in a file,
we tend to write New functions and other such constructors after the structure
definition.
// New builds a Solver by taking the path to the PNG maze, encoded in RGBA.
func New(imagePath string) (*Solver, error) {
img, err := openMaze(imagePath)
if err != nil {
return nil, fmt.Errorf("cannot open maze image: %w", err)
}
return &Solver{
maze: img,
}, nil
}
$ tree .
├── go.mod
├── internal
│ └── solver
│ ├── imagefile.go
│ └── solver.go
└── main.go
At this point you can even finish writing the main function by building your
Solver and calling its public API in the proper order. Deal with the various
errors in the way you prefer, but don’t forget that CLI tools are expected to
return a status code 1 when there is an error, viaos.Exit(1) . If you have a
doubt, you can have a look at the code in the09-
maze_solver/2_solver/2_3_add_solver/main.go folder.
Run the tests that you have written, commit and have a cup of tea, the next
section is the heart of the project.
The first step is quite straightforward, but of course we would not be here if
there were nothing to learn on the way.
Colour palette
// palette contains the colours of the different types of pixels in our maze.
type palette struct {
wall color.RGBA
path color.RGBA #A
entrance color.RGBA #B
treasure color.RGBA
solution color.RGBA #C
}
A palette structure populated with the values that we picked can be returned
by a defaultPalette() function, with the advantage over global variables
that nothing can change the values that a function will return. Unfortunately,
it would create a new structure every time you need it, allocating precious
memory that needs to be garbage-collected. It can become costly as soon as
we start exploring bigger mazes.
For the moment you can simply set these colours to some default values in
the New function of the solver package using the function we just defined,
defaultPalette() .
Pixel definition
One thing we can easily anticipate with a pixel is that we will need to find its
open neighbours: the pixels bearing the Path colour that are orthogonally
connected to it. This will be easier if the pixel can give us the coordinates of
its own neighbours. Because of how the maze is implemented, we don’t want
to include diagonally-adjacent neighbours.
package solver
import "image"
Slice or array?
Nothing in this function guarantees that the neighbours are inside the image.
For instance, two neighbours of the top left corner, at position {0, 0}, are
outside the image. We could add a safety net here, or we could require that
none of the edges of the image should be explored.
Another thing that the API doesn’t guarantee is the order of the neighbours.
We could start from the top and go clockwise or be wild and just scramble
them. This means the test is the perfect occasion to use stretchr’s
testify
framework and its assert.ElementsMatch function.
Go back to the file solver.go file and create the function findEntrance .
We’ll have to scan the whole image to find one pixel that has the entrance
colour. In order to check each pixel’s value, a common practice in image
processing is to follow the row-major order with two nested loops: an outer
loop that will iterate over the rows of the image, and an inner one that will
iterate over the rows, just as some languages such as English or Tifinagh
write text from the leftmost position to the rightmost, and then to the next
line, from the leftmost again.
The reason for this specific pattern is that image formats tend to store pixel
values in “scanline” format, where horizontally adjacent pixels of the image
are stored in adjacent memory locations. Understandable when we remember
that most image format developers are English speakers.
Most of the time, our maze’s first pixel will be at position (0, 0) - in the top
left corner. But if we’re looking at a subsection of an image, our “top left”
corner might be at another position. Here, we can access our maze’s bounds
via the Bounds() method on the image.RGBA type. This returns two points,
that define the bounding box of our image: theMin and a Max fields
Back in the Solve method, we can call this to know where to start.
return nil
}
Using goroutines can increase the performance - making finding the solution
faster - but this isn’t guaranteed. Starting goroutines takes time, and
communicating data with them adds on top of that. Usually, goroutines aren’t
necessary for very quick tasks. In our case, each goroutine has an
undetermined scope: we don’t know when it could end, so we might as well
give it its chance. What is certain is that using goroutines increases CPU
usage.
In the example below, we are looking for the Treasure (μ). Goroutine
Daedalus (δ) starts in A5, then goes to B5, and needs to branch. A second
goroutine, Theseus (θ), picks up at B6 while δ continues in B4, C4, etc. As
long as our maze contains no loop, Daedalus won’t meet Theseus as they
explore the maze, and this means Daedalus doesn’t need to know about
Theseus at all.
Communication
After finding the position of the entrance, our Solve function is in charge of
initiating the exploration of our maze, starting from there. As soon as a new
path should be explored, we want the solver to start the exploration of that
branch. Each explorer will be in charge of notifying new branches to our
solver with a channel, which will be listening to these notifications. We
understand from this that we need two new methods on our solver - the first
one will explore a path - we can call it explore - and the second one will be
in charge of listening to branches - let’s call it listenToBranches . Our solver
initiates the exploration of the maze by sending a message to the channel that
the listenToBranches method is listening.
The function listenToBranches reads the very first message and creates a
goroutine, the one we called Daedalus (δ), for the path starting at A5.
Daedalus looks at the neighbours of A5: only B5 is eligible. It integrates B5
to its explored path and checks the neighbours of B5. B4 and B6 are eligible
candidates for exploration. Our exploring goroutine, Daedalus, sends the path
to B6 to the channel and keeps exploring B4, C4, and so on. Meanwhile, the
listener reads the message sent by Daedalus and spins a new goroutine,
Theseus (θ), which goes on to C6, C7 and so on, until it finds a dead end and
finishes. Meanwhile, Daedalus has sent the path up to E2 to the channel, and
the path up to E4, and the listener has spun two new goroutines, λ and φ.
Of course in this scenario, take the grammatical tenses with a dash of salt,
because depending on your architecture and the random reassignments
decided by the CPU, the future, the present and the past can vary from one
run to the next.
Let’s ask another question: what does Theseus need to know? What does the
goroutine that gets to the treasure need to know? The path so far. That’s all.
The path through pixels that have been explored to reach this point, which we
can express as a linked list ofimage.Point .
Listing 9.13 path.go: Using a linked list to store the path so far
package solver
import "image"
Add a field to the Solver : a channel whose messages are pointers topath
a .
This is where goroutines will publish new paths to explore, and where the
listenToBranches method will listen in order to spin up new exploration
goroutines. That method will be added in 9.3.4: common sense dictates that
you cannot read from a channel into which nothing has been written yet (we
will see that it’s actually not so simple).
Considering that the aim of the program is to tell us how to go from one point
to another, we need to know, whenever we explore a new pixel, how we got
here.
Let’s write the explore function, which takes a path as its parameter and
explores it. It will go on until it either finds a dead end or the treasure and
will publish to the channel any branch it does not take. This function is what
each goroutine will do.
For a first version, if we find the treasure, let’s only print a message and stop
exploring with a return . We will come back to that later.
package solver
import (
"image"
"log"
)
pos := pathToBranch.at #A
for { #B
// We know we'll have up to 3 new neighbours to explore.
candidates := make([]image.Point, 0, 3)
for _, n := range neighbours(pos) { #C
if pathToBranch.isPreviousStep(n){ #D
// Let's not return to the previous position
continue
}
// Look at the colour of this pixel.
// RGBAAt returns a color.RGBA{} zero value if the pixel is outside the bounds of the
switch s.maze.RGBAAt(n.X, n.Y) { #E
image.
case s.palette.treasure:
log.Printf("Treasure found at %v!", n)
return
case s.palette.path: #F
candidates = append(candidates, n)
}
}
if len(candidates) == 0 {
log.Printf("I must have taken the wrong turn at position %v.", pos)
return
}
// See below
}
}
// isPreviousStep returns true if the given point is the previous position of the path.
func (p path) isPreviousStep(n image.Point) bool {
return p.previousStep != nil && p.previousStep.at == n
}
Note that, so far, neighbours can only be Wall, Path, Entrance, or Treasure.
Entrance has already been skipped because it was the previous pixel, and
there is nothing we can do about a Wall. This is why we only have 2 cases in
the switch - and we didn’t add an empty default case: we either find the
treasure, or another position to explore. Anything else is not interesting. It is
important to note that, as we look for eligible neighbours, we don’t want to
go back on our steps and return to the previous position. This could lead to an
endless creation of goroutines and a crash of the program. We’ll explore
ways of preventing this in section 9.6.1.
Then, there are two cases to consider: either we have no next pixel to explore,
in which case it was a dead end, or we do have next pixels to explore - which
is when we’ll have to send messages to the listening goroutine.. In the case of
a dead end, we print a log message to understand what is going on, but there
is nothing else to do anymore. We exit the loop and the goroutine ends its
execution.
Branching out
Can we extract the inside of the big infinite loop? There would be too
many variables to return: the next position, the next “previous” position,
a signal about whether to exit, and possibly an error.
Can we extract the first part, where we look for candidates? This is
where we chose to exit in the case of a success. Possible but not too
easy.
Can we extract the second switch, where we look at the candidates? In
this situation, we also exit in the case of a dead end. We could pull out
the publishing loop, but would the code really become easier to
understand for 3 lines?
Let’s keep it this way and see whether writing a test is overly complicated or
not. As often, because we want to isolate the logic as much as possible, the
test is a bit more complicated than the code itself and requires at least the
same notions, so we will keep it for just a bit later. Don’t make it a habit,
though.
As we explore, we need a function that listens to the channel and starts a new
goroutine for each message in the channel. It doesn’t need to be complex: for
each message in the channel, callexplore .
This is a very short implementation; it can work but it has a catch with
goroutines. We know when they start, but we don’t know when they end.
Here, we are not keeping track of the different goroutines (nor their amount),
which means the program can end while some of them are still running and
keep using memory and CPU. We will need to fix this before considering our
code correct.
But let’s first make our program work. The last thing we need to do in order
to kickstart the exploration is to publish the first message.
How do we start the first goroutine, the one we called Daedalus in our
example? We only need to publish the entrance to the channel and start
listening.
Let’s come back to the Solve function. It knows the position of the entrance
pixel. We can publish that.
Don’t forget to call the listening function listenTobranches after that and
try running the program. Do you get an error? You should. What we have is
this:
We are trying to write to a nil channel and to read from it. Initialise it in the
New function, and try again.
Unbuffered channels
As you see if you have logs everywhere, a size of one is enough to get to the
solution of a small maze, but we still finish in a deadlock. As we said, the
main goroutine listens forever, even when all subroutines have stopped
publishing. Go is able to notice that and ends the execution with a deadlock
error after a little while. We need a way to tell the listening method to stop.
When one goroutine finds the treasure, it needs to save the path leading to it
somewhere, and somehow tell all the other goroutines to stop looking, as well
as tell the listener to stop listening. We will start by implementing a quick
version, so that we can get something pretty as soon as possible, see its
limitations and find a better solution.
We took a small shortcut a few pages ago, at the point where we found the
treasure. At that point, we need to save the path of pixels somewhere where
the SaveSolution function can find it. But where? Most of the time, the
straightforward answer is good enough: we can put it inside theSolver .
Additionally, it can serve as a flag to tell different goroutines that the treasure
has been found and that they can stop looking for it.
solution *path #A
}
In the explore function, writing to the field is done in just one line. Let’s go
back to the switch:
case s.palette.treasure:
s.solution = &path{previousStep: pathToBranch, at: n} #A
log.Printf("Treasure found at %v!", n))
return
case s.palette.treasure:
s.mutex.Lock()
defer s.mutex.Unlock() #A
if s.solution == nil {
s.solution = &path{previousStep: pathToBranch, at: n}
log.Printf("Treasure found at %v!", n))
}
return
Now how do we tell other goroutines to stop? Let’s start by using this
solution field as a flag. Not a great solution, but a fast one. Since we’ll need
to check in several places whether the solution was found, we can write a
function:
We have another infinite loop that could use a stop in theexplore function.
We can change the infinite loop so that it stops when the solution is found.
for !s.solutionFound() { #A
candidates := ...
Let’s run it. Has it stopped deadlocking? Depending on the complexity of our
input maze, maybe yes, maybe no, because our solution is hacky. Before we
fix it properly, it’s time to start automating the test.
We can test this on an image that is only 4 pixels wide and 5 high: we need a
2x3 grid plus some mandatory walls on 3 sides.
If we send the first pixel as parameters, we can count the number of branches
that have been published to the channel. For this, we’ll create a Solver , but
we won’t listen to its channel. At the end of the run, each branch will have
been published to the channel. We can check the number of messages inside
a channel with the len built-in function, just as we would for slices or maps.
Since we won’t be listening to the channel, we need to build it with enough
capacity to store all the messages that will be published there.
s := &Solver{
maze: maze,
palette: defaultPalette(),
pathsToExplore: make(chan *path, 3), #E
}
Feel free to add more possibilities. You can also write a second test function
for the cases wherelen(s.pathsToExplore) > 0 , listen to all the messages
and check that we get what you expect. Be careful not to rely on the order of
the neighbours sent by theneighbours() function, because it is not
guaranteed by the implementation. Currently, the behaviour is to always
continue exploring in this order of preference: above, below, right, and left.
Imagine a future developer reordering the neighbours and breaking this
seemingly unrelated test.
Why are we not using New? We want to control the size of the channel in the
situation of our test, because we are not reading from it. A standardSolver
has an unbuffered channel; here we want a buffer of 3 paths for all the
potential candidates.
// SaveSolution saves the image as a PNG file with the solution path highlighted.
func (s *Solver) SaveSolution(outputPath string) (err error) {
f, err := os.Create(outputPath) #C
if err != nil {
return fmt.Errorf("unable to create output image file at %s", outputPath)
}
defer func() {
if closeErr := f.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("unable to close file: %w", closeErr))
}
}()
stepsFromTreasure := s.solution
// Paint the path from last position (treasure) back to first position (entrance).
for stepsFromTreasure != nil {
s.maze.Set(stepsFromTreasure.at.X, stepsFromTreasure.at.Y, s.palette.solution)
stepsFromTreasure = stepsFromTreasure.previousStep #A
}
return nil
}
You should observe a new file in your project, sol.png, which looks
somewhat like this:
Then try something bigger. It still ends with a deadlock if your maze is
complex enough, doesn’t it?
The reason is as follows: when one goroutine finds the solution and saves it
in our Solver object, in the next nanoseconds, the listener stops listening to
the channel, but some other explorers are still looping through their
neighbours and publishing to that same channel. As soon as that channel has
received as many messages as its capacity allows, writing to it causes a
deadlock - and the program exits.
This way, we can be sure that the program will only end when all the
goroutines are finished.
You might have noticed that we used an anonymous function with a
parameter. Why did we do this? The reason is both important and a bit
complicated. All versions of Go, up to 1.22, suffer from the way for loops
are handled: versions 1.21 and prior would overwrite the variable used to
iterate - in our case, thep pointer. While this would be fine if we didn’t run
concurrent activities in our loop’s body, things are different here.
var p *path
for {
p = <-s.pathsToExplore
wg.Add(1)
go func() {
defer wg.Done()
s.explore(p)
}()
}
Before Go 1.22, we would have no guarantee that the value passed to the first
call to explore is indeed the value we first read from the channel - it might
have been overridden by the second value by the time the code execution
reaches explore. There are two common and useful tricks to prevent that
value from being overridden. The first one is the one we presented above - by
passing the pointerp as a parameter of our anonymous function that starts the
goroutine (rather than somewhere within the goroutine), we ensure that it
isn’t overridden: indeed, the for loop can’t read the next message from the
channel as long as the goroutine hasn’t be started.
The other common trick that is frequently used is to simply manually copy the iteration variable inside the
loop. Most of the time, the name of the copy that is used is also the name of the iteration variable, which
mightpseem
:= p strange.
for p := range s.pathsToExplore {
wg.Add(1)
go func() {
defer wg.Done()
// use the local p
s.explore(p)
}()
...
However, most of the time we aren’t interested in only the first message
received from a channel - we want to process all of them. For this, we can use
the for-select combination, which allows us to listen to several channels at the
same time. It can be seen as an extension of the for msg := range
myChannel loop that we used to listen forever to one channel.
In our case, we can replace the for-range loop with a for-select loop, which
will have the same behaviour.
for {
select {
case p := <-s.pathsToExplore:
wg.Add(1)
go func(p *path) {
defer wg.Done()
s.explore(p)
}(p)
}
}
for {
select {
case <-s.quit: #A
log.Println("the treasure has been found, stopping worker")
return
case p := <-s.pathsToExplore: #B
wg.Add(1)
go func(p *path) {
defer wg.Done()
s.explore(p)
}(p)
}
}
}
We’re listening to the quit channel - but we need to write a message to this
channel for it to be useful. Let’s do this in the explore method, when we find
the treasure.
return
Let’s run this on a few mazes. Does it work? Since we’re working with
goroutines, nothing is absolutely deterministic, but we did have errors on our
side when running this. Indeed, as soon as our goroutine listening to the new
paths exits, we still have some goroutines trying to write in that channel - and
that’s a blocking action. Some of our explorers will go into deadlock mode,
trying to write to a channel that nothing reads. So far, we’ve just changed the
way we reach the same issue. But fortunately for us, there is hope.
To solve this, we need to make sure the explorers stop exploring as soon as
the solution is found. Could we read from the quit channel? Well, we could,
but there is a small conundrum: we don’t know how many explorers are still
running at this moment. And we would need to have one message per
explorer goroutine if we want each explorer to quit when we find the treasure.
Which means we would need to broadcast a potentially huge number of
messages in thequit channel to ensure every explorer receives its own.
But there is a more interesting way of solving this issue. We canclose the
quit channel. A closed channel is a channel to which writing is impossible,
but reading is still possible. A closed channel can’t be reopened, it’s a final
action to take. The interesting part of a closed channel is that we can always
read from it, and this will always return a value - either one that was
previously written in there, or the zero value of the type of messages the
channel transmits if there are no written values left to read.
In order to know whether the value read from a channel was written there in
the first place, or if it’s kindly returned because we’re trying to read from a
closed channel, we can use the second value returned by the <- operator:
For our business, we don’t really need to care where the empty structure
comes from, we only want to try and read from thatquit channel. Indeed, if
we make sure nothing writes to this channel, the only moment when we could
read from it will be when it’s closed. And several goroutines can try to read
from a closed channel without stealing each other’s message - which means
several goroutines can now know if it’s time to stop working.
Let’s replace the s.quit <- struct{}{} line with a close(s.quit) . This
doesn’t change the code in thelistenToBranches method.
Stop explorers
How do we make sure we only write there when allowed? We can’t first
check quit and then write to pathsToExplore - this would leave a tiny gap
during which another explorer could close quit :
select {
case <-s.quit:
log.Printf("I'm an unlucky branch, someone else found the treasure, I give up at position %v.", pos)
return
default:
# A goroutine could close quit between the line above and the line below
s.pathsToExplore <- branch
}
This solution isn’t secure enough because of that gap. Instead, we want the
same logic as we have in thelistenToBranches method:
select {
case <-s.quit:
log.Printf("I'm an unlucky branch, someone else found the treasure, I give up at position %v.", pos)
return
case s.pathsToExplore <- branch:
//continue execution after the select block
}
In this piece of code, we first check whetherquit is closed (it’s the only case
when we can read a message from it). If it is, we return, otherwise, we
publish our new branch.
Finally, we want to stop the exploring goroutines when the solution is found.
We can do this as the first operation of our infinite loop in the explore
method:
You now have a working maze explorer, with a few known limitations. If you
want to extend it, we have a few ideas to present.
9.6 Visualisation
There are numerous ways to go further with this pocket project. One of the
issues we haven’t raised yet is that of mazes that contain loops. Imagine a
maze containing the following extract:
The four X-marked spaces are paths, but they all go around a “pillar” - a
piece of wall not connected to any other wall. At each intersection, a
goroutine would either stay close to the pillar or exit the room - but it would
still create a branch that would explore around the room. This means that
such a room would create an unlimited number of goroutines, something
very, very harmful for the wellbeing of our computers. We could ask for the
user to provide a maze with no loops, but we could also try and handle it as
part of the exploration.
Finally, we did solve the maze, but wouldn’t it be nice if we could also show
the intermediary steps? Here, we’ll animate our progression through the maze
and produce a nice GIF file of the exploration.
We set ourselves a constraint in the beginning: the maze should never have
loops - if you see it as a graph, it is a tree, where each node only has one path
leading to it.
What we want to try instead in this chapter is just to find one of the solutions
and avoid going through the same pixels multiple times.
Strategy
And have our defaultPalette function return a value for this field (a
different value than from the palette.path ).
Then, in the explore function, all we have to do is paint the pixel we’re
exploring, and voil→!
pos := pathToBranch.at
for {
s.maze.Set(pos.X, pos.Y, s.palette.explored) #A
select {
From now on, a pixel that was explored will not be eligible in our search for
candidates - as it won’t be of thes.palette.path colour.
Let’s run the program, you should see an image with the solution and the path
explored coloured such as the image below:
Figure 9.9 Example of a maze solved with the explored pixels coloured blue
Implementation traps
But wait - we’re working with several goroutines, and we want each one to
modify the contents of a pixel in our image - this is a door wide open for race
conditions. Is this a problem in this scenario? One could argue that in this
case it isn’t: whichever goroutine gets there first, the result will always be the
same - each of the goroutines writing the same contents at that pixel, we’re
fine with any overwritten or partially written value. But this is totally wrong.
Race conditions
We know our solution reaches the treasure. We have some logs that tell us
which dead ends we managed to find. But this isn’t very visual, and since this
is a chapter about images, let’s make it more fun!
Now we’ve decided we want to show the state of the exploration, how do we
do it?
Well, first, we need to be able to keep track of the pixels we’ve explored so
far - which is precisely what we did in 9.5.1. Second, we need to add the
frames - the image with its currently explored pixels - at specific moments of
our exploration. Let’s start by adding to our Solver structure a new field in
charge of holding the GIF using the type from the standard library
image/gif .
There are mostly two ways in Go to make asynchronous calls. The first one is
to make the call in a goroutine:
go s.registerExploredPixel(pos)
This is a perfectly valid option, but one has to ask themself if race conditions
could happen. Ultimately, this method will require the explicit use of a
mutex. But what did we say about communication between goroutines?
The second option, which we’ll use here, is to use a channel into which
explorers send pixels they want registered. This approach means we will have
our registerExploredPixel receive pixels from a channel. There is no need
for a mutex, as long as we process the pixels read from the channel one at a
time. Let’s add this channel to our Solver structure. Don’t forget to initialise
it in the New function.
The explorer’s “infinite” for loop can be updated to either abort when the
quit channel was closed because the solution was found, or send a pixel for
registration and continue with the exploration.
Now we can write the function responsible for registering explored pixels.
explorablePixels := s.countExplorablePixels() #A
pixelsExplored := 0
for {
select {
case <-s.quit: #B
return
case pos := <-s.exploredPixels: #C
s.maze.Set(pos.X, pos.Y, s.palette.explored) #D
pixelsExplored++
if pixelsExplored%(explorablePixels/totalExpectedFrames) == 0 {
s.drawCurrentFrameToGIF()
}
}
}
}
Aliasing imports
In Go, it’s sometimes useful to alias an import. Here, we’ll use import plt
"image/color/palette". When aliasing imports, it’s best to use an alias that
resembles the original package name to keep the code clear.
We’ve created an empty canvas, let’s draw the current state of the explored
maze into it. Unfortunately, Go’s image/draw package doesn’t allow for
scaling images - and therefore doesn’t allow for any interpolation
whatsoever. Instead, we’ll have to usegolang.org/x/image/draw , its more
versatile version. This package offers agolang.org/x/image/draw.Scaler
interface, which shrinks or expands a rectangle section of an input image to a
rectangle section of an output image.golang.org/x/image/draw exposes
three types that implement theScaler interface: NearestNeighbor ,
CatmullRom , and ApproxBiLinear . For the purposes of this chapter, we’ll
stick to NearestNeighbor , as it’s the one that won’t blur our pixels’ edges.
Finally, we can add the frame to our GIF image. All three operations can be
written into a single method called by markPixelExplored:
import (
"image"
plt "image/color/palette" #A
"golang.org/x/image/draw"
)
// ...
// drawCurrentFrameToGIF adds the current state of the maze as a frame of the animation.
func (s *Solver) drawCurrentFrameToGIF() {
const (
// gifWidth is the width of the generated GIF.
gifWidth = 500
// frameDuration is the duration in hundredth of a second of each frame.
// 20 hundredths of a second per frame means 5 frames per second.
frameDuration = 20
)
// Create a paletted frame that has the same ratio as the input image
frame := image.NewPaletted(image.Rect(0, 0, gifSize,
gifWidth*s.maze.Bounds().Dy()/s.maze.Bounds().Dx()), plt.Plan9)
// Convert RGBA to paletted
draw.NearestNeighbor.Scale(frame, frame.Rect, s.maze, s.maze.Bounds(), draw.Over, nil)
We now have a single goroutine in charge of updating the values of the pixel
of our image, which does it pixel per pixel, as they come through the channel.
Let’s not forget to start this registerExploredPixels method in Solve . We
now have two “listening” goroutines we want to start - listenToBranches
and registerExploredPixels . To launch both and synchronise after they’ve
returned, we can use async.WaitGroup :
defer wg.Wait() #A
go func() { #B
defer wg.Done()
// Launch the goroutine in charge of drawing the GIF image.
s.registerExploredPixels()
}()
go func() { #C
defer wg.Done()
// Listen for new paths to explore. This only returns when the maze is solved.
s.listenToBranches()
}()
return nil
}
We’ve now added frames to our GIF. Each of them was copied, pixel by
pixel, from the maze being explored.
Let’s draw the GIF file! For this, we’ll simply plug somewhere in our code
when we know we’re ready to print it. The current SaveSolution function is
a good choice, since it’s already in charge of writing an output file. Let’s call
a new method in there to draw our final GIF.
return nil
}
defer func() {
if closeErr := outputImage.Close(); closeErr != nil {
// Return err and closeErr, in worst case scenario.
err = errors.Join(err, fmt.Errorf("unable to close file: %w", closeErr))
}
}()
return nil
This code is very similar to that of the encoding of the PNG image.
This should generate the solution.png image, but also a solution.gif file. Open
this file to see how the maze was explored! Do you notice anything? The
solution doesn’t appear very clearly - if it is at all displayed - and the loop
restarts immediately. It’d be nice to make sure the solution is added to the list
of frames, and that this final frame is printed for a longer duration. In 9.4, we
added the painting of the solution to theSaveSolution method. Now that we
need to do something on the GIF, we might want a dedicated method for this
and move the logic out of the code that writes files into the solver. Let’s write
the final lines of code for this chapter. First, paint the pixels between the
entrance and the solution in the image stored in the solver, and then add a
final frame (which will include the painted solution pixels) to the GIF. By
setting a longer value, we ensure that the final frame will be displayed long
enough to be admired!
s.writeLastFrame()
return nil
}
// writeLastFrame writes the last frame of the gif, with the solution highlighted.
func (s *Solver) writeLastFrame() {
stepsFromTreasure := s.solution
// Paint the path from entrance to the treasure.
for stepsFromTreasure != nil { #A
s.maze.Set(stepsFromTreasure.at.X, stepsFromTreasure.at.Y, s.palette.solution)
stepsFromTreasure = stepsFromTreasure.previousStep
}
Rerun the program and open the GIF. You can adjust the values of the frame
durations, or the number of frames, to get the look and feel you really want!
Unfortunately, we can’t include the GIF in this book, but share yours with
your friends!
9.7 Summary
In computer science, the main type of two-dimensional images are raster
images and vector images. Vector images are used in fonts and logos, in
infographics, or in icons. Vector images are very scalable - you can
zoom in and not see any artefacts.
The other half of the images we use are raster images - two-dimensional
grids of pixels. Each pixel of an image has a colour which can be
expressed in the RGBA colour model (but it might be encoded in
another colour model, such as the YCbCr, for JPEG images). The value
of the colour can be used to encore either a physical information, such as
the amount of light of red, green, and blue frequencies that is emitted by
an object (as in the picture of a flower), any numerical information, such
as the density of population, or finally a palette can be used to represent
areas of same category, such as in a map, where each country has its
own colour.
The
image/png package is used toDecode a file into an image.Image .
This Image will frequently be type asserted to aRGBAor NRGBA. To
encode an image, use theEncode function from the package you wish to
encode your image - available options aregif , jpeg , and png . Other
formats require third-party libraries.
Images usually have their pixel at position (0, 0) in the upper-left.
However, some images might have (0, 0) in any other corner. It all
depends on the image format and the image’s metadata. Use what the
image package returns to iterate over the pixels of an image.
You can access a pixel’s value in an with the
image.Image .At()
method. This returns acolor.Color() that you have to convert to
color.RGBA . When using an image.RGBA , you can useRGBAAt() instead,
which will return a color.RGBA that can then be compared to known
values.
In order to write a pixel to an image.RGBA , use theSet(x, y, rgba)
method.
When scanning a whole image, use two nested loops, the outermost one
iterating over the rows, and the innermost one iterating over the
columns. This is beneficial, performance-wise, for all “scanline”
formats.
When you can’t have global constants, it’s slightly cleaner to have a
function that returns configuration values rather than using global
variables. Avoid exposing global variables for safety reasons: other
pieces of code might change them.
Writing to an unbuffered channel that isn’t read from is blocking. Either
write to it in a goroutine, or use a buffered channel, whose size should
be the maximum number of elements that will be written there before
the reading starts.
When starting goroutines in loops, make sure your loop variables are
protected. The loop variables can be the messages you read from a
channel, the keys or values of a map you iterate through, or the elements
of a slice.
There are three common ways of protecting iterators of a for-loop when
a goroutine is launched inside the loop:
you can either use a version of Go that guarantees that (currently,
it’s considered for Go 1.22)
you can shadow the loop variable with another one in your loop
(usually, we give the new variable the same name as the loop
variable)
finally you can launch your goroutine with an anonymous function
that takes the loop variable as a parameter.
The select keyword allows a piece of code to listen to several channels.
Whenever a message is published in any of the channels, the code
written in the case statement will be executed.
If several case statements in aselect are eligible, Go will pick a
random one.
It is common to have one of thecase statements of aselect be a return
condition. This is especially true in servers, where the processing of an
input request should be ended as soon as the request is cancelled.
The for-select “infinite loop of listening” pattern is very common.
Usually, one of the cases of theselect block will contain the condition
to exit the loop.
10 Habits Tracker using gRPC
This chapter covers
Writing a web service using Protobuf and generating the Go code of its
gRPC definition
The Context interface in Go
Running the service with basic endpoints
Testing with integration tests
As developers, we spend most of our day in front of a screen for work, on top
of any leisure activity we might have. Unfortunately, the effects of a high
number of hours watching these lit pixels - albeit sometimes positive for
moral or psychological aspects - are mostly considered negative for eyesight,
causing eye fatigue, dry eyes, or difficulty focusing. On the other hand, there
are some activities that will alleviate these ophthalmic conditions - most of
them include simply doing something else than watching a screen. Usually,
recommendations go along the path of regularly taking a stroll, reading a
book, or having a physical activity.
It’s never easy to pick up a new habit, and no one has ever gone from never
jogging to running a marathon. The goal is always incremental. But the
important point is to track how much of these habits one can get done in a
week, and maybe adjust objectives for the next week.
In this chapter, we’ll write a service in charge of registering such habits. The
user will be able to create habits, give them an expected frequency - a number
of times per week they are expected to be completed - and list them. We’ve
already written an HTTP service, this time we’ll focus on another popular
network remote procedure call protocol, this one developed by Google:
gRPC.
Functional requirements
Technical requirements
In this case, we want the communication between the clients and our service
to use the gRPC framework, where messages are encoded using the Protocol
Buffers (Protobuf) format and using the HTTP/2 network layer. Protocol
Buffers are a programming language-independent description of how these
messages are encoded.
Protocol Buffers
Protocol Buffer fields are a mechanism used for serializing structured data.
While this can also be achieved by lots of other ways (JSON, XML, yaml,
…), Protocol Buffers have an emphasis on two important points: versioning
the serialized model, and reducing any non-data information. Invented by
Google in 2001 and released to the public in 2008, they are perfect for high-
network applications like microservices. Protocol Buffers is a way to describe
communication between programs in a cross-language way. You can define
what data is being sent via message definitions. You can also define
endpoints for what is being communicated. Messages and service APIs, the
endpoints are written in Protocol Buffers files (text files with, usually, the
.proto file extension), which can then be compiled to generate clients for the
programming language of your choice, as we’ll explain below. Clients can be
generated for many common languages, including Go. A few limitations:
Protobuf messages are not self-describing – you need to know how to read
them before you can access their contents. And this also means we can’t
simply use regular tools such as curl to send messages to a gRPC endpoint -
testing will also be a bit trickier than with JSON APIs.
The final API will resemble the following, and throughout this chapter, we go
through each step necessary to implement these endpoints:
Initialise your go module the usual way: create a directory, and run:
Even before creating amain.go or anything, create a folder at the root of the
project, named api/proto , where we can store the Protobuf files. Their
extension is .proto .
{"practice Go", 5}
Habit entity
Let’s start with a minimal API definition of what a habit is. It has a name and
a weekly frequency. We can write a Protobuf file with the entity.
Each proto file starts with the version of the protocol, then defines a Protobuf
package, and in our case, because we want to generate Go code, a Go
package. Generated code will end up in the folder named after the package
and situated inside thego_package module path. As often, a piece of code
will make things clearer.
syntax = "proto3"; #A
package habits; #B
option go_package = "learngo-pockets/habits/api"; #C
// Habit represents an objective one wants to complete a given number of times per week.
message Habit { #A
// Name of the habit, cannot be empty
string name = 1; #B
// Frequency, expressed in times per week.
int32 weekly_frequency = 2;
}
Service definition
syntax = "proto3";
package habits;
option go_package = "learngo-pockets/habits/api"; #A
// Habits is a service for registering and tracking habits.
service Habits { #B
}
This service exposes nothing, as you can see. The first endpoint that we need
is for creating a habit to track.
func CreateHabit(Habit)
func CreateHabit(CreateHabitRequest) CreateHabitResponse
In the first case, we give a habit and expect nothing in return. Simple. In the
second case, we need to define two additional structures, it’s verbose, it’s
annoying - the first one will just contain a Habit field and the other will be
empty. What would be the point?
The point is version intercompatibility. Let’s say in the next version we want
to add a user token to identify which user is creating the habit, and then
return a habit identifier. In the more verbose case, we would just add a field
in each structure, and if it is not mandatory, any code written for the initial
version will still work, whereas in the first and straightforward case, we
would break the whole API.
For this reason, theCreateHabit endpoint will use its Request and Response .
You might wonder what happens in the case of errors - why wouldn’t they
appear in the proto API? The answer is that the gRPC-compilation tool will
be in charge of adding support for errors. This support differs from language
to language - in Go, we can have several returned values, whereas in C++ or
Java, the error needs to be returned differently - which means we don’t write
errors in the proto file - but, don’t panic, the Golang interface compiled from
this proto will allow us to return an error.
We can add the endpoint to the service with one line, then define 2 new
messages.
service Habits {
// CreateHabit is the endpoint that registers a habit.
rpc CreateHabit(CreateHabitRequest) returns (CreateHabitResponse); #A
}
import "habit.proto"; #A
service Habits {
...
}
And done. In a handful of lines, we have an API for the first step of the
tracker, which is the creation of a habit. As you can see, there is no path and
no verb: they are specific to HTTP. gRPC does not use them.
Installation steps
$ go install
google.golang.org/protobuf/cmd/protoc-gen-go@latest
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
These two utilities are used to compile.proto files declaring messages and
services into Golang files. They work as plugins for protoc – they will be
called if protoc detects we want to compile Golang files.
Compilation
In your favourite terminal, navigate to the root of the go module and try the
very minimal version:
protoc api/proto/habit.proto
The compiler complains: it needs output directives. For what language should
it generate the compiled files? Where? Thego_out parameter will tell the
compiler both the requested output language and the location for compiled
files by specifying the target folder. By specifying this option, we also tell
protoc to use theprotoc-gen-go plugin.
.
├── api
│ ├── learngo-pockets
│ │ └── habits
│ │ └── api
│ │ └── habit.pb.go
│ └── proto
│ ├── habit.proto
│ └── service.proto
That’s not what we want, we would like the Go file to appear directly in the
api folder. Fortunately for us, there is an option for that: --
go_opt=paths=source_relative .
Now the tree looks like what we want. The last step is to compile all of the
proto files, not only Habit.
It doesn’t work. What the compiler is doing with this command is to take
each file separately and generate a Go file for it. When it reaches
service.proto , it cannot import Habit because we never told it where to
look.
The -I option has the following documentation if you run protoc --help :
Specify the directory in which to search for imports. May be specified
multiple times; directories will be searched in order. If not given, the current
working directory is used.
Once you have run this command, your tree should look like this:
.
├── api
│ ├── habit.pb.go
│ ├── proto
│ │ ├── habit.proto
│ │ └── service.proto
│ └── service.pb.go
├── go.mod
└── go.sum
All the messages exist as Go structures, but not the service yet. We also need
to generate the gRPC part.
The options are quite similar to the pure Go ones:go-grpc_out and go-
grpc_opt . Passing these options on the command line will silently tell
protoc to use theprotoc-gen-go-grpc plugin.
protoc -I=api/proto/
--go_out=api/ --go_opt=paths=source_relative
--go-grpc_out=api/
--go-grpc_opt=paths=source_relative api/proto/*.proto
There is one final parameter that we must talk about, when it comes to the Go
gRPC compiler, and this has to do with forward-compatibility. Suppose that
we’re happy with the current proto API, that we use it to compile the Golang
files, and that we implement the server interface with a structure of our own.
Then, let’s assume we want to add a new endpoint - we’ll have to update the
proto file, and regenerate the Golang files. As mentioned on the go-grpc
repository, “it is a requirement that adding methods to a service cannot break
existing implementations of the service”. So, how did they ensure this
requirement is always met?
There are two options. The first one is to require that any implementation of
the server embeds a type defined in the generated file. The other is to allow
for the developers to not implement the required server interface. While this
second option is not recommended, it is still available by passing another
parameter to the command line:
--go-grpc_opt=paths=source_relative,require_unimplemented_servers=false
In the rest of this chapter, we will use files that were generated without this
final option - and we’ll remind you to embed the type when creating the
server type.
Automated generation
Remember to put this massive command in a place where you and future
maintainers will find it, typically in a Makefile or as part of a script. You
might wonder why we wouldn’t place this in a generate.go file with a
//go:generate directive – the reason is that we were lazy in our command-
line and used a* to send all the .proto files to protoc . Unfortunately, while
shells understand how to expand*.proto into “every file with a proto
expansion”, go generate doesn’t, which prevents us from using the same
command-line directly in a //go:generate directive. However, if you have
access tobash or sh , or any other shell you fancy, you can tell go generate
to run a command in a shell with the following syntax (don’t forget the
double quotes around the command that you really want to run) :
Make sure to document it, like everything that is not considered general
knowledge in the industry.
.
├── api
│ ├── proto
│ │ ├── habit.proto
│ │ └── service.proto
│ ├── generate.go
│ ├── habit.pb.go
│ ├── service_grpc.pb.go
│ └── service.pb.go
├── go.mod
└── go.sum
A logger is often the first package that is written in a module, as it’ll likely be
used by every other package. But loggers can sometimes be problematic -
they’ll write to whatever output we tell them to write to. Sometimes, this
causes issues - for instance, should the log messages always be printed when
testing? And to what output? In this section, we’ll implement a small logger
that will make it easier for us to both run and test our code with logs.
package log
import (
"io"
"log"
"sync"
)
Now that we have our basic logger, we can start implementing the server
package. We will need this logger there.
First, we create a structure that will be our server. It would not make sense if
it were to stay empty for long; it will soon contain a repository for data
retention.
In a new folder internal/server , create aserver.go file and add the struct
with a New function. As we’ll want to use a logger, let’s declare a one-method
interface that we will use as our logger.
A gRPC server, just as the HTTP server we saw in Chapter 8, is first and
foremost a good listener. We give it a port to listen to, and start it with a call
to Serve . This call will only return when the server shuts down.
But a gRPC server is a bit more than a HTTP server - it must implement the
desired gRPC API. For this, we start by creating a barren server using the
grpc package, and we then attach our implementation to that server by
registering it.
import (
...
"google.golang.org/grpc"
"learngo-pockets/habits/api"
)
grpcServer := grpc.NewServer() #B
api.RegisterHabitsServer(grpcServer, s) #C
err = grpcServer.Serve(listener) #D
if err != nil {
return fmt.Errorf("error while listening: %w", err)
}
There are better ways of starting the server to support graceful shutdown. We
will improve this later in the chapter. Additionally, if you want to allocate a
free port randomly, you can use port0. The documentation of net.Listen
explains which networks are supported.
Wait… This does not compile. We cannot register aHabitService that does
not know how to create a habit. As you can see,api.RegisterHabitsServer
takes as a second parameter anything that implements the HabitServer
interface, which was generated from our Protobuf service. We just need to
implement that one method.
When trying to compile or run, we also faced an error mentioning that our
Server type cannot be registered as aHabitsServer because it doesn’t
implement a method namedmustEmbedUnimplementedHabitsServer . This is
a reminder that, when we generated the Go files from the proto files, we used
the recommended way, which requires embedding a structure, as the non-
implemented method’s name suggests. So, let’s embed the required type:
Both concepts extend the notion of a structure, but in a different way. While
composition, which in Go is achieved by listing named fields of a structure,
represents a “has-a” relationship between two types, embedding corresponds
to a “is-a” relationship. In our case, our Server being an
UnimplementedHabitsServer, it has an implementation for that required
method.
As we know that this method will require tests and probably side functions,
we can already put it in a create.go file in the server package. The signature
of this function was generated by theprotoc toolchain; we can’t alter it. As
we’ll see, there is a mysterious first parameter, into which we’ll dive later in
this chapter.
return &api.CreateHabitResponse{
Habit: request.Habit,
}, nil
}
This should be enough for now. We will come back to it very quickly. Our
endpoint is implemented - our whole service is implemented. It’s now time to
spin it up.
What the main function does is create a new instance of our server and call
Listen , which only returns if there is an error. Since we need to inject a
logger into our server instance, we can create it in the main function and pass
it via server.New . We can use that logger in the main function too.
Here is how we create a new server in our main package and run it:
package main
import
"fmt"
"os"
"learngo-pockets/habits/internal/server"
"learngo-pockets/habits/log"
)
func main() {
lgr := log.New(os.Stdout) #B
srv := server.New(lgr) #C
err := srv.Listen(port) #D
if err != nil {
lgr.Logf("Error while running the server: %s", err.Error())
os.Exit(1) #E
}
}
There is basically no logic inside the main function. It means that our service
will be easier to test: all the logic is in isolated packages.
Run it!
go run cmd/habits-server/main.go
It does absolutely nothing, but it runs. How wonderful! Add a few logs in
ListenAndServe to make sure.
For the exact same reasons that we saw in Chapter 8, the Go structures
representing the transferable data, here the generated code, must be capable
of evolving independently from the rest of the code.
// Habit to track.
type Habit struct {
ID ID #A
Name Name
WeeklyFrequency WeeklyFrequency
CreationTime time.Time #A
}
It is always good to create a specific type for each of the fields in our main
entity, even though the usage might seem more verbose: functions and
methods will take typed arguments that will serve as documentation and
make the API clearer. For example, if a function takes the name and the ID
and both are strings, it’s quite easy to mix them up, while if one is explicitly
an ID and the other explicitly a name, casting the name into an ID type
should raise a red flag to the developer writing the call.
What if the input is invalid? Just like HTTP, gRPC uses different status codes
to make sense of the response defined by the RPC API. These codes are
included in the error that is returned alongside the response, by the endpoint.
These are only a few, the rest, with deeper explanations, can be found in the
official documentation. You can run go doc
google.golang.org/grpc/codes to have the list. Well, not exactly: go doc
limits its output, which causes only the first few codes to be printed. To get
the whole list, run:
// validateAndCompleteHabit fills the habit with values that we want in our database.
// Returns InvalidInputError. #D
func validateAndCompleteHabit(h Habit) (Habit, error) {
// name cannot be empty
h.Name = Name(strings.TrimSpace(string(h.Name))) #A
if h.Name == "" {
return Habit{}, InvalidInputError{field: "name", reason: "cannot be empty"} #E
}
if h.WeeklyFrequency == 0 { #B
h.WeeklyFrequency = 1
}
if h.ID == "" { #C
h.ID = ID(uuid.NewString())
}
if h.CreationTime.Equal(time.Time{}) {
h.CreationTime = time.Now()
}
return h, nil
}
We now need to define this typed error, in a newerrors.go file in the habit
package:
We could expose the given value of the field too: it is very useful when we
get an error to know what the server actually got - it differs from what we
think we sent more often than we care to admit. But this error will be logged,
copied around, and malevolent users could send in gigabytes of data and
crash our system. There are some ways to avoid this (limiting the size of
requests, truncating logs, etc.) but for now, let’s just avoid logging the field.
When writing this test, we found out that each test case had very different
assertions, so we chose to write a named function for each. You can write
several independentTestXxx functions, or group them inside a single one
with an explicit name:
t.Run("Full", testValidateAndFillDetailsFull)
t.Run("Partial", testValidateAndFillDetailsPartial)
t.Run("SpaceName", testValidateAndFillDetailsSpaceName)
}
The second function checks that if the habit is incomplete, ID and creation
time are filled up, the rest did not change. Each run of the test will give us
different values, so we are only checking for “not empty”. If you want to be
more thorough, you can check that the ID follows a given format, using
regular expressions and that the time is within the past second or so.
h := Habit{...}
h := Habit{Name:" ",...}
_, err := validateAndCompleteHabit(h)
assert.ErrorAs(t, err, &InvalidInputError{})
}
Good. Run the test, make sure you are happy about your coverage.
Now let’s call the endpoint. The Create function in the business, or domain,
layer, will fill the habit and will be ready to save it to a data storage.
return nil
}
You can already write a closed-box test for this one, or at least the structure
for the test.
Now that we’ve implemented the validation in the domain layer, let’s move
back to the server package and update the
CreateHabit method on the server
structure.
What should it do? This is the gRPC layer, where we transform an API-
specific signature into domain objects, call the domain function and
transform the response back into API-specific types.
h := habit.Habit{
Name: habit.Name(request.Name),
WeeklyFrequency: habit.WeeklyFrequency(freq),
}
return &api.CreateHabitResponse{
Habit: &api.Habit{
Id: string(createdHabit.ID),
Name: string(createdHabit.Name),
WeeklyFrequency: int32(createdHabit.WeeklyFrequency),
},
}, nil #B
}
How do we manage the error returned by the domain layer? We made sure
that if the error is caused by a bad input, it will have a specific type. We can
use errors.As to cast it into the InvalidInputError type and check whether
we should return a code 3. To be perfectly honest, we could use errors.Is
instead because we are not using any field of method specific to the type
InvalidInputError , but we chose to show you howAs can be used.
When a service ends up having several endpoints, checking the error and
outputting the appropriate status code can be factored in a single function,
toAPIErrorf(err error, format string, args ...any) . Feel free to
implement it when the need arises.
Hand testing
The tool we used 2 chapters ago to call our service,curl , only does HTTP
calls, but it has a cousin,grpcurl , which does the same job. There are
alternative options to grpcurl - many providing a graphical user interface,
but this one is the one we find most convenient. If you fancy a nice GUI,
Postman supports gRPC and can send Protobuf messages to servers since its
version 10.
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
Now, we can start using the tool to send requests to our server. There is a
major difference between curl and grpcurl : the format of the message was,
for curl , a regular JSON document, whereas forgrpcurl , we need to provide
a valid Protobuf entity. If you remember the beginning of this chapter,
Protocol Buffers have indexed fields, which means the message we’ll send
via grpcurl will need to be properly written, with its fields in the correct
positions. There are two options for us here - we can either provide the proto
definition to grpcurl , or we could have it ask for that definition from the
server. The second option is called reflection, and we won’t be using it here.
Indeed, reflection adds a small overhead to our server - something that
usually we don’t want to ship to production.
So, here is how we tell grpcurl how to structure our query (and understand
the response): we simply pass it the proto files with the-proto parameter -
we’ll give it the service.proto file, as this is where the definition of the
endpoints lie. Since some of the files include other files, we need to specify
the “root” from which they refer, via the -import-path parameter. Finally,
we need to tell which endpoint we want to aim at. This is passed as the final
parameter of the request - in the form of{package}.{service}/{endpoint} .
grpcurl \
-import-path /path/to/learngo-pockets/habits/api/proto/ \ #A
-proto service.proto \ #B
-plaintext -d '{"name":"clean the kitchen"}' \ #C
localhost:28710 \ #D
habits.Habits/CreateHabit #E
If everything went fine, you should receive a response from the server
(formatted in JSON). Does it contain an ID field? Is the weekly frequency
set?
Did you also try with an “invalid” name for the habit?
The service tells its clients that it can create habits, but it doesn’t store them.
We need to fix that.
Repository package
For the first version, we can use the same kind of in-memory repository that
we used for games in Chapter 8, in a package called internal/repository .
It has the same drawbacks: unscalable, probably unstable very soon, but it
gives us something quickly, so it is ok for a proof of concept.
Write a Repository structure with a New function that builds it and initialises
its map of data. Similarly to the New function of the server package, we want
to inject a Logger in here too. For now, we will need one method on the
Repository type, Create ; but soon we’ll want to add List , which will return
all the contents of our database.
If you have followed us through nine chapters, you should be able to create
the package, expose the right functions, structures and methods, and of
course cover them with some tests. Do not forget to add a mutex to lock the
data when reading and writing on the repository storage.
$ go doc
package repository // import "learngo-pockets/habits/internal/repository"
$ go doc HabitRepository
package repository // import "."
If you need it, remember that an example of the code can be found in the
book’s repository.
Dependency injection
Now, we didn’t really test this repository package. The main reason here is
that all we do in it is write to a map, and that we list all values of a map. We
can add the call toAdd inside the domain function. Here is a flow of the call
from the client to the database. You can imagine the same flow back with
either errors or nils.
Let’s first inject a repository dependency to the server. But why don’t we
simply call repository.New() in the server, rather than doing it in themain
function? As we’ll see, this makes tests a lot simpler than having to rely on a
hardcoded implementation of that dependency. This is one of Go’s best
usages of its lightweight interfaces. We are using an interface here so that
tests for the server can use mocks.
Update the main function to comply with this new signature of New - we need
to pass an entity that implements that interface, such as the output of
repository.New(...) .
err = db.Add(ctx, h)
if err != nil {
return Habit{}, fmt.Errorf("cannot save habit: %w", err)
}
return h, nil
}
Here we are, right? Can you see in the logs that your call goes all the way to
the DB? Let’s write a couple of tests, to ensure we properly catch the errors.
For this, we’ll start with a simple stub, as we did in Chapter 6, to implement
the habitCreator interface.
But how can we update the tests ofCreate to make sure thatAdd is properly
called? That’s what we are going to see in the next part.
Stubs vs Mocks
Stubbing and mocking are two very common ways of making use of an
interface for tests. While stubbing consists in writing a structure that
implements the interface and returns “hard-coded” values, in order to test the
behaviour of your code when the stubbed dependencies returns this or that,
mocking adds on top a check on how many times each dependency was
called, and if it was with the correct parameters.
The best known libraries are mockgen,mockify and minimock . They are
based on different design decisions, so feel free to pick your favourite. In our
example, we choseminimock because it provides mocked functions with
typed parameters.
go install github.com/gojuno/minimock/v3/cmd/minimock@latest
Because the mocks are generated, this is a perfect occasion to use the
go:generate syntax. Pick an interface, e.g.habitCreator , and add this line
above:
$ go generate .
or alternatively, navigate to the root of the module and run all the generate
commands in the project with
$ go generate ./...
You can see a new file has appeared in the mocks folder. Check the contents
with go doc .
$ go doc internal/habit/mocks
package mocks // import "learngo-pockets/habits/internal/habit/mocks"
The closed-box test for Create does not compile anymore. Let’s fix it.
First, there are two imports that we need to add. One is pretty obvious but the
second calls for a little explanation.
import (
// ...
"learngo-pockets/habits/internal/habit/mocks"
"github.com/gojuno/minimock/v3"
)
The first import is here to access the mocks we just generated. The second,
on the other hand, is about theminimock library.
If you pay extremely close attention, you’ll realise that the second import’s
path ends with “/v3 ”. Are we really importing a package named v3 ? This
would be a very strange name for a package…
Versioning modules in Go
Sometimes, a module needs to go through heavy changes that make the new
version incompatible with the previous one. Interrupting the backward
compatibility of a module requires a version change. When this happens, the
go.mod file should be updated to reflect the version: the first line of
minimock’s go.mod is module github.com/gojuno/minimock/v3 .
Users who want to useminimock (or any other versioned module) have to
remember to specify the version they want to use in the import path, right
after the name of the module, for instance :import
"github.com/jackc/pgx/v5" and import
"github.com/jackc/pgx/v5/pgxpool" . When using functions or types
defined in these packages, ignore the /v5 “ ” part : pgx.Connect(...) or
pgxpool.New(...) .
Next, we can now define a function that builds a mock for each of the test
cases. It takes a controller and returns a mocked instance of the required
interface, habitCreator . The test case structure will hold a new field whose
type is a function - to be honest, if you look at the test with error cases that
we have in the book’s repository, you will see that it would be far easier to
read if we had written it as two separate functions, but we wanted to show
you how functions can make your life better as fields of a test case struct.
The controller is created at the start of the test, it can be shared by all the test
cases, and, if your version of minimock is recent enough (v3.3.0), it
automatically registers a check at the end of the test to ensure each expected
call was met with an actual call.
In the nominal test case, or happy flow, the mock should take the input habit,
previously declared as a variable calledh, and return no error.
tests := map[string]struct {
db func(ctl *minimock.Controller) *mocks.HabitCreatorMock #A
expectedErr error
}{
"nominal": {
db: func(ctl *minimock.Controller) *mocks.HabitCreatorMock {
db := mocks.NewHabitCreatorMock(ctl)
db.AddMock.Expect(ctx, h).Return(nil) #A
return db
},
expectedErr: nil,
},
}
ctrl := minimock.NewController(t)
defer ctrl.Finish() #A
db := tt.db(ctrl)
It runs and succeeds. You can commit to make sure you don’t forget this
state, before playing around with the mocks. For example, what happens if
you comment out the call to Add ? Your test should tell you.
You can also read the documentation of the mocks package and how the
minimock tool can best be used, in order to define your favourite style.
There is one more way of testing that is perfect for this kind of CRUD
service: integration testing.
And so on. Some people will intertwine this with API testing, others will
separate testing the flows from testing the gRPC response for each endpoint’s
error cases.
In order to test a whole flow, we need to be able to list the habits that we
saved. Then, we will write the first scenario.
10.5.1 List habits
1. Update the Protobuf file with a ListHabits endpoint. First, because this
way you can already publish the interface for the rest of your team to use and
mock.
From there you can regenerate the corresponding go files withgo generate
./...
// FindAll is a mock which returns the passed list of items and error.
func (l MockList) FindAll(context.Context) ([]habit.Habit, error) { return list.Items, list.Err }
"empty": {
db: MockList{Items: nil, Err: nil}, #B
expectedErr: nil,
expectedHabits: nil,
},
"2 items": {
db: MockList{Items: habits, Err: nil},
expectedErr: nil,
expectedHabits: habits,
},
"error case": {
db: MockList{Items: nil, Err: dbErr},
expectedErr: dbErr,
expectedHabits: nil,
},
3. If you don’t have one already, write the repository function that lists all the
saved habits. The repository should return a deterministic list of habits, sorted
using a specific criterium such as the creation date of the habits.
habits := make([]habit.Habit, 0)
for _, h := range hr.habits {
habits = append(habits, h)
}
Now that you trust that this new endpoint works the way you expect, we can
write an integration test.
We want to write a test that will go through every layer of the service, all the
way to the network outside of it. Considering that our database is currently a
hacky in-memory thing, there is no point in mocking it, but when we finally
use a real database system, it will be necessary to either mock it or run an
instance locally.
This test will run the service for real and call it as any client would.
Run a service
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
err = grpcServ.Serve(listener)
require.NoError(t, err)
}()
defer func() {
// terminate the GRPC server
grpcServ.Stop()
// when that is done, and no error were caught, we can end this test
wg.Wait() #A
}()
return s.registerGRPCServer()
}
Create a client
A client needs to know to which address to send its requests and what the
shape of the server is (what the endpoints are). We create a function to build
that new client - it takes the address as a parameter.
Note that we need to pass some credentials to connect to the server. The grpc
library kindly offers a function that generates credentials that disable
transport security (TLS). While this is usually a security breach, we’re
running our server in a very restricted environment, and we can accept not
having to pass credentials. Depending on which network the request will be
sent through, you might have to use credentials, or you might be able to use
the insecure package to generate some for you.
Run scenario
As we are only testing two endpoints, we can create a function for the happy
path for each of them. Then we create an error path function for
CreateHabit , becauseListHabits never returns a business error.
Here is an example with the list habits endpoints, which is the trickier one: it
returns generated IDs whose value will change at every run. So, we overwrite
it after checking it’s been filled.
Consider the option of basing your integration test on a struct that holds the
client as a field and can hold methods that wrap calls to the client, to make
your test easier to read. We decided to use functions only, but all of them will
start with the same 2 arguments, which can become very verbose - and is
usually a cue for refactoring.
With this kind of helper functions, the scenario can look fairly readable:
Listing 10.29 integration_test.go: scenario in the code
// add 2 habits
addHabit(t, habitsCli, nil, "walk in the forest")
addHabit(t, habitsCli, ptr(3), "read a few pages")
addHabitWithError(t, habitsCli, 5, " ", codes.InvalidArgument)
// ...
Make sure you isolate your different scenarios so that they can run in parallel
- you can even run that many instances of the server in parallel, one for each
integration scenario. You can also use this opportunity to play with
concurrency and call Add a large number of times concurrently to check for
performance.
So far, our test resembles any other unit test that we’ve written - apart that it’s
called “integration”. Sometimes, these integration tests can be quite intense,
because they go through lots of features and cases, or because they include
some benchmarks or run some load or performance tests. These tests usually
take quite some time, and it isn’t advised to include them in continuous
integration toolchains, as they might slow down the delivery process. For
instance, it could be a requirement to have “light” tests run on pull requests,
but “heavy” tests to run on tagging and image building. The
go test ./...
command accepts a-short flag. Setting this flag in the command line will
change the output of thetesting.Short() function, which we can invoke in
any test.
grpcServ := newServer(t)
Now, let’s have a look at the output of go test -v ./... . Using the -v flag
makes the output verbose and lists each test function called. The output
should contain TestIntegration :
Let’s try the same, but this time with the short flag: go test -v -short
./... . This time, the output should explicitly indicate that TestIntegration
was skipped:
Now, so far, we’ve been using our own in-memory database, which we quite
trust. Indeed, if that database were to be unavailable, we’d have serious issues
in our server itself, since the server and the database are part of the same
program. But most of the time, the database is a remote entity, one that could
behave erratically, because of network issues, or external load, or lots of
other pesky bugs - or, worse, what if our own query crashes that database?
We already handle the case when the database returns an error, but what if it
doesn’t answer our query? How long should we wait before realising
something is wrong?
Earlier in the chapter, we haven’t detailed what aContext really is, but now
is the time to run go doc context.Context to find out. As we can read there,
the purpose of a context is to carry around deadlines, cancellation signals,
and values across API boundaries.
The Deadline method returns when a context’s deadline is set - and if it is.
The deadline is the time when the context will start saying it’s reached its
expiration date to whomever might ask.
The Done method returns a channel that will be closed when the context has
reached its end. CallingDone() is simpler than comparing Deadline() with
time.Now() , so this is what is usually done.
Finally, the Err method returns an error describing why the channel returned
by Done is closed, or nil if it isn’t closed yet.
Golang’s context package offers several functions that allow for the creation
of a context. They are mostly divided into two categories: those that create a
child context from a parent one, and those that spring one out of the blue.
Now that we’ve got a context, we can create children. They can come in a
variety of shapes, but the main difference lies in how we want to set their
Deadline property. We can call WithDeadline to provide a specific
timestamp at which time the child will be cancelled, or WithTimeout if we
want to specify how long a context should “live”. The second option is by far
the most common when it comes to making calls to remote services.
Most of the time, however, a function will receive a context as one of its
parameters. There is a silent convention to always provide the context as the
first parameter of a call to a function - and, just as we usually name the errors
err , we similarly very often call our contexts ctx . And when a function
receives a context, it should not try to create a new one withBackground() or
TODO() . In our gRPC generated code, we can observe that the endpoints’
signatures all start with an incoming context - that’s the one we should be
using.
When should we create a child context, though? Why not provide the parent
context - after all, it might have a deadline itself… The answer here is having
good practices in coding. It’s about controlling with precision every call that
is made across your network. If a service needs to call two other services to
answer a request, we want to know which of these two is taking an awfully
long time to return a response, if any. To achieve this, each remote call must
be using its own context, with its own deadline. Depending on your
application, a timeout value can range between 10 milliseconds and some
days. Don’t be too strict and take into account network latency.
Let’s have an example with our database. Let’s say that we want to ensure
that the repository call in our Create endpoint doesn’t take too long. We do
this by creating a context with a timeout of 100 milliseconds, and we call our
repository.
return h, nil
}
If we run this, everything works fine. But it’s not tested, and we should test it.
It’s even worse than not tested - it actually breaks our existing tests! Indeed,
if you remember, we are using mocks, and mocks are very strict about what
they expect. Our tests didn’t worry too much about the context - after all, we
didn’t fiddle with it so far. But now, the context used to call Add in the
Create endpoint is not the Background one any more. We should update the
test, but how can we provide the exact same context? We would need to
know exactly when the deadline is to be able to expect it properly.
Many mocking libraries face this issue at some point. Minimock has decided
to expose aminimock.AnyContext variable that will match any
context.Context variable. Some other mocking libraries go a step beyond,
and propose amock.Anything variable that can be used as a wildcard for any
input parameter. We only need to use the context, so we’ll limit ourselves to
this option.
While this fixes the current tests, it doesn’t test the current feature of having a
timeout on our database call. For this, we will need to improve our mock.
As you now know, a context is something that can reach its deadline, and,
when this happens, the channel returnedDone() is closed - which means
reading from it starts returning a zero value instead of being a blocking call.
This is how applications check for a cancelled / expired timeout. The
following piece of code is present in various forms in most libraries that
handle timeouts:
select {
// Read from channel used by backend to communicate response
case response := <-responseChan:
return response, nil
// Check for deadline
case <-ctx.Done():
return nil, ctx.Err()
}
In this select, whichever happens first causes the function to return - either we
received a response, or the deadline was met. The line case <- ctx.Done():
appears more than 60 times in the standard library alone - and it’s always
followed by returning the cause of the deadline, viactx.Err() .
Let’s add a test that implements this logic. For this, we can’t useExpect , as
this immediately returns the specified values. Instead, we’ll have to overwrite
the behaviour of the Add method, which minimock allows with the Set
method:
So, as we’ve seen, contexts can be used to detect unexpectedly long remote
calls. Some functions allow you to register pairs of key-values inside a
context, but we recommend keeping that option as a last resort.
Instead, let’s resume our habits server, and implement the final endpoint -
one that allows us to keep track of what we do on a weekly basis.
First, let’s quickly reset the final goal of this pocket project and define what
tracking a habit means. We have the possibility to create a list of habits with a
target weekly frequency - how about being able to tick one of our habits
whenever we achieve it and retrieve the current status, so we can plan the rest
of the week? Am I done for the week? Should I block a time slot to go for a
walk? All these questions will you be able to answer!
We are missing the bricks to fulfil step 3 and 4 so let’s go for the
implementation. On the API, we will need two new endpoints TickHabit and
GetHabitStatus .
Let’s define a new endpoint TickHabit on the proto side with its associated
request and response.
Then, add the implementation on the server with the following signature:
Ticks and habits being different notions, we would store them in different
tables in an SQL database. In our memory implementation, we will store the
ticks in a structure of its own, next to the habits. This will allow us, if we
develop a UI, to retrieve only the list of habits or the full status of a habit for
a week.
10.7.2 Store ticks per week
This is the same logic that we did previously for the habits in memory, the
only tricky part is the data definition. We want to store all the ticks for each
habit, and because we want to get a weekly status, we will store ticks grouped
by week. The built-in Go library package provides a very useful method
named ISOWeek() that returns the ISO 8601 year and week number in which
that time occurs. Running go doc time.ISOWeek returns:
We will use the naming ISOWeek in our code. Let’s create a new package
called isoweek and a new file where we will define a ISO8601 structure
holding a Year and a Week.
package isoweek
storage map[habit.ID]map[isoweek.ISO8681][]time.Time
Do you feel confident in creating the needed methods? But let’s not anticipate
and create only theAdd for the moment. All the logic of ISO8601 computation
is done in a dedicated function and pass it directly as a parameter, it will be
easy to reuse over the other endpoints and tests.
Do not forget to verify if the habit and the ISOWeek exist in the storage
before inserting a new tick.
The full implementation of Tick on the domain side should now be ready!
package habit
import (
"context"
"fmt"
"time"
)
return nil
}
We now just have to call it on the server side and transform the request and
the response. Since we need to provide a timestamp when the habit was
ticked, we could either have it passed by the caller or the gRPC endpoint. If
the value is set in the server layer rather than read from the request, we need
to remember that the server and the client might be in different timezones,
and that the “current day” is only a relative notion.
Wait! What happens if I try to tick a habit that does not exist?
If the habit does not exist in the habits repository, we do not want to have
inconsistent data and store a new tick for an unknown habit. Let’s create a
Find method on the habit repository:
A good practice is to create a custom error that is checked on the server side
to return the proper gRPC code. Here, we chose to switch on the domain
error and convert in codes.NotFound for example if the habit does not exist
in the database.
You can test the endpoint manually usinggrpcurl on a created habit, for
instance one with the following ID: 98ab1bbe-41d5-4ed3-8f33-
e4f7bec448c8.
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"98ab1bbe-41d5-4ed3-8f33-e4f7bec448c8"}' \
localhost:28710 \
habits.Habits/TickHabit
Upon inspection, no errors have been returned. Our interest now lies in
determining the frequency of calls made to the Tick endpoint for a given
habit. Let us proceed to retrieve this information.
The last task entails retrieving the count of habit ticks for a given week. This
requires both the habit ID and a timestamp to specify the desired week. We
shall proceed by implementing an endpoint capable of accepting the ID and
timestamp parameters, which will then furnish habit details alongside the tick
count. The proto definition looks like below:
If you test with grpcurl, you should obtain something like below:
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"98ab1bbe-41d5-4ed3-8f33-e4f7bec448c8"}' \
localhost:28710 \
habits.Habits/GetHabitStatus
{
"habit": {
"id": "98ab1bbe-41d5-4ed3-8f33-e4f7bec448c8",
"name": "read a few pages",
"weeklyFrequency": 3
},
"ticksCount": 2
}
It would be more fun to be able to dive into the past to tick a habit we forgot
to update. To do so, we can extend the two last endpoints by adding a
timestamp to the requests.
In proto, there are different types we can import that will be nicely serialised
in the programming language you choose. You can always refer to the full
list of well-known types in the Protocol Buffers documentation
(https://protobuf.dev/reference/protobuf/google.protobuf/). The expected
format is a RFC3339 date string such as “2024-01-25T10:05:08+00:00”.
Let’s import the timestamp type by adding this line in the top imports of our
file service.proto.
import "google/protobuf/timestamp.proto";
message GetHabitStatusRequest {
string habit_id = 1;
google.protobuf.Timestamp time = 2;
}
We are now able to play with the habit tracker, so let’s play a full scenario
where we add habits, we tick them, retrieve their status, tick again with a
timestamp and retrieve their status for the given date. We can do it manually
by using grpcurl in your terminal or we can update the integration test. Let’s
see first the grpcurl commands and compare the responses.
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"name":"Write some Go code", "weekly_frequency":3}' \
localhost:28710 \
habits.Habits/CreateHabit
Response:
{
"habit": {
"id": "94c573f1-df03-45ec-97fc-8b8fc9943472",
"name": "Write some Go code",
"weeklyFrequency": 3
}
}
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"name":"Read a few pages", "weekly_frequency":5}' \
localhost:28710 \
habits.Habits/CreateHabit
Response:
{
"habit": {
"id": "96b72dce-7a2e-43ce-9091-0f9fc447b8a1",
"name": "Read a few pages",
"weeklyFrequency": 5
}
}
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{}' \
localhost:28710 \
habits.Habits/ListHabits
Response:
{
"habits": [
{
"id": "94c573f1-df03-45ec-97fc-8b8fc9943472",
"name": "Write some Go code",
"weeklyFrequency": 3
},
{
"id": "96b72dce-7a2e-43ce-9091-0f9fc447b8a1",
"name": "Read a few pages",
"weeklyFrequency": 5
}
]
}
4. Tick habit “Write some Go code” without a timestamp because you just
did it
Request:
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"94c573f1-df03-45ec-97fc-8b8fc9943472"}' \
localhost:28710 \
habits.Habits/TickHabit
Response:
5. Get the status of the habit “Write some Go code” for the current week
Request:
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"94c573f1-df03-45ec-97fc-8b8fc9943472"}' \
localhost:28710 \
habits.Habits/GetHabitStatus
Response:
{
"habit": {
"id": "94c573f1-df03-45ec-97fc-8b8fc9943472",
"name": "Write some Go code",
"weeklyFrequency": 3
},
"ticksCount": 1
}
6. Tick habit “Read a few pages” without a timestamp because you are
doing it
Request:
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"96b72dce-7a2e-43ce-9091-0f9fc447b8a1"}' \
localhost:28710 \
habits.Habits/TickHabit
Response:
7. Get the status of the habit “Read a few pages” for the current week
Request:
$ grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"96b72dce-7a2e-43ce-9091-0f9fc447b8a1"}' \
localhost:28710 \
habits.Habits/GetHabitStatus
Response:
{
"habit": {
"id": "96b72dce-7a2e-43ce-9091-0f9fc447b8a1",
"name": "Read a few pages",
"weeklyFrequency": 5
},
"ticksCount": 1
}
8. Tick habit “Read a few pages” with a timestamp in the previous week
Request:
grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"96b72dce-7a2e-43ce-9091-0f9fc447b8a1", "timestamp": "2024-01-
24T20:24:06+00:00"}' \
localhost:28710 \
habits.Habits/TickHabit
Response:
9. Get the status of the habit “Read a few pages” during the previous week
Request:
grpcurl \
-import-path api/proto/ \
-proto service.proto \
-plaintext -d '{"habit_id":"96b72dce-7a2e-43ce-9091-0f9fc447b8a1", "timestamp": "2024-01-
24T20:24:06+00:00"}' \
localhost:28710 \
habits.Habits/GetHabitStatus
Response:
{
"habit": {
"id": "96b72dce-7a2e-43ce-9091-0f9fc447b8a1",
"name": "Read a few pages",
"weeklyFrequency": 5
},
"ticksCount": 1
}
First, we can write helper functions as we did previously to tick a habit and to
verify the habit status matches when callingGetHabitStatus . These
functions will both make the call to the API and validate the returned values.
Listing 10.39 integration_test.go: Add TickHabit and GetStatus calls
func addHabit(t *testing.T, habitsCli api.HabitsClient, freq *int32, name string) string { #A
resp, err := habitsCli.CreateHabit(context.Background(), &api.CreateHabitRequest{
Name: name,
WeeklyFrequency: freq,
}) #B
require.NoError(t, err)
return resp.Habit.Id #C
}
You can now add the calls to the main test function by calling the two helpers
above.
func addHabit(t *testing.T, habitsCli api.HabitsClient, freq *int32, name string) string {
// add 2 ticks for Walk habit
tickHabit(t, habitsCli, idWalk)
tickHabit(t, habitsCli, idWalk)
10.8 Summary
The go generate command is a Go tool that gives the possibility to
generate programs from the source code. The compiler will scan
comments with the specific syntax //go:generate and will execute the
following commands.
gRPC, standing for Google Remote Procedure Call, is a framework to
connect services, devices, applications and more. It is a powerful and
efficient framework for transporting light-weight messages in Protobuf
format.
Protobuf, short for Protocol Buffers, provides serialisation for structured
data while guaranteeing high performance. It comes with its own syntax
composed of Protocol Buffer messages and services written in .proto
files.
Protobuf messages are language-neutral, thanks to the Protobuf compiler
(
protoc ), you can generate interfaces and structures in many
programming languages (Go, Java, C++ and more).
gRPC clients and servers communications are standardised thanks to
status codes defined by the RPC API. A status is composed of an integer
code and a string message. While designing the API, you should pick
the most appropriate return code for your use case and you can always
refer to the documentation
(https://grpc.github.io/grpc/core/md_doc_statuscodes.html).
grpcurl is an open-source CLI tool that enables you to communicate
with gRPC servers easily. It is basically like curl but for gRPC. Human-
friendly, it allows you to define JSON requests instead of unreadable
bytes. It is very handy when you need to test services manually.
Declare small interfaces close to their use. It comes in handy when
testing to mock your dependencies instead of counting on hardcoded
behaviours.
Dependency injection is a technique to give all the needed objects to a
function instead of creating or building them internally. Note that it
comes in very handy to mock the dependencies when testing the
function.
While testing, there are different ways of simulating dependency
behaviours, mock tools are handy for describing expected results. The
main well-known mock tools are mockgen coming with Go,
mockify
and minimock , which we used during the chapter.
context is a standard library which provides the Context type and its
associated methods to carry information along requests and responses.
For example, when a user sends a request, you can store its identity in
the context and retrieve it later in the chain of functions. It is safe to use
methods like WithCancel or WithDeadline across multiple goroutines.
A program should create only one context and provide it to its functions.
Most of the time, the context will be coming from an external caller.
Creating children for a context is perfectly fine and encouraged.
Always pass a context.Context as the first parameter of a function,
even if you are not using it (you can use the blank identifier in this rare
case).
A context will be necessary any time we make a remote call across the
network, whichever protocol or framework is being used. Setting a
deadline for the remote call is a good safety net - otherwise, your
application calls might be hanging forever.
Don’t use a
context.Context as a key-value storage inside an
application.
Mocking functions that use a context is sometimes tricky, especially if
the function being tested creates a context of its own. To solve this,
many libraries offering mocks expose a variable that can be used as a
wildcard for the context.
Generated mocks are programmed to behave as specified. If they receive
a call for a precise set of parameters, they will return the specified
values. However, it is sometimes necessary to override this default
behaviour of always returning something, especially when testing the
behaviour when a deadline is reached.
Using
testing.Short() allows us to know if the -short flag was
passed on thego test command line. Long or expensive tests should be
skipped altogether when this flag is set, by usingt.Skip() .
Appendix A. Installation steps
Any compiled language needs, first and foremost, a compiler.
A.1 Install
Start by visiting the Go website. It explains in a simple way (did we tell you
that Go aims for simplicity?) how to download the installer and run it on
either Linux, Mac or Windows. Follow the installation steps and do not
forget to add go to your path.
There is no good reason to pick old versions. Just for the record, we are
writing this book using Go 1.20.
https://go.dev/doc/install
A.2 Check
As mentioned on the online installation guide, you can check the version of
Go that you are using and also verify that Go is properly installed by running
this command in any directory at all:
$ go version
go version go1.19.0 darwin/arm64
If you’ve just installed Go, these variables won’t be set in your sessions. “But
how come Go uses them if they’re not set?” might you ask. A very wise
question. Go is able to use default values for these (and more, as we’ll see)
variables.
The values returned bygo env here are the default values, which are based
on your machine’s architecture and your installation directory of Go. We
hardly ever need to modify any of these values, but, for your knowledge, they
can be overridden with regular environment variables.
# On Linux:
$ CGO_ENABLED=0 go env -json CGO_ENABLED
{
"CGO_ENABLED": "0",
}
# On Windows:
C:\> set "CGO_ENABLED=0" & go env -json CGO_ENABLED
They can also be written in Go’s configuration file (which is pointed by the
GOENVvariable) with the go env -w VARIABLE=VALUE command.
# On Linux:
$ go env -w GOBIN=/home/user/bin
$ go env GOBIN
{
"GOBIN": "/home/user/bin",
}
# On Windows
C:\> go env -w GOBIN=%LOCALAPPDATA%
$ go install golang.org/x/tools/cmd/godoc@latest
# On Linux
$ go env -w GOPATH=${GOPATH}:/path/to/workspace
# On Windows
C:\> go env -w "GOPATH=%GOPATH%;C:\path\to\workspace"
A.4 Hello!
The Hello World instructions are detailed on Go’s website, but here is a short
version. You will find, at the very beginning of the first project, in chapter 2,
explanations regarding each line of the typical hello world.
Create a hello folder in your ${GOPATH} with a file named hello.go and paste
the following:
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
$ go mod init
A go.mod file appeared. It contains the path to your module and your Go
version.
Then run your code in the same folder and wave at your screen: your
machine is trying to communicate!
$ go run hello.go
Hello, World!
Both commands accept the name of a repo, and will retrieve its contents at a
specific version. The main difference is that go get only retrieves go files
from that repo, when go install also compiles the retrieved package into an
executable. Which one you use will depend on what you need - do you want
the sources or the executable ?
go install is rather recent, and some public repositories will still list go get
as the method to install their binaries. If you follow that path, you will be
faced with a message suggesting usinggo install - in these cases, use the
second option of go install listed below.
A.5.1 go install
If you need to retrieve a binary written in Go, use go install . It will fetch
the sources and compile them locally for your machine’s architecture. There
are three different ways of calling the go install tool.
The first option lets you retrieve a specific version of a repository. This is
very useful when writing automation tools, when you want a constant and
deterministic flow.
The second option is very similar, and retrieves the code at the latest version
of the repository, using its main (or master ) branch. This is the most common
way of using go install manually.
The last option will use the contents of your project’s go.mod file to find
which version to download and install. This will only work if you are running
the command from within a Go project.
A.5.2 go get
If you need sources that your own code depends upon, go get will update
your module file (see below) and download the sources into
${GOPATH}/pkg/mod . You can then look into them in order to understand
what the code does. Similarly togo install , go get can be used with
different behaviours.
The first option you have is to run go get on an URL, without specifying a
version or anything. This will retrieve the contents of that dependency, and
its own dependencies to yourgo.mod file - you are telling go you need that
repo in your project.
The second option you have is to retrieve the code by explicitly giving the
name of a tag, branch, or commit. This is extremely useful when working on
two projects at the same time, or when working with a project that hasn’t
been merged into main yet. This option will register that new package into
your go.mod file, at the desired version.
# Retrieve the experimental slices package using the version defined in the go.mod file.
$ go get golang.org/x/exp/slices
# Retrieve the experimental slices package, latest commit on branch master.
$ go get golang.org/x/exp/slices@master
# Retrieve the experimental slices package at a specific commit or tag.
$ go get golang.org/x/exp/slices@c99f07
Go is now installed on your machine and you can start using it and follow the
book’s instructions. We learned about the Go environment variables and
important paths. Your terminal knows how to greet you in English using Go,
you can move to Chapter 2 and teach it to greet you in other human
languages.
Appendix B. Formatting cheat sheet
Go offers several verbs that are passed to printing functions to format Go
values. In this appendix, we present the most known verbs and special values
that can be passed to these functions. You can refer to these tables all along
the book. The result for each of the following entries was generated by
fmt.Printf("{{verb}}", value) .
%v [0 1] Default format
%d 15 Base 10
%o 17 Base 8 (octal)
Output for
fmt.Printf("
Verb {{Verb}}", Description
123.456)
%e 1.234560e+02 Scientific notation
%c A Character
%U U+0041 Unicode
%#U U+0041 'A' Unicode with character
Verb Description
\b U+0008 backspace
\\ U+005c backslash
All Unicode values can be encoded with backslash escapes and can be used
in string literals.
You can find below a table of examples from the simplest to more complex
types with their zero-values. Feel free to come back to this table through the
book.
var r
r == 0
rune
var f
f == 0.
float32
var b
b == false
bool
var i
[] i == nil (*)
int
var a
complex64
var m
[string]int
type person
struct
p has been allocated in memory, it can’t benil ( nil is
{ also not of type person )
age int p.age == 0
var p person
var i
* i == nil
int
type Doer
interface
{
d == nil
Do()
var d Doer
var c
chan c == nil
string
type translate
func
t == nil
(string) string
var t translate
(*) Maps and slices should be declared with themake() function. If not, they
take the zero-value of nil, as described here. There are a few things to know
about slices and maps that can come in handy at any time.
The len function can be called onnil slices or maps, and returns the value 0.
In a vast majority of cases, checking the length is better than checking if the
structure is nil . Let’s have a look at an example:
func main() {
data := []string{}
fmt.Println(data == nil)
fmt.Println(len(data))
fmt.Println(data[0])
}
======
false
0
panic: runtime error: index out of range [0] with length 0
As you can see in this example, declaring an empty slice doesn’t return nail
slice. In order to be able to check any of its elements, we should always
check the length of a slice.
There is, however, one thing that we can do with uninitialised slices, and this
is appending entries to them. This won’t cause any panic error, and will
simply return a non-nil slice with the new elements, if there were any.
func main() {
var data []string
fmt.Println(data == nil)
data = append(data, "hello")
fmt.Println(data)
}
======
true
[hello]
Maps follow the same logic: when declaring one without initialising it, the
map will be nil . The important information is that you can’t write data in
such a map.
func main() {
var m map[string]int
m["hello"] = 37
}
======
panic: assignment to entry in nil map
However, accessing items in anil map will return the zero-value of this item
(it’s obviously not present). This is useful information, because sometimes,
you receive a map from a library. It’s safe to check for keys in the map, but
it’s even safer to check for its length first.
func main() {
var m map[string]int
count, found := m["hello"]
fmt.Printf("found: %v; count: %d\n", found, count)
}
======
found: false; count: 0
wordCount := make(map[string]int)
When accessing an entry absent from this map - a word that we haven’t seen
so far - the returned value at the index of the new word will be the zero value
of the integer tye: 0. This is extremely convenient, as it means we can
consider words that haven’t been seen so far as words that have been seen
zero times. Recording an occurrence of a word doesn’t need any extra effort
if the word had or hadn’t been registered before: we simply add 1 to the
counter.
import (
"fmt"
"strings"
)
// print results
for word, count := range wordCounter {
fmt.Printf("We recorded the word %q %d time(s).\n", word, count)
}
}
func main() {
countWords("to be or not to be")
}
======
We recorded the word "or" 1 time(s).
We recorded the word "not" 1 time(s).
We recorded the word "to" 2 time(s).
We recorded the word "be" 2 time(s).
Appendix D. Benchmarking
One of the great tools Go offers is a benchmarking command. Writing
benchmarks to compare the allocation of memory and the execution time is
extremely simple - it’s very similar to writing a test over a function.
We’ll use the type B, defined in the testing package. You’ll never guess
what B stands for…
The type B has one exposed field, and integerN, which counts the number of
iterations the benchmark has executed. When running benchmarks, this field
has an initial value that will allow at least a certain amount of iterations to
ensure we have a steady result - no need to try and set it manually.
As mentioned earlier, the benchmark can be run using our friend thego test
tool, with specific options. In order to run benchmarks (just as we had for
tests) for all files in subdirectories, we pass the-bench=. option, and, if we
want to display details of memory operations, we can add-benchmem .
Running benchmarks will, however, also run the tests. If we want to avoid
that, and run only the benchmarks, we can add an extra parameter to the
command-line, an indication to help Go find our tests by their name: a
regular expression. We’ll cover this topic a bit more extensively, but, for
now, we’ll use the (very) loose ^$ , which will match all benchmark test
functions.
goos: darwin
goarch: arm64
pkg: github.com/ablqk/tiny-go-projects/chapter-04/2_feedback/gordle
BenchmarkStringConcat1-10 174882942 6.850 ns/op 0 B/op 0 allocs/op
BenchmarkStringConcat2-10 15633693 74.28 ns/op 24 B/op 2 allocs/op
BenchmarkStringConcat3-10 8609542 137.1 ns/op 56 B/op 4 allocs/op
BenchmarkStringConcat4-10 5873654 201.1 ns/op 104 B/op 6 allocs/op
BenchmarkStringConcat5-10 4455464 275.2 ns/op 160 B/op 8 allocs/op
The output of this command can be a bit scary at first. After all, we only
wrote a 5-line test! Let’s have a look. We can see several lines, and several
columns. Each line corresponds to a function to benchmark that the go tool
found in our code (respecting our earlier loose regexp). The columns
represent metrics that were observed by the test tool during the execution of
the benchmark.
The first column is the name of the function, with a suffix indicating the
number of processors on the machine.
The second column indicates the number of loops that were executed
(the b.N value, if you remember).
The third column indicates the amount of time (usually in nanoseconds)
each operation took.
The fourth column indicates the number of bytes allocated per operation.
The final column indicates the number of memory allocations per
operation.
Some quick maths should show that the benchmark tool gave roughly the
same amount of execution time to each line (second column multiplied by
third column). The benchmark results are interesting as they are pretty simple
to read.
String concatenation is three to four times slower than using the string builder
when we need to append five times. Using thea + b string concatenation
makes a number of memory allocations proportional to the number of strings
to concatenate (which makes sense, since strings are immutable), and these
operations cost more and more memory every time. On the other hand, the
memory allocations of the string builder are scarcer and lighter. This
benchmark confirms we definitely should be using the string builder to
generate feedback!
D.2 Summary
In order to compare the relative efficiency of two implementations, Go’s
test tool allows for a simple implementation of benchmark functions. A
BenchmarkNameOfFunc(b *testing.B) function will be considered as a
benchmarking function and will be ran with go test ./... -
run=NameOfFunc -bench=. -benchmem . The benchmarked function must be
called inside a for n := 0; n < b.N; n++ { loop.
welcome
Thank you for purchasing the Learn Go with Pocket-Sized Projects! We hope
you will have fun and make immediate use of your learnings.
This book is for developers who want to learn the language in a fun and
interactive way, and be comfortable enough to use it professionally. Each
chapter is an independent pocket-sized project. The book covers the
specificities of the language, such as implicit interfaces and how they help in
test design. Testing the code is included throughout the book. We want to
help the reader become a good modern software developer while using the
Go language.
This book also contains tutorials for command-line interfaces, and for both
REST and gRPC microservices, showing how the language is great for cloud
computing. It finishes with a project that uses TinyGo, the compiler for
embedded systems.
We encourage you to ask your questions and post the feedback you have
about the content in theliveBook Discussion forum . We want you to get the
most out of your readings to increase your understanding of the projects.
In this book